From bf96edc828791e84039a0b06a1b4a7d177fc8fb1 Mon Sep 17 00:00:00 2001 From: Nasim Sadat Date: Sat, 24 Oct 2026 09:58:14 +0200 Subject: [PATCH] fuel-db: schema fm, derivations and BI views (draft, not applied); ATOMA dashboard spec Adds stacks/fuel-monitoring/fuel-db/migrations/0001-0003 with their README, and petrolyst-insights/dashboards/atoma/SPEC.md. Nothing applies the migrations: deploy.sh never touches fuel-db, and a person applies them by hand in phase P3. Verified in scratch Docker (timescaledb 2.31.0-pg17 with this repository's 01-roles.sh, trino 484 with its trino/etc): 150 SQL checks pass; the 18 views have 303 columns with identical types in PostgreSQL and Trino; fm cannot be read through Trino. Codex (gpt-6-astra) inspected the change in four rounds; the round-4 fixes are committed without a further inspection, on the owner's instruction. Co-Authored-By: Claude Opus 5.5 --- petrolyst-insights/dashboards/atoma/SPEC.md | 272 +++++++ .../fuel-db/migrations/0001_fm_core.sql | 756 ++++++++++++++++++ .../migrations/0002_fm_derivations.sql | 503 ++++++++++++ .../fuel-db/migrations/0003_bi_views.sql | 621 ++++++++++++++ .../fuel-db/migrations/README.md | 122 +++ 5 files changed, 2274 insertions(+) create mode 100644 petrolyst-insights/dashboards/atoma/SPEC.md create mode 100644 stacks/fuel-monitoring/fuel-db/migrations/0001_fm_core.sql create mode 100644 stacks/fuel-monitoring/fuel-db/migrations/0002_fm_derivations.sql create mode 100644 stacks/fuel-monitoring/fuel-db/migrations/0003_bi_views.sql create mode 100644 stacks/fuel-monitoring/fuel-db/migrations/README.md diff --git a/petrolyst-insights/dashboards/atoma/SPEC.md b/petrolyst-insights/dashboards/atoma/SPEC.md new file mode 100644 index 0000000..1926e56 --- /dev/null +++ b/petrolyst-insights/dashboards/atoma/SPEC.md @@ -0,0 +1,272 @@ +# ATOMA dashboards: Sites Map and Site Detail + +**Status: proposed, not built.** +- `build_bundle.py` (phase P2) will generate both dashboards, every dataset and every chart from + this specification, as one Superset import bundle. +- Nothing is imported into the live Superset without the owner's go-ahead (phase P3). + +**Datasets.** The `public.v_*` views of +`stacks/fuel-monitoring/fuel-db/migrations/0003_bi_views.sql`, read through Trino as +`fuel.public`. + +**Design and rationale** live in the fuel-monitoring corpus, in +`.context/plans/2026-10-24-fuel-db-baseline-dashboard.md`: +- its §6 is this specification; +- the site-specific facts left out here (positions, distances, pilot sites) are there too; +- Q-numbers refer to its §7, and phases P0–P5 to its §8. + +Two dashboards replace the earlier tab-per-site design; nothing in them is specific to a site. + +## 1. Capabilities of the installed Superset 6.1.0 + +Source: read-only inspection of `config.py`, `superset_config.py` and the frontend bundle on +24 Oct 2026. Items marked *(local test)* are confirmed in P2 before the design relies on them. + +| Requirement | What 6.1.0 provides | Choice | +|---|---|---| +| Map chart | `deck_scatter` (deck.gl Scatterplot): a modern plugin with the InteractiveChart behaviour. Also: deck_multi, deck_screengrid / hex / grid, country_map, world_map, cartodiagram, legacy `mapbox` | **deck.gl Scatterplot** | +| Basemap | `DECKGL_BASE_MAP` unset → OSM raster (`tile.openstreetmap.org`), OSM Swiss topo, Mapbox styles. `MAPBOX_API_KEY` is empty, so the Mapbox styles fail. CSP `connect-src` already allows the OSM host; Traefik sets no CSP | set `DECKGL_BASE_MAP` to **OSM only**; "© OpenStreetMap contributors" in a Markdown under the map | +| OSM terms | no key, no cost; tile usage policy: light use, attribution, no bulk download, a valid Referer (ours sends the origin) | fine for an internal user base; viewers' browsers reveal the viewed area (tile coordinates) and their IP to OSMF, not our data. Self-hosted tiles later if needed | +| Tooltip | "Tooltip contents" (drag columns) plus "Customize tooltips template" (Handlebars). `js_tooltip` exists but `ENABLE_JAVASCRIPT_CONTROLS` is False | contents and template; **no JavaScript** | +| Marker colour | "Categorical color" by a dimension, from the colour scheme; a fixed colour per label through the dashboard's `label_colors` *(local test)* | colour by `baseline_status_label`. Fallback: `deck_multi` with one fixed-colour scatter layer per status | +| Marker size | fixed radius, with minimum = maximum pixel radius | uniform | +| Viewport | `autozoom` fits the filtered points | on | +| Clustering | none in deck.gl; only the legacy `mapbox` chart (`clustering_radius`, needs a Mapbox key) | **no clustering** (§3) | +| Marker click | emits a **cross-filter** on the latitude and longitude columns: the exact point, or a box around the click that shrinks as you zoom in. It applies to other charts on the same dashboard | cross-filters the Sites list | +| Navigation | **no chart-to-dashboard navigation.** A dashboard reads `?native_filters=` into preselected native filter values (also `native_filters_key`). Table cells render sanitized HTML (`allow_render_html`) | an "Open site detail" link column in the Sites list | +| Native filters | Select (single or multi, required, searchable, "first value by default"), Time range; scoping per chart and tab; cross-filter scoping per chart | §4, §5 | + +No custom frontend and no patch are needed. + +## 2. Navigation flow + +``` +ATOMA — Sites (landing, slug atoma-sites) + filters: Site search · Zone · Province · Pilot status (default: active) · Baseline status + M1 map: markers fitted to the filtered sites ──hover──► tooltip + └─click marker──► cross-filter: M3 Sites list shows that site (or the few under the click) + M3 Sites list ─────────────"Open site detail"──► ATOMA — Site Detail ?native_filters=(site preset) + M4/M5 Sites without an accepted location ──────"Open site detail"──► same +ATOMA — Site Detail (slug atoma-site-detail) + filters: Site (exactly one, searchable) · Viewing period + tabs: Summary · Fuel & Deliveries · Runtime & Electricity · Baseline & Evidence + header link "← Sites map" +``` + +## 3. Sites Map (landing dashboard) + +``` +[Filter bar: Site search · Zone · Province · Pilot status · Baseline status] +[M1 Map (deck.gl Scatterplot) ................................ w8][M2 Status key + attribution w4] +[M3 Sites list (table with link column) ..................................................... w12] +[M4 Sites without accepted location: count w3][M5 Sites without accepted location (table) .. w9] +``` + +| # | Question | Type | Dataset | Content | Missing or insufficient | +|---|---|---|---|---|---| +| M1 | Where are the sites, and what is each one's baseline status? | deck.gl Scatterplot; lat/lon from `latitude`, `longitude`; uniform size; colour = `baseline_status_label`; autozoom | v_site | tooltip: `site_code`, `site_name`, `lph_estimate` ("not available" when NULL), `lph_method`, `baseline_status_label`, `baseline_validity_note` (when set), `latest_record_on` | sites without an accepted location have NULL lat/lon and are not plotted; M4/M5 show them | +| M2 | What do the colours mean? | Markdown (static) | — | the status definitions below; "Colours show evidence status, not performance"; OSM attribution | — | +| M3 | Which sites match, and how do I open one? | Table; search box; server pagination | v_site | code, name, province, pilot status, baseline status, L/h (+ method), L/day, latest record, location status, `detail_link` | marker click narrows it; "not available" shown as text | +| M4 | How many sites lack an accepted location? | Big Number Total, COUNT(*) with saved filter `location_status <> 'accepted'` | v_site | count; follows the dashboard filters | 0 is shown as 0 | +| M5 | Which sites are they? | Table; search box | v_site, same saved filter | code, name, province, `location_status` (awaiting acceptance · missing), baseline status, `detail_link` | — | + +**Filters** (all on `v_site`, scoped to M1, M3, M4 and M5): +- Site search: Select on `site_label`, multi, searchable. The label holds code, name and uid, so + code or name both match. +- Zone, Province: Select. +- Pilot status: default `active`. +- Baseline status: Select on `baseline_status_label`. + +The cross-filter from M1 is scoped **to M3 only**. M4 and M5 hold the sites without coordinates and +must not be emptied by it. + +**Baseline status and marker colours.** Colours are neutral categorical hues, with no red or green +and no reference to L/h: + +| Status | Definition (`fm.v_site_baseline_status`) | Colour | +|---|---|---| +| Approved baseline | an approved `baseline_version` with a decision reference. The **only** state that says "approved" | teal #009688 | +| Eligible – not yet approved | no approval; the latest proposed KPI version has `meets_policy` (BP-1: ≥ 2 eligible G1 inputs, ≥ 60 matched days, no conditions, one population and metric) | blue #3987e5 | +| Provisional | no approval; a proposed version below BP-1, **or** usable intervals with no version yet (no estimate shown, method "no baseline version proposed") | amber #c98500 | +| Insufficient evidence | no usable interval. **Every pilot site while no runtime evidence exists** | slate #64748B | + +**Nearby sites.** +- Sites a few kilometres apart separate at zoom ≥ 11 (≈ 60 px per 2–4 km at zoom 12) and coincide + at country zoom (≈ 2 km per pixel at zoom 6). The pilot's own distances are in the design doc. +- Handling: + - autozoom to the filtered sites (the default "active" filter opens on the pilot area); + - small uniform markers (min = max radius, about 7 px) with an outline; + - a click near overlapping markers selects every site in the click box, and M3 lists them; + - no clustering. +- With hundreds of sites, a `deck_multi` density layer (screengrid) for low zoom is a later option. + +## 4. Site Detail (one dashboard, any site) + +**Filters** +- **Site** (`NATIVE_FILTER-site`): + - Select on `v_site.site_label`, single value, **required**, searchable, no default; + - scope: every chart on every tab; + - every detail dataset carries `site_label` from `v_site`, so this one filter drives all of + them *(local test)*. +- **Viewing period** (`NATIVE_FILTER-period`): + - Time range, default no filter; + - scope: the time-series charts on Fuel & Deliveries and Runtime & Electricity only; + - Summary and Baseline & Evidence are outside its scope, so a viewing range never changes a + baseline's fixed window. + +**Exactly one site, and never network totals:** +1. A required, single-value filter *(local test: with the value cleared, no chart queries + unfiltered data)*. +2. The header (D0) and KPI (S1) are Handlebars templates that render a site only when exactly one + row arrives. Otherwise they show "Select one site". +3. No Big Number or aggregate tile for site figures. They are computed per site in `v_site`, + never aggregated by Superset. +4. Fallback, if (1) fails its test: "Select first filter value by default", so the Site filter + is never empty. + +``` +[D0 Site header: code + name (large) · province · pilot status · baseline status · "← Sites map"] +Summary [S1 L/h KPI card w6][S2 Supporting measures w6] + [S3 Evidence coverage timeline (Gantt) w12] +Fuel & Deliveries [F1 Deliveries w6][F2 Recorded stock w6] + [F3 Fuel per calendar day by interval w12] + [F4 Interval table w12] +Runtime & Electricity[R1 Runtime per day by meter interval w6][R2 Hour-meter evidence w6] + [R3 Grid energy, kWh w6][R4 Electricity cost, AFN w6] +Baseline & Evidence [B1 Baseline versions w6][B2 Inputs of each version w6] + [B3 All intervals: eligibility and exclusions w12] + [B4 Interval L/h against baseline w12] + [B5 Assumptions w6][B6 Missing data and quality checks w6] + [B7 Evidence references w6][B8 Workbook calculations, assessed w6] + [B9 Stock against engine-measured fuel w6][B10 Equipment specifications w6] +``` + +| # | Question it answers | Type | Dataset | Dims / measures (units) | Grain | When data are missing or insufficient | +|---|---|---|---|---|---|---| +| D0 | Which site am I looking at? | Handlebars | v_site | code and name (large), province, pilot status, baseline status, location status, link "← Sites map" | site | "Select one site" unless exactly one row | +| S1 | What is the site's L/h, by which method, over which window, how strong? | Handlebars | v_site | `lph_estimate`, `lph_method`, `baseline_status_label`, `baseline_validity_note`, window, decision; L/day whole record | site | Keeps its position. **In v1 every site shows "Insufficient runtime evidence: no hour-meter readings"**, plus L/day labelled "per calendar day, not per running hour". "Eligible" and "Provisional" carry "not approved" | +| S2 | What supports it? | Table | v_kpi_measures | measure, value, unit, `value_kind` (recorded · calculated · assumed · not established), window. Two L/day rows: the whole record (moves with new records) and the L/day reference version (fixed window, its status and any input drift in the note) | site × measure | Rows stay and show "not established" | +| S3 | Which periods are covered by which evidence, and where are the gaps? | Gantt | v_coverage | lane; start/end; colour = status | span | Empty lanes are visible; nothing is interpolated | +| F1 | When and how much was delivered? | Bar (time axis, **day** grain) | v_delivery (one row per delivery event) | `delivery_date`; **SUM(`delivered_l`)**, titled "Litres delivered per day (all deliveries that day)"; tooltip COUNT(deliveries) | day | Bars only on delivery dates; subtitle "deliveries are not consumption"; F4 lists the individual deliveries | +| F2 | What stock was recorded? | Scatter (no lines) | v_stock_reading | `observed_on`; litres; series = reading type × `value_kind` | observation | Points only; nothing between readings | +| F3 | How much fuel was used per calendar day in each observed interval? | Bar (categorical x, ordered by start) | v_interval | `interval_label`; MAX(`fuel_l_per_day`) (L/d); colour = `stock_status` | stock interval | Excluded intervals greyed, with the reason in the tooltip; no monthly spreading | +| F4 | Interval detail | Table | v_interval | dates, opening, deliveries, closing, used, L/d, status | interval | Shows the reasons | +| R1 | How many hours did each generator run per day? | Bar (categorical) | v_runtime | interval × meter; MAX(h/day). h/day is NULL for an invalid pair (`pair_valid` false: two epochs, a decreasing timer or over 24 h/day); such pairs show no bar and are listed with their flags in the tooltip | meter interval | **v1: empty**, with "No hour-meter readings" in the description | +| R2 | What runtime evidence exists? | Table | v_hour_meter_evidence | meter, mapped set, date, hours, state, epoch | reading | **v1: no rows**, "No hour-meter readings" | +| R3 | How much grid energy did the site receive? | Handlebars → bar once kWh exist | v_kpi_measures (kWh row) | kWh per bill month | month | "No kWh records; bills are cost only and are not converted to fuel or hours" | +| R4 | What did grid electricity cost? | Bar (month) + table | v_electricity_cost | bill month; amount (AFN, inferred) | month | Own chart, never on a fuel axis. A disputed bill is listed without amount or kWh | +| B1 | Which baseline versions exist, and who approved them? | Table | v_baseline_version | code, status, value, window, grade, decision | version | "No baseline defined" | +| B2 | What went into each version? | Table | v_baseline_input | interval, included, reason, fuel, runtime, drift | version × interval | empty until a version exists | +| B3 | Which intervals are usable, and why not? | Table | v_interval | eligibility, exclusion reasons, grade, conditions, in current baseline | interval | v1: every interval excluded, "no timer reading at both ends" (and "a generator has neither a counter nor an idle assumption") | +| B4 | What L/h did each interval give, against the approved baseline? | Mixed: bars + line | v_interval | x `interval_label`; A: MAX(`l_per_h`) where eligibility ≠ excluded; B: MAX(`current_baseline_l_per_h`) (approved value only, and only beside intervals of the same metric and population, `baseline_comparable`) | interval | v1: no bars; description points to S1 | +| B5 | Which assumptions apply? | Table | v_assumption | code, topic, value and range, basis, status, rationale | assumption | "None recorded" | +| B6 | What is missing or inconsistent? | Table | v_data_quality | check, status, detail | check | Lists the gaps: no runtime, placeholder rows, no kWh, no transfer records, location awaiting acceptance | +| B7 | Which documents do the figures rest on? | Table | v_evidence | title, kind, issuer, date, used for | document | — | +| B8 | What did the operator workbook calculate, and is it usable? | Table | v_imported_calculation | label, formula, value, our reading, use, reason, `claim_status` | cell | Shows "excluded" with the reason for "actual L/h" and "CP L/h". A disputed transcription shows its status, without formula or value | +| B9 | Does stock depletion match engine-measured fuel? | Table | v_reconciliation | stock L, engine L, `covered_h` of `interval_h`, difference L and %. The window runs from the opening visit's departure to the closing visit's arrival. The difference is shown only for an `ok` stock interval with both visit times recorded, in which every generator is placed at the site throughout and covered for the whole window | interval × generator | "No flow-meter data yet"; partial coverage, unknown visit times or a mid-interval placement show the hours covered and no difference | +| B10 | What does the equipment specification say? | Table | v_spec_reference | attribute, value, unit, basis, applicability, `claim_status` | spec | Titled "Manufacturer and nameplate data, not site performance". A disputed specification shows its status, without value | + +Modelled L/h (M3) gets a table on Baseline & Evidence only if Q9 approves a runtime assumption. It +pools only the usable intervals inside that assumption's dates, and shows that window and its days. + +## 5. Datasets and filter mapping + +Physical datasets, one per `public.v_*` view on Trino `fuel.public`. Every dataset has the same +four site columns from `v_site`: `site_uid`, `site_code`, `site_name`, `site_label`. + +| Dataset | Grain | Used by | Native filters that apply | +|---|---|---|---| +| v_site | site | M1–M5, D0, S1 | landing: all five filters, plus the M1 cross-filter (on M3); detail: Site | +| v_kpi_measures | site × measure | S2, R3 | Site | +| v_coverage | site × lane × span | S3 | Site | +| v_delivery | delivery | F1 | Site, Viewing period | +| v_stock_reading | observation | F2 | Site, Viewing period | +| v_interval | stock interval | F3, F4, B3, B4 | Site; Viewing period on F3 and F4 only | +| v_runtime | meter interval | R1 | Site, Viewing period | +| v_hour_meter_evidence | reading | R2 | Site, Viewing period | +| v_electricity_cost | bill month | R4 | Site, Viewing period | +| v_baseline_version, v_baseline_input | version (× interval) | B1, B2 | Site | +| v_assumption, v_data_quality, v_evidence, v_imported_calculation, v_reconciliation, v_spec_reference | per view | B5–B10 | Site | + +**Metrics.** Saved metrics enforce pooling, with the same eligibility filter on numerator and +denominator, e.g. `pooled_l_per_h = SUM(CASE WHEN eligibility <> 'excluded' THEN fuel_used_l END) +/ NULLIF(SUM(CASE WHEN eligibility <> 'excluded' THEN runtime_h END), 0)`. A chart that uses it +is grouped or filtered by `metric` and `population_key` (both in `v_interval`), so it never pools +across methods or generator sets. The site's L/h figure itself comes only from a baseline version +(`v_site`), never from this metric. There is no AVG metric on ratio columns. + +**Link column.** `detail_link` is a calculated column on the `v_site` dataset, defined in the +bundle in Trino SQL. The native filter id stays inside Superset's own configuration: + +```sql +'Open site detail' +-- q = '''' || replace(replace(site_label, '!', '!!'), '''', '!''') || '''' -- rison string quoting +``` + +- The exact rison state shape is a *(local test)*. +- Fallback: the link opens Site Detail with the Site filter expanded (`expand_filters=1`) and the + site's label shown beside the link, to pick by search. + +## 6. Build as code, scale, access + +**Build** +- `petrolyst-insights/dashboards/atoma/build_bundle.py` writes one import bundle containing both + dashboards, every dataset and every chart: + - stable UUIDs (uuid5), so a re-import updates instead of duplicating; + - fixed native-filter ids (`NATIVE_FILTER-site`, `-period`, `-search`, …); + - `label_colors` for the four statuses; + - the existing "Fuel (Trino)" database UUID, read from an export first. +- Import: `superset import-dashboards`. + +**Adding a site** +- Import its `site.yaml` and its records. Nothing in either dashboard names a site. +- New sites appear in M1 (once their location is accepted) or in M5, in M3, and in the Site filter. + +**Hundreds of sites** +- `v_site` has one row per site, and the scatter layer handles thousands of points. +- The Select filters load the distinct labels; M3 and M5 paginate on the server. + +**Superset configuration change** (petrolyst-insights; image rebuild in P3): +`DECKGL_BASE_MAP = [["tile://https://tile.openstreetmap.org/{z}/{x}/{y}.png", "OpenStreetMap"]]`. +The CSP needs no change *(local test: no CSP violation in the browser console)*. + +**Access, internal first.** Both dashboards for Admin and Alpha only. No Gamma role until the +ATOMA-facing edit. + +## 7. Acceptance checks + +Run in P2 against a local Superset, and repeated as a smoke test in P3. + +1. **New site, no new objects.** Count dashboards, charts and datasets in the metadata database. + Then import a synthetic fourth site with records and an accepted location, and a fifth with no + location. The counts are unchanged; site 4 appears in M1, M3 and the Site filter; site 5 + appears in M5, and M4 goes up by 1. +2. **Site switching.** In Site Detail, select pilot site A, then site B. D0 shows the new code and + name; every chart's query (View query, or chart-data API) carries `site_label = ……`; no + chart keeps site A's rows. +3. **Clearing the selection.** With the Site filter cleared, no chart shows multi-site data; D0 and + S1 show "Select one site". If not, switch to the §4 fallback and re-test. +4. **Unavailable L/h.** For each pilot site: + - the tooltip and M3 say "not available" with the method "no runtime evidence"; + - S1 shows the insufficient-evidence state plus L/day; + - B3 shows every interval excluded, with its reason. +5. **Missing coordinates.** A site without an accepted location is not plotted, is listed in M5 + and counted in M4, and its link opens its detail. +6. **Filter propagation.** + - On the landing page, each filter changes M1, M3, M4 and M5, and the default "active" shows + exactly the active pilot sites. + - A marker click filters M3 only. + - In Site Detail, the Viewing period changes F1–F3 and R1, R2, R4, but not S1 or B1–B4. +7. **Detail links.** For each active pilot site, "Open site detail" in M3 opens Site Detail with + exactly that site selected, and D0 matches. Same from M5. +8. **Colours.** + - Fixtures in each status show the four colours. + - "Eligible" never renders as "Approved" unless the fixture has an approved version with a + decision reference. + - Marker size is identical across sites. +9. **Basemap.** OSM tiles load, there are no CSP violations in the console, and the attribution + is visible. diff --git a/stacks/fuel-monitoring/fuel-db/migrations/0001_fm_core.sql b/stacks/fuel-monitoring/fuel-db/migrations/0001_fm_core.sql new file mode 100644 index 0000000..013b1aa --- /dev/null +++ b/stacks/fuel-monitoring/fuel-db/migrations/0001_fm_core.sql @@ -0,0 +1,756 @@ +-- 0001_fm_core.sql: fuel-db schema `fm` (tables, constraints, trigger-backed rules). +-- DRAFT, not applied. Applied by hand by a loader acting as fuel_owner (README in this folder); +-- deploy.sh never touches fuel-db. Design and rationale: fuel-monitoring +-- .context/plans/2026-10-24-fuel-db-baseline-dashboard.md (sections cited as "design §n"). +-- Order: 0001, 0002, 0003, each in its own transaction. +BEGIN; +CREATE SCHEMA fm AUTHORIZATION fuel_owner; +CREATE EXTENSION IF NOT EXISTS btree_gist WITH SCHEMA fm; -- trusted extension; for EXCLUDE on text +-- Default GiST operator classes are found by type, independent of search_path; fm is put first +-- anyway, and the local test applies this file in a session whose search_path is the loader's default. +SET LOCAL search_path = fm, pg_catalog, public; + +-- Provenance ------------------------------------------------------------------------------ +CREATE TABLE fm.source_document ( + sha256 text PRIMARY KEY CHECK (sha256 ~ '^[0-9a-f]{64}$'), + source_family text NOT NULL, -- stable across versions: '-', 'site-yaml:SITE-0001' + legacy_key16 text UNIQUE CHECK (legacy_key16 ~ '^[0-9a-f]{16}$'), -- catalog.yaml key + kind text NOT NULL CHECK (kind IN ('delivery_form','site_logbook','operator_workbook', + 'operator_record','survey_workbook','photo','datasheet','mail','our_analysis')), + issuer text NOT NULL, -- organisation or role, never a person + title text NOT NULL, + document_date date, + corpus_path text, -- NULL when held outside the repository + transcription_path text, -- the corpus note that transcribes it + contains_personal_data boolean NOT NULL, + supersedes_sha256 text UNIQUE REFERENCES fm.source_document, + null_reasons jsonb NOT NULL DEFAULT '{}', + CHECK (corpus_path IS NOT NULL OR transcription_path IS NOT NULL) +); +CREATE TABLE fm.import_batch ( + import_batch_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + source_kind text NOT NULL CHECK (source_kind IN ('site_yaml','operator_workbook','catalog','review_manifest','baseline_run')), + source_path text NOT NULL, + source_sha256 text NOT NULL CHECK (source_sha256 ~ '^[0-9a-f]{64}$'), + corpus_commit text, + importer_version text NOT NULL, + started_at timestamptz NOT NULL DEFAULT now(), + finished_at timestamptz, + status text NOT NULL DEFAULT 'running' CHECK (status IN ('running','loaded','failed')), + report jsonb NOT NULL DEFAULT '{}' -- counts, unmapped paths, conflicts +); +CREATE TABLE fm.import_record ( + import_record_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + logical_key text NOT NULL, -- = source_key: ':delivery:SITE-0001:2026-01-31:1' + source_locator text NOT NULL, -- YAML path or sheet!range as read + payload jsonb NOT NULL, -- original values, strings as written, 'UNKNOWN' kept + payload_sha256 text NOT NULL, + first_batch_id bigint NOT NULL REFERENCES fm.import_batch, + last_seen_batch_id bigint NOT NULL REFERENCES fm.import_batch, + UNIQUE (logical_key, payload_sha256) +); + +-- Identity -------------------------------------------------------------------------------- +CREATE TABLE fm.site ( + site_uid text PRIMARY KEY CHECK (site_uid ~ '^SITE-[0-9]{4}$'), + name text, name_on_plate text, + zone text NOT NULL, province text NOT NULL, district text, + timezone text NOT NULL DEFAULT 'Asia/Kabul', + import_record_id bigint NOT NULL REFERENCES fm.import_record, -- full site.yaml payload + null_reasons jsonb NOT NULL DEFAULT '{}' +); +CREATE TABLE fm.site_location ( -- coordinates are claims; the map uses only an accepted one + location_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + source_key text NOT NULL, -- 'site-yaml:SITE-0001:coordinates' + assertion_sha256 text NOT NULL CHECK (assertion_sha256 ~ '^[0-9a-f]{64}$'), -- site, lat, lon, precision, basis + site_uid text NOT NULL REFERENCES fm.site, + latitude numeric(9,6) NOT NULL CHECK (latitude BETWEEN -90 AND 90), + longitude numeric(9,6) NOT NULL CHECK (longitude BETWEEN -180 AND 180), + precision_m integer, -- e.g. 110 where the source rounds to 0.001 deg + basis text NOT NULL CHECK (basis IN ('operator_plate','operator_workbook','field_gps','geocoded')), + source_note text NOT NULL, -- 'operator site plate photo ' + acceptance text NOT NULL DEFAULT 'pending' CHECK (acceptance IN ('pending','accepted','rejected','superseded','retracted')), + accepted_on date, accepted_by_role text, acceptance_ref text, -- copied from the matching fm.review row + superseded_by bigint REFERENCES fm.site_location, + import_record_id bigint NOT NULL REFERENCES fm.import_record, + CHECK (acceptance <> 'accepted' OR (accepted_on IS NOT NULL AND accepted_by_role IS NOT NULL)), + CHECK (acceptance <> 'superseded' OR superseded_by IS NOT NULL) +); +CREATE UNIQUE INDEX site_location_live_key ON fm.site_location (source_key) + WHERE acceptance IN ('pending','accepted','rejected'); +CREATE UNIQUE INDEX site_location_one_accepted ON fm.site_location (site_uid) WHERE acceptance = 'accepted'; +CREATE TABLE fm.site_alias ( + site_alias_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + site_uid text NOT NULL REFERENCES fm.site, + label text NOT NULL, issuer text NOT NULL, + valid_from date, valid_to date, -- NULL from = unknown start; NULL to = still valid + is_primary boolean NOT NULL DEFAULT false, + null_reasons jsonb NOT NULL DEFAULT '{}', + CHECK (valid_from IS NOT NULL OR null_reasons ? 'valid_from'), + EXCLUDE USING gist (issuer WITH =, label WITH =, + daterange(coalesce(valid_from,'-infinity'::date), valid_to, '[)') WITH &&), + EXCLUDE USING gist (site_uid WITH =, issuer WITH =, + daterange(coalesce(valid_from,'-infinity'::date), valid_to, '[)') WITH &&) WHERE (is_primary) +); +CREATE TABLE fm.pilot_participation ( + site_uid text NOT NULL REFERENCES fm.site, + programme text NOT NULL DEFAULT 'ATOMA OFMS pilot', + status text NOT NULL CHECK (status IN ('active','reference','candidate','withdrawn')), + valid_from date NOT NULL, valid_to date, + decision_ref text, note text, + EXCLUDE USING gist (site_uid WITH =, programme WITH =, daterange(valid_from, valid_to, '[)') WITH &&) +); +CREATE TABLE fm.asset ( + asset_uid text PRIMARY KEY CHECK (asset_uid ~ '^AST-[A-Z0-9-]+$'), + kind text NOT NULL CHECK (kind IN ('generator','tank')), + make text, model text, serial text, year_of_manufacture smallint, + import_record_id bigint NOT NULL REFERENCES fm.import_record, + null_reasons jsonb NOT NULL DEFAULT '{}' +); +CREATE TABLE fm.asset_placement ( + placement_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + asset_uid text NOT NULL REFERENCES fm.asset, + site_uid text NOT NULL REFERENCES fm.site, + slot_label text NOT NULL, -- 'GEN-01': a position label, not an identity + occupied_from date, occupied_to date, -- NULL from = before any record (conservative) + earliest_evidence_on date, + installed_on date, + null_reasons jsonb NOT NULL DEFAULT '{}', + CHECK (installed_on IS NOT NULL OR null_reasons ? 'installed_on'), + EXCLUDE USING gist (asset_uid WITH =, + daterange(coalesce(occupied_from,'-infinity'::date), occupied_to, '[)') WITH &&), + EXCLUDE USING gist (site_uid WITH =, slot_label WITH =, + daterange(coalesce(occupied_from,'-infinity'::date), occupied_to, '[)') WITH &&) +); +CREATE TABLE fm.runtime_meter ( + meter_id text PRIMARY KEY, -- ours, stable: 'MTR-SITE-0001-GENSET1' + site_uid text NOT NULL REFERENCES fm.site, + source_label text NOT NULL, -- as written: 'Genset 1', 'R.H' + asset_uid text REFERENCES fm.asset, + mapping_status text NOT NULL CHECK (mapping_status IN ('confirmed','proposed','unmapped')), + mapping_basis text, + UNIQUE (site_uid, source_label), + CHECK ((mapping_status = 'unmapped') = (asset_uid IS NULL)) +); +CREATE TABLE fm.meter_epoch ( + epoch_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + meter_id text NOT NULL REFERENCES fm.runtime_meter, + epoch_no smallint NOT NULL, + starts_on date, -- NULL: began before the first reading held + start_reason text NOT NULL CHECK (start_reason IN ('first_seen','display_replaced','controller_replaced','reset','set_replaced')), + continuity text NOT NULL CHECK (continuity IN ('confirmed','assumed','uncertain')), + rollover_at numeric(9,1), + source_sha256 text REFERENCES fm.source_document, note text, + UNIQUE (meter_id, epoch_no) +); + +-- Operations (claims) --------------------------------------------------------------------- +CREATE TABLE fm.visit ( + visit_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + visit_key text NOT NULL UNIQUE, -- 'SITE-0001:2026-01-31:1' + site_uid text NOT NULL REFERENCES fm.site, + visit_on date NOT NULL, -- site-local calendar date + seq_in_day smallint NOT NULL DEFAULT 1, + time_in_local time, time_out_local time, -- as written; NULL when the source has a date only + kind text NOT NULL CHECK (kind IN ('delivery','stock_check','maintenance','survey')), + -- descriptive only: views use fm.v_visit_live and the live claims + UNIQUE (site_uid, visit_on, seq_in_day), + UNIQUE (visit_id, site_uid) -- target of site-checked composite foreign keys +); +-- A visit is never deleted. Once all its claims are superseded or retracted it stays for provenance +-- only, and drops out of every analytical view (fm.v_visit_live). +-- Claim columns, repeated in each claim table: +-- source_key, assertion_sha256, source_sha256, record_ref, import_record_id, claim_status, +-- superseded_by, review_note, null_reasons +CREATE TABLE fm.fuel_movement ( + movement_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + source_key text NOT NULL, -- ':delivery:SITE-0001:2026-01-31:1' + assertion_sha256 text NOT NULL CHECK (assertion_sha256 ~ '^[0-9a-f]{64}$'), + event_key text NOT NULL, -- the physical movement: 'SITE-0001:2026-01-31:1:delivery:1'; + -- claims sharing it are alternative reports of ONE movement + visit_id bigint NOT NULL REFERENCES fm.visit, + site_uid text NOT NULL REFERENCES fm.site, + kind text NOT NULL CHECK (kind IN ('delivery','external_in','internal_transfer', + 'withdrawal','drain','spill','loss_reported')), + from_tank_uid text REFERENCES fm.asset, to_tank_uid text REFERENCES fm.asset, + source_tank_label text, + litres numeric(8,1) CHECK (litres >= 0), + litres_state text NOT NULL CHECK (litres_state IN ('stated','converted_from_level','converted_from_mass','not_recorded','illegible')), + density_kg_per_l numeric(5,3), + unit_price numeric(8,2), price_currency text, + source_sha256 text NOT NULL REFERENCES fm.source_document, + record_ref text NOT NULL, -- 'form ' | '!J20' | 'logbook p2 r1' + import_record_id bigint NOT NULL REFERENCES fm.import_record, + claim_status text NOT NULL DEFAULT 'asserted' CHECK (claim_status IN ('asserted','reviewed','disputed','superseded','retracted')), + superseded_by bigint REFERENCES fm.fuel_movement, + review_note text, + null_reasons jsonb NOT NULL DEFAULT '{}', + CHECK ((litres IS NULL) = (litres_state IN ('not_recorded','illegible'))), + CHECK (claim_status <> 'superseded' OR superseded_by IS NOT NULL), + CHECK (kind <> 'internal_transfer' OR (from_tank_uid IS NOT NULL AND to_tank_uid IS NOT NULL)) +); +CREATE UNIQUE INDEX fuel_movement_live_key ON fm.fuel_movement (source_key) + WHERE claim_status IN ('asserted','reviewed','disputed'); +CREATE INDEX ON fm.fuel_movement (visit_id, event_key); +CREATE TABLE fm.stock_observation ( + stock_obs_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + source_key text NOT NULL, + assertion_sha256 text NOT NULL CHECK (assertion_sha256 ~ '^[0-9a-f]{64}$'), + visit_id bigint NOT NULL REFERENCES fm.visit, + site_uid text NOT NULL REFERENCES fm.site, + scope text NOT NULL CHECK (scope IN ('site_total','tank')), + tank_uid text REFERENCES fm.asset, -- only when the source identifies the tank + source_tank_label text, -- 'T1' as written, even when unmapped + phase text NOT NULL CHECK (phase IN ('before_delivery','after_delivery','standalone')), + level_cm numeric(6,1), + litres numeric(8,1), + litres_basis text NOT NULL CHECK (litres_basis IN ('gauge_converted_by_source','stated_without_level', + 'stated_total','level_only','not_recorded','illegible','placeholder_zero')), + resolution_l numeric(6,1), -- ±1 cm of dip at this level, from tank geometry + resolution_basis text, + source_sha256 text NOT NULL REFERENCES fm.source_document, + record_ref text NOT NULL, + import_record_id bigint NOT NULL REFERENCES fm.import_record, + claim_status text NOT NULL DEFAULT 'asserted' CHECK (claim_status IN ('asserted','reviewed','disputed','superseded','retracted')), + superseded_by bigint REFERENCES fm.stock_observation, + review_note text, + null_reasons jsonb NOT NULL DEFAULT '{}', + CHECK (scope = 'site_total' OR tank_uid IS NOT NULL OR source_tank_label IS NOT NULL), + CHECK ((litres IS NULL) = (litres_basis IN ('level_only','not_recorded','illegible','placeholder_zero'))), + CHECK (claim_status <> 'superseded' OR superseded_by IS NOT NULL) +); +CREATE UNIQUE INDEX stock_obs_live_key ON fm.stock_observation (source_key) + WHERE claim_status IN ('asserted','reviewed','disputed'); +CREATE INDEX ON fm.stock_observation (visit_id, phase); +CREATE TABLE fm.hour_meter_reading ( + reading_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + source_key text NOT NULL, + assertion_sha256 text NOT NULL CHECK (assertion_sha256 ~ '^[0-9a-f]{64}$'), + visit_id bigint NOT NULL REFERENCES fm.visit, + meter_id text NOT NULL REFERENCES fm.runtime_meter, + epoch_id bigint REFERENCES fm.meter_epoch, -- NULL = not yet judged; excluded from runtime + source_field text NOT NULL CHECK (source_field IN ('current','previous','logbook_rh','display_photo')), + date_basis text NOT NULL CHECK (date_basis IN ('stated','inferred_previous_visit','capture_date_unknown')), + hours numeric(9,1), + resolution_h numeric(4,1) NOT NULL DEFAULT 1.0, + reading_state text NOT NULL CHECK (reading_state IN ('clear','best_reading','ambiguous','illegible','crossed_out','not_recorded')), + alternatives text, -- competing readings of one illegible value, '|'-separated + transcribed_by text NOT NULL CHECK (transcribed_by IN ('source_operator','our_reading')), + source_sha256 text NOT NULL REFERENCES fm.source_document, + record_ref text NOT NULL, + import_record_id bigint NOT NULL REFERENCES fm.import_record, + claim_status text NOT NULL DEFAULT 'asserted' CHECK (claim_status IN ('asserted','reviewed','disputed','superseded','retracted')), + superseded_by bigint REFERENCES fm.hour_meter_reading, + review_note text, + null_reasons jsonb NOT NULL DEFAULT '{}', + CHECK ((hours IS NULL) = (reading_state IN ('ambiguous','illegible','crossed_out','not_recorded'))), + CHECK (claim_status <> 'superseded' OR superseded_by IS NOT NULL) +); +CREATE UNIQUE INDEX hour_reading_live_key ON fm.hour_meter_reading (source_key) + WHERE claim_status IN ('asserted','reviewed','disputed'); +CREATE INDEX ON fm.hour_meter_reading (visit_id, meter_id); +CREATE TABLE fm.runtime_interval ( -- future: controller / ATS logs, telemetry + runtime_interval_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + source_key text NOT NULL, -- live-unique, as for every claim + assertion_sha256 text NOT NULL CHECK (assertion_sha256 ~ '^[0-9a-f]{64}$'), + site_uid text NOT NULL REFERENCES fm.site, + asset_uid text REFERENCES fm.asset, + scope text NOT NULL CHECK (scope IN ('generator','site_any_generator')), + started_at timestamptz NOT NULL, ended_at timestamptz NOT NULL CHECK (ended_at > started_at), + source_kind text NOT NULL CHECK (source_kind IN ('controller_event_log','ats_log','gateway_telemetry','manual_log')), + source_sha256 text REFERENCES fm.source_document, + claim_status text NOT NULL DEFAULT 'asserted' CHECK (claim_status IN ('asserted','reviewed','disputed','superseded','retracted')), + superseded_by bigint REFERENCES fm.runtime_interval, + CHECK ((scope = 'generator') = (asset_uid IS NOT NULL)), + CHECK (claim_status <> 'superseded' OR superseded_by IS NOT NULL), + EXCLUDE USING gist (site_uid WITH =, scope WITH =, (coalesce(asset_uid,'')) WITH =, source_kind WITH =, + tstzrange(started_at, ended_at) WITH &&) WHERE (claim_status IN ('asserted','reviewed')) +); +CREATE UNIQUE INDEX runtime_interval_live_key ON fm.runtime_interval (source_key) + WHERE claim_status IN ('asserted','reviewed','disputed'); +CREATE TABLE fm.engine_fuel_total ( -- future: flow-meter net fuel per period + total_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + source_key text NOT NULL, -- live-unique, as for every claim + assertion_sha256 text NOT NULL CHECK (assertion_sha256 ~ '^[0-9a-f]{64}$'), + asset_uid text NOT NULL REFERENCES fm.asset, + started_at timestamptz NOT NULL, ended_at timestamptz NOT NULL CHECK (ended_at > started_at), + net_fuel_l numeric(10,2) NOT NULL, -- feed − return as the device reports + engine_hours numeric(8,2), -- same device, if reported + source_kind text NOT NULL CHECK (source_kind IN ('differential_flow_meter','single_flow_meter','controller_fuel_counter')), + source_sha256 text REFERENCES fm.source_document, + claim_status text NOT NULL DEFAULT 'asserted' CHECK (claim_status IN ('asserted','reviewed','disputed','superseded','retracted')), + superseded_by bigint REFERENCES fm.engine_fuel_total, + CHECK (claim_status <> 'superseded' OR superseded_by IS NOT NULL), + EXCLUDE USING gist (asset_uid WITH =, tstzrange(started_at, ended_at) WITH &&) + WHERE (claim_status IN ('asserted','reviewed')) +); +CREATE UNIQUE INDEX engine_fuel_total_live_key ON fm.engine_fuel_total (source_key) + WHERE claim_status IN ('asserted','reviewed','disputed'); +CREATE TABLE fm.electricity_bill ( + bill_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + source_key text NOT NULL, + assertion_sha256 text NOT NULL CHECK (assertion_sha256 ~ '^[0-9a-f]{64}$'), + site_uid text NOT NULL REFERENCES fm.site, + bill_month date NOT NULL CHECK (extract(day FROM bill_month) = 1), -- month as the source assigns it + period_start date, period_end date, + amount numeric(12,2), + currency text, currency_basis text CHECK (currency_basis IN ('stated','inferred')), + kwh numeric(10,1), + source_sha256 text NOT NULL REFERENCES fm.source_document, + record_ref text NOT NULL, + import_record_id bigint NOT NULL REFERENCES fm.import_record, + claim_status text NOT NULL DEFAULT 'asserted' CHECK (claim_status IN ('asserted','reviewed','disputed','superseded','retracted')), + superseded_by bigint REFERENCES fm.electricity_bill, + null_reasons jsonb NOT NULL DEFAULT '{}', + CHECK (kwh IS NOT NULL OR null_reasons ? 'kwh'), + CHECK (period_start IS NOT NULL OR null_reasons ? 'period_start') +); +CREATE UNIQUE INDEX bill_live_key ON fm.electricity_bill (source_key) + WHERE claim_status IN ('asserted','reviewed','disputed'); + +-- Specifications, assumptions, imported calculations ------------------------------------- +CREATE TABLE fm.equipment_spec ( -- versioned like a claim: corrections supersede + spec_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + source_key text NOT NULL, -- 'site-yaml:SITE-0001:AST-…:rated_prime_kw' + assertion_sha256 text NOT NULL CHECK (assertion_sha256 ~ '^[0-9a-f]{64}$'), + asset_uid text NOT NULL REFERENCES fm.asset, + attribute text NOT NULL, -- rated_prime_kw, capacity_l, fuel_rate_at_load, … + load_pct numeric(5,1), + value_num numeric(12,3), value_text text, unit text, + basis text NOT NULL CHECK (basis IN ('nameplate','manufacturer_datasheet','derived_from_datasheet', + 'operator_stated','our_inference','geometry')), + applicability text NOT NULL DEFAULT 'confirmed' CHECK (applicability IN ('confirmed','conditional','unconfirmed')), + applicability_note text, + source_sha256 text REFERENCES fm.source_document, + import_record_id bigint NOT NULL REFERENCES fm.import_record, + claim_status text NOT NULL DEFAULT 'asserted' CHECK (claim_status IN ('asserted','reviewed','disputed','superseded','retracted')), + superseded_by bigint REFERENCES fm.equipment_spec, + CHECK (claim_status <> 'superseded' OR superseded_by IS NOT NULL), + CHECK ((attribute IN ('fuel_rate_at_load','specific_fuel_at_load')) = (load_pct IS NOT NULL)) +); +CREATE UNIQUE INDEX equipment_spec_live_key ON fm.equipment_spec (source_key) + WHERE claim_status IN ('asserted','reviewed','disputed'); +CREATE TABLE fm.assumption ( -- one row per version; a change is a new version + assumption_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + code text NOT NULL UNIQUE, -- 'A-SITE-0001-GEN02-IDLE-v1' + fingerprint text NOT NULL CHECK (fingerprint ~ '^[0-9a-f]{64}$'), -- all fields below + sorted members + created_xact xid8 NOT NULL DEFAULT pg_current_xact_id(), -- rule 9: members only in this transaction + site_uid text NOT NULL REFERENCES fm.site, + asset_uid text REFERENCES fm.asset, + topic text NOT NULL CHECK (topic IN ('runtime_hours_per_day','generator_idle','meter_coverage', + 'tank_linkage','no_unrecorded_movements','density','other')), + -- meter_coverage: its members name the exact counters, + -- the sets they cover together and the idle sets + value_num numeric(10,3), low_num numeric(10,3), high_num numeric(10,3), unit text, + applies_from date, applies_to date, + basis text NOT NULL CHECK (basis IN ('operator_stated','our_assumption','evidence_based')), + derived_from_fuel boolean NOT NULL, -- true: never a runtime input to an L/h figure + status text NOT NULL CHECK (status IN ('proposed','accepted','rejected','superseded')), + accepted_on date, accepted_by_role text, decision_ref text, -- copied from the matching fm.review row + supersedes_id bigint UNIQUE REFERENCES fm.assumption, + rationale text NOT NULL, contradicted_by text, + source_sha256 text REFERENCES fm.source_document, + CHECK (status <> 'accepted' OR (accepted_on IS NOT NULL AND accepted_by_role IS NOT NULL)), + CONSTRAINT assumption_no_runtime_from_fuel -- rule 5 + CHECK (NOT (derived_from_fuel AND topic = 'runtime_hours_per_day' AND status = 'accepted')), + CHECK (topic <> 'generator_idle' OR asset_uid IS NOT NULL), + CHECK (topic <> 'meter_coverage' OR asset_uid IS NULL), -- its sets are in assumption_member + EXCLUDE USING gist (site_uid WITH =, daterange(applies_from, applies_to, '[]') WITH &&) + WHERE (topic = 'meter_coverage' AND status IN ('proposed','accepted')), + EXCLUDE USING gist (asset_uid WITH =, daterange(applies_from, applies_to, '[]') WITH &&) + WHERE (topic = 'generator_idle' AND status IN ('proposed','accepted')) +); +CREATE TABLE fm.assumption_member ( -- exact membership of a meter_coverage assumption + assumption_id bigint NOT NULL REFERENCES fm.assumption, + member_role text NOT NULL CHECK (member_role IN ('counter','covered_generator','idle_generator')), + meter_id text REFERENCES fm.runtime_meter, + asset_uid text REFERENCES fm.asset, + CHECK ((member_role = 'counter') = (meter_id IS NOT NULL)), + CHECK ((member_role = 'counter') = (asset_uid IS NULL)) +); +CREATE UNIQUE INDEX assumption_member_meter ON fm.assumption_member (assumption_id, meter_id) WHERE meter_id IS NOT NULL; +CREATE UNIQUE INDEX assumption_member_asset ON fm.assumption_member (assumption_id, asset_uid) WHERE asset_uid IS NOT NULL; + -- a set is either covered or idle within one assumption +CREATE TABLE fm.imported_calculation ( -- versioned like a claim: a revised formula supersedes + calc_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + source_key text NOT NULL, -- ':calc:SITE-0001: