yau-plant-assistant/db/002_fixtures.sql
Claude 34d2ccc576 Scaffold the WRPS plant operations assistant repository
Build spec and host brief carried in from C:\Claude and WRPS/02-env; the
plant model (equipment, tags, alarm bitmask, enums, unit conversions) is
derived from WRPS/04-plc/register-map.csv, WRPS/05-scada/modbus/scada-points.csv
and WRPS-CTL-003.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 13:56:32 +10:00

300 lines
14 KiB
SQL

-- =============================================================================
-- 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 EXISTS: Phase 4 is the only true blocker in the build. Cube, the
-- contracts, the agent and the UI can all be built and tested against fixtures;
-- swapping to imh then becomes a connection-string change plus a re-verify of
-- the Phase 5 gate.
--
-- THE COLUMN NAMES BELOW ARE ASSUMED, NOT AGREED. Section 10 of
-- BUILD-AI-CONTAINERS.md must be updated with the real imh schema as soon as
-- the owner confirms it, and this file changed to match — the point of the
-- fixtures is that the shape is right, so a wrong shape is worse than useless.
--
-- Still to agree with whoever builds imh (do this now, not at Phase 4):
-- * the read-only login name, and how the password reaches you
-- * the alarm / process value / operation table names and key columns
-- * whether timestamps are UTC or local, and DST behaviour
-- * whether an `operations` concept exists at all, or must be derived
-- * an NSG rule allowing lin001 -> imh on 1433 only
--
-- 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. See db/002_fixtures.sql.';
-- -----------------------------------------------------------------------------
-- fixture.alarm_history — one row per alarm state transition.
--
-- Definition used here, and it must match whatever imh turns out to do:
-- an ALARM is a transition INTO the active state. A row with state 'RTN' is the
-- return to normal for the preceding activation, not a second alarm. Counting
-- every row doubles every answer, which is the single most likely way the
-- "how many times last week" question returns a wrong number confidently.
-- -----------------------------------------------------------------------------
CREATE TABLE fixture.alarm_history (
alarm_id BIGSERIAL PRIMARY KEY,
event_time TIMESTAMPTZ NOT NULL, -- UTC. Cube converts, once.
tag_id TEXT NOT NULL,
equipment_id TEXT,
alarm_type TEXT NOT NULL, -- HIGH_LEVEL|HIGH_HIGH_LEVEL|SPILL|
-- PUMP_TRIP|SEAL_LEAK|HIGH_VIBRATION|
-- LEVEL_SIGNAL_FAULT|MAINS_FAILURE|
-- SETPOINT_REJECTED|LOW_LOW_LEVEL
priority INT, -- 1 highest .. 3 lowest
state TEXT NOT NULL, -- ACTIVE | RTN | ACK
value DOUBLE PRECISION, -- process value at transition
engineering_unit TEXT,
description TEXT,
is_fixture BOOLEAN NOT NULL DEFAULT TRUE
);
CREATE INDEX ON fixture.alarm_history (event_time);
CREATE INDEX ON fixture.alarm_history (tag_id, event_time);
CREATE INDEX ON fixture.alarm_history (equipment_id, event_time);
-- -----------------------------------------------------------------------------
-- fixture.process_value_history — sampled analogue history.
--
-- 1-minute samples. Real CI Server historisation is deadband-compressed, so the
-- real table will be irregular; anything in Cube that assumes an even sample
-- interval will be wrong against imh. Time-weight the averages, do not mean the
-- rows. This fixture is deliberately regular so that the difference shows up as
-- a behaviour change when imh is connected, rather than hiding.
-- -----------------------------------------------------------------------------
CREATE TABLE fixture.process_value_history (
sample_time TIMESTAMPTZ NOT NULL, -- UTC
tag_id TEXT NOT NULL,
equipment_id TEXT,
value DOUBLE PRECISION,
engineering_unit TEXT,
quality TEXT DEFAULT 'GOOD', -- GOOD | BAD | UNCERTAIN
is_fixture BOOLEAN NOT NULL DEFAULT TRUE,
PRIMARY KEY (tag_id, sample_time)
);
CREATE INDEX ON fixture.process_value_history (sample_time);
-- -----------------------------------------------------------------------------
-- fixture.operation_history — pump-down operations ("fills" inverted).
--
-- WRPS is a pump station, not a tank farm: the operation of interest is a
-- PUMP-DOWN — the well fills on inflow, pumps start at the duty level, the
-- level is drawn back to the stop level. One row per pump-down.
--
-- If imh has no equivalent, derive it in Cube from a monotonic level fall while
-- pumps_running > 0, and document the heuristic in cube/model/operations.yml.
-- Keep the heuristic simple; a clever one nobody can explain is not evidence.
-- -----------------------------------------------------------------------------
CREATE TABLE fixture.operation_history (
operation_id BIGSERIAL PRIMARY KEY,
equipment_id TEXT NOT NULL, -- STN-001
operation_type TEXT NOT NULL DEFAULT 'PUMP_DOWN',
start_time TIMESTAMPTZ NOT NULL, -- UTC
end_time TIMESTAMPTZ,
start_level_pct DOUBLE PRECISION,
end_level_pct DOUBLE PRECISION,
max_level_pct DOUBLE PRECISION,
avg_inflow_m3h DOUBLE PRECISION,
avg_discharge_m3h DOUBLE PRECISION,
peak_pumps_running INT,
duty_pump TEXT, -- PU-301 | PU-302 | PU-303
high_level_alarm BOOLEAN DEFAULT FALSE, -- did it reach the HLA setpoint
spill BOOLEAN DEFAULT FALSE, -- did it go over the weir
is_fixture BOOLEAN NOT NULL DEFAULT TRUE
);
CREATE INDEX ON fixture.operation_history (start_time);
CREATE INDEX ON fixture.operation_history (equipment_id, start_time);
-- =============================================================================
-- Generated rows. 30 days ending at the load time, UTC.
--
-- The pattern is deliberately boring and explainable: a diurnal inflow with a
-- morning and evening peak, a wet-weather week in the middle, pump-downs every
-- couple of hours, and a small number of alarms clustered in the wet week. It
-- is enough to exercise every question class and no more. Do not add realism
-- here — realism in fixtures is how a fixture number ends up in a slide.
-- =============================================================================
-- --- process values: 1-minute wet well level and flows, 30 days -------------
INSERT INTO fixture.process_value_history
(sample_time, tag_id, equipment_id, value, engineering_unit)
SELECT
ts,
'PS_STN_WET_WELL_LEVEL',
'WW-101',
-- sawtooth between the stop-all level and roughly the duty start level,
-- riding on the diurnal inflow, pushed higher during the wet week.
round((
30.0
+ 25.0 * abs(((extract(epoch FROM ts)::int / 60) % 140)::numeric / 140.0 - 0.5) * 2
+ 12.0 * sin(extract(epoch FROM ts) / 13750.0)
+ CASE WHEN ts BETWEEN now() - interval '18 days'
AND now() - interval '11 days' THEN 22.0 ELSE 0.0 END
)::numeric, 1)::double precision,
'%'
FROM generate_series(now() - interval '30 days', now(), interval '1 minute') AS ts;
INSERT INTO fixture.process_value_history
(sample_time, tag_id, equipment_id, value, engineering_unit)
SELECT
ts,
'PS_STN_INFLOW',
'STN-001',
round((
180.0
+ 90.0 * sin((extract(epoch FROM ts) - 21600) * 2 * pi() / 86400.0)
+ 40.0 * sin((extract(epoch FROM ts)) * 4 * pi() / 86400.0)
+ CASE WHEN ts BETWEEN now() - interval '18 days'
AND now() - interval '11 days' THEN 420.0 ELSE 0.0 END
)::numeric, 1)::double precision,
'm3/h'
FROM generate_series(now() - interval '30 days', now(), interval '1 minute') AS ts;
INSERT INTO fixture.process_value_history
(sample_time, tag_id, equipment_id, value, engineering_unit)
SELECT
ts,
'PS_STN_TOTAL_DISCHARGE_FLOW',
'STN-001',
CASE WHEN ((extract(epoch FROM ts)::int / 60) % 140) < 55
THEN 0.0
ELSE round((432.0 + 30.0 * sin(extract(epoch FROM ts) / 900.0))::numeric, 1)::double precision
END,
'm3/h'
FROM generate_series(now() - interval '30 days', now(), interval '1 minute') AS ts;
-- --- pump-down operations: roughly every 140 minutes for 30 days ------------
INSERT INTO fixture.operation_history
(equipment_id, operation_type, start_time, end_time,
start_level_pct, end_level_pct, max_level_pct,
avg_inflow_m3h, avg_discharge_m3h, peak_pumps_running, duty_pump,
high_level_alarm, spill)
SELECT
'STN-001',
'PUMP_DOWN',
st,
st + make_interval(mins => 35 + (n % 12)),
66.7,
16.7,
max_lvl,
inflow,
CASE WHEN max_lvl > 83.3 THEN 1296.0 WHEN max_lvl > 75.0 THEN 864.0 ELSE 432.0 END,
CASE WHEN max_lvl > 83.3 THEN 3 WHEN max_lvl > 75.0 THEN 2 ELSE 1 END,
-- duty rotates on lowest run hours; over a long window that is round-robin
'PU-30' || (1 + (n % 3))::text,
max_lvl >= 86.7,
max_lvl >= 100.0
FROM (
SELECT
n,
now() - interval '30 days' + make_interval(mins => n * 140) AS st,
CASE WHEN now() - interval '30 days' + make_interval(mins => n * 140)
BETWEEN now() - interval '18 days' AND now() - interval '11 days'
THEN 84.0 + (n % 7) * 2.9 -- wet week: reaches HLA, sometimes spills
ELSE 68.0 + (n % 5) * 1.4 -- normal: comfortably below HLA
END AS max_lvl,
CASE WHEN now() - interval '30 days' + make_interval(mins => n * 140)
BETWEEN now() - interval '18 days' AND now() - interval '11 days'
THEN 620.0 ELSE 205.0
END AS inflow
FROM generate_series(0, (30 * 24 * 60) / 140) AS n
) s
WHERE st < now();
-- --- alarms: derived from the operations above, so they stay consistent -----
-- One ACTIVE row per alarm, one RTN row per activation. Counting must count
-- ACTIVE only.
INSERT INTO fixture.alarm_history
(event_time, tag_id, equipment_id, alarm_type, priority, state, value,
engineering_unit, description)
SELECT
o.start_time + interval '20 minutes',
'PS_STN_HIGH_LEVEL_ALARM',
'WW-101',
'HIGH_LEVEL',
2,
'ACTIVE',
o.max_level_pct,
'%',
'Wet well level above the high level alarm setpoint'
FROM fixture.operation_history o
WHERE o.high_level_alarm;
INSERT INTO fixture.alarm_history
(event_time, tag_id, equipment_id, alarm_type, priority, state, value,
engineering_unit, description)
SELECT
o.start_time + interval '32 minutes',
'PS_STN_HIGH_LEVEL_ALARM',
'WW-101',
'HIGH_LEVEL',
2,
'RTN',
70.0,
'%',
'Wet well level returned below the high level alarm setpoint'
FROM fixture.operation_history o
WHERE o.high_level_alarm;
INSERT INTO fixture.alarm_history
(event_time, tag_id, equipment_id, alarm_type, priority, state, value,
engineering_unit, description)
SELECT
o.start_time + interval '25 minutes',
'PS_STN_SPILL_ACTIVE',
'WEIR-105',
'SPILL',
1,
'ACTIVE',
o.max_level_pct,
'%',
'Spill over the weir - environmental reportable event'
FROM fixture.operation_history o
WHERE o.spill;
-- A handful of pump trips, one per week, so trip questions have something to
-- find and the duty-promotion story is visible.
INSERT INTO fixture.alarm_history
(event_time, tag_id, equipment_id, alarm_type, priority, state, value,
engineering_unit, description)
VALUES
(now() - interval '26 days 4 hours', 'PS_PU302_TRIPPED', 'PU-302', 'PUMP_TRIP', 1, 'ACTIVE', 1, NULL, 'PU-302 tripped - no flow 20 s after start (PIT-321 below 150 kPa)'),
(now() - interval '26 days 1 hour', 'PS_PU302_TRIPPED', 'PU-302', 'PUMP_TRIP', 1, 'RTN', 0, NULL, 'PU-302 trip reset by operator command'),
(now() - interval '19 days 9 hours', 'PS_PU301_TRIPPED', 'PU-301', 'PUMP_TRIP', 1, 'ACTIVE', 1, NULL, 'PU-301 tripped - motor thermal TE-312'),
(now() - interval '19 days 2 hours', 'PS_PU301_TRIPPED', 'PU-301', 'PUMP_TRIP', 1, 'RTN', 0, NULL, 'PU-301 trip reset by operator command'),
(now() - interval '14 days 6 hours', 'PS_STN_ALARM_BITMASK', 'PU-303', 'SEAL_LEAK', 3, 'ACTIVE', 1, NULL, 'PU-303 seal leak MSE-333 - alarm only, unit remains available'),
(now() - interval '12 days 3 hours', 'PS_STN_ALARM_BITMASK', 'PU-301', 'HIGH_VIBRATION', 2, 'ACTIVE', 8.4, 'mm/s', 'PU-301 bearing vibration above 7.1 mm/s alarm threshold'),
(now() - interval '12 days 1 hour', 'PS_STN_ALARM_BITMASK', 'PU-301', 'HIGH_VIBRATION', 2, 'RTN', 6.2, 'mm/s', 'PU-301 bearing vibration returned below threshold'),
(now() - interval '6 days 11 hours', 'PS_STN_ALARM_BITMASK', 'WW-101', 'LEVEL_SIGNAL_FAULT', 1, 'ACTIVE', NULL, NULL, 'LIT-101 frozen - no change greater than 1 mm for 10 minutes with a pump running'),
(now() - interval '6 days 10 hours', 'PS_STN_ALARM_BITMASK', 'WW-101', 'LEVEL_SIGNAL_FAULT', 1, 'RTN', NULL, NULL, 'LIT-101 signal restored'),
(now() - interval '3 days 5 hours', 'PS_STN_ALARM_BITMASK', 'STN-001', 'SETPOINT_REJECTED', 3, 'ACTIVE', 6500, 'mm', 'Setpoint write rejected - start duty level above the spill weir; previous value retained');
-- =============================================================================
-- Sanity check after loading. Expect roughly: 30 days of 1-minute samples on
-- three tags, ~300 pump-downs, and alarms concentrated in the wet week.
--
-- SELECT count(*) FROM fixture.process_value_history;
-- SELECT count(*) FROM fixture.operation_history;
-- SELECT alarm_type, state, count(*)
-- FROM fixture.alarm_history GROUP BY 1,2 ORDER BY 1,2;
--
-- And the check that matters: everything here answers TRUE.
--
-- SELECT bool_and(is_fixture) FROM fixture.alarm_history;
-- =============================================================================