Files
petrolyst-infra/stacks/fuel-monitoring/fuel-db/migrations/0003_bi_views.sql
T
Nasim SadatandClaude Opus 5.5 bf96edc828 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 <noreply@anthropic.com>
2026-10-24 09:58:14 +02:00

622 lines
44 KiB
PL/PgSQL

-- 0003_bi_views.sql: the views Superset reads through Trino (schema `public`), plus
-- fm.v_site_baseline_status. Owned by fuel_owner, so trino_ro can read them through the existing
-- default privileges; they run with the owner's rights, so Trino never reads `fm` (it lists fm's
-- table names, as it lists every schema's, but any query on them is refused).
-- DRAFT, not applied. Applied after 0002 by a loader acting as fuel_owner (README in this folder).
-- Design: fuel-monitoring .context/plans/2026-10-24-fuel-db-baseline-dashboard.md, §5.2 and §6.
BEGIN;
SET LOCAL search_path = fm, pg_catalog, public;
-- Status, estimate and method come from ONE baseline_version, never from ad-hoc pooling of intervals
-- (exploratory interval figures stay on the Baseline & Evidence tab):
-- approved : the latest approved KPI version; a decision is recorded. The only state that says "approved".
-- If its inputs changed since, it stays approved and baseline_validity_note says so.
-- eligible : no approval; the latest proposed KPI version passed BP-1 when `baseline propose`
-- computed it from its inputs (meets_policy), and those inputs are unchanged today.
-- provisional : no approval; a proposed version below BP-1, or usable intervals but no version yet
-- (then no estimate is shown).
-- insufficient_evidence: none of the above (today: every site, since there is no runtime).
CREATE VIEW fm.v_site_baseline_status AS
WITH cur AS (
SELECT DISTINCT ON (b.site_uid) b.*
FROM fm.baseline_version b
WHERE b.status IN ('approved','proposed')
AND b.metric IN ('measured_generator_l_per_h','apparent_l_per_generator_h',
'apparent_l_per_set_h','apparent_l_per_site_dg_h') -- never modelled, never per-day
ORDER BY b.site_uid, (b.status = 'approved') DESC, b.approved_on DESC NULLS LAST, b.computed_at DESC),
ev AS (
SELECT site_uid, count(*) FILTER (WHERE eligibility <> 'excluded') AS n_usable
FROM fm.v_lph_interval GROUP BY site_uid)
SELECT s.site_uid,
CASE WHEN cur.status = 'approved' THEN 'approved'
WHEN cur.status = 'proposed' AND cur.meets_policy AND val.inputs_unchanged THEN 'eligible'
WHEN cur.status = 'proposed' OR coalesce(ev.n_usable, 0) > 0 THEN 'provisional'
ELSE 'insufficient_evidence' END AS baseline_status,
CASE WHEN cur.baseline_id IS NOT NULL AND NOT coalesce(val.inputs_unchanged, false)
THEN CASE cur.status WHEN 'approved' THEN 'Inputs changed since approval: review needed'
ELSE 'Inputs changed since computation: re-propose' END END AS baseline_validity_note,
cur.code AS baseline_code,
CASE WHEN cur.status = 'approved' OR val.inputs_unchanged THEN cur.value END AS lph_estimate,
CASE WHEN cur.status = 'approved' OR val.inputs_unchanged THEN cur.range_low END AS range_low,
CASE WHEN cur.status = 'approved' OR val.inputs_unchanged THEN cur.range_high END AS range_high,
cur.metric AS lph_metric, cur.population, cur.window_start AS lph_window_start,
cur.window_end AS lph_window_end, cur.conditions, cur.decision_ref,
coalesce(ev.n_usable, 0) AS n_usable_intervals
FROM fm.site s LEFT JOIN cur USING (site_uid) LEFT JOIN ev USING (site_uid)
LEFT JOIN fm.v_baseline_validity val ON val.baseline_id = cur.baseline_id;
-- One row per site, every status: the map dataset and the single source of site-level fields.
-- "Current" means the period that contains today's date in Kabul, not "no end date".
CREATE VIEW public.v_site AS
WITH today AS (SELECT (now() AT TIME ZONE 'Asia/Kabul')::date AS d),
alias AS ( -- v1: the operator is ATOMA; later: the site's current operator
SELECT a.site_uid, a.label FROM fm.site_alias a, today
WHERE a.issuer = 'ATOMA' AND a.is_primary
AND daterange(coalesce(a.valid_from, '-infinity'::date), a.valid_to, '[)') @> today.d),
pp AS ( -- one programme, chosen explicitly
SELECT p.site_uid, p.status FROM fm.pilot_participation p, today
WHERE p.programme = 'ATOMA OFMS pilot' AND daterange(p.valid_from, p.valid_to, '[)') @> today.d),
-- alias and pp are at most one row per site: the exclusion constraints forbid overlapping primary
-- labels per (site, issuer) and overlapping periods per (site, programme).
loc AS (SELECT site_uid, latitude, longitude, basis, precision_m FROM fm.site_location WHERE acceptance = 'accepted'),
loc_any AS (SELECT site_uid, bool_or(acceptance = 'pending') AS has_pending FROM fm.site_location GROUP BY site_uid),
cal AS (SELECT site_uid, sum(consumed_l) AS fuel_l, sum(days) AS days
FROM fm.v_stock_interval WHERE stock_status = 'ok' GROUP BY site_uid),
latest AS (SELECT site_uid, max(visit_on) AS latest_record_on -- live records only: a fully
FROM fm.v_visit_live GROUP BY site_uid) -- retracted visit does not count
SELECT s.site_uid::varchar(12) AS site_uid,
coalesce(alias.label, s.site_uid)::varchar(20) AS site_code,
coalesce(s.name, 'name not recorded')::varchar(120) AS site_name,
(coalesce(alias.label, s.site_uid) || ' - ' || coalesce(s.name, 'name not recorded')
|| ' - ' || s.site_uid)::varchar(170) AS site_label, -- the one filter value
s.zone::varchar(40) AS zone, s.province::varchar(40) AS province, s.district::varchar(60) AS district,
coalesce(pp.status, 'none')::varchar(12) AS pilot_status,
loc.latitude::numeric(9,6) AS latitude, loc.longitude::numeric(9,6) AS longitude, -- NULL unless accepted
(CASE WHEN loc.site_uid IS NOT NULL THEN 'accepted'
WHEN loc_any.has_pending THEN 'awaiting acceptance' ELSE 'missing' END)::varchar(20) AS location_status,
loc.basis::varchar(20) AS location_basis, loc.precision_m::integer AS location_precision_m,
bs.baseline_status::varchar(24) AS baseline_status,
(CASE bs.baseline_status WHEN 'approved' THEN 'Approved baseline'
WHEN 'eligible' THEN 'Eligible - not yet approved' WHEN 'provisional' THEN 'Provisional'
ELSE 'Insufficient evidence' END)::varchar(40) AS baseline_status_label,
bs.baseline_validity_note::varchar(60) AS baseline_validity_note,
round(bs.lph_estimate, 2)::numeric(6,2) AS lph_estimate,
(CASE bs.lph_metric WHEN 'apparent_l_per_generator_h' THEN 'Stock balance per generator running hour'
WHEN 'apparent_l_per_set_h' THEN 'Stock balance per summed generator-hour'
WHEN 'apparent_l_per_site_dg_h' THEN 'Stock balance per hour with any generator running'
WHEN 'measured_generator_l_per_h' THEN 'Measured engine fuel per running hour'
ELSE CASE WHEN bs.n_usable_intervals > 0 THEN 'Not available: no baseline version proposed'
ELSE 'Not available: no runtime evidence' END END)::varchar(60) AS lph_method,
bs.lph_window_start, bs.lph_window_end, bs.conditions::varchar(200) AS baseline_conditions,
bs.decision_ref::varchar(12) AS baseline_decision_ref,
round(cal.fuel_l / NULLIF(cal.days, 0), 1)::numeric(7,1) AS fuel_l_per_calendar_day,
latest.latest_record_on
FROM fm.site s
LEFT JOIN alias USING (site_uid) LEFT JOIN loc USING (site_uid) LEFT JOIN loc_any USING (site_uid)
LEFT JOIN pp USING (site_uid) LEFT JOIN fm.v_site_baseline_status bs USING (site_uid)
LEFT JOIN cal USING (site_uid) LEFT JOIN latest USING (site_uid);
-- Every view below joins public.v_site for site_uid, site_code, site_name and site_label, so one
-- Site filter drives every dataset, and casts to Trino-safe types (varchar(n), integer,
-- numeric(p,s), date, timestamp(0), boolean). Live = asserted, reviewed or disputed.
-- S2, R3: one row per site and measure; the rows stay when a value is not established.
CREATE VIEW public.v_kpi_measures AS
WITH cur AS ( -- the site's L/h version; its snapshot only while approved or its inputs unchanged
SELECT bs.site_uid, b.baseline_id, b.code, b.fuel_l, b.runtime_h, b.matched_days, b.window_start, b.window_end,
(b.status = 'approved' OR coalesce(val.inputs_unchanged, false)) AS shown,
' (' || b.status || ')'
|| CASE WHEN coalesce(val.inputs_unchanged, false) THEN ''
WHEN b.status = 'approved' THEN '; inputs changed since approval: review needed'
ELSE '; inputs changed since computation: re-propose' END AS state
FROM fm.v_site_baseline_status bs JOIN fm.baseline_version b ON b.code = bs.baseline_code
LEFT JOIN fm.v_baseline_validity val ON val.baseline_id = b.baseline_id),
ninp AS (SELECT baseline_id, count(*) FILTER (WHERE included) AS n FROM fm.baseline_input GROUP BY baseline_id),
m4 AS (SELECT site_uid, sum(consumed_l) AS fuel_l, sum(days) AS days, min(start_on) AS first_on, max(end_on) AS last_on
FROM fm.v_m4_interval WHERE eligibility = 'eligible' GROUP BY site_uid),
span AS (SELECT site_uid, min(visit_on) AS first_on, max(visit_on) AS last_on FROM fm.v_visit_live GROUP BY site_uid),
del AS (SELECT v.site_uid, count(*) AS n FROM fm.v_movement_event e JOIN fm.v_visit_live v ON v.visit_id = e.visit_id
WHERE e.kind = 'delivery' GROUP BY v.site_uid),
rdg AS (SELECT m.site_uid, count(*) AS n FROM fm.hour_meter_reading h JOIN fm.runtime_meter m ON m.meter_id = h.meter_id
WHERE h.claim_status IN ('asserted','reviewed','disputed') GROUP BY m.site_uid),
kwh AS (SELECT site_uid, sum(kwh) AS kwh, count(kwh) AS n_kwh, min(bill_month) AS first_m, max(bill_month) AS last_m
FROM fm.electricity_bill WHERE claim_status IN ('asserted','reviewed') GROUP BY site_uid), -- values never from disputed
lday AS ( -- the L/day reference: the latest approved, else proposed, L/day version
SELECT DISTINCT ON (b.site_uid) b.site_uid, b.code, b.status, b.value, b.window_start, b.window_end,
coalesce(val.inputs_unchanged, false) AS inputs_unchanged
FROM fm.baseline_version b LEFT JOIN fm.v_baseline_validity val ON val.baseline_id = b.baseline_id
WHERE b.metric = 'fuel_l_per_calendar_day' AND b.status IN ('approved','proposed')
ORDER BY b.site_uid, (b.status = 'approved') DESC, b.approved_on DESC NULLS LAST, b.computed_at DESC)
SELECT s.site_uid, s.site_code, s.site_name, s.site_label,
x.ord::integer AS measure_order, x.measure::varchar(60) AS measure,
round(x.value, 2)::numeric(12,2) AS value, x.unit::varchar(12) AS unit,
(CASE WHEN x.value IS NULL THEN 'not established' ELSE x.kind END)::varchar(16) AS value_kind,
x.window_start, x.window_end, x.note::varchar(200) AS note
FROM public.v_site s
LEFT JOIN cur ON cur.site_uid = s.site_uid
LEFT JOIN ninp ON ninp.baseline_id = cur.baseline_id
LEFT JOIN m4 ON m4.site_uid = s.site_uid
LEFT JOIN span ON span.site_uid = s.site_uid
LEFT JOIN del ON del.site_uid = s.site_uid
LEFT JOIN rdg ON rdg.site_uid = s.site_uid
LEFT JOIN kwh ON kwh.site_uid = s.site_uid
LEFT JOIN lday ON lday.site_uid = s.site_uid
CROSS JOIN LATERAL (VALUES
(1, 'Matched fuel', CASE WHEN cur.shown THEN cur.fuel_l END, 'L', 'calculated', cur.window_start, cur.window_end,
'fuel used in the intervals of L/h baseline version ' || cur.code || cur.state),
(2, 'Matched runtime', CASE WHEN cur.shown THEN cur.runtime_h END, 'h', 'calculated', cur.window_start, cur.window_end,
'generator running hours in the same intervals' || coalesce(cur.state, '')),
(3, 'Runtime per day', CASE WHEN cur.shown THEN cur.runtime_h / NULLIF(cur.matched_days, 0) END, 'h/day', 'calculated',
cur.window_start, cur.window_end, 'matched runtime / matched days' || coalesce(cur.state, '')),
(4, 'Intervals used', CASE WHEN cur.shown THEN ninp.n::numeric END, 'intervals', 'calculated', cur.window_start, cur.window_end,
'included inputs of the L/h baseline version' || coalesce(cur.state, '')),
(5, 'Fuel per calendar day, whole record', m4.fuel_l / NULLIF(m4.days, 0), 'L/day', 'calculated',
m4.first_on, m4.last_on, 'stock balance over all usable intervals / calendar days; not per running hour'),
(6, 'L/day reference (baseline version)',
CASE WHEN lday.status = 'approved' OR lday.inputs_unchanged THEN lday.value END, 'L/day', 'calculated',
lday.window_start, lday.window_end,
CASE WHEN lday.code IS NULL THEN 'no L/day baseline version'
ELSE 'version ' || lday.code || ' (' || lday.status || '), fixed window; not per running hour'
|| CASE WHEN lday.inputs_unchanged THEN ''
WHEN lday.status = 'approved' THEN '; inputs changed since approval: review needed'
ELSE '; inputs changed since computation: re-propose' END END),
(7, 'Days covered by usable stock intervals', m4.days::numeric, 'days', 'calculated', m4.first_on, m4.last_on,
'compare with the record span'),
(8, 'Record span', (span.last_on - span.first_on)::numeric, 'days', 'recorded', span.first_on, span.last_on,
'first to last live record'),
(9, 'Deliveries recorded', coalesce(del.n, 0)::numeric, 'deliveries', 'recorded', span.first_on, span.last_on,
'one per delivery event'),
(10, 'Timer readings', coalesce(rdg.n, 0)::numeric, 'readings', 'recorded', NULL::date, NULL::date,
CASE WHEN coalesce(rdg.n, 0) = 0 THEN 'no hour-meter readings' END),
(11, 'Grid energy', kwh.kwh, 'kWh', 'recorded', kwh.first_m, kwh.last_m,
CASE WHEN coalesce(kwh.n_kwh, 0) = 0
THEN 'no kWh records; bills are cost only and are not converted to fuel or hours' END)
) AS x(ord, measure, value, unit, kind, window_start, window_end, note);
-- R2: timer readings as recorded (no rows in v1).
CREATE VIEW public.v_hour_meter_evidence AS
SELECT s.site_uid, s.site_code, s.site_name, s.site_label,
m.source_label::varchar(40) AS meter_label,
(CASE WHEN m.asset_uid IS NULL THEN 'unmapped'
ELSE m.asset_uid || ' (' || m.mapping_status || ')' END)::varchar(60) AS mapped_set,
v.visit_on AS reading_date, h.date_basis::varchar(30) AS date_basis,
round(h.hours, 1)::numeric(9,1) AS hours, h.reading_state::varchar(16) AS reading_state,
h.source_field::varchar(16) AS source_field, e.epoch_no::integer AS epoch_no,
h.claim_status::varchar(12) AS claim_status
FROM fm.hour_meter_reading h
JOIN fm.runtime_meter m ON m.meter_id = h.meter_id
JOIN fm.visit v ON v.visit_id = h.visit_id
JOIN public.v_site s ON s.site_uid = m.site_uid
LEFT JOIN fm.meter_epoch e ON e.epoch_id = h.epoch_id
WHERE h.claim_status IN ('asserted','reviewed','disputed');
-- B5: assumption versions in force or proposed; a coverage assumption lists its members.
CREATE VIEW public.v_assumption AS
SELECT s.site_uid, s.site_code, s.site_name, s.site_label,
x.code::varchar(60) AS code, x.topic::varchar(30) AS topic, x.asset_uid::varchar(40) AS asset_uid,
x.value_num::numeric(10,3) AS value, x.low_num::numeric(10,3) AS value_low,
x.high_num::numeric(10,3) AS value_high, x.unit::varchar(20) AS unit,
x.applies_from, x.applies_to, x.basis::varchar(20) AS basis, x.derived_from_fuel,
x.status::varchar(12) AS status, x.accepted_on, x.decision_ref::varchar(12) AS decision_ref,
x.rationale::varchar(400) AS rationale, x.contradicted_by::varchar(200) AS contradicted_by,
mem.members::varchar(400) AS members
FROM fm.assumption x
JOIN public.v_site s ON s.site_uid = x.site_uid
LEFT JOIN LATERAL (
SELECT string_agg(m.member_role || ': ' || coalesce(m.meter_id, m.asset_uid), '; '
ORDER BY m.member_role, coalesce(m.meter_id, m.asset_uid)) AS members
FROM fm.assumption_member m WHERE m.assumption_id = x.assumption_id) mem ON true
WHERE x.status <> 'superseded';
-- B1: baseline versions, with whether their inputs are unchanged today.
CREATE VIEW public.v_baseline_version AS
SELECT s.site_uid, s.site_code, s.site_name, s.site_label,
b.code::varchar(60) AS code, b.metric::varchar(40) AS metric, b.status::varchar(12) AS status,
b.population::varchar(200) AS population,
round(b.value, 3)::numeric(8,3) AS value, round(b.range_low, 3)::numeric(8,3) AS range_low,
round(b.range_high, 3)::numeric(8,3) AS range_high,
(CASE WHEN b.metric = 'fuel_l_per_calendar_day' THEN 'L/day' ELSE 'L/h' END)::varchar(8) AS unit,
b.window_start, b.window_end, round(b.fuel_l, 1)::numeric(10,1) AS fuel_l,
round(b.runtime_h, 1)::numeric(10,1) AS runtime_h, b.matched_days::integer AS matched_days,
b.policy_version::varchar(10) AS policy_version, b.meets_policy, b.policy_check::varchar(300) AS policy_check,
b.evidence_grade::varchar(2) AS evidence_grade, b.conditions::varchar(200) AS conditions,
b.season_note::varchar(200) AS season_note, b.approved_on, b.approved_by_role::varchar(40) AS approved_by_role,
b.decision_ref::varchar(12) AS decision_ref, coalesce(val.inputs_unchanged, false) AS inputs_unchanged,
(b.computed_at AT TIME ZONE 'Asia/Kabul')::timestamp(0) AS computed_at
FROM fm.baseline_version b
JOIN public.v_site s ON s.site_uid = b.site_uid
LEFT JOIN fm.v_baseline_validity val ON val.baseline_id = b.baseline_id;
-- B2: the inputs of each version, with dependency drift since computation.
CREATE VIEW public.v_baseline_input AS
SELECT s.site_uid, s.site_code, s.site_name, s.site_label,
b.code::varchar(60) AS baseline_code, b.status::varchar(12) AS baseline_status,
(to_char(bi.start_on, 'YYYY-MM-DD') || ' to ' || to_char(bi.end_on, 'YYYY-MM-DD'))::varchar(30) AS interval_label,
bi.start_on, bi.end_on, bi.included, bi.exclusion_reason::varchar(300) AS exclusion_reason,
round(bi.fuel_l, 1)::numeric(10,1) AS fuel_l, round(bi.runtime_h, 1)::numeric(10,1) AS runtime_h,
bi.days::integer AS days, bi.eligibility_at_computation::varchar(12) AS eligibility_at_computation,
bi.evidence_grade_at_computation::varchar(2) AS evidence_grade_at_computation,
(d.interval_key IS NULL OR d.stock_dep_sha256 <> bi.stock_dep_sha256
OR (b.metric <> 'fuel_l_per_calendar_day'
AND d.runtime_dep_sha256 IS DISTINCT FROM bi.runtime_dep_sha256)) AS dependencies_changed
FROM fm.baseline_input bi
JOIN fm.baseline_version b ON b.baseline_id = bi.baseline_id
JOIN public.v_site s ON s.site_uid = bi.site_uid
LEFT JOIN fm.v_interval_dependency d ON d.open_visit_id = bi.open_visit_id AND d.close_visit_id = bi.close_visit_id;
-- B7: documents the site's figures rest on (the document itself is never served).
CREATE VIEW public.v_evidence AS
WITH cites AS (
SELECT site_uid, source_sha256, 'fuel movement' AS record_type
FROM fm.fuel_movement WHERE claim_status IN ('asserted','reviewed','disputed')
UNION SELECT site_uid, source_sha256, 'stock reading'
FROM fm.stock_observation WHERE claim_status IN ('asserted','reviewed','disputed')
UNION SELECT m.site_uid, h.source_sha256, 'timer reading'
FROM fm.hour_meter_reading h JOIN fm.runtime_meter m ON m.meter_id = h.meter_id
WHERE h.claim_status IN ('asserted','reviewed','disputed')
UNION SELECT site_uid, source_sha256, 'electricity bill'
FROM fm.electricity_bill WHERE claim_status IN ('asserted','reviewed','disputed')
UNION SELECT site_uid, source_sha256, 'imported calculation'
FROM fm.imported_calculation WHERE site_uid IS NOT NULL AND claim_status IN ('asserted','reviewed','disputed')
UNION SELECT p.site_uid, e.source_sha256, 'equipment specification'
FROM fm.equipment_spec e JOIN fm.asset_placement p ON p.asset_uid = e.asset_uid
WHERE e.source_sha256 IS NOT NULL AND e.claim_status IN ('asserted','reviewed','disputed')
UNION SELECT site_uid, source_sha256, 'assumption'
FROM fm.assumption WHERE source_sha256 IS NOT NULL AND status <> 'superseded')
SELECT s.site_uid, s.site_code, s.site_name, s.site_label,
left(d.sha256, 16)::varchar(16) AS document_key, d.title::varchar(200) AS title,
d.kind::varchar(20) AS kind, d.issuer::varchar(80) AS issuer, d.document_date,
string_agg(c.record_type, ', ' ORDER BY c.record_type)::varchar(200) AS used_for,
d.contains_personal_data
FROM cites c
JOIN fm.source_document d ON d.sha256 = c.source_sha256
JOIN public.v_site s ON s.site_uid = c.site_uid
GROUP BY s.site_uid, s.site_code, s.site_name, s.site_label,
d.sha256, d.title, d.kind, d.issuer, d.document_date, d.contains_personal_data;
-- F1, F4: one row per delivery event; quantity NULL = not recorded. No monthly sums.
CREATE VIEW public.v_delivery AS
SELECT s.site_uid, s.site_code, s.site_name, s.site_label,
e.event_key::varchar(80) AS event_key, v.visit_on AS delivery_date, v.seq_in_day::integer AS seq_in_day,
round(e.litres, 1)::numeric(8,1) AS delivered_l,
b.unit_price::numeric(8,2) AS unit_price, b.price_currency::varchar(8) AS currency,
b.record_ref::varchar(60) AS source_ref,
e.n_claims::integer AS n_sources, (e.spread_l > 0) AS sources_disagree, e.all_reviewed AS reviewed
FROM fm.v_movement_event e
JOIN fm.v_visit_live v ON v.visit_id = e.visit_id
JOIN public.v_site s ON s.site_uid = v.site_uid
CROSS JOIN LATERAL ( -- price and reference of the best-ranked claim;
-- a disputed claim gives the reference only, never a price
SELECT CASE WHEN m.claim_status <> 'disputed' THEN m.unit_price END AS unit_price,
CASE WHEN m.claim_status <> 'disputed' THEN m.price_currency END AS price_currency, m.record_ref
FROM fm.fuel_movement m JOIN fm.v_source_rank r ON r.sha256 = m.source_sha256
WHERE m.event_key = e.event_key AND m.claim_status IN ('asserted','reviewed','disputed')
ORDER BY (m.claim_status = 'disputed'), r.rank, m.movement_id LIMIT 1) b
WHERE e.kind = 'delivery';
-- F2: stock readings as recorded, plus after-delivery points derived as before + delivered.
CREATE VIEW public.v_stock_reading AS
SELECT s.site_uid, s.site_code, s.site_name, s.site_label,
v.visit_on AS observed_on, v.seq_in_day::integer AS seq_in_day,
(CASE st.phase WHEN 'before_delivery' THEN 'before delivery' WHEN 'after_delivery' THEN 'after delivery'
ELSE 'stock check' END)::varchar(20) AS reading_type,
round(st.litres, 1)::numeric(8,1) AS litres, 'recorded'::varchar(12) AS value_kind,
round(st.spread_l, 1)::numeric(8,1) AS spread_l, st.all_reviewed AS reviewed
FROM fm.v_visit_stock st
JOIN fm.v_visit_live v ON v.visit_id = st.visit_id
JOIN public.v_site s ON s.site_uid = v.site_uid
UNION ALL
SELECT s.site_uid, s.site_code, s.site_name, s.site_label,
p.visit_on, p.seq_in_day::integer, 'after delivery'::varchar(20),
round(p.depart_l, 1)::numeric(8,1), 'calculated'::varchar(12),
round(p.depart_spread_l, 1)::numeric(8,1), p.depart_reviewed
FROM fm.v_stock_point p
JOIN public.v_site s ON s.site_uid = p.site_uid
WHERE p.depart_basis = 'derived';
-- F3, F4, B3, B4: one row per stock interval, with its L/h evaluation. The approved baseline is set
-- beside an interval only when both have the same metric and population (B4 compares like with like).
CREATE VIEW public.v_interval AS
WITH cur AS (SELECT bs.site_uid, bs.baseline_code, bs.baseline_status, bs.lph_estimate, b.metric, b.population_key
FROM fm.v_site_baseline_status bs LEFT JOIN fm.baseline_version b ON b.code = bs.baseline_code),
inb AS ( -- the inputs of each site's KPI version
SELECT bi.open_visit_id, bi.close_visit_id, bi.included
FROM fm.baseline_input bi
JOIN fm.baseline_version b ON b.baseline_id = bi.baseline_id
JOIN cur ON cur.baseline_code = b.code),
cond AS ( -- assumptions a valid interval depends on
SELECT l.interval_key, string_agg(DISTINCT x.code, ', ') AS codes
FROM fm.v_lph_interval l
JOIN fm.assumption x ON x.site_uid = l.site_uid AND x.status IN ('proposed','accepted')
AND x.topic IN ('generator_idle','meter_coverage')
AND daterange(x.applies_from, x.applies_to, '[]') @> daterange(l.start_on, l.end_on, '[]')
WHERE l.population_valid
GROUP BY l.interval_key)
SELECT s.site_uid, s.site_code, s.site_name, s.site_label,
(to_char(l.start_on, 'YYYY-MM-DD') || ' to ' || to_char(l.end_on, 'YYYY-MM-DD'))::varchar(30) AS interval_label,
l.start_on, l.end_on, l.days::integer AS days,
round(l.opening_l, 1)::numeric(9,1) AS opening_l, l.opening_basis::varchar(8) AS opening_basis,
round(l.deliveries_between_l, 1)::numeric(9,1) AS deliveries_l,
round(l.out_l, 1)::numeric(9,1) AS withdrawals_l,
round(l.closing_l, 1)::numeric(9,1) AS closing_l, l.closing_basis::varchar(8) AS closing_basis,
round(l.consumed_l, 1)::numeric(9,1) AS fuel_used_l, round(l.consumed_unc_l, 1)::numeric(9,1) AS fuel_used_unc_l,
round(l.consumed_l / NULLIF(l.days, 0), 1)::numeric(8,1) AS fuel_l_per_day,
l.stock_status::varchar(40) AS stock_status,
round(l.runtime_h, 1)::numeric(9,1) AS runtime_h,
round(l.runtime_h / NULLIF(l.days, 0), 2)::numeric(6,2) AS runtime_h_per_day,
coalesce(l.n_meters, 0)::integer AS n_meters, l.metric::varchar(40) AS metric,
l.population_key::varchar(200) AS population_key, -- pool only within one metric and population
round(l.l_per_h, 3)::numeric(8,3) AS l_per_h, round(l.l_per_h_low, 3)::numeric(8,3) AS l_per_h_low,
round(l.l_per_h_high, 3)::numeric(8,3) AS l_per_h_high,
l.eligibility::varchar(12) AS eligibility, nullif(l.exclusion_reasons, '')::varchar(600) AS exclusion_reasons,
l.evidence_grade::varchar(2) AS evidence_grade, cond.codes::varchar(200) AS conditions,
coalesce(inb.included, false) AS in_current_baseline,
coalesce(cur.baseline_status = 'approved' AND l.metric = cur.metric
AND l.population_key = cur.population_key, false) AS baseline_comparable,
(CASE WHEN cur.baseline_status = 'approved' AND l.metric = cur.metric
AND l.population_key = cur.population_key
THEN cur.lph_estimate END)::numeric(8,3) AS current_baseline_l_per_h
FROM fm.v_lph_interval l
JOIN public.v_site s ON s.site_uid = l.site_uid
JOIN cur ON cur.site_uid = l.site_uid
LEFT JOIN inb ON inb.open_visit_id = l.open_visit_id AND inb.close_visit_id = l.close_visit_id
LEFT JOIN cond ON cond.interval_key = l.interval_key;
-- R1: hours per day by meter interval (no rows in v1). Every pair is listed with its flags; runtime
-- and h/day only for a valid pair (one epoch, not decreasing, at most 24 h per day).
CREATE VIEW public.v_runtime AS
SELECT s.site_uid, s.site_code, s.site_name, s.site_label,
m.source_label::varchar(40) AS meter_label,
(CASE WHEN m.asset_uid IS NULL THEN 'unmapped'
ELSE m.asset_uid || ' (' || m.mapping_status || ')' END)::varchar(60) AS mapped_set,
(m.source_label || ': ' || to_char(r.start_on, 'YYYY-MM-DD') || ' to '
|| to_char(r.end_on, 'YYYY-MM-DD'))::varchar(80) AS interval_label,
r.start_on, r.end_on, r.days::integer AS days,
(CASE WHEN v.pair_valid THEN round(r.runtime_h, 1) END)::numeric(9,1) AS runtime_h,
(CASE WHEN v.pair_valid THEN round(r.h_per_day, 2) END)::numeric(6,2) AS h_per_day,
v.pair_valid, r.same_epoch, r.monotonic, r.over_24h_per_day, r.reviewed
FROM fm.v_runtime_pair r
CROSS JOIN LATERAL (SELECT coalesce(r.same_epoch AND r.monotonic AND NOT r.over_24h_per_day, false) AS pair_valid) v
JOIN fm.runtime_meter m ON m.meter_id = r.meter_id
JOIN public.v_site s ON s.site_uid = r.site_uid;
-- S3 (Gantt): which periods each kind of evidence covers; Kabul midnight as timestamp(0).
CREATE VIEW public.v_coverage AS
WITH spans AS (
SELECT site_uid, '1 Stock intervals' AS lane, stock_status AS status, start_on, end_on
FROM fm.v_stock_interval
UNION ALL
SELECT r.site_uid, '2 Runtime: ' || m.source_label,
CASE WHEN r.same_epoch AND r.monotonic AND NOT r.over_24h_per_day THEN 'ok' ELSE 'flagged' END,
r.start_on, r.end_on
FROM fm.v_runtime_pair r JOIN fm.runtime_meter m ON m.meter_id = r.meter_id
UNION ALL
SELECT site_uid, '3 Baseline window', baseline_status, lph_window_start, lph_window_end
FROM fm.v_site_baseline_status WHERE lph_window_start IS NOT NULL
UNION ALL
SELECT site_uid, '4 Electricity bills',
CASE WHEN claim_status = 'disputed' THEN 'disputed' WHEN kwh IS NULL THEN 'cost only' ELSE 'kWh' END,
coalesce(period_start, bill_month), coalesce(period_end, (bill_month + interval '1 month')::date)
FROM fm.electricity_bill WHERE claim_status IN ('asserted','reviewed','disputed'))
SELECT s.site_uid, s.site_code, s.site_name, s.site_label,
x.lane::varchar(60) AS lane, x.status::varchar(40) AS status, x.start_on, x.end_on,
x.start_on::timestamp(0) AS start_ts, x.end_on::timestamp(0) AS end_ts
FROM spans x JOIN public.v_site s ON s.site_uid = x.site_uid;
-- B9: stock-balance fuel against engine-measured fuel inside the interval. No rows until flow-meter
-- data exist. The window runs from the opening visit's departure to the closing visit's arrival
-- (Kabul time) when both times are recorded, else from midnight to midnight. covered_h = the hours
-- of that window the generator's records cover (live records never overlap per asset). The
-- difference is shown only for an `ok` stock interval with both times recorded, in which every
-- generator is placed at the site throughout (one placement spans the interval, as in
-- fm.v_lph_interval) and covered for the whole window.
CREATE VIEW public.v_reconciliation AS
WITH gen AS ( -- one row per interval and generator placed at the site at any time in it
SELECT l.interval_key, l.site_uid, l.start_on, l.end_on, l.consumed_l, l.stock_status, p.asset_uid,
bool_or(daterange(coalesce(p.occupied_from, '-infinity'::date), p.occupied_to, '[)')
@> daterange(l.start_on, l.end_on, '[]')) AS placed_throughout,
(o.time_out_local IS NOT NULL AND c.time_in_local IS NOT NULL) AS times_known,
(l.start_on + coalesce(o.time_out_local, '00:00'::time)) AT TIME ZONE 'Asia/Kabul' AS t0,
(l.end_on + coalesce(c.time_in_local, '00:00'::time)) AT TIME ZONE 'Asia/Kabul' AS t1
FROM fm.v_stock_interval l
JOIN fm.visit o ON o.visit_id = l.open_visit_id
JOIN fm.visit c ON c.visit_id = l.close_visit_id
JOIN fm.asset_placement p ON p.site_uid = l.site_uid
JOIN fm.asset a ON a.asset_uid = p.asset_uid AND a.kind = 'generator'
WHERE daterange(coalesce(p.occupied_from, '-infinity'::date), p.occupied_to, '[)')
&& daterange(l.start_on, l.end_on, '[]')
GROUP BY l.interval_key, l.site_uid, l.start_on, l.end_on, l.consumed_l, l.stock_status, p.asset_uid,
o.time_out_local, c.time_in_local),
eng AS (
SELECT g.interval_key, g.asset_uid, sum(t.net_fuel_l) AS engine_l,
sum(extract(epoch FROM t.ended_at - t.started_at)) / 3600 AS covered_h,
sum(extract(epoch FROM t.ended_at - t.started_at)) >= extract(epoch FROM g.t1 - g.t0) AS full_cover
FROM gen g JOIN fm.engine_fuel_total t ON t.asset_uid = g.asset_uid
AND t.claim_status IN ('asserted','reviewed')
AND t.started_at >= g.t0 AND t.ended_at <= g.t1
GROUP BY g.interval_key, g.asset_uid, g.t0, g.t1),
tot AS (
SELECT g.interval_key, count(*) AS n_generators,
count(*) FILTER (WHERE g.placed_throughout AND e.full_cover) AS n_complete, sum(e.engine_l) AS engine_all_l
FROM gen g LEFT JOIN eng e ON e.interval_key = g.interval_key AND e.asset_uid = g.asset_uid
GROUP BY g.interval_key)
SELECT s.site_uid, s.site_code, s.site_name, s.site_label,
(to_char(g.start_on, 'YYYY-MM-DD') || ' to ' || to_char(g.end_on, 'YYYY-MM-DD'))::varchar(30) AS interval_label,
g.start_on, g.end_on, g.stock_status::varchar(40) AS stock_status, g.times_known AS endpoint_times_known,
g.asset_uid::varchar(40) AS asset_uid, g.placed_throughout,
round(g.consumed_l, 1)::numeric(9,1) AS stock_balance_l, round(e.engine_l, 1)::numeric(9,1) AS engine_l,
round(e.covered_h, 1)::numeric(9,1) AS covered_h,
round(extract(epoch FROM g.t1 - g.t0) / 3600, 1)::numeric(9,1) AS interval_h,
round(t.engine_all_l, 1)::numeric(9,1) AS engine_all_generators_l,
(t.n_complete = t.n_generators) AS all_generators_metered,
(CASE WHEN t.n_complete = t.n_generators AND g.times_known AND g.stock_status = 'ok'
THEN round(g.consumed_l - t.engine_all_l, 1) END)::numeric(9,1) AS difference_l,
(CASE WHEN t.n_complete = t.n_generators AND g.times_known AND g.stock_status = 'ok'
THEN round(100 * (g.consumed_l - t.engine_all_l) / NULLIF(t.engine_all_l, 0), 1) END)::numeric(7,1) AS difference_pct
FROM gen g
JOIN eng e ON e.interval_key = g.interval_key AND e.asset_uid = g.asset_uid
JOIN tot t ON t.interval_key = g.interval_key
JOIN public.v_site s ON s.site_uid = g.site_uid;
-- B6: one row per site and check; rows stay when ok.
CREATE VIEW public.v_data_quality AS
WITH c AS (
SELECT s.site_uid,
(SELECT count(*) FROM fm.stock_observation o WHERE o.site_uid = s.site_uid
AND o.litres_basis = 'placeholder_zero' AND o.claim_status IN ('asserted','reviewed','disputed')) AS n_placeholder,
(SELECT count(*) FROM fm.v_movement_event e JOIN fm.visit v ON v.visit_id = e.visit_id
WHERE v.site_uid = s.site_uid AND e.spread_l > 0)
+ (SELECT count(*) FROM fm.v_visit_stock st JOIN fm.visit v ON v.visit_id = st.visit_id
WHERE v.site_uid = s.site_uid AND st.spread_l > 0) AS n_conflicts,
(SELECT count(*) FROM fm.hour_meter_reading h JOIN fm.runtime_meter m ON m.meter_id = h.meter_id
WHERE m.site_uid = s.site_uid AND h.claim_status IN ('asserted','reviewed','disputed')) AS n_readings,
(SELECT count(*) FROM fm.runtime_meter m WHERE m.site_uid = s.site_uid AND m.mapping_status <> 'confirmed') AS n_unmapped,
(SELECT count(*) FROM fm.asset_placement p JOIN fm.asset a ON a.asset_uid = p.asset_uid
WHERE p.site_uid = s.site_uid AND a.kind = 'generator'
AND daterange(coalesce(p.occupied_from, '-infinity'::date), p.occupied_to, '[)')
@> (now() AT TIME ZONE 'Asia/Kabul')::date
AND NOT EXISTS (SELECT 1 FROM fm.runtime_meter m JOIN fm.hour_meter_reading h ON h.meter_id = m.meter_id
WHERE m.asset_uid = p.asset_uid AND m.mapping_status = 'confirmed'
AND h.claim_status IN ('asserted','reviewed'))) AS n_gen_no_readings,
(SELECT count(*) FROM fm.fuel_movement x WHERE x.site_uid = s.site_uid AND x.claim_status = 'asserted')
+ (SELECT count(*) FROM fm.stock_observation x WHERE x.site_uid = s.site_uid AND x.claim_status = 'asserted')
+ (SELECT count(*) FROM fm.hour_meter_reading x JOIN fm.runtime_meter m ON m.meter_id = x.meter_id
WHERE m.site_uid = s.site_uid AND x.claim_status = 'asserted')
+ (SELECT count(*) FROM fm.electricity_bill x WHERE x.site_uid = s.site_uid AND x.claim_status = 'asserted') AS n_unreviewed,
(SELECT count(*) FROM fm.v_stock_point p WHERE p.site_uid = s.site_uid
AND abs(p.after_vs_before_plus_delivered_l) > 0.5) AS n_after_mismatch,
(SELECT count(*) FROM fm.v_stock_interval i WHERE i.site_uid = s.site_uid
AND i.stock_status IN ('negative','same_day')) AS n_neg_same_day,
(SELECT count(*) FROM fm.v_stock_interval i WHERE i.site_uid = s.site_uid
AND i.stock_status NOT IN ('ok','negative','same_day')) AS n_gaps,
(SELECT count(*) FROM fm.electricity_bill b WHERE b.site_uid = s.site_uid
AND b.claim_status IN ('asserted','reviewed','disputed')) AS n_bills,
(SELECT count(*) FROM fm.electricity_bill b WHERE b.site_uid = s.site_uid
AND (b.kwh IS NULL OR b.claim_status = 'disputed')
AND b.claim_status IN ('asserted','reviewed','disputed')) AS n_bills_no_kwh, -- no usable kWh
(SELECT count(*) FROM fm.fuel_movement m WHERE m.site_uid = s.site_uid
AND m.kind IN ('withdrawal','drain','spill','loss_reported','internal_transfer')
AND m.claim_status IN ('asserted','reviewed','disputed')) AS n_transfer_records,
(SELECT count(*) FROM fm.baseline_version b JOIN fm.v_baseline_validity v ON v.baseline_id = b.baseline_id
WHERE b.site_uid = s.site_uid AND b.status IN ('proposed','approved') AND NOT v.inputs_unchanged) AS n_drift,
(SELECT count(*) FROM fm.v_review_stale r WHERE r.site_uid = s.site_uid) AS n_stale,
(SELECT count(*) FROM fm.v_visit_provenance_only v WHERE v.site_uid = s.site_uid) AS n_provenance_only,
(SELECT count(*) FROM fm.v_lph_interval l WHERE l.site_uid = s.site_uid
AND l.exclusion_reasons LIKE '%confirmed for a generator not placed here%') AS n_unplaced_counter,
(SELECT count(*) FROM fm.v_lph_interval l WHERE l.site_uid = s.site_uid
AND l.exclusion_reasons LIKE '%meter-coverage assumption % does not name%') AS n_cov_mismatch
FROM fm.site s)
SELECT vs.site_uid, vs.site_code, vs.site_name, vs.site_label,
x.ord::integer AS check_order, x.code::varchar(40) AS check_code, x.label::varchar(80) AS label,
x.status::varchar(10) AS status, x.detail::varchar(300) AS detail
FROM c JOIN public.v_site vs ON vs.site_uid = c.site_uid
CROSS JOIN LATERAL (VALUES
(1, 'placeholder_rows', 'Placeholder rows',
CASE WHEN c.n_placeholder > 0 THEN 'warning' ELSE 'ok' END,
c.n_placeholder || ' row(s) imported as "no stock reading", never as 0'),
(2, 'claim_conflicts', 'Conflicting claims',
CASE WHEN c.n_conflicts > 0 THEN 'conflict' ELSE 'ok' END,
c.n_conflicts || ' movement or stock reading(s) with disagreeing sources'),
(3, 'runtime_evidence', 'Runtime evidence',
CASE WHEN c.n_readings = 0 THEN 'missing' ELSE 'ok' END,
CASE WHEN c.n_readings = 0 THEN 'no hour-meter readings: no L/h can be calculated'
ELSE c.n_readings || ' timer reading(s)' END),
(4, 'location', 'Location',
CASE vs.location_status WHEN 'accepted' THEN 'ok' WHEN 'awaiting acceptance' THEN 'warning' ELSE 'missing' END,
vs.location_status::text),
(5, 'unmapped_meters', 'Counters not tied to a generator',
CASE WHEN c.n_unmapped > 0 THEN 'warning' ELSE 'ok' END, c.n_unmapped || ' counter(s)'),
(6, 'generators_without_readings', 'Generators without timer readings',
CASE WHEN c.n_gen_no_readings > 0 THEN 'missing' ELSE 'ok' END, c.n_gen_no_readings || ' generator(s) placed now'),
(7, 'unreviewed_claims', 'Claims not yet reviewed against the originals',
CASE WHEN c.n_unreviewed > 0 THEN 'warning' ELSE 'ok' END, c.n_unreviewed || ' claim(s)'),
(8, 'after_vs_before_plus_delivered', 'Stated after-delivery stock differs from before + delivered',
CASE WHEN c.n_after_mismatch > 0 THEN 'conflict' ELSE 'ok' END, c.n_after_mismatch || ' visit(s)'),
(9, 'negative_or_same_day_intervals', 'Negative or same-day stock intervals',
CASE WHEN c.n_neg_same_day > 0 THEN 'warning' ELSE 'ok' END, c.n_neg_same_day || ' interval(s)'),
(10, 'interval_gaps', 'Stock intervals excluded for missing or unordered data',
CASE WHEN c.n_gaps > 0 THEN 'warning' ELSE 'ok' END, c.n_gaps || ' interval(s)'),
(11, 'no_kwh', 'Grid energy (kWh)',
CASE WHEN c.n_bills = 0 OR c.n_bills_no_kwh > 0 THEN 'missing' ELSE 'ok' END,
CASE WHEN c.n_bills = 0 THEN 'no electricity bills'
WHEN c.n_bills_no_kwh > 0 THEN c.n_bills_no_kwh || ' bill(s) without usable kWh (cost only or disputed)'
ELSE 'kWh on every bill' END),
(12, 'transfer_records', 'Transfer, drain or loss records (HD-05)',
CASE WHEN c.n_transfer_records = 0 THEN 'warning' ELSE 'ok' END,
CASE WHEN c.n_transfer_records = 0 THEN 'none recorded: every stock balance assumes no unrecorded movements'
ELSE c.n_transfer_records || ' record(s)' END),
(13, 'baseline_input_drift', 'Baseline inputs changed since computation',
CASE WHEN c.n_drift > 0 THEN 'warning' ELSE 'ok' END, c.n_drift || ' proposed or approved version(s)'),
(14, 'approval_status', 'Baseline approval',
CASE WHEN vs.baseline_status = 'approved' THEN 'ok' ELSE 'warning' END,
vs.baseline_status_label || coalesce(' - ' || vs.baseline_validity_note, '')),
(15, 'stale_reviews', 'Review entries for values changed since',
CASE WHEN c.n_stale > 0 THEN 'warning' ELSE 'ok' END, c.n_stale || ' entr(y/ies)'),
(16, 'provenance_only_visits', 'Visits kept for provenance only',
CASE WHEN c.n_provenance_only > 0 THEN 'warning' ELSE 'ok' END,
c.n_provenance_only || ' visit(s) whose records were all retracted or superseded'),
(17, 'counter_on_unplaced_generator', 'Counters confirmed for a generator not placed here',
CASE WHEN c.n_unplaced_counter > 0 THEN 'conflict' ELSE 'ok' END, c.n_unplaced_counter || ' interval(s)'),
(18, 'coverage_assumption_mismatch', 'Meter-coverage assumption does not match the counters read',
CASE WHEN c.n_cov_mismatch > 0 THEN 'conflict' ELSE 'ok' END, c.n_cov_mismatch || ' interval(s)')
) AS x(ord, code, label, status, detail);
-- B10: specifications, labelled as such; live rows only. A disputed specification is listed with its
-- status and without its value.
CREATE VIEW public.v_spec_reference AS
SELECT s.site_uid, s.site_code, s.site_name, s.site_label,
p.slot_label::varchar(20) AS slot, e.asset_uid::varchar(40) AS asset_uid, a.kind::varchar(10) AS asset_kind,
concat_ws(' ', a.make, a.model)::varchar(120) AS make_model,
e.attribute::varchar(40) AS attribute, e.load_pct::numeric(5,1) AS load_pct,
(CASE WHEN e.claim_status <> 'disputed' THEN e.value_num END)::numeric(12,3) AS value,
(CASE WHEN e.claim_status <> 'disputed' THEN e.value_text END)::varchar(120) AS value_text, e.unit::varchar(20) AS unit,
e.basis::varchar(30) AS basis, e.applicability::varchar(12) AS applicability,
e.applicability_note::varchar(300) AS applicability_note,
'specification, not measured'::varchar(30) AS value_kind, e.claim_status::varchar(12) AS claim_status
FROM fm.equipment_spec e
JOIN fm.asset a ON a.asset_uid = e.asset_uid
JOIN (SELECT DISTINCT ON (asset_uid, site_uid) asset_uid, site_uid, slot_label
FROM fm.asset_placement ORDER BY asset_uid, site_uid, occupied_from DESC NULLS LAST) p ON p.asset_uid = e.asset_uid
JOIN public.v_site s ON s.site_uid = p.site_uid
WHERE e.claim_status IN ('asserted','reviewed','disputed');
-- M3 sensitivity table, reference tab only (shown only once Q9 approves a runtime assumption).
CREATE VIEW public.v_modelled_lph AS
SELECT s.site_uid, s.site_code, s.site_name, s.site_label,
m.code::varchar(60) AS assumption_code, m.status::varchar(12) AS status, m.basis::varchar(20) AS basis,
m.h_per_day::numeric(5,2) AS h_per_day, m.h_per_day_low::numeric(5,2) AS h_per_day_low,
m.h_per_day_high::numeric(5,2) AS h_per_day_high, m.window_start, m.window_end,
m.matched_days::integer AS matched_days, round(m.l_per_day, 1)::numeric(7,1) AS l_per_day,
round(m.modelled_l_per_h, 2)::numeric(6,2) AS modelled_l_per_h,
round(m.modelled_l_per_h_low, 2)::numeric(6,2) AS modelled_l_per_h_low,
round(m.modelled_l_per_h_high, 2)::numeric(6,2) AS modelled_l_per_h_high,
'modelled: L per calendar day / assumed runtime; not measured'::varchar(70) AS value_kind
FROM fm.v_modelled_lph m JOIN public.v_site s ON s.site_uid = m.site_uid;
-- R4: electricity cost per bill month; cost context only, never joined into fuel metrics. A
-- disputed bill stays listed, without amount or kWh.
CREATE VIEW public.v_electricity_cost AS
SELECT s.site_uid, s.site_code, s.site_name, s.site_label,
b.bill_month, b.period_start, b.period_end, (b.period_start IS NOT NULL) AS period_known,
(CASE WHEN b.claim_status <> 'disputed' THEN b.amount END)::numeric(12,2) AS amount, b.currency::varchar(8) AS currency,
b.currency_basis::varchar(10) AS currency_basis,
(CASE WHEN b.claim_status <> 'disputed' THEN b.kwh END)::numeric(10,1) AS kwh,
b.record_ref::varchar(60) AS source_ref, b.claim_status::varchar(12) AS claim_status
FROM fm.electricity_bill b JOIN public.v_site s ON s.site_uid = b.site_uid
WHERE b.claim_status IN ('asserted','reviewed','disputed');
-- B8: operator workbook calculations and our assessment; live rows only. A disputed transcription is
-- listed with its status and without its formula or value.
CREATE VIEW public.v_imported_calculation AS
SELECT s.site_uid, s.site_code, s.site_name, s.site_label,
c.cell_ref::varchar(40) AS cell_ref, c.source_label::varchar(80) AS source_label,
(CASE WHEN c.claim_status <> 'disputed' THEN c.formula END)::varchar(200) AS formula,
(CASE WHEN c.claim_status <> 'disputed' THEN c.cached_value END)::numeric(14,6) AS cached_value,
c.unit_as_labelled::varchar(30) AS unit_as_labelled, c.our_reading::varchar(300) AS our_reading,
c.baseline_use::varchar(12) AS baseline_use, c.exclusion_reason::varchar(300) AS exclusion_reason,
left(c.source_sha256, 16)::varchar(16) AS document_key, c.claim_status::varchar(12) AS claim_status
FROM fm.imported_calculation c JOIN public.v_site s ON s.site_uid = c.site_uid
WHERE c.claim_status IN ('asserted','reviewed','disputed');
COMMIT;