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>
233 lines
12 KiB
SQL
233 lines
12 KiB
SQL
-- =============================================================================
|
|
-- 001_schema.sql — pg-ai reference data and document store.
|
|
--
|
|
-- psql -h pg-ai -U postgres -d plant -f 001_schema.sql
|
|
--
|
|
-- What pg-ai is FOR: pgvector document chunks, Cube pre-aggregations, and the
|
|
-- equipment/tag reference data including the alias lists.
|
|
--
|
|
-- What pg-ai is NOT for: historian data. There is no replication job and no
|
|
-- mirror table. imh is already an isolated copy of the raw SCADA historian, so
|
|
-- Cube queries imh directly over TDS/1433 with a read-only login. The only
|
|
-- exception is 002_fixtures.sql, which stands in for imh until it exists.
|
|
--
|
|
-- Storage is UTC everywhere. Conversion to SITE_TIMEZONE happens exactly once,
|
|
-- in Cube. Never in SQL here and never in a prompt.
|
|
-- =============================================================================
|
|
|
|
CREATE EXTENSION IF NOT EXISTS vector;
|
|
|
|
-- -----------------------------------------------------------------------------
|
|
-- equipment — what the operator says.
|
|
--
|
|
-- "Pump 02" is equipment; its data lives on tags. Without this table every
|
|
-- equipment-level question fails, because no historian point is called
|
|
-- "Pump 02". Aliases are the whole point: operators do not type tag numbers.
|
|
-- -----------------------------------------------------------------------------
|
|
CREATE TABLE IF NOT EXISTS equipment (
|
|
equipment_id TEXT PRIMARY KEY, -- PU-302
|
|
display_name TEXT, -- Pump 02
|
|
aliases TEXT[], -- {'Pump 02','pump2','P2','PU-302'}
|
|
equipment_type TEXT, -- pump | vessel | station | piping | ...
|
|
unit_name TEXT, -- WRPS
|
|
description TEXT
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS equipment_aliases_gin ON equipment USING gin (aliases);
|
|
|
|
-- -----------------------------------------------------------------------------
|
|
-- tags — the historian points and field instruments, and how to read them.
|
|
--
|
|
-- engineering_unit, range and setpoints are recorded in the units the HISTORIAN
|
|
-- stores, which are not always the units the PLC works in. Wet well level is
|
|
-- the trap: the PLC works in mm, CI Server historises percent of the spill weir
|
|
-- crest (mm / 60). The description field carries the conversion for every tag
|
|
-- where the two differ. Read it before interpreting a number.
|
|
--
|
|
-- The description also records whether a tag IS HISTORISED. Field inputs to the
|
|
-- PLC (%IW / %IX) are not published to SCADA and have no history at all. An
|
|
-- answer that trends PU-301 vibration is fabricating data.
|
|
-- -----------------------------------------------------------------------------
|
|
CREATE TABLE IF NOT EXISTS tags (
|
|
tag_id TEXT PRIMARY KEY, -- AID.WRPS.STN.LEVEL, LIT-101
|
|
equipment_id TEXT REFERENCES equipment(equipment_id),
|
|
display_name TEXT,
|
|
aliases TEXT[],
|
|
signal_type TEXT, -- level|flow|pressure|status|state|...
|
|
engineering_unit TEXT,
|
|
range_low DOUBLE PRECISION,
|
|
range_high DOUBLE PRECISION,
|
|
alarm_setpoint_hi DOUBLE PRECISION,
|
|
alarm_setpoint_lo DOUBLE PRECISION,
|
|
trip_setpoint DOUBLE PRECISION,
|
|
description TEXT
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS tags_aliases_gin ON tags USING gin (aliases);
|
|
CREATE INDEX IF NOT EXISTS tags_equipment_ix ON tags (equipment_id);
|
|
|
|
-- -----------------------------------------------------------------------------
|
|
-- historian_items — the CI Server item dictionary, and the ONLY place an
|
|
-- item name is joined to a tag.
|
|
--
|
|
-- FOUR NAMESPACES DESCRIBE THE SAME MEASUREMENT and only the last is what the
|
|
-- historian is keyed on:
|
|
--
|
|
-- 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
|
|
--
|
|
-- The history was previously keyed on the third of those while the tag seed
|
|
-- carried it only as an alias of the first, so a tag-level lookup for the wet
|
|
-- well matched zero rows and reported "no records found" — indistinguishable
|
|
-- from an absence of data. That was Phase 5 finding (a).
|
|
--
|
|
-- Two rules keep it from recurring, and both are enforced rather than reviewed:
|
|
--
|
|
-- 1. tag_id is NULLABLE, but a NULL one must carry an exclusion_reason.
|
|
-- The CHECK below is the enforcement. scripts/gen_historian_items.py
|
|
-- refuses to write the seed at all if a historised item has neither.
|
|
-- 2. NOTHING IN THE HISTORY CARRIES AN equipment_id. Equipment is asserted
|
|
-- once, in tags.equipment_id, and reached from history through this
|
|
-- table. Finding (c) was a denormalised equipment column in the history
|
|
-- disagreeing with the tag seed; there is now only one assertion to
|
|
-- disagree with.
|
|
--
|
|
-- Regenerate with: python scripts/gen_historian_items.py --wrps <path>
|
|
-- -----------------------------------------------------------------------------
|
|
CREATE TABLE IF NOT EXISTS historian_items (
|
|
item_name TEXT PRIMARY KEY, -- AID.WRPS.STN.LEVEL
|
|
tag_id TEXT REFERENCES tags(tag_id),
|
|
exclusion_reason TEXT, -- why this item is unanswerable
|
|
section_path TEXT, -- AID.WRPS.STN
|
|
section TEXT, -- STN, PU301, SP, SIM
|
|
attribute TEXT, -- LEVEL, RUNNING, TRIPPED
|
|
section_description TEXT,
|
|
description TEXT,
|
|
eng_unit TEXT, -- what CI Server presents, not PLC units
|
|
value_format TEXT,
|
|
conv_type TEXT, -- Linear | Digital
|
|
has_sign BOOLEAN,
|
|
phys_low DOUBLE PRECISION,
|
|
phys_high DOUBLE PRECISION,
|
|
eng_gain DOUBLE PRECISION, -- raw register x gain, offset always 0
|
|
raw_to_eng TEXT,
|
|
his_group TEXT, -- WRPS_ONE_SEC | WRPS_THIRTY_SEC | WRPS_EVENT
|
|
scan_interval_seconds INT, -- NULL for the on-change group
|
|
life_time TEXT, -- retention, "1 weeks" on every WRPS group
|
|
ci_station TEXT, -- Modbus station, WRPS_PLC
|
|
ci_point TEXT, -- CI Server point name, STN_LEVEL
|
|
poll_group TEXT, -- PS_PUBLISHED | PS_STATUS_BITS | PS_SETPOINTS | PS_SIM_CONTROL
|
|
iec_address TEXT,
|
|
modbus_kind TEXT,
|
|
modbus_address INT,
|
|
data_type TEXT,
|
|
point_time_zone TEXT, -- "Date+time GMT" on all 49 points
|
|
CONSTRAINT historian_items_resolvable CHECK (
|
|
tag_id IS NOT NULL
|
|
OR exclusion_reason IS NOT NULL
|
|
OR his_group IS NULL
|
|
)
|
|
);
|
|
CREATE INDEX IF NOT EXISTS historian_items_tag_ix ON historian_items (tag_id);
|
|
CREATE INDEX IF NOT EXISTS historian_items_group_ix ON historian_items (his_group);
|
|
|
|
-- -----------------------------------------------------------------------------
|
|
-- alarm_bits — how the PLC alarm word decomposes.
|
|
--
|
|
-- Every alarm at this station is a bit of %QW17, historised as the item
|
|
-- AID.WRPS.STN.ALARM_WORD. The discrete items (STN.HIGH_LEVEL,
|
|
-- STN.SPILL_ACTIVE, PU30x.TRIPPED) mirror bits 0, 3 and 4-6 rather than
|
|
-- being separate sources, so decomposing the word is the single derivation
|
|
-- that produces every alarm — see cube/model/alarms.yml.
|
|
--
|
|
-- WHY THIS IS REFERENCE DATA AND NOT A CUBE CONSTANT: the bit map is a
|
|
-- property of the PLC program and it survives the cutover to imh unchanged.
|
|
-- Putting it here means the equipment behind "PU-303 seal leak" is reached by
|
|
-- bit -> tag_id -> tags.equipment_id, the same single assertion as everything
|
|
-- else, instead of being a string inside a model file.
|
|
--
|
|
-- READ THE WORD AS UNSIGNED. Bit 15 does not fit a signed INT, so a signed
|
|
-- read turns the alarm word negative exactly when the most severe alarm sets.
|
|
-- -----------------------------------------------------------------------------
|
|
CREATE TABLE IF NOT EXISTS alarm_bits (
|
|
bit INT PRIMARY KEY CHECK (bit BETWEEN 0 AND 15),
|
|
alarm_type TEXT NOT NULL,
|
|
priority INT NOT NULL CHECK (priority BETWEEN 1 AND 3),
|
|
tag_id TEXT NOT NULL REFERENCES tags(tag_id),
|
|
alarm_text TEXT,
|
|
description TEXT
|
|
);
|
|
|
|
-- -----------------------------------------------------------------------------
|
|
-- doc_chunks — controlled documents, chunked and embedded.
|
|
--
|
|
-- superseded and effective_date matter more than they look. Citing a withdrawn
|
|
-- revision of a procedure is worse than finding nothing at all, so retrieval
|
|
-- filters superseded = FALSE by default and the citation always carries the
|
|
-- revision and effective date.
|
|
-- -----------------------------------------------------------------------------
|
|
CREATE TABLE IF NOT EXISTS doc_chunks (
|
|
id BIGSERIAL PRIMARY KEY,
|
|
source_file TEXT NOT NULL,
|
|
doc_type TEXT NOT NULL, -- procedure|manual|rationalisation|design
|
|
doc_number TEXT, -- WRPS-CTL-002
|
|
revision TEXT,
|
|
effective_date DATE,
|
|
superseded BOOLEAN DEFAULT FALSE,
|
|
equipment_id TEXT,
|
|
page INT,
|
|
section_title TEXT,
|
|
chunk_text TEXT NOT NULL,
|
|
embedding VECTOR(1536), -- text-embedding-3-small
|
|
created_at TIMESTAMPTZ DEFAULT now(),
|
|
CONSTRAINT doc_chunks_type_ck
|
|
CHECK (doc_type IN ('procedure','manual','rationalisation','design'))
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS doc_chunks_embedding_hnsw
|
|
ON doc_chunks USING hnsw (embedding vector_cosine_ops);
|
|
CREATE INDEX IF NOT EXISTS doc_chunks_live_type
|
|
ON doc_chunks (doc_type) WHERE superseded = FALSE;
|
|
CREATE INDEX IF NOT EXISTS doc_chunks_source
|
|
ON doc_chunks (source_file);
|
|
CREATE INDEX IF NOT EXISTS doc_chunks_equipment
|
|
ON doc_chunks (equipment_id);
|
|
|
|
-- Re-ingesting a file replaces its chunks; it must never duplicate them.
|
|
-- ingest.py deletes by source_file inside the same transaction as the insert.
|
|
|
|
-- -----------------------------------------------------------------------------
|
|
-- Cube writes its pre-aggregations into their own schema, with its own role.
|
|
-- -----------------------------------------------------------------------------
|
|
CREATE SCHEMA IF NOT EXISTS cube_preagg;
|
|
|
|
-- =============================================================================
|
|
-- Seed load. Aliases are pipe-separated in the CSVs because a Postgres array
|
|
-- literal inside CSV is unreadable and unmergeable in review. Load via a
|
|
-- staging table and split on load.
|
|
--
|
|
-- psql -h pg-ai -U postgres -d plant -v ON_ERROR_STOP=1 <<'PSQL'
|
|
-- \i 001_schema.sql
|
|
-- CREATE TEMP TABLE eq_stage (LIKE equipment INCLUDING ALL);
|
|
-- ALTER TABLE eq_stage ALTER COLUMN aliases TYPE TEXT;
|
|
-- \copy eq_stage FROM 'seed/equipment.csv' WITH (FORMAT csv, HEADER true)
|
|
-- INSERT INTO equipment
|
|
-- SELECT equipment_id, display_name, string_to_array(aliases, '|'),
|
|
-- equipment_type, unit_name, description
|
|
-- FROM eq_stage
|
|
-- ON CONFLICT (equipment_id) DO UPDATE SET
|
|
-- display_name = EXCLUDED.display_name, aliases = EXCLUDED.aliases,
|
|
-- equipment_type = EXCLUDED.equipment_type, unit_name = EXCLUDED.unit_name,
|
|
-- description = EXCLUDED.description;
|
|
-- PSQL
|
|
--
|
|
-- Same shape for tags. scripts/deploy.sh does this for you.
|
|
--
|
|
-- Phase 1 gate: every equipment item and every tag has at least one
|
|
-- human-friendly alias. Check it, do not assume it:
|
|
--
|
|
-- SELECT equipment_id FROM equipment WHERE coalesce(array_length(aliases,1),0) = 0;
|
|
-- SELECT tag_id FROM tags WHERE coalesce(array_length(aliases,1),0) = 0;
|
|
-- =============================================================================
|