-- =============================================================================
-- 002_fixtures.sql
--
--   ############################################################
--   ##  FIXTURE DATA.  NOT REAL PLANT HISTORY.  DO NOT QUOTE.  ##
--   ############################################################
--
-- Interim stand-in for `imh` (yau-sls-poc-imh), which is not built yet.
-- Everything in the `fixture` schema is generated. A number produced from
-- these tables is a test result about the pipeline, never a fact about the
-- station.
--
-- WHY THIS FILE WAS REWRITTEN (2026-08-31)
-- ----------------------------------------
-- The previous version was keyed on names delivered as PS_STN_* - which were
-- never CI Server names at all, but a proposal from the PLC register map. The
-- historian is keyed on CI Server ITEM names (AID.WRPS.*). Four namespaces
-- describe the same measurement and only the last is what `imh` stores:
--
--   LIT-101                instrument tag     WRPS/01-design-doc
--   %QW0                   PLC symbol         WRPS/04-plc/register-map.csv
--   AID.WRPS.STN.LEVEL  CI Server point    WRPS/05-scada/modbus/scada-points.csv
--   AID.WRPS.STN.LEVEL     CI Server ITEM     WRPS/05-scada/modbus/wrps_item_df.qli
--
-- Being keyed two layers off the real one is what produced all three of the
-- Phase 5 findings. They are not patched here; the substitution that caused
-- them is removed. See the notes on each below.
--
-- WHAT IS NOW TAKEN FROM THE SCADA CONFIGURATION RATHER THAN INVENTED
--   * item names, units, gains, sections   wrps_item_df.qli, scada-points.csv
--   * which items are historised at all    item_his.qli
--   * sample rates and retention           export_his_group.qli (the LIVE one)
--   * timestamp semantics                  wrps_modbus_point_df.qli
--
-- All of it is pinned into db/seed/historian_items.csv by
-- scripts/gen_historian_items.py and loaded into public.historian_items by
-- scripts/deploy.sh. THIS FILE HARDCODES NO ITEM NAME IT DID NOT GET FROM
-- THERE, and no sample interval at all.
--
-- THREE PROPERTIES OF THE REAL HISTORIAN THAT THIS ONE COPIES
-- ----------------------------------------------------------
-- 1. RETENTION IS SEVEN DAYS. Every WRPS history group carries
--    LIFE_TIME "1 weeks". The old fixtures generated thirty days, which is
--    why questions about last month worked here and would have failed on the
--    real thing. They now fail here too. That is the point: see REQUESTS.md,
--    which asks the historian owner to extend it, because a plant assistant
--    that cannot answer "last month" is of limited use.
--
-- 2. SAMPLES ARE REGULAR, NOT DEADBAND-COMPRESSED. Every group has
--    DATA_COMP = 0 and every item STORE_DEADBAND = 0, with
--    COL_STOR_TYPE "Scan/Time". The previous comment in
--    cube/model/process_values.yml warned that real history would be
--    irregular and that a plain average would therefore be biased. That is
--    true only of WRPS_EVENT, which is Event/Item and genuinely on-change.
--
-- 3. TIMESTAMPS ARE UTC. Not an assumption: all 49 Modbus points carry
--    TIME_ZONE "Date+time GMT" in wrps_modbus_point_df.qli, and every WRPS
--    history group carries CORRECT_DAYLIGHT = 0. Store UTC, convert once in
--    Cube. Finding (b) is fixed on that basis.
--
-- WHAT IS STILL A GUESS, AND MUST BE CONFIRMED AT THE PHASE 4 GATE
--   * the SQL Server table and column names `imh` exposes these items as.
--     The SHAPE below is now right - one item-keyed history table, alarms and
--     operations derived rather than stored - but the names are ours.
--   * whether `imh` exposes CI Server's built-in ALARM_HISTORY group at all.
--     It is configured on the server but every item imports with alarming OFF
--     and limits at 0 (05-scada/modbus/README.md), so it is empty. Alarms
--     here are derived from the alarm word instead, which needs no SCADA
--     configuration that does not exist.
--
-- >>> REMOVING THIS: db/README-standin-historian.md <<<
-- It carries the seam between generation and contract, an inventory of what to
-- delete versus repoint, and a table of TWELVE ASSUMPTIONS about imh that are
-- NOT confirmed - table names, column types, how quality is expressed, whether
-- values arrive in engineering units. Two of them (A5 quality codes, A10 the
-- 32767 sentinel) fail SILENTLY, producing a plausible wrong number rather than
-- an error. Do not connect imh without working through that table.
--
-- HOW IT IS SWITCHED OFF: USE_FIXTURES=true in ~/ai/api.env points Cube here.
-- Set it false and repoint CUBEJS_DB_* at imh. Every Cube model file carries a
-- fixture/real note at the top; check them all when you flip it.
--
-- Every fixture table carries an is_fixture column, defaulted TRUE, so a row
-- that reaches the UI can be traced back to here. The API surfaces it as a
-- banner. Do not remove it as tidying-up.
-- =============================================================================

DROP SCHEMA IF EXISTS fixture CASCADE;
CREATE SCHEMA fixture;
COMMENT ON SCHEMA fixture IS
  'GENERATED FIXTURE DATA standing in for imh. Not real plant history. Keyed on '
  'CI Server item names. See db/002_fixtures.sql.';

-- public.historian_items must be loaded before this file runs: it is what
-- says which items exist, which group each belongs to and how often each is
-- sampled. scripts/deploy.sh loads it with the other seeds.
DO $$
BEGIN
    IF (SELECT count(*) FROM public.historian_items) = 0 THEN
        RAISE EXCEPTION
          'public.historian_items is empty - load db/seed/historian_items.csv '
          'before running this file (scripts/deploy.sh does it for you)';
    END IF;
END $$;

-- =============================================================================
-- The build clock.
--
-- Everything is generated relative to one origin, held in a table rather than
-- recomputed from now() in each statement. Two reasons, and the second is the
-- one that bites: a statement-by-statement now() drifts within a long load, so
-- the level series and the alarms derived from it would be computed against
-- slightly different clocks and the derived alarm count would not be
-- reproducible. Truncating to the hour also makes every count below EXACT
-- rather than approximate, which is what lets this file assert them.
-- =============================================================================
CREATE TABLE fixture.build_meta (
    origin        TIMESTAMPTZ NOT NULL,   -- UTC, start of the retained window
    horizon_days  INT         NOT NULL,
    built_at      TIMESTAMPTZ NOT NULL DEFAULT now(),
    note          TEXT
);
INSERT INTO fixture.build_meta (origin, horizon_days, note)
SELECT date_trunc('hour', now()) - interval '7 days', 7,
       'Retention copied from CI Server: every WRPS history group is LIFE_TIME '
       '"1 weeks". Questions older than this return no rows, here and on imh.';

-- =============================================================================
-- The generating functions.
--
-- The plant is PRESCRIBED, not simulated: the wet well level is a closed-form
-- function of time and everything else is derived from it. That is deliberate.
-- A simulation produces numbers nobody can check by hand; this produces
-- numbers an engineer can verify with a calculator, which is the only reason
-- the assertions at the foot of this file mean anything.
--
-- The shape is a 140-minute pump-down cycle - 55 minutes filling on inflow,
-- 85 minutes drawing down - riding on a diurnal inflow, with a wet-weather
-- event in the middle of the week. Do not add realism. Realism in fixtures is
-- how a fixture number ends up in a slide.
-- =============================================================================

-- Peak level reached by pump-down number n, percent of the weir crest.
-- Cycles 31-46 are the wet-weather event: those peaks reach the high level
-- alarm at 86.7 %, most reach LSHH at 91.7 %, and two go over the weir.
-- Outside it nothing comes close to alarming.
CREATE FUNCTION fixture.f_peak(n int) RETURNS double precision AS $$
    SELECT CASE WHEN n BETWEEN 31 AND 46
                THEN 84.0 + (n % 7) * 3.0      -- 84.0 .. 102.0
                ELSE 68.0 + (n % 5) * 1.4      -- 68.0 ..  73.6
           END
$$ LANGUAGE sql IMMUTABLE;

-- Wet well level, percent of the weir crest, s seconds after the origin.
--
-- The level signal fault at bit 13 FREEZES the transmitter: the historian goes
-- on sampling and the samples go on reading the last good value. Modelled by
-- evaluating the level at the fault's start time for its duration, and the
-- samples are flagged BAD so that every Cube measure excludes them. This is
-- the only thing in the fixtures that exercises the quality filter.
CREATE FUNCTION fixture.f_level(s int) RETURNS double precision AS $$
    SELECT CASE WHEN v.phase_min < 55
                THEN 16.7 + (v.pk - 16.7) * v.phase_min / 55.0
                ELSE v.pk  - (v.pk - 16.7) * (v.phase_min - 55) / 85.0
           END
    FROM (
        SELECT (e.eff % 8400) / 60.0                     AS phase_min,
               fixture.f_peak((e.eff / 8400)::int)       AS pk
        FROM (SELECT CASE WHEN s >= 558000 AND s < 561600
                          THEN 558000 ELSE s END AS eff) e
    ) v
$$ LANGUAGE sql IMMUTABLE;

-- Units running. Zero while filling; during the draw-down the cascade follows
-- the documented start levels - 66.7 % duty, 75.0 % assist 1, 83.3 % assist 2.
CREATE FUNCTION fixture.f_pumps(s int) RETURNS int AS $$
    SELECT CASE
        WHEN (s % 8400) / 60.0 < 55        THEN 0
        WHEN fixture.f_level(s) >= 83.3    THEN 3
        WHEN fixture.f_level(s) >= 75.0    THEN 2
        ELSE 1
    END
$$ LANGUAGE sql IMMUTABLE;

-- Station inflow, m3/h. Diurnal with a morning and an evening peak, lifted
-- through the wet-weather cycles.
CREATE FUNCTION fixture.f_inflow(s int) RETURNS double precision AS $$
    SELECT round((
        180.0
        + 90.0 * sin((s - 21600) * 2 * pi() / 86400.0)
        + 40.0 * sin(s * 4 * pi() / 86400.0)
        + CASE WHEN (s / 8400) BETWEEN 31 AND 46 THEN 420.0 ELSE 0.0 END
    )::numeric, 1)::double precision
$$ LANGUAGE sql IMMUTABLE;

-- Total discharge, m3/h. Approximately 120 L/s per unit against the 22 m
-- static lift: 120 x 3.6 = 432 m3/h.
CREATE FUNCTION fixture.f_discharge(s int) RETURNS double precision AS $$
    SELECT fixture.f_pumps(s) * 432.0
$$ LANGUAGE sql IMMUTABLE;

-- Duty unit for pump-down n. Duty rotates on lowest accumulated run hours,
-- which over a long window is round-robin.
CREATE FUNCTION fixture.f_duty(s int) RETURNS int AS $$
    SELECT 1 + ((s / 8400) % 3)
$$ LANGUAGE sql IMMUTABLE;

-- =============================================================================
-- Injected discrete conditions.
--
-- The level-driven alarms - high level, LSHH, spill - fall out of f_level and
-- are not listed here. These are the ones that have no analogue signature: the
-- trips, the seal leak, the vibration alarm, the frozen level transmitter and
-- a rejected setpoint write.
--
-- BOTH TRIPS START ON A CYCLE BOUNDARY, the instant a pump-down ends, and are
-- reset well inside the following fill. That keeps a tripped unit from ever
-- overlapping a running one, so the discharge arithmetic stays exactly
-- checkable. It is a real scenario - a unit that trips as the set shuts down
-- and is reset before the next start - and it is a deliberate simplification.
-- =============================================================================
CREATE TABLE fixture.injected_condition (
    bit       INT NOT NULL REFERENCES public.alarm_bits(bit),
    start_s   INT NOT NULL,
    end_s     INT NOT NULL,
    narrative TEXT
);
INSERT INTO fixture.injected_condition (bit, start_s, end_s, narrative) VALUES
    ( 5,  84000,  86700, 'PU-302 tripped - no flow 20 s after start (PIT-321 below 150 kPa). Reset by operator command 45 minutes later.'),
    ( 4, 277200, 279900, 'PU-301 tripped on motor thermal TE-312 during the wet weather event. Reset by operator command.'),
    (10, 442800, 450000, 'PU-301 bearing vibration above the 7.1 mm/s alarm threshold for two hours. Below the 11.0 mm/s trip, so the unit kept running.'),
    ( 9, 453600, 518400, 'PU-303 seal leak MSE-333. Alarm only - availability is unaffected and the unit went on running, per WRPS-PRO-001 section 5.5.'),
    (13, 558000, 561600, 'LIT-101 frozen - no change greater than 1 mm for 10 minutes with a pump running. Level samples are flagged BAD for the hour.'),
    (15, 590400, 590460, 'Setpoint write rejected - start duty level above the spill weir crest; the previous value was retained.');

-- The alarm word, exactly as the PLC assembles it: the OR of every active
-- condition. Level-driven bits come from f_level, the rest from the table
-- above. Bit 0 is mirrored by AID.WRPS.STN.HIGH_LEVEL and bit 3 by
-- AID.WRPS.STN.SPILL_ACTIVE; the assertions at the foot of this file check
-- that the mirrors agree, which is a real test of the derivation.
CREATE FUNCTION fixture.f_alarm_word(s int) RETURNS int AS $$
    SELECT (CASE WHEN fixture.f_level(s) >=  86.7 THEN 1 ELSE 0 END)     -- bit0
         + (CASE WHEN fixture.f_level(s) >=  91.7 THEN 2 ELSE 0 END)     -- bit1
         + (CASE WHEN fixture.f_level(s) >= 100.0 THEN 8 ELSE 0 END)     -- bit3
         + COALESCE((SELECT sum(1 << c.bit)::int
                       FROM fixture.injected_condition c
                      WHERE s >= c.start_s AND s < c.end_s), 0)
$$ LANGUAGE sql STABLE;

-- =============================================================================
-- fixture.item_history - THE ONLY HISTORY TABLE.
--
-- One row per sample of one item. That is all CI Server holds, and it is all
-- this holds. Alarms and pump-down operations are DERIVED from it below,
-- because that is what will have to happen against imh too.
--
-- THERE IS NO equipment_id COLUMN, AND THERE MUST NEVER BE ONE. CI Server's
-- section tree stops at the station and the three pumps; it has no wet well,
-- no weir, no manifold, no switchboard. Equipment is asserted exactly once, in
-- public.tags.equipment_id, and reached from here through
-- public.historian_items. Finding (c) was a denormalised equipment column in
-- the history disagreeing with the tag seed about which asset an alarm
-- belonged to. With one assertion there is nothing left to disagree.
-- =============================================================================
CREATE TABLE fixture.item_history (
    item_name   TEXT        NOT NULL REFERENCES public.historian_items(item_name),
    sample_time TIMESTAMPTZ NOT NULL,          -- UTC ("Date+time GMT")
    value       DOUBLE PRECISION,
    quality     TEXT        NOT NULL DEFAULT 'GOOD',   -- GOOD | BAD | UNCERTAIN
    is_fixture  BOOLEAN     NOT NULL DEFAULT TRUE,
    PRIMARY KEY (item_name, sample_time)
);
CREATE INDEX ON fixture.item_history (sample_time);

-- --- WRPS_ONE_SEC: the five 5-second analogues ------------------------------
-- The interval is read from public.historian_items, never written here. The
-- old fixtures hardcoded 60 seconds and two Cube measures hardcoded it again
-- to convert sample counts into durations; against a 5-second group that
-- arithmetic is wrong by a factor of twelve.
INSERT INTO fixture.item_history (item_name, sample_time, value, quality)
SELECT
    'AID.WRPS.STN.LEVEL',
    m.origin + make_interval(secs => s),
    round(fixture.f_level(s)::numeric, 1)::double precision,
    -- Flagged BAD for the hour the transmitter is frozen. Cube excludes
    -- non-GOOD samples from every measure.
    CASE WHEN s >= 558000 AND s < 561600 THEN 'BAD' ELSE 'GOOD' END
FROM fixture.build_meta m,
     public.historian_items hi,
     LATERAL generate_series(0, m.horizon_days * 86400 - hi.scan_interval_seconds,
                             hi.scan_interval_seconds) AS s
WHERE hi.item_name = 'AID.WRPS.STN.LEVEL';

INSERT INTO fixture.item_history (item_name, sample_time, value)
SELECT hi.item_name,
       m.origin + make_interval(secs => s),
       CASE hi.item_name
           WHEN 'AID.WRPS.STN.INFLOW'    THEN fixture.f_inflow(s)
           WHEN 'AID.WRPS.STN.DISCHARGE' THEN fixture.f_discharge(s)
           WHEN 'AID.WRPS.STN.NET_ACCUM' THEN fixture.f_inflow(s) - fixture.f_discharge(s)
           -- Percent of 50 Hz, clamped at the documented 76 % floor: below
           -- 38 Hz the 22 m static lift means no delivery.
           WHEN 'AID.WRPS.STN.SPEED'     THEN
               CASE WHEN fixture.f_pumps(s) = 0 THEN 0.0
                    ELSE round(least(100.0, 76.0 + (fixture.f_level(s) - 16.7) * 0.3)::numeric, 1)::double precision
               END
       END
FROM fixture.build_meta m,
     public.historian_items hi,
     LATERAL generate_series(0, m.horizon_days * 86400 - hi.scan_interval_seconds,
                             hi.scan_interval_seconds) AS s
WHERE hi.his_group = 'WRPS_ONE_SEC'
  AND hi.item_name <> 'AID.WRPS.STN.LEVEL';

-- --- WRPS_THIRTY_SEC: the four 30-second items ------------------------------
-- Volume and time to spill reconcile with the level through the plant
-- geometry - 120 m3 per metre, weir crest at 6000 mm - so an engineer can
-- check one against the other. That cross-check is the same one the WRPS team
-- used to confirm the Modbus address base.
INSERT INTO fixture.item_history (item_name, sample_time, value)
SELECT hi.item_name,
       m.origin + make_interval(secs => s),
       CASE hi.item_name
           WHEN 'AID.WRPS.STN.PUMPS_RUNNING' THEN fixture.f_pumps(s)::double precision
           WHEN 'AID.WRPS.STN.VOL_TO_SPILL'  THEN
               round(greatest(0.0, 720.0 - 7.2 * fixture.f_level(s))::numeric, 0)::double precision
           -- 32767 is the SENTINEL for "drawing down or holding". It is not a
           -- duration and every Cube measure excludes it. Do not remove that
           -- filter to make a number look tidier.
           WHEN 'AID.WRPS.STN.TIME_TO_SPILL' THEN
               CASE WHEN fixture.f_inflow(s) - fixture.f_discharge(s) <= 0 THEN 32767.0
                    ELSE least(32766.0, round((greatest(0.0, 720.0 - 7.2 * fixture.f_level(s))
                              / (fixture.f_inflow(s) - fixture.f_discharge(s)) * 3600.0)::numeric, 0)::double precision)
               END
           WHEN 'AID.WRPS.STN.TIME_TO_LSHH'  THEN
               CASE WHEN fixture.f_level(s) >= 91.7 THEN 0.0
                    WHEN fixture.f_inflow(s) - fixture.f_discharge(s) <= 0 THEN 32767.0
                    ELSE least(32766.0, round(((91.7 - fixture.f_level(s)) * 7.2
                              / (fixture.f_inflow(s) - fixture.f_discharge(s)) * 3600.0)::numeric, 0)::double precision)
               END
       END
FROM fixture.build_meta m,
     public.historian_items hi,
     LATERAL generate_series(0, m.horizon_days * 86400 - hi.scan_interval_seconds,
                             hi.scan_interval_seconds) AS s
WHERE hi.his_group = 'WRPS_THIRTY_SEC';

-- --- WRPS_EVENT: on value change only ---------------------------------------
-- Event/Item storage writes a row only when the value changes. Everything
-- below is evaluated on the 5-second grid and then reduced to its transitions,
-- which is genuinely irregular history - the one place the deadband warning in
-- cube/model/process_values.yml actually applies.
CREATE TEMP TABLE _event_grid AS
SELECT s,
       fixture.f_pumps(s)                                            AS pumps,
       fixture.f_level(s)                                            AS level,
       fixture.f_duty(s)                                             AS duty,
       fixture.f_alarm_word(s)                                       AS alarm_word,
       EXISTS (SELECT 1 FROM fixture.injected_condition c
                WHERE c.bit = 4 AND s >= c.start_s AND s < c.end_s)   AS trip1,
       EXISTS (SELECT 1 FROM fixture.injected_condition c
                WHERE c.bit = 5 AND s >= c.start_s AND s < c.end_s)   AS trip2,
       EXISTS (SELECT 1 FROM fixture.injected_condition c
                WHERE c.bit = 6 AND s >= c.start_s AND s < c.end_s)   AS trip3,
       -- A trip clears only on the operator's reset command, never
       -- automatically. Each trip therefore ends with a command word write and
       -- an acknowledgement echoed back from the PLC - momentary, 60 seconds.
       EXISTS (SELECT 1 FROM fixture.injected_condition c
                WHERE c.bit IN (4, 5, 6)
                  AND s >= c.end_s AND s < c.end_s + 60)              AS reset_pulse
FROM fixture.build_meta m,
     LATERAL generate_series(0, m.horizon_days * 86400 - 5, 5) AS s;
CREATE INDEX ON _event_grid (s);

-- One row per (item, value) pair over the grid, reduced to changes.
CREATE TEMP TABLE _event_values AS
SELECT item_name, s, value FROM (
    SELECT 'AID.WRPS.STN.ALARM_WORD' AS item_name, s, alarm_word::double precision AS value FROM _event_grid
    UNION ALL SELECT 'AID.WRPS.STN.HIGH_LEVEL',   s, (level >=  86.7)::int::double precision FROM _event_grid
    UNION ALL SELECT 'AID.WRPS.STN.SPILL_ACTIVE', s, (level >= 100.0)::int::double precision FROM _event_grid
    UNION ALL SELECT 'AID.WRPS.STN.IN_AUTO',      s, 1.0::double precision FROM _event_grid
    UNION ALL SELECT 'AID.WRPS.STN.DUTY_PUMP',    s, CASE WHEN pumps = 0 THEN 0 ELSE duty END::double precision FROM _event_grid
    -- Station state enum: 4 Emergency (LSHH) - 3 High level - 6 Fault -
    -- 2 Pumping - 1 Idle. Report the label, never the bare number.
    UNION ALL SELECT 'AID.WRPS.STN.STATE',        s,
        (CASE WHEN level >= 91.7 THEN 4 WHEN level >= 86.7 THEN 3
              WHEN trip1 OR trip2 OR trip3 THEN 6
              WHEN pumps > 0 THEN 2 ELSE 1 END)::double precision FROM _event_grid
    -- The reset command and the PLC's acknowledgement of it. Both are
    -- momentary: they pulse for 60 s as each trip is reset and are 0 the rest
    -- of the time. Command word 1 is "reset trips" - see db/seed/tags.csv.
    UNION ALL SELECT 'AID.WRPS.SP.CMD_WORD',      s,
        (CASE WHEN reset_pulse THEN 1 ELSE 0 END)::double precision FROM _event_grid
    UNION ALL SELECT 'AID.WRPS.STN.CMD_ACK',      s,
        (CASE WHEN reset_pulse THEN 1 ELSE 0 END)::double precision FROM _event_grid
    -- Per-unit signals. run_order 0 is the duty unit, then the other two in
    -- ascending number; a unit runs when the cascade has called for its
    -- position. A tripped unit never runs and is never available.
    UNION ALL SELECT 'AID.WRPS.PU30' || p || '.RUN_CMD', s,
        ((p - duty + 3) % 3 < pumps)::int::double precision
        FROM _event_grid, generate_series(1,3) p
    UNION ALL SELECT 'AID.WRPS.PU30' || p || '.RUNNING', s,
        (((p - duty + 3) % 3 < pumps)
         AND NOT (p = 1 AND trip1 OR p = 2 AND trip2 OR p = 3 AND trip3))::int::double precision
        FROM _event_grid, generate_series(1,3) p
    UNION ALL SELECT 'AID.WRPS.PU30' || p || '.AVAILABLE', s,
        (NOT (p = 1 AND trip1 OR p = 2 AND trip2 OR p = 3 AND trip3))::int::double precision
        FROM _event_grid, generate_series(1,3) p
    UNION ALL SELECT 'AID.WRPS.PU30' || p || '.TRIPPED', s,
        (p = 1 AND trip1 OR p = 2 AND trip2 OR p = 3 AND trip3)::int::double precision
        FROM _event_grid, generate_series(1,3) p
    -- Pump state enum: 6 Tripped - 3 Running - 1 Available stopped.
    UNION ALL SELECT 'AID.WRPS.PU30' || p || '.STATE', s,
        (CASE WHEN p = 1 AND trip1 OR p = 2 AND trip2 OR p = 3 AND trip3 THEN 6
              WHEN (p - duty + 3) % 3 < pumps THEN 3
              ELSE 1 END)::double precision
        FROM _event_grid, generate_series(1,3) p
) v;

-- Accumulated run hours. Published as whole hours, so the value changes once
-- per hour of runtime rather than every scan. A STEP DOWN in this trend is a
-- service - command word 5 resets it - not a data error.
INSERT INTO _event_values (item_name, s, value)
SELECT item_name, s,
       (start_hours + floor(run_seconds / 3600.0))::double precision
FROM (
    SELECT 'AID.WRPS.PU30' || p || '.RUN_HOURS' AS item_name,
           g.s,
           CASE p WHEN 1 THEN 3200 WHEN 2 THEN 3350 ELSE 2980 END AS start_hours,
           sum(CASE WHEN ((p - g.duty + 3) % 3 < g.pumps)
                     AND NOT (p = 1 AND g.trip1 OR p = 2 AND g.trip2 OR p = 3 AND g.trip3)
                    THEN 5 ELSE 0 END)
             OVER (PARTITION BY p ORDER BY g.s) AS run_seconds
    FROM _event_grid g, generate_series(1,3) p
) x;

INSERT INTO fixture.item_history (item_name, sample_time, value)
SELECT v.item_name, m.origin + make_interval(secs => v.s), v.value
FROM (
    SELECT item_name, s, value,
           LAG(value) OVER (PARTITION BY item_name ORDER BY s) AS prev
    FROM _event_values
) v, fixture.build_meta m
WHERE v.prev IS DISTINCT FROM v.value;

-- Setpoints and simulation controls. Constant across the window - the one
-- setpoint write in the period was REJECTED, so the previous value was
-- retained and no value row follows it. That is why the rejected write shows
-- up only as bit 15 of the alarm word.
INSERT INTO fixture.item_history (item_name, sample_time, value)
SELECT hi.item_name, m.origin,
       CASE hi.item_name
           WHEN 'AID.WRPS.SP.MODE'        THEN 1.0      -- auto
           WHEN 'AID.WRPS.SP.CMD_PARAM'   THEN 0.0
           WHEN 'AID.WRPS.SP.LEVEL_SP'    THEN 70.0     -- 4200 mm
           WHEN 'AID.WRPS.SP.START_DUTY'  THEN 66.7     -- 4000 mm
           WHEN 'AID.WRPS.SP.START_P2'    THEN 75.0     -- 4500 mm
           WHEN 'AID.WRPS.SP.START_P3'    THEN 83.3     -- 5000 mm
           WHEN 'AID.WRPS.SP.STOP_ALL'    THEN 16.7     -- 1000 mm
           WHEN 'AID.WRPS.SP.HIGH_ALARM'  THEN 86.7     -- 5200 mm
           WHEN 'AID.WRPS.SP.MIN_SPEED'   THEN 76.0     -- 38 Hz
           WHEN 'AID.WRPS.SP.SERVICE_HRS' THEN 4000.0
           WHEN 'AID.WRPS.SIM.INFLOW'     THEN 0.0
           WHEN 'AID.WRPS.SIM.SCENARIO'   THEN 1.0      -- diurnal dry weather
           WHEN 'AID.WRPS.SIM.RESET'      THEN 0.0
           WHEN 'AID.WRPS.SIM.TIME_SCALE' THEN 1.0
       END
FROM fixture.build_meta m, public.historian_items hi
WHERE hi.section IN ('SP', 'SIM')
  -- CMD_WORD is momentary and is generated with the event items above, not
  -- held at rest here.
  AND hi.item_name <> 'AID.WRPS.SP.CMD_WORD';

DROP TABLE _event_grid;
DROP TABLE _event_values;

-- =============================================================================
-- fixture.process_value_history - what Cube reads for analogue history.
--
-- A view, not a table. It resolves the item onto its tag through
-- public.historian_items and exposes nothing else: no equipment column, and
-- no row for an item that is deliberately unanswerable.
--
-- AT PHASE 4 this view is what gets repointed at imh. Everything above it is
-- fixture generation and goes away; everything below it is contract.
-- =============================================================================
CREATE VIEW fixture.process_value_history AS
SELECT
    h.sample_time,
    h.item_name,
    hi.tag_id,
    h.value,
    hi.eng_unit AS engineering_unit,
    h.quality,
    hi.his_group,
    hi.scan_interval_seconds,
    h.is_fixture
FROM fixture.item_history h
JOIN public.historian_items hi USING (item_name)
WHERE hi.his_group IN ('WRPS_ONE_SEC', 'WRPS_THIRTY_SEC')
  AND hi.tag_id IS NOT NULL;

COMMENT ON VIEW fixture.process_value_history IS
  'Scan/Time analogue history, item-keyed. Regular samples - CI Server has '
  'DATA_COMP=0 and STORE_DEADBAND=0 on these groups. scan_interval_seconds is '
  'carried so no measure has to assume a rate.';

-- =============================================================================
-- fixture.alarm_history - DERIVED, not stored.
--
-- Every alarm at this station is a bit of the PLC alarm word, historised as
-- AID.WRPS.STN.ALARM_WORD. An ALARM IS A TRANSITION INTO THE ACTIVE STATE - a
-- 0 -> 1 on one bit. The 1 -> 0 is the return to normal for the activation
-- that preceded it, not a second alarm. Counting every row roughly doubles
-- every answer, which is the single most likely way "how many times last week"
-- returns a wrong number confidently.
--
-- WHY DERIVED. CI Server's built-in ALARM_HISTORY group exists on the server
-- but every WRPS item imports with alarming OFF and limits at 0
-- (05-scada/modbus/README.md, "one thing left for you to set"), so it holds
-- nothing. Deriving from the alarm word needs no configuration that does not
-- exist, and it is the same derivation that will run against imh.
--
-- EQUIPMENT comes from public.alarm_bits -> tags -> equipment. A bitmask packs
-- several units' alarms into one item, so the item alone cannot say which pump
-- a seal leak belongs to; the bit can. This is the reason the bit map is
-- reference data rather than a constant inside a Cube model.
-- =============================================================================
CREATE VIEW fixture.alarm_history AS
WITH word AS (
    -- READ THE ALARM WORD AS UNSIGNED. Bit 15 (setpoint rejected) does not fit
    -- a signed 16-bit integer, so any source that hands it back signed turns
    -- the whole word NEGATIVE exactly when bit 15 sets - and a negative int
    -- shifted right in Postgres sign-extends, which would report every higher
    -- bit as active at once. The fixtures store it as a positive double and
    -- never trip this, but imh is SQL Server and SMALLINT there IS signed.
    --
    -- The modulo below normalises both cases and is a no-op on a value that is
    -- already unsigned. Do not "simplify" it away when repointing at imh; that
    -- is precisely when it starts mattering. See db/README-standin-historian.md,
    -- assumption A11.
    SELECT sample_time,
           ((value::int % 65536) + 65536) % 65536 AS mask,
           LAG(((value::int % 65536) + 65536) % 65536)
               OVER (ORDER BY sample_time) AS prev_mask,
           is_fixture
    FROM fixture.item_history
    WHERE item_name = 'AID.WRPS.STN.ALARM_WORD'
), bits AS (
    SELECT w.sample_time, w.is_fixture, b.bit,
           (w.mask >> b.bit) & 1                        AS active,
           COALESCE((w.prev_mask >> b.bit) & 1, 0)      AS prev_active
    FROM word w CROSS JOIN public.alarm_bits b
)
SELECT
    row_number() OVER (ORDER BY b.sample_time, b.bit) AS alarm_id,
    b.sample_time                                     AS event_time,
    'AID.WRPS.STN.ALARM_WORD'::text                   AS item_name,
    b.bit,
    ab.alarm_type,
    ab.priority,
    ab.tag_id,
    CASE WHEN b.active = 1 THEN 'ACTIVE' ELSE 'RTN' END AS state,
    -- The process value at the transition, for the alarms that have one. A
    -- pump trip has no level reading worth quoting; a high level alarm does.
    CASE WHEN ab.tag_id IN ('AID.WRPS.STN.HIGH_LEVEL', 'LSHH-102', 'LSLL-103',
                            'AID.WRPS.STN.SPILL_ACTIVE', 'LIT-101')
         THEN lv.value END                            AS value,
    CASE WHEN ab.tag_id IN ('AID.WRPS.STN.HIGH_LEVEL', 'LSHH-102', 'LSLL-103',
                            'AID.WRPS.STN.SPILL_ACTIVE', 'LIT-101')
         THEN '%' END                                 AS engineering_unit,
    ab.alarm_text,
    COALESCE(ic.narrative, ab.description)            AS description,
    b.is_fixture
FROM bits b
JOIN public.alarm_bits ab USING (bit)
LEFT JOIN fixture.item_history lv
       ON lv.item_name = 'AID.WRPS.STN.LEVEL'
      AND lv.sample_time = b.sample_time
LEFT JOIN fixture.build_meta m ON true
LEFT JOIN fixture.injected_condition ic
       ON ic.bit = b.bit
      AND m.origin + make_interval(secs => ic.start_s) = b.sample_time
WHERE b.active IS DISTINCT FROM b.prev_active;

COMMENT ON VIEW fixture.alarm_history IS
  'Alarm activations and returns, derived from bit transitions of '
  'AID.WRPS.STN.ALARM_WORD. state = ACTIVE is an activation; RTN is the '
  'return to normal of the one before it. Count ACTIVE only.';

-- =============================================================================
-- fixture.operation_history - DERIVED pump-downs.
--
-- A pump-down starts where AID.WRPS.STN.PUMPS_RUNNING goes from 0 to non-zero
-- and ends where it returns to 0. Its max level is the maximum level over that
-- span plus the ten minutes before it, because the peak is usually just before
-- the pumps catch up. Operations shorter than five minutes are start/stop
-- noise and are discarded.
--
-- That heuristic is the one written into cube/model/operations.yml, and it is
-- deliberately simple: a heuristic nobody can explain is not evidence, and
-- this is the table that answers the advisory question with evidence.
--
-- MATERIALISED because lin001 is a shared 2 vCPU host and this scans the
-- 5-second level series once per operation. Fixtures are static, so it is
-- built once here and never refreshed.
-- =============================================================================
CREATE MATERIALIZED VIEW fixture.operation_history AS
WITH pr AS (
    SELECT sample_time, value,
           LAG(value) OVER (ORDER BY sample_time) AS prev
    FROM fixture.item_history
    WHERE item_name = 'AID.WRPS.STN.PUMPS_RUNNING'
), grouped AS (
    SELECT sample_time, value,
           sum(CASE WHEN value > 0 AND COALESCE(prev, 0) = 0 THEN 1 ELSE 0 END)
               OVER (ORDER BY sample_time) AS run_id
    FROM pr
), runs AS (
    SELECT run_id,
           min(sample_time) AS start_time,
           max(sample_time) AS end_time,
           max(value)::int  AS peak_pumps_running
    FROM grouped
    WHERE value > 0
    GROUP BY run_id
    HAVING max(sample_time) - min(sample_time) >= interval '5 minutes'
)
SELECT
    r.run_id                       AS operation_id,
    'PUMP_DOWN'::text              AS operation_type,
    r.start_time,
    r.end_time,
    r.peak_pumps_running,
    lvl.start_level_pct,
    lvl.end_level_pct,
    lvl.max_level_pct,
    flow.avg_inflow_m3h,
    flow.avg_discharge_m3h,
    duty.duty_pump,
    lvl.max_level_pct >= 86.7      AS high_level_alarm,
    lvl.max_level_pct >= 100.0     AS spill,
    TRUE                           AS is_fixture
FROM runs r
CROSS JOIN LATERAL (
    -- max over the operation PLUS the ten minutes before it: the peak is
    -- usually just before the pumps catch up. start and end are read at the
    -- exact boundary samples, which line up because the 30-second group the
    -- operation is derived from is a multiple of the 5-second level group.
    SELECT max(value)                                            AS max_level_pct,
           max(value) FILTER (WHERE sample_time = r.start_time)  AS start_level_pct,
           max(value) FILTER (WHERE sample_time = r.end_time)    AS end_level_pct
    FROM fixture.item_history
    WHERE item_name = 'AID.WRPS.STN.LEVEL'
      AND quality = 'GOOD'
      AND sample_time BETWEEN r.start_time - interval '10 minutes' AND r.end_time
) lvl
CROSS JOIN LATERAL (
    SELECT avg(value) FILTER (WHERE item_name = 'AID.WRPS.STN.INFLOW')    AS avg_inflow_m3h,
           avg(value) FILTER (WHERE item_name = 'AID.WRPS.STN.DISCHARGE') AS avg_discharge_m3h
    FROM fixture.item_history
    WHERE item_name IN ('AID.WRPS.STN.INFLOW', 'AID.WRPS.STN.DISCHARGE')
      AND quality = 'GOOD'
      AND sample_time BETWEEN r.start_time AND r.end_time
) flow
CROSS JOIN LATERAL (
    -- The duty unit in force when the set started. DUTY_PUMP is an on-change
    -- item, so take the last value at or before the start rather than an
    -- aggregate over a window - two different duty values can fall inside any
    -- window wide enough to be safe, and max() would silently pick the higher.
    SELECT 'PU-30' || value::int AS duty_pump
    FROM fixture.item_history
    WHERE item_name = 'AID.WRPS.STN.DUTY_PUMP'
      AND sample_time <= r.start_time
      AND value > 0
    ORDER BY sample_time DESC
    LIMIT 1
) duty;

CREATE UNIQUE INDEX ON fixture.operation_history (operation_id);
CREATE INDEX ON fixture.operation_history (start_time);

COMMENT ON MATERIALIZED VIEW fixture.operation_history IS
  'Pump-downs derived from AID.WRPS.STN.PUMPS_RUNNING. No equipment column - '
  'the station is the only equipment a pump-down belongs to, and duty_pump '
  'names the unit that led it.';

-- =============================================================================
-- Grants. These live HERE and not in 003_roles.sql on purpose: this file
-- starts with DROP SCHEMA fixture CASCADE, which destroys every grant on it.
-- Grants belong with the object they are granted on, so a fixture reload keeps
-- them. Read-only for both roles - nothing writes fixtures except this file.
-- =============================================================================
GRANT USAGE ON SCHEMA fixture TO agent_ro, cube_rw;
GRANT SELECT ON ALL TABLES IN SCHEMA fixture TO agent_ro, cube_rw;
ALTER DEFAULT PRIVILEGES IN SCHEMA fixture GRANT SELECT ON TABLES TO agent_ro, cube_rw;

-- =============================================================================
-- Assertions.
--
-- These are not a comment suggesting what to check. They FAIL THE LOAD.
--
-- The counts are exact rather than approximate because the origin is truncated
-- to the hour and the plant is prescribed rather than simulated. They were
-- derived independently before this file was written, not read back out of it,
-- which is what makes them a test rather than a restatement.
--
-- If one of these fires after an intentional change to the generating
-- functions, recompute it - do not relax it.
-- =============================================================================
DO $$
DECLARE
    n     BIGINT;
    m     BIGINT;
    txt   TEXT;
BEGIN
    -- Analogue row counts follow from the rates in public.historian_items:
    -- 5 items x 7 days / 5 s, plus 4 items x 7 days / 30 s.
    SELECT count(*) INTO n FROM fixture.process_value_history;
    IF n <> 685440 THEN
        RAISE EXCEPTION 'process_value_history has % rows, expected 685440', n;
    END IF;

    -- Every historised, answerable item must actually have history. This is
    -- the assertion that would have caught finding (a) on the day it was
    -- introduced: the level item existed, the tag existed, and nothing joined.
    SELECT count(*) INTO n
      FROM public.historian_items hi
     WHERE hi.his_group IS NOT NULL
       AND hi.tag_id IS NOT NULL
       AND NOT EXISTS (SELECT 1 FROM fixture.item_history h
                        WHERE h.item_name = hi.item_name);
    IF n <> 0 THEN
        SELECT string_agg(hi.item_name, ', ') INTO txt
          FROM public.historian_items hi
         WHERE hi.his_group IS NOT NULL AND hi.tag_id IS NOT NULL
           AND NOT EXISTS (SELECT 1 FROM fixture.item_history h
                            WHERE h.item_name = hi.item_name);
        RAISE EXCEPTION 'answerable items with no history: %', txt;
    END IF;

    -- Alarm activations, by bit. Derived from the level series and the
    -- injected conditions; see the prototype in the commit that added this.
    SELECT count(*) INTO n FROM fixture.alarm_history WHERE state = 'ACTIVE';
    IF n <> 32 THEN
        RAISE EXCEPTION 'expected 32 alarm activations, found %', n;
    END IF;

    SELECT count(*) INTO n FROM fixture.alarm_history
     WHERE state = 'ACTIVE' AND alarm_type = 'HIGH_LEVEL';
    IF n <> 14 THEN
        RAISE EXCEPTION 'expected 14 high level activations, found %', n;
    END IF;

    SELECT count(*) INTO n FROM fixture.alarm_history
     WHERE state = 'ACTIVE' AND alarm_type = 'SPILL';
    IF n <> 2 THEN
        RAISE EXCEPTION 'expected 2 spill activations, found %', n;
    END IF;

    SELECT count(*) INTO n FROM fixture.alarm_history
     WHERE state = 'ACTIVE' AND priority = 1;
    IF n <> 15 THEN
        RAISE EXCEPTION 'expected 15 priority 1 activations, found %', n;
    END IF;

    -- THE CROSS-CHECK THAT MATTERS. Bit 0 of the alarm word and the discrete
    -- item AID.WRPS.STN.HIGH_LEVEL are two independent records of the same
    -- condition. If the derivation is right they agree exactly. This is the
    -- check the old fixtures had no way to express, because the alarm table
    -- was written by hand rather than derived from the signal.
    SELECT count(*) INTO n FROM fixture.alarm_history
     WHERE state = 'ACTIVE' AND bit = 0;
    SELECT count(*) INTO m FROM (
        SELECT value, LAG(value) OVER (ORDER BY sample_time) AS prev
          FROM fixture.item_history
         WHERE item_name = 'AID.WRPS.STN.HIGH_LEVEL'
    ) t WHERE value = 1 AND COALESCE(prev, 0) = 0;
    IF n <> m THEN
        RAISE EXCEPTION
          'alarm word bit 0 shows % high level activations but the discrete '
          'item AID.WRPS.STN.HIGH_LEVEL shows % - the derivation disagrees '
          'with the signal it is derived from', n, m;
    END IF;

    -- Same cross-check for the spill bit against its own discrete item.
    SELECT count(*) INTO n FROM fixture.alarm_history
     WHERE state = 'ACTIVE' AND bit = 3;
    SELECT count(*) INTO m FROM (
        SELECT value, LAG(value) OVER (ORDER BY sample_time) AS prev
          FROM fixture.item_history
         WHERE item_name = 'AID.WRPS.STN.SPILL_ACTIVE'
    ) t WHERE value = 1 AND COALESCE(prev, 0) = 0;
    IF n <> m THEN
        RAISE EXCEPTION
          'alarm word bit 3 shows % spill activations but AID.WRPS.STN.'
          'SPILL_ACTIVE shows %', n, m;
    END IF;

    -- Pump-downs, and how many of them alarmed.
    SELECT count(*) INTO n FROM fixture.operation_history;
    IF n <> 72 THEN
        RAISE EXCEPTION 'expected 72 pump-downs, found %', n;
    END IF;

    SELECT count(*) INTO n FROM fixture.operation_history WHERE high_level_alarm;
    IF n <> 14 THEN
        RAISE EXCEPTION 'expected 14 pump-downs reaching the high level alarm, found %', n;
    END IF;

    -- Every pump-down that reached the alarm setpoint must have produced an
    -- alarm activation, and vice versa. Two derivations from two different
    -- items agreeing is worth more than either on its own.
    SELECT count(*) INTO n FROM fixture.operation_history WHERE spill;
    SELECT count(*) INTO m FROM fixture.alarm_history
     WHERE state = 'ACTIVE' AND alarm_type = 'SPILL';
    IF n <> m THEN
        RAISE EXCEPTION 'operations show % spills, the alarm word shows %', n, m;
    END IF;

    -- The frozen transmitter. One hour of 5-second samples, flagged BAD, and
    -- excluded from every Cube measure.
    SELECT count(*) INTO n FROM fixture.item_history
     WHERE item_name = 'AID.WRPS.STN.LEVEL' AND quality = 'BAD';
    IF n <> 720 THEN
        RAISE EXCEPTION 'expected 720 BAD level samples, found %', n;
    END IF;

    -- Geometry. Volume remaining to spill must reconcile with the level
    -- through the plant's own dimensions - 120 m3 per metre, crest at 6000 mm
    -- - at every sample where both were recorded. This is the check the WRPS
    -- team used to confirm the Modbus address base, applied continuously.
    SELECT count(*) INTO n
      FROM fixture.item_history lv
      JOIN fixture.item_history vs
        ON vs.item_name = 'AID.WRPS.STN.VOL_TO_SPILL'
       AND vs.sample_time = lv.sample_time
     WHERE lv.item_name = 'AID.WRPS.STN.LEVEL'
       AND lv.value < 100.0
       AND abs(vs.value - (6.0 - lv.value * 0.06) * 120.0) > 1.0;
    IF n <> 0 THEN
        RAISE EXCEPTION
          '% samples where volume remaining to spill does not reconcile with '
          'the level through the plant geometry', n;
    END IF;

    -- Nothing here is real, and every row must say so.
    IF NOT (SELECT bool_and(is_fixture) FROM fixture.item_history) THEN
        RAISE EXCEPTION 'a row in fixture.item_history is not flagged as fixture data';
    END IF;

    RAISE NOTICE 'fixture load OK: % analogue rows, % alarm activations, % pump-downs',
        (SELECT count(*) FROM fixture.process_value_history),
        (SELECT count(*) FROM fixture.alarm_history WHERE state = 'ACTIVE'),
        (SELECT count(*) FROM fixture.operation_history);
END $$;
