yau-plant-assistant/cube/model/process_values.yml
Claude d6b6f4f116 Fix two Cube measures that were invalid SQL
Hand-verifying the measures against the fixtures on lin001, per the Phase 5
gate. Two of them had never executed anywhere, and both failed outright rather
than returning a wrong number - which is the good version of this, but they
failed at the point an operator asks a question, not at review.

  - time_weighted_avg put LEAD() inside SUM(). Postgres rejects that flatly:
    "aggregate function calls cannot contain window function calls". The per
    sample duration now comes from the cube's source query, which changes
    sql_table to sql, and the measure just sums value * duration over duration.
    The last sample of each tag gets a NULL duration and SUM skips it, which is
    correct - how long it stood is not yet known.

    This is the measure that matters most later. On the regular one-minute
    fixtures it agrees with avg_value to thirteen decimal places
    (42.45934027777778 against 42.45934027777775), which proves it is wired up
    and proves nothing about imh, where the deadband makes samples irregular
    and the two will not agree. Re-verify it there.

  - p95_value applied the quality filter through a Cube measure `filters:`
    block, which lands outside the aggregate and cannot work on an ordered-set
    aggregate: "column process_values.quality must appear in the GROUP BY
    clause". Folded into the CASE inside PERCENTILE_CONT, whose NULL handling
    does the exclusion.

Also: the priority dimension said only SPILL and PUMP_TRIP are priority 1,
while the data has LEVEL_SIGNAL_FAULT at priority 1 too - correctly, losing the
level signal on a well that can spill is a priority 1 condition. That comment is
the line an engineer reads when checking a priority_1_count, so it disagreeing
with the data matters more than its length suggests.

eval cases H24 and H25 record the two failures, added before the fix.

Verified against hand-written SQL on the same pinned windows: p95_value
61.104999999999976 and time_weighted_avg 42.45934027777778 both match to the
floating point tail, as do sample_count, avg_value, max_value, min_value,
seconds_above_high_level_alarm (7680 = 128 samples x 60) and every measure in
alarms and operations.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 14:36:20 +10:00

194 lines
7.8 KiB
YAML

# =============================================================================
# process_values.yml — sampled analogue history.
#
# SOURCE: fixture.process_value_history while USE_FIXTURES=true; the agreed imh
# process value table from Phase 4.
#
# THE THING THAT WILL BITE WHEN imh IS CONNECTED: CI Server historises with a
# deadband, so real samples are IRREGULAR. The fixtures are regular 1-minute
# samples. Any measure that averages rows rather than time-weighting them will
# look correct on fixtures and be wrong on imh - a flat period compresses to
# one row and a noisy period to hundreds, so a plain avg is weighted by how
# interesting the signal was. avg_value below is a plain average and is
# documented as an approximation; time_weighted_avg is the one to trust, and it
# must be re-verified against imh at the Phase 5 gate.
#
# SENTINELS: PS_STN_TIME_TO_SPILL_WEIR and PS_STN_TIME_TO_LSHH use 32767 to
# mean "drawing down or holding" - it is not a duration. Every measure here
# excludes it. Do not remove that filter to make a number look tidier.
#
# QUALITY: rows with quality other than GOOD are excluded from every measure.
# A BAD sample from a failed transmitter is not a low reading.
#
# UNITS: whatever the historian stores, which is not always what the PLC works
# in. Wet well level is historised as percent of the spill weir crest (raw mm
# divided by 60): 100.0 % = 6000 mm. See db/seed/tags.csv for every conversion.
# =============================================================================
cubes:
- name: process_values
# NOT sql_table, because time_weighted_avg needs to know how long each
# sample stood, and that is a window function - which Postgres will not
# allow inside an aggregate. So the gap is computed once here, per tag, and
# the measure just sums it. The alternative (LEAD inside SUM) is what this
# file used to say, and it failed every query outright on lin001.
#
# The last sample of each tag gets a NULL duration, which is correct: how
# long it stood is not yet known, and SUM skips it.
#
# AT PHASE 4 this window runs over imh, not over 130k fixture rows next
# door. Check the plan before trusting it - if it scans the whole history
# per query, push the LEAD into a pre-aggregation or a derived table.
sql: >
SELECT
sample_time,
tag_id,
equipment_id,
value,
engineering_unit,
quality,
is_fixture,
EXTRACT(EPOCH FROM (
LEAD(sample_time) OVER (PARTITION BY tag_id ORDER BY sample_time)
- sample_time
)) AS sample_duration_seconds
FROM fixture.process_value_history -- -> imh PV table at Phase 4
description: >
Sampled analogue history - wet well level, inflow, discharge flow, drive
speed, run hours. This is what makes an advisory question answerable with
evidence; you cannot answer a flow question from alarms.
joins:
- name: equipment
sql: "{CUBE}.equipment_id = {equipment}.equipment_id"
relationship: many_to_one
dimensions:
- name: id
sql: "{CUBE}.tag_id || '@' || {CUBE}.sample_time"
type: string
primary_key: true
- name: sample_time
sql: sample_time
type: time
description: Stored UTC, presented in SITE_TIMEZONE. Converted once, here.
- name: tag_id
sql: tag_id
type: string
- name: equipment_id
sql: equipment_id
type: string
- name: engineering_unit
sql: engineering_unit
type: string
description: >
Always report this with the number. A level of 86.7 is a percentage
of the weir crest, not a metre reading.
- name: quality
sql: quality
type: string
- name: is_fixture
sql: is_fixture
type: boolean
measures:
- name: sample_count
type: count
filters:
- sql: "{CUBE}.quality = 'GOOD' AND {CUBE}.value <> 32767"
- name: avg_value
sql: value
type: avg
filters:
- sql: "{CUBE}.quality = 'GOOD' AND {CUBE}.value <> 32767"
description: >
APPROXIMATION. Plain average of samples. Correct on the regular
fixture data; biased on deadband-compressed imh data. Prefer
time_weighted_avg for anything an engineer will check.
- name: time_weighted_avg
sql: >
SUM(CASE WHEN {CUBE}.quality = 'GOOD' AND {CUBE}.value <> 32767
THEN {CUBE}.value * {CUBE}.sample_duration_seconds END)
/ NULLIF(SUM(CASE WHEN {CUBE}.quality = 'GOOD' AND {CUBE}.value <> 32767
THEN {CUBE}.sample_duration_seconds END), 0)
type: number
description: >
Time-weighted average - each sample weighted by how long it stood,
from sample_duration_seconds in the cube's source query above. This
is the honest average on deadband-compressed history, and on regular
fixture data it agrees with avg_value to a rounding error - which is
exactly why it must be re-verified against imh, where the two will
NOT agree. Bad and sentinel samples are excluded in the CASE rather
than by a measure filter, for the same reason as p95_value below.
- name: max_value
sql: value
type: max
filters:
- sql: "{CUBE}.quality = 'GOOD' AND {CUBE}.value <> 32767"
- name: min_value
sql: value
type: min
filters:
- sql: "{CUBE}.quality = 'GOOD' AND {CUBE}.value <> 32767"
- name: p95_value
# The quality filter is INSIDE the ordered-set aggregate on purpose.
# A Cube measure `filters:` block cannot be applied to PERCENTILE_CONT
# - it lands outside the aggregate and Postgres rejects the query with
# "column process_values.quality must appear in the GROUP BY clause".
# PERCENTILE_CONT ignores the NULLs the CASE produces.
sql: >
PERCENTILE_CONT(0.95) WITHIN GROUP (
ORDER BY CASE WHEN {CUBE}.quality = 'GOOD' AND {CUBE}.value <> 32767
THEN {CUBE}.value END)
type: number
description: >
95th percentile. More useful than max for "how high does it normally
get", because max is one sample and often a transient.
- name: seconds_above_high_level_alarm
sql: >
SUM(CASE WHEN {CUBE}.tag_id = 'PS_STN_WET_WELL_LEVEL'
AND {CUBE}.value >= 86.7 THEN 60 ELSE 0 END)
type: number
description: >
Seconds the wet well spent above the high level alarm setpoint
(86.7 % = 5200 mm, the %MW8 default). ASSUMES A 60 SECOND SAMPLE
INTERVAL, true of the fixtures and NOT true of imh. When imh is
connected this must be rewritten to sum actual sample gaps - it is on
the Phase 5 gate list for exactly that reason. If the setpoint itself
was changed during the window (PS_STN_HIGH_LEVEL_ALARM_SP), this
measure is wrong and the answer must say so.
- name: seconds_above_lshh
sql: >
SUM(CASE WHEN {CUBE}.tag_id = 'PS_STN_WET_WELL_LEVEL'
AND {CUBE}.value >= 91.7 THEN 60 ELSE 0 END)
type: number
description: >
Seconds above LSHH (91.7 % = 5500 mm). Same 60 second assumption as
above. Any non-zero value here is worth reporting explicitly.
pre_aggregations:
- name: pv_by_hour
measures: [avg_value, max_value, min_value, sample_count]
dimensions: [tag_id, equipment_id, engineering_unit]
time_dimension: sample_time
granularity: hour
partition_granularity: month
refresh_key:
every: 10 minutes
build_range_start:
sql: "SELECT now() - interval '180 days'"
build_range_end:
sql: "SELECT now()"