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