45 of the 65 rows in db/seed/tags.csv had a PS_* name as their PRIMARY KEY, and historian_items.csv, alarm_bits.csv, the fixtures, two Cube models and the exam all referenced them. They are gone. The tag seed is now keyed on the CI Server item for everything the historian carries - AID.WRPS.STN.LEVEL - and on the instrument tag for the 20 field devices that never reach SCADA. gen_historian_items.py reads db/seed/scada-source/ by default, so it runs for anyone with a clone: --wrps is now --source. The tag match is the item name itself, an identity lookup, and the TAG_FOR_ITEM special case is deleted - all 49 item names are unique, which the old point names were not. scada_point in the output is replaced by ci_station, ci_point and poll_group; 001_schema.sql and deploy.sh's upsert follow. Regenerated, and it comes out the same shape it went in: 49 items, 45 answerable, 4 deliberately excluded, three groups at 5 s, 30 s and on change. Every historian_items.tag_id and alarm_bits.tag_id resolves to a tags.csv row. No duplicate keys. THE ONE NAME THAT WAS AMBIGUOUS, AND NEARLY COST US PS_STN_HIGH_LEVEL_ALARM named two different things in the old delivery: the high level alarm STATUS BIT on coil 10, and the alarm SETPOINT on holding register 1032. Building the rename map from that file kept whichever came last, so the status bit was silently renamed onto the setpoint. check_mapping() refused to write and named the item that no longer resolved - which is the only reason this is a paragraph in a commit message rather than a defect. Had it gone through, alarm bit 0 - wet well high level - would have pointed at the setpoint. "How many high level alarms last week" would have counted setpoint changes and returned a small, plausible, confident, wrong number. That check exists because the same ambiguity caused the first Phase 5 finding in August. It has now bitten twice. NOT YET VERIFIED: pytest api/tests could not be run here - this machine has neither fastapi nor psycopg. The tests are unchanged and reference no PS_ name, but they have not been run. pg-ai on lin001 still holds the old keys and must be reloaded, Cube pre-aggregations rebuilt, and the Phase 1 gate re-run. eval/testset.jsonl changed, so the 78-case exam - never yet run - should be run after. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
856 lines
43 KiB
PL/PgSQL
856 lines
43 KiB
PL/PgSQL
-- =============================================================================
|
|
-- 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 $$;
|