yau-plant-assistant/cube/model/process_values.yml
Claude 156d2a6d43 Refresh pre-aggregations daily, not every ten minutes
lin001 is 2 vCPU with 3.8 GiB and no swap, shared with 27 other containers.
Cube was rebuilding three pre-aggregations over static fixture data on 10, 10
and 30 minute keys, and its own log shows what that cost:

    "Previous interval #19593 was not finished with 30000 interval"
    "Interval #19594 finished after 00:02:50"

A refresh taking 2m50s scheduled every 30 seconds, at interval #19594 -
overlapping and never catching up, for days. Every rebuild produced a
byte-identical result, because the fixtures do not change.

24 hours until imh makes the data genuinely live, at which point these get
tuned deliberately rather than left at a number that was never chosen. Cube
also suggests fewer partitions via rollup_lambda; that is a modelling change,
not a demo-day one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 10:53:51 +10:00

212 lines
9 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.
#
# DEFERRED DEFECT - THE LEVEL TAG NAME DOES NOT AGREE WITH THE REFERENCE DATA.
# The history is keyed PS_STN_WET_WELL_LEVEL, hardcoded below in
# seconds_above_high_level_alarm and seconds_above_lshh. db/seed/tags.csv
# carries that name only as an ALIAS of LIT-101, so public.tags has no row with
# that tag_id and a tag-level lookup for WW-101 matches ZERO history rows -
# surfacing as "no records found", which an operator cannot distinguish from
# there genuinely being no data. Filtering by equipment_id works, so whether a
# level question fails depends on the path the agent takes.
#
# Unfixed on purpose: which name is correct is a question for the WRPS register
# map and for imh, not something to guess against fixtures. See Phase 4,
# "Deferred from Phase 5", finding (a) in BUILD-AI-CONTAINERS.md, and eval case
# H26. Fix the seed, these hardcoded names and db/002_fixtures.sql together.
# =============================================================================
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:
# 24h, not minutes: the fixtures are static, so a shorter
# interval rebuilds a byte-identical result on a 2-vCPU host
# and was a standing CPU load for no gain. Tune this back
# deliberately when imh makes the data genuinely live.
every: 24 hours
build_range_start:
sql: "SELECT now() - interval '180 days'"
build_range_end:
sql: "SELECT now()"