-- ============================================================================= -- 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 $$;