diff --git a/api/tools/equipment.py b/api/tools/equipment.py index d2dfb37..a1f63d4 100644 --- a/api/tools/equipment.py +++ b/api/tools/equipment.py @@ -18,6 +18,7 @@ import psycopg from psycopg.rows import dict_row from config import settings +from contracts import ContractViolation log = logging.getLogger("tools.equipment") @@ -56,12 +57,43 @@ def _connect() -> psycopg.Connection: ) +# PS_* IS NOT A NAMESPACE, AND ASKING FOR ONE IS AN ERROR - NOT AN EMPTY RESULT. +# +# The point list delivered in August carried names like PS_STN_WET_WELL_LEVEL +# where the CI Server ITEM name belongs (AID.WRPS.STN.LEVEL). CI Server never +# used them; they were a proposal derived from the PLC register map. A corrected +# delivery arrived 2026-09-01 and the seed carries no PS_ key any more. +# +# Without this guard the old names simply match nothing, and the assistant says +# "no records found" - which an operator reads as "the plant recorded nothing", +# not as "you asked with a retired name". That is the exact confusion the four +# namespaces exist to prevent, so it fails loudly instead. +# +# The four Modbus POLL GROUPS keep the prefix legitimately. They are groups, not +# point or tag names, and nothing resolves an operator term onto one. +_POLL_GROUPS = {"PS_STATUS_BITS", "PS_PUBLISHED", "PS_SETPOINTS", "PS_SIM_CONTROL"} +_RETIRED_TAG = re.compile(r"^PS_[A-Z][A-Z0-9_]*$") + + +def reject_retired_tag(term: str) -> None: + """Raise if `term` is one of the retired PS_* names. See the note above.""" + candidate = term.strip().upper() + if _RETIRED_TAG.match(candidate) and candidate not in _POLL_GROUPS: + raise ContractViolation( + f"{candidate} is a retired name and is not a tag in this system. " + "It came from a superseded SCADA point list; CI Server never used it. " + "Use the CI Server item name instead, for example AID.WRPS.STN.LEVEL. " + "See db/seed/scada-source/README.md." + ) + + def resolve(term: str, *, conn: psycopg.Connection | None = None) -> list[Resolved]: """Resolve one operator term to equipment and/or tags, best first. Returns every plausible match rather than picking one. An ambiguous term is a clarifying question, not a coin toss - agent.py surfaces the alternatives. """ + reject_retired_tag(term) owned = conn is None conn = conn or _connect() try: