Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

open-control-library

Executable, engine-verified HVAC fault detection rules. Every rule is a CDL block graph, shipped as CXF JSON-LD that the open-control engine loads directly — no code generation, no black box.

📖 Browse the library — every rule, playbook, and point dictionary, published as a book.

Status: early access. Shared for review ahead of a broader release; content and contracts may still change.

Why

Most FDD libraries are papers, spreadsheets, or vendor black boxes. This one runs — and you can audit every step, from the plain-language fault card down to the exact logic the engine verified.

How a rule ships

Each rule is one folder of four files that travel together:

FileWhat it is
card.mdthe fault card — what the rule detects, the points and parameters it needs, likely diagnoses, energy impact, and every judgment call written down
rule.cxf.jsonldthe detection logic itself, as elementary CDL blocks
vectors.jsonexecutable test scenarios that pin the rule’s behavior from both sides of every threshold
diagram.svgthe block graph, drawn

A rule is marked verified only when the engine at a pinned revision replays every vector green. The engine’s content_id is stamped on the card, so the verified logic stays identifiable forever.

Here’s a real one — AHU-0016, simultaneous heating and cooling:

AHU-0016 block graph

What’s inside

  • 137 verified fault rules across fourteen equipment families — air handlers, VAV boxes, fan-powered terminals, rooftop units, heat pumps, chillers, cooling towers, hot-water plants, hydronic heat exchangers, fan coils, energy recovery ventilators, pumps, VFDs, and cross-equipment sensor-health rules. The Fault Code Map lists them all.
  • Point dictionaries (points/) — every canonical point name grounded in Brick 1.4.4 and ASHRAE 223P, so binding a rule to a real building is mechanical.
  • Remediation playbooks (playbooks/) — verify → remote fix → on-site service → confirm, with typical costs and times.
  • Fault clusters (clusters/) — faults that share a root cause, with a trigger rule and a fix order.
  • CI verification (tools/verify) — every push replays every rule against the pinned engine.

Every card cites its sources — standards, research reports, and this library’s own simulation studies — and records each place the rule departs from them. The detail lives on the cards; start with any rule in the book.

Design stance

The graph computes fault-given-valid-data, and only that. Data quality, operating-state gating, and suppression are host concerns, declared on the card for the host to enforce — never buried in the logic. Rules stay portable, and every judgment call is written down where a reviewer can disagree with it.

Quick start

# read the contracts
$EDITOR SCHEMA.md

# run one rule's vectors against the engine (sibling checkout of open-control required)
cargo run --manifest-path tools/verify/Cargo.toml -- faults/ahu/AHU-0016

# run everything
cargo run --manifest-path tools/verify/Cargo.toml -- --all

# build the book locally
python3 tools/book/generate.py && mdbook serve book --open

Layout

  • SCHEMA.md — the normative contracts. Read this first.
  • faults/<equip>/<FAULT-ID>/ — one folder per rule; faults/registry.json maps them all.
  • points/ — canonical point dictionaries.
  • playbooks/, clusters/ — remediation workflows and fault syndromes.
  • tools/ — the verifier, lints, and book generator.

License

Licensed under either of

at your option. Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in the work by you, as defined in the Apache-2.0 license, shall be dual licensed as above, without any additional terms or conditions.


This book is generated from the repository by tools/book/generate.py; the files above are the source of truth.

cxf-library Schema

Normative contract for this library’s layout and file formats. Contract versions are per artifact: fault contracts remain v1 where named, and routine catalog artifacts use the identifiers in their section. Changes to any contract in this file require bumping the affected identifier.

Layout

cxf-library/
├── SCHEMA.md                    # this contract
├── points/<equip>.points.json   # canonical point dictionary per equipment family
├── faults/<equip>/<FAULT-ID>/   # one folder per fault rule
│   ├── card.md                  # fault card: YAML frontmatter (machine) + prose (human)
│   ├── rule.cxf.jsonld          # detection logic, hand-authored CXF JSON-LD
│   ├── vectors.json             # executable test scenarios
│   └── diagram.svg              # block-graph figure referenced from card.md
├── faults/<equip>/README.md     # chapter index and status table
├── playbooks/<slug>.md          # remediation playbooks (shared across faults)
├── clusters/clusters.json       # fault clusters (syndromes with shared root cause)
├── routines/                    # planned control-routine catalog
│   ├── registry.json            # canonical class inventory
│   ├── generated-registry.json  # executable deployment inventory
│   ├── ontology/                # immutable ontology pins and local vocabulary
│   ├── schemas/                 # six governed routine contract schemas
│   └── g36/                     # G36 pins, source inventory, scope, and coverage
├── tools/verify/                # Rust harness: loads each rule into the engine, runs vectors

Equipment family keys: ahu, vav, fpb, rtu, hp, fcu, chw, hw, hx, erv, pmp, vfd, sys, tower. Fault IDs live in a general namespace: {EQUIP}-{NNNN} — uppercase family key, four digits, contiguous from 0001 per family in authoring order. The folder name is the fault ID. The number carries no semantic meaning; provenance lives in each card’s source: list. IDs are stable identifiers and are never reused; renames are exceptional (the CXF does not embed the fault ID, so a rename never churns content_id, but it does break external links and every cross-reference).

faults/registry.json (cxf-library/registry/v1) is the library-wide fault-code map: one row per rule — id, family, name, method, status, and legacy_id (the rule’s pre-2026-08-18 {EQUIP}-FC-{NNN} code, for continuity with older references). The registry is orchestrator-maintained like clusters/clusters.json, and tools/lint/registry.py enforces in CI that it stays a bijection with the fault dirs, that IDs match the format and their folder, and that names/statuses match the cards. When a card is added, renamed, or changes status, the registry row moves with it in the same PR.

Reserved-but-unauthored IDs (a planned rule a README or card already names) are allowed: they appear in prose and index tables marked planned/deferred, never in the registry, and the next authored rule in that family takes the next free number, honoring any reservation.

Routine catalog

Routine contracts are independent of fault contracts. Nothing in this section changes a fault schema identifier or fault behavior. The routine catalog is schema-defined and non-executable. The current contract defines future canonical class, interface, specialization, semantic-profile, and derivation shapes without adding a production class, source mapping, semantic profile, derivation manifest, specialization, or executable deployment.

Pin ownership is split by purpose:

  • Root ENGINE_PIN is the runtime evaluator revision.
  • routines/g36/SOURCE_RELEASE_PIN is the stable Modelica Buildings release baseline.
  • routines/g36/SOURCE_DEVELOPMENT_PIN is the reviewed development source baseline for material absent from the release.

Each source pin file contains exactly one lowercase 40-hex Git commit followed by a newline, and the commits MUST differ. The pin files are the authoritative source identities. routines/g36/DONOR_PIN and routines/g36/SOURCE_PIN are retired and MUST be absent.

Catalog inventories

routines/registry.json is the canonical class inventory. Its top-level object has exactly schema and routines; schema is cxf-library/routine-registry/v2. routines MUST be an array and remains empty until production class rows are implemented. This registry remains the sole catalog inventory; the schemas below do not replace it. A scope anchor is not a canonical class. The source inventory defined below records Git blobs; it does not identify Modelica classes or subsequences.

routines/generated-registry.json is the only inventory that may eventually drive routine execution. Its top-level object has exactly schema and deployments; schema is cxf-library/generated-routine-registry/v1. deployments MUST be an array and remains empty until generated deployments are implemented. The verifier’s --routines mode reads this file, accepts the empty array, and rejects nonempty arrays until that contract is implemented.

Canonical IDs MUST NOT encode fixed parameter values. Generated deployment IDs and row schemas are not defined by this version.

Canonical routine schema resources

This schema set governs six JSON Schema Draft 2020-12 resources:

Path$id
routines/schemas/common.schema.jsonhttps://open-control-library.example/schemas/routine-common-v1.json
routines/schemas/class-manifest.schema.jsonhttps://open-control-library.example/schemas/routine-class-manifest-v1.json
routines/schemas/interface.schema.jsonhttps://open-control-library.example/schemas/routine-interface-v3.json
routines/schemas/specialization.schema.jsonhttps://open-control-library.example/schemas/routine-specialization-v1.json
routines/schemas/routine-semantic-profile.schema.jsonhttps://open-control-library.example/schemas/routine-semantic-profile-v1.json
routines/schemas/routine-derivation-manifest.schema.jsonhttps://open-control-library.example/schemas/routine-derivation-manifest-v1.json

Each resource declares https://json-schema.org/draft/2020-12/schema. References use same-resource fragments or the six absolute IDs above and resolve from an in-memory registry. Validation performs no network or filesystem retrieval for schema references. Objects are closed unless stated otherwise. Semantic-only definitions belong to the semantic-profile resource. routine-common-v1 retains its unchanged reusable definitions; the interface v3 resource defines its revised dimension form locally.

Canonical class IDs have the form G36-05-(01..22)-<UPPERCASE-HYPHENATED-CLASS-SLUG>. Scope IDs are invalid canonical IDs. A canonical ID identifies a parameterized engineering class; it MUST be independent of source paths and revisions, fixed parameter values, ordering, hashes, and future generated content IDs. IDs are immutable and MUST NOT be reused. Immutability and reuse are authoring and review invariants; the checker has no historical registry against which to prove them. A positive integer revision records contract changes separately from identity.

Parameter, connector, type, and dimension IDs use bounded lower-case snake_case. Stable repeated-member IDs use bounded lower-case hyphenated text beginning with a letter; a dense numeric index is not a stable member ID. Type, dimension, parameter, and connector IDs MUST be unique within their respective lists. Enum member IDs and symbols MUST be unique within their enum.

Class manifests (cxf-library/routine-class-manifest/v1)

A future class manifest has exactly schema, id, revision, section, source, and artifacts. section is 5.1 through 5.22; its number MUST agree with the section encoded in id.

source is a closed union selected by kind:

  • upstream records snapshot (release or development), an exact lower-case 40-hex Git revision, a Modelica class path, and one or more file locators. Each locator contains a safe path below Buildings/Controls/OBC/ASHRAE/G36/ and a sha1:<40 lowercase hex> Git blob ID.
  • independent records one or more safe repository-relative source paths.

Duplicate source paths, absolute paths, backslashes, control characters, and empty, ., or .. segments are invalid. artifacts has exactly interface, specialization_schema, and specialization_config. Their safe relative paths share one non-root class directory and end in interface.json, specialization.schema.json, and specialization.json, respectively. This is an artifact-location contract, not a production source-to-class mapping.

Interfaces (cxf-library/routine-interface/v3)

An interface has exactly schema, canonical_id, revision, types, dimensions, parameters, and connectors. Types and enums are local to that interface; this contract defines no global type catalog.

The primitive symbols are exactly real, integer, and boolean. String and runtime object types are excluded. A named alias selects one primitive and may record nonempty trimmed quantity, unit, and display_unit strings. These strings assert no QUDT, Brick, or ASHRAE 223 semantics. An enum declares a nonempty ordered list of unique stable member IDs and unique symbols. Enum values use the stable member IDs; no integer lowering code is assigned.

A type use is either primitive or a reference to a local named type. A shape is either scalar or an array with an ordered list of one or two dimension IDs. Dimensions have unique IDs. A fixed dimension declares a positive integer extent and a nonempty ordered members list whose count MUST equal that extent. A parameter-driven dimension references a scalar Integer parameter and MUST NOT declare canonical members; routine-specialization/v1 owns its member list. Dimension members are authored stable identities, not identities derived from array ordinals. Authored list order is contract order. Within one interface and specialization pair, member IDs MUST be unique across every fixed and parameter-driven dimension. Rank greater than two, zero extents, ragged matrices, and arithmetic dimension expressions are invalid.

Parameters have unique IDs, a type use, shape, fixed or configurable configurability, an optional typed default, and optional numeric minimum and maximum constraints. A fixed parameter MUST have a default and cannot be assigned by specialization. A configurable parameter without a default MUST be assigned by specialization.

Connectors have unique IDs, input or output direction, a type use, shape, and explicit presence. Presence is always or when with a closed guard AST. Guards support and, or, not, and eq, ne, lt, lte, gt, or gte comparisons. Operands are scalar parameter references or typed scalar literals. Ordering comparisons require numeric operands; Integer and Real operands are compatible. Runtime signals, connectors, time, point IDs, operating states, and host or fault logic cannot appear in guards. The checker validates guard structure, references, and operand compatibility but does not evaluate a guard or resolve optional branches.

Specialization inputs (cxf-library/routine-specialization/v1)

A specialization input has exactly schema, canonical_id, revision, parameters, and members. parameters is an ordered list of unique parameter IDs and JSON values. members binds each parameter-driven dimension ID to a nonempty ordered list of stable member IDs. Fixed dimensions are invalid targets. These IDs MUST be unique across the specialization’s member records and against the canonical members of every fixed dimension in the paired interface.

The interface and specialization canonical ID and revision MUST agree with the class manifest. Specialization checks parameter existence, fixed-parameter override rejection, required configurable assignments, primitive and enum value compatibility, numeric bounds, concrete dimension extents, rectangular rank-one and rank-two values, and parameter-driven member count. All numeric values MUST be finite. A parameter-driven dimension resolves only from a positive Integer effective value, and its member count MUST equal that value.

Specialization is input only. It contains no connector bindings, point IDs, resolved connector set, source map, generated CXF, runtime state, engine identity, or deployment identity.

Ontology identities and local vocabulary

routines/ontology/ontology-pins.json is the sole product ontology-pin record. It has the closed identifier cxf-library/ontology-pins/v1 and records these immutable authorities:

AuthorityIdentity
Bricknamespace https://brickschema.org/schema/Brick#; BrickSchema/Brick release v1.4.4; commit 4b5be60d27f9b4d96fe477f45513fa71afebe684; release Brick.ttl SHA-256 b65720b7b9b64c646745c689777e6138c0d59ce0088df0aeb78fbd444d04d8e7
ASHRAE 223 compatibilitycore namespace http://data.ashrae.org/standard223#; G36 extension http://data.ashrae.org/standard223/1.0/extensions/g36#; version 1.0.0-ppr.2.1; open223/open223.info commit 97656845cab16183e64e9611c94f40a6fad95226; blob c2ee998a1e0f5cc3e496ff9c20c30e01019ff250; artifact SHA-256 1f156f9938c0be430d2216e01e31bb183c438ba318d8d4a23d2f074ebcd6f573
QUDTquantity-kind namespace http://qudt.org/vocab/quantitykind/; unit namespace http://qudt.org/vocab/unit/; qudt/qudt-public-repo release v3.1.4; tag object e6cba51f5769691a926e000cbeb044d4d5cd754e; commit 5a19ef66a5b8d8c404f469244304afc7d9f83eaa; exact quantity-kind and unit paths, blobs, and SHA-256 values in the pin record
OCLnamespace urn:open-control-library:ontology:; version 0.1.0-draft; checked-in path routines/ontology/ocl-vocabulary.ttl; byte hash in the pin record

The S223 artifact imports <http://qudt.org/3.1.8/shacl/qudt-all>. The pin record keeps that as a compatibility observation. It does not replace the Library’s QUDT 3.1.4 authority, and the S223 artifact is not represented as the final published standard.

ocl-vocabulary.ttl contains only Library-owned profile, connector-binding, software-signal, derived-signal, aggregate, derivation, and policy terms used by the governed fixtures. It has no imports. Its SHA-256 is part of the pin record; changing the Turtle bytes requires updating that hash in the same change.

routines/ontology/shacl/open-control-routine-shapes.ttl is the governed SHACL Core graph for the two synthetic semantic fixtures. Shape identifiers use urn:open-control-library:shacl:routine:; every OCL term in the graph MUST be declared by ocl-vocabulary.ttl. The graph defines no SPARQL constraints, SHACL-AF rules, or SHACL-JS. Validation performs no imports or inference.

Routine semantic profiles (cxf-library/routine-semantic-profile/v1)

A semantic profile has a stable JSON-LD @id, type ocl:RoutineSemanticProfile, canonical class ID and revision, the exact routines/ontology/ontology-pins.json reference, and one or more connector roles. Its context is one closed embedded object. String, list, nested, remote, or imported contexts are invalid.

Connector role IDs use the interface connector-ID syntax and are unique. Each role has a bounded nonempty semantic_role, a mapping_status of verified or provisional, and a closed list of bounded topology requirements. verified means the author reviewed the mapping against the named pin evidence; provisional marks a mapping that still needs review. Neither value certifies a building instance. A physical role requires at least one location, topology, or ownership obligation; software and derived roles may use an empty list.

Direction is connector dataflow (input or output) and does not determine the S223 property class. For example, an active setpoint may be an input while its property remains actuatable. Requirement is R, A, O, N, S, D, or P; cardinality records integer minimum and maximum values with minimum not greater than maximum. Bindings are a closed union:

  • physical-or-bms-point carries one points/<family>.points.json#<point_key> reference plus closed Brick and S223 mappings;
  • software-signal carries an OCL class and no physical ontology mapping; and
  • derived-signal carries an OCL class, output ID, and local derivation-manifest reference.

Physical mappings allow only the reviewed directional S223 property classes: s223:QuantifiableObservableProperty, s223:QuantifiableActuatableProperty, s223:EnumeratedObservableProperty, and s223:EnumeratedActuatableProperty. Quantifiable mappings require a QUDT quantity kind and unit. Enumerated mappings require an enumeration kind. Both mapping variants record the S223 medium as a CURIE or explicit null. Allowed aspects are s223:Aspect-Setpoint, s223:Aspect-Delta, and s223:Aspect-Maximum; s223:EnumeratedProperty is invalid. Topology strings are structural authoring obligations, not topology instances or SHACL results.

Derivation manifests (cxf-library/routine-derivation-manifest/v1)

A derivation manifest identifies one ocl:DerivedSignal or ocl:DerivedAggregate output. It records the canonical class revision, an exact routines/ontology/ontology-pins.json reference, an immutable function ID and version, ordered typed inputs with stable source IDs, stable members, exclusions, data-quality handling, freshness and alignment limits in seconds, readiness and in-domain policy, output unit and conversion policy, output scope, and reset behavior.

Member-linked inputs, exclusions, member output scopes, and source-triggered resets must resolve inside the manifest. IDs are unique. Data-quality and ready minimums cannot exceed the member population and must agree. A profile’s derived output ID and manifest fragment must equal the manifest output ID; a manifest output must be referenced by exactly one derived connector role in the synthetic fixture pair.

Schema validation boundary

The schemas enforce required and closed shapes, discriminators, ID patterns, primitive JSON types, and array-rank bounds. tools/lint/routine_schemas.py adds deterministic cross-document checks for uniqueness, section coherence, reference existence and kind, finite and compatible values, dimensions, rectangular arrays, guards, and specialization completeness. It rejects duplicate JSON keys and non-finite numbers before schema validation, checks all six schema resources with Draft202012Validator.check_schema, and reports sorted errors without a traceback for expected failures.

The linter validates one coherent fixture set under tools/lint/tests/fixtures/routine_schemas/. Those documents are synthetic, test-only contract evidence. They MUST NOT appear below routines/g36/ or be added to a registry, coverage claim, source inventory, book, or production catalog destination.

tools/lint/routine_semantics.py checks the closed pin record, recomputes the local-vocabulary hash, parses local Turtle bytes, applies the same six-resource in-memory schema registry, and rejects unsafe JSON-LD constructs before RDFLib parsing. It passes each fixture’s parsed graph independently to pySHACL 0.31.0 with imports, inference, advanced features, JavaScript, and in-place mutation disabled. SHACL reports are normalized into sorted diagnostics; Python retains the uniqueness, member, unit-policy, and cross-document checks.

The SHACL graph checks the RDF projection’s expected local classes and predicates, nested node/cardinality/datatype/class structure, and closed current entities. It does not duplicate every JSON Schema lexical rule or the Python checks above. The two fixtures under tools/lint/tests/fixtures/routine_semantics/ are synthetic. Their point references are syntax examples and are not resolved against production dictionaries or routine interfaces.

No external ontology is vendored or fetched. Brick, S223, and QUDT CURIE checks therefore prove closed syntax and selected S223 class and aspect policy, not that every external term exists in its pinned ontology. Connector semantic-role and topology requirements are authoring evidence, not building-instance evidence. Production profiles must later be paired with typed interfaces, canonical point dictionaries, ontology-term evidence, and building-instance validation before any semantic-conformance claim.

routines/g36/source-inventory.json (cxf-library/g36-source-inventory/v1)

The source inventory records two independent Git-tree snapshots from https://github.com/lbl-srg/modelica-buildings.git. Its top-level object has exactly these keys in order: schema, repository, source_root, inventory_scope, dependency_closure, license, and snapshots.

  • schema is cxf-library/g36-source-inventory/v1;
  • repository is https://github.com/lbl-srg/modelica-buildings.git;
  • source_root is Buildings/Controls/OBC/ASHRAE/G36;
  • inventory_scope is source-root-regular-files; and
  • dependency_closure is not-inventoried.

license has exactly upstream_path, retained_path, git_blob_sha1, and sha256, in that order. upstream_path is Buildings/legal.html and retained_path is routines/g36/LICENSE-BUILDINGS.html. The retained file MUST equal the Git blob bytes at both pins. git_blob_sha1 uses sha1:<40 lowercase hex> and sha256 uses sha256:<64 lowercase hex>.

snapshots contains exactly two rows, ordered release then development. Each row has exactly these keys in order: role, revision, root_tree_sha1, file_count, total_bytes, modelica_file_count, package_order_count, and files. revision MUST equal the corresponding pin file. root_tree_sha1 is the Git tree ID for source_root, encoded as sha1:<40 lowercase hex>.

Each files row has exactly path, mode, bytes, git_blob_sha1, and sha256, in that order. Paths are full upstream repository-relative POSIX paths below source_root, sorted lexicographically and unique. Empty, ., and .. path segments, absolute paths, backslashes, and control characters are invalid. Version 1 supports only regular 100644 Git blobs; other modes and object types MUST be rejected. bytes and both hashes are calculated from Git object bytes rather than working-tree files.

file_count is the number of file rows, and total_bytes is the sum of their bytes values. modelica_file_count counts paths ending in .mo. package_order_count counts paths ending in /package.order; package.order content is not parsed or validated. All files remain in the inventory whether or not another source file names them.

The release and development snapshots MUST remain separate. The inventory does not choose a snapshot for a future canonical class and MUST NOT use one as a fallback for the other. It inventories no dependency outside source_root and makes no claim about Modelica declarations, classes, package members, references, imports, inheritance, source-family mapping, or executable coverage.

tools/lint/g36_source.py --write regenerates the inventory and retained legal notice from two supplied checkouts. --check verifies each checkout origin and exact HEAD, derives all records through Git object commands, and compares both tracked artifacts byte-for-byte without rewriting them. JSON uses two-space indentation, the field order above, and one final newline. It contains no timestamp, branch name, checkout path, or moving source identity. A source pin change requires regenerating both snapshots; generation fails if the pinned legal blobs or bytes differ.

routines/g36/scope.json (cxf-library/g36-scope/v1)

The scope manifest makes the Section 5 planning boundary discoverable without claiming canonical classes. Its top-level object has exactly schema, profile, status, and sections:

  • schema is cxf-library/g36-scope/v1;
  • profile is ASHRAE Guideline 36-2021 Section 5;
  • status is planned; and
  • sections is an array of exactly 22 rows, ordered from 5.1 through 5.22.

Each row has exactly id, section, name, status, source_disposition, and destination. IDs, sections, and destinations MUST each be unique. name is an independently written, nonempty display name and status is planned. Scope IDs identify planning anchors only; they MUST NOT be used as canonical class IDs.

The IDs, destinations, and reviewed planning dispositions are:

SectionScope IDDestinationSource disposition
5.1G36-SCOPE-05-01g36/shared/generalmixed
5.2G36-SCOPE-05-02g36/zones/ventilationupstream-partial
5.3G36-SCOPE-05-03g36/zones/thermalupstream-broad
5.4G36-SCOPE-05-04g36/zone-groupsupstream-broad
5.5G36-SCOPE-05-05g36/terminal-units/cooling-onlyupstream-broad
5.6G36-SCOPE-05-06g36/terminal-units/reheatupstream-broad
5.7G36-SCOPE-05-07g36/terminal-units/parallel-fan-cvupstream-broad
5.8G36-SCOPE-05-08g36/terminal-units/parallel-fan-vvupstream-broad
5.9G36-SCOPE-05-09g36/terminal-units/series-fan-cvupstream-broad
5.10G36-SCOPE-05-10g36/terminal-units/series-fan-vvupstream-broad
5.11G36-SCOPE-05-11g36/terminal-units/dual-duct-snapupstream-broad
5.12G36-SCOPE-05-12g36/terminal-units/dual-duct-mix-inletupstream-broad
5.13G36-SCOPE-05-13g36/terminal-units/dual-duct-mix-dischargeupstream-broad
5.14G36-SCOPE-05-14g36/terminal-units/dual-duct-cold-minupstream-broad
5.15G36-SCOPE-05-15g36/ahus/system-modesupstream-embedded
5.16G36-SCOPE-05-16g36/ahus/multizone-vavupstream-broad
5.17G36-SCOPE-05-17g36/ahus/dual-fan-dual-ductindependent-authoring
5.18G36-SCOPE-05-18g36/ahus/single-zone-vavupstream-broad
5.19G36-SCOPE-05-19g36/exhaust-fans/constant-speedindependent-authoring
5.20G36-SCOPE-05-20g36/plants/chilled-waterdevelopment-source
5.21G36-SCOPE-05-21g36/plants/hot-waterindependent-authoring
5.22G36-SCOPE-05-22g36/fan-coil-unitsupstream-partial

These dispositions classify the source plan; they do not identify a source class or prove implementation. Destinations are safe repository-relative POSIX paths below g36/. Absolute paths, backslashes, empty segments, . segments, and .. segments are invalid. The manifest reserves destinations without requiring placeholder directories.

routines/g36/coverage.json (cxf-library/g36-coverage/v2)

Coverage has exactly schema, profile, status, scope, and claims. schema is cxf-library/g36-coverage/v2; profile and status MUST equal scope.json; scope is scope.json; and claims remains empty until production coverage claims are implemented. Coverage does not repeat scope rows or inventory and makes no completeness, implementation, or evidence claim.

No routine.cxf.jsonld may appear below routines/g36/ until generated deployments are implemented. The retired routines/g36/generic/air-economizer-high-limits fixed-variant path MUST be absent.

Deferred routine contracts

Production class manifests, interfaces, specialization inputs, semantic profiles, and derivation manifests remain deferred, as do source-to-family and class mapping instances, point migrations, a specializer, resolved connectors, generated deployment bundle schemas and rows, source maps, vectors, generated CXF, building-instance conformance, and execution.

Design stance (why the pieces split this way)

  • A fault rule is a CDL composite block: canonical point inputs → elementary comparison/logic/timing blocks → boolean fault output(s). Stored as CXF the open-control engine loads directly.
  • The rule computes the fault condition given valid data. Data quality (NO_EVAL), operating-state gating, suppression, and energy accumulation are host/runtime concerns declared in the card frontmatter, never encoded in the block graph. The engine is deliberately status-blind; hosts enforce preconditions and operating_states before trusting yFault.
  • Semantics live in the point dictionary (Brick + ASHRAE 223P), keyed by canonical point name. CXF documents stay semantics-free in v1; a generator may later inject annotations from the dictionary (the engine preserves unknown keys losslessly).

card.md contract (cxf-library/fault-card/v1)

YAML frontmatter followed by Markdown prose. Frontmatter fields:

FieldTypeReqMeaning
schemastringcxf-library/fault-card/v1
idstringFault ID, equals folder name
namestringShort human name
equipmentstringEquipment family key
statusenumdraft | verified | adopted | deprecated
phaseintRollout phase (1–4)
methodenumrule | statistical | ml | meta
severityint1 Critical · 2 High · 3 Warning · 4 Info
categoryenumCRITICAL_WASTE | EFFICIENCY_LOSS | EXCESS_CONSUMPTION | COMFORT_ENERGY | PROTECTIVE
confidenceenumHIGH | MEDIUM | LOW (evidence strength)
estimation_methodenumDIRECT_MEASUREMENT | BASELINE_COMPARISON | PROXY_ESTIMATION | QUALITATIVE_ONLY
sourcelistProvenance refs (reference §, PNNL report, G36 §)
g36string|nullG36 clause for 001-range rules, else null
clusterslistCluster IDs this rule participates in
suppresseslistRule IDs silenced while this fault is active
suppressed_bylistRule IDs that silence this rule when active
adjudicatesmapSensor-health rules only: {points: [...], verdict: invalid_while_active | ambiguous} — the canonical point(s) whose data validity this rule judges. Hosts derive the NO_EVAL fan-out from downstream cards’ points lists (point-keyed, so it stays complete as rules are added — never a hand-written rule list). invalid_while_active: treat the point as invalid for all consumers while this fault asserts; ambiguous: a redundancy-pair rule that cannot name which member drifted.
relatedlistCo-occurring rules (informational)
playbookslistPlaybook slugs in playbooks/
operating_statesstringApplicable states (all or list, prose ok)
preconditionsstringHost-enforced evaluation gate, prose
pointslistBare point names consumed; resolved in the equipment dictionary (see below)
outputslist{name, description} — boundary outputs
paramsmapname → {default, unit, description, cxf}
energy_impactmap{affected_subsystem, savings_range, climate_sensitivity, runtime_estimation}
emissionsmap{scope, method}
verifiedmap{engine_rev, content_id, date} — all null until verified

Conventions:

  • Every entry in points is a bare name resolvable in points/<equipment>.points.json as either a local concrete point or an explicit v2 alias. The CXF boundary input connector keeps that exact bare name. Resolution yields the canonical concrete target; a compatibility alias is not itself a canonical identity.
  • params.*.cxf is the parameter’s CXF path relative to the root block (<instance>.<param>, e.g. persist.delayTime) so hosts can retune deployed rules via set_param without re-authoring. It may be a list of paths when one card parameter binds several block parameters (e.g. an evaluation window driving sampler periods and dwell times); hosts must set every listed path together.
  • Signal units are those declared in the point dictionary (°C, Pa, %, bool). Hosts must feed those units; rules do no unit conversion in v1.
  • verified.engine_rev is the open-control git rev the vectors last passed against; verified.content_id is the engine’s exported cxf:fnv1a128:… diagnostic identity. Git history is the integrity record for the bytes.

Prose sections (in order): ## Description, ## Detection Logic, ## Possible Diagnoses, ## Energy Impact, ## Emissions Impact, ## Deviations (differences from the source reference and why — required, may be “None”), ## Notes (optional).

## Detection Logic contains the equation in a fenced block plus the block graph as ![…](diagram.svg) (a standalone SVG file — GitHub renders linked SVG but strips inline SVG). Diagram conventions: boundary inputs as blue pills on the left, fault outputs as red pills on the right, elementary blocks as rounded rectangles labeled instance over Class · key params, signal flow left to right.

validation: (optional frontmatter block, adopted 2026-08-18). Records empirical validation runs against the card’s rule. A list; each entry: kind (simulation_fpr | simulation_tpr — for simulation_tpr, failures counts MISSED detections), harness (e.g. simharness/v1), date, fleet (one-line description of buildings × climates × period), scenarios (count), failures (count), optional notes (one line, e.g. the finding a failure represents). Results are facts about a specific fleet and gating configuration, not guarantees; the harness README documents mapping proxies and gating. Cards without the block simply have not been swept yet.

Card style (conciseness contract, adopted 2026-08-18; exemplar: faults/ahu/AHU-0016/card.md). Cards are clear, concise, and outlay the conditions — they are specifications, not design journals. Targets: Description ≤ ~10 lines; Detection Logic prose ≤ ~15 lines beyond the equation and diagram (timing semantics, strictness, deployer must-knows only — no block-by-block narration of the diagram); Energy/Emissions ≤ ~8 lines each; Notes ≤ ~8 lines or omitted. Deviations keeps EVERY engineering decision but each as one bullet of 2–5 lines (decision + one-sentence why + citation) — no alternatives-considered essays. Never narrate vector scenarios in the card (vectors.json is that record), except a sentence naming a deliberately-pinned engine behavior. Typical full card: ~140–220 lines (statistical cards may run ~250).

rule.cxf.jsonld contract

Target dialect: the open-control engine’s composite subset (open-control/docs/cxf-composite-subset.md), matching its G36 fixture style:

  • @context: {"S231": "http://data.ashrae.org/S231P#", "base": "<fault ns>"} where the fault namespace is urn:cxf-library:<fault-id-lowercase>#.
  • Flat @graph, full IRIs written out. Root block: <ns><fault_id_snake> with @type S231:Block, S231:label, S231:containsBlock, S231:hasInput (boundary points), S231:hasOutput.
  • Child instances: <root>.<label>; @type is <ns>Buildings.Controls.OBC.CDL.<ClassPath> (the engine resolves the class from the IRI fragment). Only registry-supported classes.
  • Ports <instance>.<portName> typed S231:RealInput/S231:BooleanInput/… with S231:isOfDataType; connections via S231:isConnectedTo on the source (output) node.
  • Parameters: <instance>.<param> nodes carrying S231:value typed literals; referenced from S231:hasParameter. Set only non-default values.
  • Fault outputs are BooleanOutputs; primary output is named yFault (true while the fault condition persists). Additional outputs allowed (e.g. sub-condition flags) and must be listed in the card’s outputs. When the reference semantics include an in-rule evaluability condition (a NO_EVAL test vector), expose it as an additional boolean output (y… name); the card documents that false means NO_EVAL — the host must consult it before interpreting yFault. Secondary outputs come in TWO kinds and the card’s outputs prose must say which: evaluability flags (y…Ok — false means NO_EVAL) and sub-condition/direction flags (e.g. SYS-0006’s yBias/yNoise, SYS-0008’s direction flags — diagnostic detail only; false never means NO_EVAL). Hosts must not treat every non-yFault boolean as an evaluability gate.
  • No semantic annotations in v1 (see design stance). No oce.* class aliases.

vectors.json contract (cxf-library/vectors/v1)

{
  "schema": "cxf-library/vectors/v1",
  "clock": { "step_s": 60, "horizon_s": 1800 },
  "scenarios": [
    {
      "name": "snake_case_name",
      "description": "optional",
      "inputs": {
        "htg_vlv_cmd": 20.0,
        "clg_vlv_cmd": [ { "t": 0, "value": 30.0 }, { "t": 600, "value": 0.0 } ]
      },
      "expect": [
        { "output": "yFault", "from_s": 0, "to_s": 840, "equals": false }
      ]
    }
  ]
}
  • inputs: canonical point name → constant (number/bool) or a step list [{t, value}…] (piecewise-constant; each value staged before the first tick with model time ≥ its t).
  • expect: windowed assertions on boundary outputs, inclusive of both ends, checked at every tick whose time falls in the window. Reals compare with optional tolerance (default 1e-9).
  • Scenarios are independent: each runs on a freshly loaded engine.
  • Windows must respect timing parameters (e.g. leave ≥ one step of margin around an alarm_delay edge rather than asserting the exact boundary tick).
  • Scenarios must cover at minimum: the reference card’s published test vectors, one threshold-edge case, and one transient case exercising delay/reset behavior where the rule has timing state.

points/<equip>.points.json contract (cxf-library/points/v1 and v2)

{
  "schema": "cxf-library/points/v2",
  "equipment": "vav",
  "namespaces": {
    "brick": {
      "iri": "https://brickschema.org/schema/Brick#",
      "verified_version": "1.4.4"
    },
    "s223": {
      "iri": "http://data.ashrae.org/standard223#",
      "verified_version": "1.0.0-ppr.2.1"
    },
    "quantitykind": {
      "iri": "http://qudt.org/vocab/quantitykind/",
      "verified_version": "QUDT 3.1.4"
    },
    "unit": {
      "iri": "http://qudt.org/vocab/unit/",
      "verified_version": "QUDT 3.1.4"
    }
  },
  "imports": ["points/zone.points.json"],
  "aliases": [
    {
      "name": "zone_temp",
      "target": "points/zone.points.json#zone_temp"
    }
  ],
  "points": [
    {
      "name": "zone_airflow",
      "description": "VAV box measured supply airflow",
      "kind": "real",
      "unit": "L/s",
      "qudt_unit": "L-PER-SEC",
      "brick": "Supply_Air_Flow_Sensor",
      "s223": {
        "pattern": "Sensor observes QuantifiableObservableProperty at the VAV box discharge via hasProperty",
        "property_class": "QuantifiableObservableProperty",
        "quantitykind": "VolumeFlowRate",
        "unit": "L-PER-SEC",
        "medium": "Fluid-Air",
        "aspects": []
      },
      "provisional": false,
      "notes": "Attach isPointOf the VAV box; hosts convert CFM before binding."
    }
  ]
}
  • A v1 dictionary has exactly the required top-level fields schema, equipment, namespaces, and points. Only notes is optional. Existing v1 dictionaries remain valid and MUST NOT contain imports or aliases.
  • A v2 dictionary has exactly the required top-level fields schema, equipment, namespaces, imports, aliases, and points. Only notes is optional. Namespace and concrete point records use the same closed shapes as v1.
  • A concrete point’s canonical identity is points/<family>.points.json#<name>. name is snake_case; suffixes are none for measured values, _sp for setpoints, _cmd for commands, _status for status, and _fbk for feedback. A bare name is meaningful only in the context of one family dictionary.
  • imports is an array of unique root-relative points/<lowercase-family>.points.json paths. Absolute paths, traversal, URLs, self-imports, missing files, duplicate entries, and import cycles are invalid.
  • aliases is an array of closed {name, target} objects. name MUST be unique and MUST NOT collide with a local concrete point. target MUST be a fully qualified point reference whose dictionary appears in imports and whose fragment names a concrete record. Alias-to-alias targets are invalid.
  • Contextual resolution of (family dictionary, bare name) checks one local concrete record, then one explicitly declared alias. It does not search other dictionaries. A fully qualified reference resolves its named dictionary and fragment; a legacy alias fragment normalizes once to its concrete target. Malformed, missing, colliding, or ambiguous references fail closed. There is no alias chain, filesystem-first match, or fallback.
  • Fault cards keep bare boundary names. Rust fault lint resolves each name in the card family’s dictionary before the existing card/CXF/vector checks. Book generation uses the canonical resolved target for links and lists compatibility aliases without copying the target record or anchor.
  • Role points (documented exception, points/sys.points.json only): the cross-equipment sensor-health rules bind role names (sensor_value, sensor_value_a/b, equip_active) rather than canonical points, because the same graph deploys against many real points. Role entries carry brick: null, s223: null; the host’s instance configuration records each binding, and that record is also what resolves the rule’s adjudicates target and drives its NO_EVAL fan-out. The reference’s own SYS-0005 card uses the same role form (“varies by application”).
  • derived: true marks host-computed points rather than physical ones — both aggregates (a max or fraction across zones) and physical transforms (e.g. saturation temperatures from pressure via a refrigerant P-T lookup). The entry’s notes must state the derivation and its site-specific inputs (which refrigerant, which underlying points); rules consume derived points exactly like physical ones, and the derivation itself never appears in a rule graph.
  • A top-level namespaces map records the exact ontology IRIs and the versions the terms were verified against.
  • routines/ontology/ontology-pins.json is the point dictionaries’ sole immutable authority for Brick, S223 compatibility, and QUDT namespace IRIs and version echoes.
  • brick: verified Brick class local name (namespace https://brickschema.org/schema/Brick#).
  • s223: object {pattern, property_class, quantitykind, unit, medium, aspects, enumerationkind?} using verified ASHRAE 223P terms (enumerationkind for enumerated properties). See the internal 223P point-modeling note (local-only, not distributed) for the modeling pattern.
  • Every term must be reviewed against the evidence named by the pin record — never from memory. provisional: true additionally marks entries with genuine ambiguity (class-choice judgment calls, unit conflicts, or patterns unattested in the standard’s reference models); the per-point notes field records the specifics. All s223 entries also await confirmation against the formal ASHRAE 223 standard text once obtained.
  • tools/lint/point_semantics.py checks the strict mixed v1/v2 corpus, import/alias resolution, namespace echoes, command/setpoint direction, actuatable quantity and unit requirements, and the bounded reviewed VAV/SYS/zone mappings. It reads local files without network access and emits sorted diagnostics. It does not verify arbitrary external term existence, evaluate topology or SHACL, certify building instances, or change Engine or Studio runtime behavior. The Engine executes CXF and does not consume point dictionaries.

clusters/clusters.json (cxf-library/clusters/v1)

Array of {id, name, trigger, members, playbook, prevalence, energy_impact}. trigger fires first; fixing it should clear members within 24–48 h.

Playbooks

playbooks/<slug>.md: prose with a header block (Applies To, Fix Complexity, Typical Time, Typical Cost, Energy Impact) and the four-step workflow: Verify → Remote fix → On-site service → Confirm resolution. Faults reference playbooks by slug; playbooks list the fault IDs they apply to.

Verification

tools/verify loads each fault’s rule.cxf.jsonld into the open-control engine (path dependency, in-process), replays every vectors.json scenario tick by tick, and checks the assertion windows. A fault may be marked status: verified only when all scenarios pass; record the engine rev, exported content ID, and date in verified. Re-verify (and re-record) after any engine pin bump or rule edit.

AHU Fault Rules

Air handling unit fault detection rules (AHU-*). Source grounding: HVAC FDD Reference v1.0 ch.9 (adapted authority — see each card’s Deviations section) and ASHRAE Guideline 36-2021 §5.16.14 for the 001-range. AHU-0032–068 are library-authored extensions past the reference’s ch.9 range (which ends at 065), grounded in Bushby, Castro, Schein & House (2001), NIST/CEC PIER Project 2.3 — the original APAR rule set — with AHU-0034 additionally corroborated by PNNL-27338 §3.4 (HW-0004 extension precedent: explicit non-reference sourcing on each card).

Point dictionary: points/ahu.points.json.

Index

IDNameSevMethodStatus
AHU-0001Duct static pressure too low at full fan speed3ruleverified
AHU-0002Mixed air temperature too low3ruleverified
AHU-0003Mixed air temperature too high3ruleverified
AHU-0004Excessive operating state changes per hour3ruleverified
AHU-0005SAT too low vs MAT in heating3ruleverified
AHU-0006OA fraction deviation3ruleverified
AHU-0007SAT too low at full heating3ruleverified
AHU-0008SAT ≠ MAT in economizer mode3ruleverified
AHU-0009OAT too high for free cooling3ruleverified
AHU-0010OAT ≠ MAT in mech + econ cooling3ruleverified
AHU-0011OAT too low for mechanical cooling3ruleverified
AHU-0012SAT too high vs MAT in cooling3ruleverified
AHU-0013SAT too high at full cooling3ruleverified
AHU-0014Inactive cooling coil temperature drop2ruleverified
AHU-0015Inactive heating coil temperature rise2ruleverified
AHU-0016Simultaneous heating and cooling2ruleverified
AHU-0017Economizer not operational when favorable3ruleverified
AHU-0018Unoccupied override — running during off-hours3ruleverified
AHU-0019SAT setpoint too low (over-cooling)3ruleverified
AHU-0020Stuck actuator2ruleverified
AHU-0021Excess outdoor air while occupied3ruleverified
AHU-0022SAT hunting3statisticalverified
AHU-0023SAT reset missing3statisticalverified
AHU-0024DSP reset missing3statisticalverified
AHU-0025Heating/cooling lockout not active3ruleverified
AHU-0026OA damper not closed when unoccupied3ruleverified
AHU-0027Manual override active4ruleverified
AHU-0028Mixing box damper fault3ruleverified
AHU-0029Operating mode mismatch3ruleverified
AHU-0030Excess OA during heating3ruleverified
AHU-0031Fan at excess static pressure3ruleverified
AHU-0032SAT too high vs RAT in cooling3ruleverified
AHU-0033SAT tracking error (ungated, all occupied modes)3ruleverified
AHU-0034Economizing past changeover3ruleverified
AHU-0035Supply air temperature too high for the zone population3ruleverified
AHU-0036Duct static pressure too low for the zone population3ruleverified
AHU-0037Economizing when it should not (damper position)3ruleverified
AHU-0038Cooling coil valve-position creep (fouling / authority loss)3statisticalverified
AHU-0039Supply fan proof-of-operation failure2ruleverified

Reference note: the FDD Reference’s index (§5.8.1) lists 31 AHU codes while its ch.9 header claims “20 fully specified” — some 0xx cards in the reference are abbreviated. Our library treats every code above as in scope; severities shown are the reference’s and may be adjusted per card (recorded under Deviations).

Working order (first pass)

  1. AHU-0016 — highest-impact fault, simplest logic; proves the schema.
  2. AHU-0018, 057, 058 — the remote-fix “74% problem” cluster (CLU-02/04).
  3. AHU-0017 — economizer (CLU-03).
  4. AHU-0001…015 — the G36 001-range.
  5. Remaining 05x/06x research rules.

AHU-0001 — Duct static pressure too low at full fan speed

Statusverified — engine e2ff2f8, cxf:fnv1a128:e621fd6c71d0e16c2eefa5e05edad265, 2026-08-17
Severity3
Methodrule
Phase1
CategoryPROTECTIVE
ConfidenceLOW
EstimationQUALITATIVE_ONLY
G36§5.16.14 FC#1
Clusters
Suppresses
Suppressed by
RelatedAHU-0024, AHU-0031, FPB-0002
Playbooks
SourceHVAC FDD Reference v1.0 §9, AHU-0001; G36 §5.16.14 FC#1
Operating statesOS 1–5 (all)

Preconditions (host-enforced): Supply fan running — with the fan off both the pressure reading and the speed feedback are meaningless. The unit must be a multi-zone VAV AHU under duct static pressure control; a constant-volume unit has no dsp_sp to compare against. The speed feedback must be a real VFD readback rather than the commanded speed echoed back, since a defeated or bypassed drive reports 100% while the fan turns at line speed or not at all. When any gate is unmet the verdict is NO_EVAL, not healthy.

Points: dsp, dsp_sp, sf_speed

Outputs:

  • yFault — True while duct static pressure has stayed more than dsp_error_threshold below its setpoint with the fan above speed_full_threshold, for at least alarm_delay

Parameters:

NameDefaultUnitCXF pathDescription
dsp_error_threshold25.0PagapBig.tShortfall below the duct static pressure setpoint that counts as a real deficit rather than control error (0.1 inWC)
speed_full_threshold99.0%spdFull.tSupply fan speed above which the fan is treated as having no reserve left
alarm_delay1800.0spersist.delayTimeContinuous fault persistence required before the alarm asserts (30 min)

Description

The fan is at the stop and the duct is still short of pressure. VAV boxes throttle against that pressure to hold zone airflow; without it they run wide open and still starve. The control loop has already asked for everything and the measurement has not moved, so the deficit is mechanical rather than a tuning problem — a belt slipping on its sheaves, a fire/smoke damper that closed and never reopened, a filter bank past its change-out point, or a VFD that has silently derated all read the same way from the outside. This is a protective fault: it catches the mechanical failure early and explains complaints that would otherwise be chased zone by zone. The energy story is real but indirect — a fan pinned at 100% against a leaking or obstructed duct burns full fan power to deliver less than design air.

Detection Logic

press_gap = dsp_sp − dsp
yFault    = (press_gap > dsp_error_threshold)      pressure short of setpoint by more than tolerance
        AND (sf_speed  > speed_full_threshold)     fan has no reserve left
            sustained continuously for alarm_delay

Block graph (rule.cxf.jsonld):

AHU-0001 block graph

The gap form is the reference’s dsp < dsp_sp − eps_dsp rearranged so one positive number carries the tolerance (see Deviations). The speed conjunct is what makes the rule mean anything: pressure below setpoint at part speed is an ordinary control loop working, and only a loop that has run out of fan is evidence of a defect. Both comparisons are strict, so a deficit sitting exactly on 25 Pa and a speed feedback parked exactly on 99.0% both read healthy. persist requires 30 minutes of continuous violation — enough to separate a mechanical fault from a morning start-up, a damper stroke, or the pressure dip after a bank of boxes opens at once — and any interruption restarts the timer.

Possible Diagnoses

  1. Ductwork obstruction or collapse — a closed fire/smoke damper, a dropped internal liner, or debris left after a renovation
  2. Fan belt slipping or broken
  3. Fan motor or VFD fault — a drive derating on a thermal or current limit reports full speed command while delivering less
  4. Excessive duct leakage — a disconnected branch or a failed flex connection downstream of the sensor
  5. DSP sensor fault — a plugged or disconnected sensing tube reads low with a perfectly healthy duct behind it

Energy Impact

PROTECTIVE, LOW confidence, QUALITATIVE_ONLY. No waste term is computable and none is published: the alarm is about equipment damage and undeliverable airflow, not kilowatt-hours. What energy there is depends on the cause — duct leakage means the fan produces air that never reaches a zone, an obstruction means the fan spends pressure across the blockage, and a slipping belt or derated drive costs comfort and a repair bill instead. No PNNL measure covers this fault, hence LOW confidence and no range. Climate-neutral. If a leakage or obstruction cause is confirmed, size the recovered fan energy per Energy Impact Reference §4.4 from fan hours and the pressure spent against the defect.

Emissions Impact

QUALITATIVE_EMISSIONS, LOW confidence; negligible direct emissions. A fan already at 100% draws what it draws whether or not the duct is short of pressure, so detecting this fault avoids no emissions on its own. Scope is N/A — there is no emitting stream to attribute, the general shape of a PROTECTIVE fault. Any credit belongs to the repair that follows and to the DSP reset it enables, which is AHU-0031’s to claim. Avoided-emissions basis: N/A.

Deviations

  • The fan-speed conjunct uses the reference’s bare sf_speed ≥ 99%. G36-2018 §5.16.14 FC#1 subtracts a VFD speed error allowance (> 99% − eps_VFDSPD, 94% at the Table 5.16.14.5 default); the reference ch.9 card simplifies to a bare 99% and this card follows its primary source. Drives that plateau just under 99% read NO-fault under the shipped default — retune speed_full_threshold to 94.0 for the G36 form.
  • dsp < dsp_sp − eps_dsp rewritten in gap form. Subtracting first and testing dsp_sp − dsp > eps_dsp keeps eps_dsp the positive number the reference publishes, retunable at one CXF path, instead of negating it into an AddParameter. Same rearrangement as AHU-0028; the one-ulp difference at the threshold is not observable at 1 Pa sensor resolution.
  • sf_speed >= 99% → strict >. CDL Reals offers only strict comparisons, so a feedback parked at exactly 99.000% reads as not-at-full- speed and the rule stays silent. A host binding a coarsely quantized speed point (integer percent) should retune speed_full_threshold to 98.9 rather than rely on the drive overshooting.
  • The fan-running condition is a precondition, not a wire. The reference lists it under Preconditions rather than in its Logic row, so it stays in frontmatter for the host. This is the opposite choice from AHU-0031, where the reference puts sf_status = ON in the logic and the graph carries it.
  • Operating states OS 1–5 are declared, not gated. The reference marks the fault applicable in every operating state, so the frontmatter records applicability and the graph carries no state logic.
  • First PROTECTIVE fault in the library, and the first with no emissions scope. emissions.scope is the literal string "N/A"; the schema types the field as free text, so the convention is set here.
  • persist.delayOnInit = true (Modelica/CDL default is false), the library’s standing choice: a deficit already present at load waits out the full 30 minutes instead of alarming on the first tick after a controller restart.

Notes

Rule out diagnosis 5 first: a plugged sensing tube reads low forever and the fan chases it to 100%, which is the same trace as a collapsed duct. Two tells separate them — a genuine deficit moves when zone demand moves, and a healthy duct with a bad sensor still delivers zone airflow. This fault is the low-side mirror of AHU-0031 (excessive static pressure): same two signals, opposite failure, and they cannot be true at once. If the DSP setpoint has never been reset from its design value — the case AHU-0024 detects — a fan at 100% against an unrealistic setpoint is a tuning artifact, not a broken belt.

Test Vectors

10 scenarios, clock step 300 s over 5400 s.

ScenarioDescription
normal_part_speed_operationReference vector: 350 Pa against a 375 Pa setpoint with the fan at 60% — the fan still has headroom, and the 25 Pa gap does not clear the strict threshold either; nothing to report
at_full_speed_holding_setpointReference vector: 370 Pa against a 375 Pa setpoint with the fan at 100% — the fan is maxed out but the duct is only 5 Pa short, well inside dsp_error_threshold, so the system is simply sized close to its design point
underpressure_at_full_speedReference vector: 300 Pa against a 375 Pa setpoint with the fan at 100% — a 75 Pa deficit with no speed left to give; the alarm asserts one alarm_delay (1800 s) after the condition appears
gap_exactly_at_thresholdThreshold edge: dsp_sp − dsp = 25.0 Pa exactly with the fan at 100% — gapBig is a strict >, so a deficit sitting precisely on dsp_error_threshold reads healthy
gap_just_over_thresholdThreshold edge, other side: dsp_sp − dsp = 25.1 Pa with the fan at 100% clears the strict comparison and alarms after alarm_delay
speed_exactly_at_thresholdThreshold edge: the fan sits exactly on speed_full_threshold (99.0%) with a 75 Pa deficit — spdFull is a strict >, so a feedback parked on 99.000% does not count as full speed and the rule stays silent
speed_just_over_thresholdThreshold edge, other side: 99.5% clears the strict comparison, so the same 75 Pa deficit alarms after alarm_delay
startup_ramp_clears_before_delayTransient: the fan pins at 100% and runs 75 Pa short while the system comes up, then pressure recovers to 370 Pa at t=1200 — a morning start-up is shorter than alarm_delay, so nothing alarms
fault_clears_on_recoveryA sustained deficit alarms at 1800 s; a closed fire/smoke damper reopens at t=3000, pressure recovers to 370 Pa, and the alarm drops on that tick — TrueDelay has no off-delay
speed_backs_off_before_delayTransient: the deficit holds but the fan drops to 92% at t=1200 as zone demand falls — the rule needs both halves at once, so persistence restarts and no alarm follows
vectors.json
{
  "schema": "cxf-library/vectors/v1",
  "clock": {
    "step_s": 300,
    "horizon_s": 5400
  },
  "scenarios": [
    {
      "name": "normal_part_speed_operation",
      "description": "Reference vector: 350 Pa against a 375 Pa setpoint with the fan at 60% \u2014 the fan still has headroom, and the 25 Pa gap does not clear the strict threshold either; nothing to report",
      "inputs": {
        "dsp": 350.0,
        "dsp_sp": 375.0,
        "sf_speed": 60.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        }
      ]
    },
    {
      "name": "at_full_speed_holding_setpoint",
      "description": "Reference vector: 370 Pa against a 375 Pa setpoint with the fan at 100% \u2014 the fan is maxed out but the duct is only 5 Pa short, well inside dsp_error_threshold, so the system is simply sized close to its design point",
      "inputs": {
        "dsp": 370.0,
        "dsp_sp": 375.0,
        "sf_speed": 100.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        }
      ]
    },
    {
      "name": "underpressure_at_full_speed",
      "description": "Reference vector: 300 Pa against a 375 Pa setpoint with the fan at 100% \u2014 a 75 Pa deficit with no speed left to give; the alarm asserts one alarm_delay (1800 s) after the condition appears",
      "inputs": {
        "dsp": 300.0,
        "dsp_sp": 375.0,
        "sf_speed": 100.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1500,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2100,
          "to_s": 5400,
          "equals": true
        }
      ]
    },
    {
      "name": "gap_exactly_at_threshold",
      "description": "Threshold edge: dsp_sp \u2212 dsp = 25.0 Pa exactly with the fan at 100% \u2014 gapBig is a strict `>`, so a deficit sitting precisely on dsp_error_threshold reads healthy",
      "inputs": {
        "dsp": 350.0,
        "dsp_sp": 375.0,
        "sf_speed": 100.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        }
      ]
    },
    {
      "name": "gap_just_over_threshold",
      "description": "Threshold edge, other side: dsp_sp \u2212 dsp = 25.1 Pa with the fan at 100% clears the strict comparison and alarms after alarm_delay",
      "inputs": {
        "dsp": 349.9,
        "dsp_sp": 375.0,
        "sf_speed": 100.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1500,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2100,
          "to_s": 5400,
          "equals": true
        }
      ]
    },
    {
      "name": "speed_exactly_at_threshold",
      "description": "Threshold edge: the fan sits exactly on speed_full_threshold (99.0%) with a 75 Pa deficit \u2014 spdFull is a strict `>`, so a feedback parked on 99.000% does not count as full speed and the rule stays silent",
      "inputs": {
        "dsp": 300.0,
        "dsp_sp": 375.0,
        "sf_speed": 99.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        }
      ]
    },
    {
      "name": "speed_just_over_threshold",
      "description": "Threshold edge, other side: 99.5% clears the strict comparison, so the same 75 Pa deficit alarms after alarm_delay",
      "inputs": {
        "dsp": 300.0,
        "dsp_sp": 375.0,
        "sf_speed": 99.5
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1500,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2100,
          "to_s": 5400,
          "equals": true
        }
      ]
    },
    {
      "name": "startup_ramp_clears_before_delay",
      "description": "Transient: the fan pins at 100% and runs 75 Pa short while the system comes up, then pressure recovers to 370 Pa at t=1200 \u2014 a morning start-up is shorter than alarm_delay, so nothing alarms",
      "inputs": {
        "dsp": [
          {
            "t": 0,
            "value": 300.0
          },
          {
            "t": 1200,
            "value": 370.0
          }
        ],
        "dsp_sp": 375.0,
        "sf_speed": 100.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        }
      ]
    },
    {
      "name": "fault_clears_on_recovery",
      "description": "A sustained deficit alarms at 1800 s; a closed fire/smoke damper reopens at t=3000, pressure recovers to 370 Pa, and the alarm drops on that tick \u2014 TrueDelay has no off-delay",
      "inputs": {
        "dsp": [
          {
            "t": 0,
            "value": 300.0
          },
          {
            "t": 3000,
            "value": 370.0
          }
        ],
        "dsp_sp": 375.0,
        "sf_speed": 100.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1500,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2100,
          "to_s": 2700,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 3300,
          "to_s": 5400,
          "equals": false
        }
      ]
    },
    {
      "name": "speed_backs_off_before_delay",
      "description": "Transient: the deficit holds but the fan drops to 92% at t=1200 as zone demand falls \u2014 the rule needs both halves at once, so persistence restarts and no alarm follows",
      "inputs": {
        "dsp": 300.0,
        "dsp_sp": 375.0,
        "sf_speed": [
          {
            "t": 0,
            "value": 100.0
          },
          {
            "t": 1200,
            "value": 92.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        }
      ]
    }
  ]
}

AHU-0002 — Mixed air temperature too low

Statusverified — engine e2ff2f8, cxf:fnv1a128:8591ae042848563cd91744d8168eba08, 2026-08-17
Severity3
Methodrule
Phase1
CategoryCOMFORT_ENERGY
ConfidenceLOW
EstimationQUALITATIVE_ONLY
G36§5.16.14 FC#2
ClustersCLU-09
Suppresses
Suppressed byAHU-0028
RelatedAHU-0003, AHU-0028
Playbookssensor-drift
SourceHVAC FDD Reference v1.0 §9, AHU-0002; G36 §5.16.14 FC#2; PNNL-25985 (EEM-01, sensor recalibration); Bushby et al. 2001 (NIST/CEC PIER Project 2.3, APAR rules 26/27 — earliest form of this envelope test)
Operating statesOS 1-5

Preconditions (host-enforced): Supply fan running — MAT means nothing in still air. The host must not evaluate during coil freeze-protection or within a few minutes of an economizer mode transition, when MAT lags the mixture it is supposed to report, and must silence this rule while AHU-0028 is active. When a gate is unmet the verdict is NO_EVAL, not healthy.

Points: oat, rat, mat

Outputs:

  • yFault — True while MAT has stayed more than mat_tolerance below min(oat, rat) for at least alarm_delay

Parameters:

NameDefaultUnitCXF pathDescription
mat_tolerance2.0°CgapBig.tCombined sensor accuracy allowance; MAT may sit this far below the lower envelope bound before it counts as a fault. The 2.0 degC default is the reference ch.9 card’s own eps_MAT (4 degF); G36-2018 Table 5.16.14.5 publishes eps_MAT = 3 degC — retune to 3.0 for strict G36 conformance
alarm_delay1800.0spersist.delayTimeContinuous fault persistence required before the alarm asserts (30 min)

Description

Mixed air is a blend of outdoor and return air, so it cannot be colder than both streams. When MAT reads more than combined sensor accuracy below the colder of OAT and RAT, the reading is impossible and something is wrong with the measurement or the mixing plenum: a sensor out of calibration, or outdoor air leaking past the mixing box straight onto the MAT sensor.

This is G36 §5.16.14 FC#2, the low half of the mixed-air envelope check. AHU-0003 is its mirror on the high side; both sit in cluster CLU-09 (Sensor Integrity Failure), whose trigger AHU-0028 tests the envelope in both directions at once. Roughly 15% of buildings have at least one AHU with a temperature sensor this far out.

Detection Logic

yFault = mat < min(oat, rat) − mat_tolerance,
         sustained continuously for alarm_delay

Block graph (rule.cxf.jsonld):

AHU-0002 block graph

Min rather than a fixed OAT-below-RAT assumption, because which stream is colder flips with the season. The threshold comparison is strict, so a MAT sitting exactly 2.0 °C below the bound is healthy and 2.1 °C below is not. persist requires 30 minutes of continuous violation — a MAT that dips out and recovers during a damper stroke never alarms, and any interruption restarts the timer — while recovery is immediate on the tick MAT climbs back inside the bound. Nothing above the envelope can trip this rule: a MAT 4 °C above both sources violates the same physics and reads healthy here, because that case is AHU-0003’s.

Possible Diagnoses

  1. MAT sensor out of calibration, reading low
  2. OAT sensor out of calibration, reading high
  3. RAT sensor out of calibration, reading high
  4. Cold air leakage into the mixing plenum — outdoor air short-circuiting to the sensor rather than mixing

Energy Impact

COMFORT_ENERGY, LOW confidence, QUALITATIVE_ONLY. A mis-read temperature costs nothing directly; the cost is downstream. A MAT biased low reads as excess cold in the mixture, so the mixed-air loop closes outdoor air below what the mixture needs — giving up free cooling in mild weather — and can call for preheat that nothing requires. PNNL-25985 EEM-01 (sensor recalibration) puts the recoverable range at 0–5% of site energy across a whole sensor population, sensor-dependent and climate-neutral. Prevalence ~15% of buildings have sensor faults.

Emissions Impact

QUALITATIVE_EMISSIONS, LOW confidence; no direct emissions. Scope is 1|2 because it depends on which subsystem the bad reading distorts: unnecessary preheat lands in Scope 1 (on-site combustion), a disabled economizer calling for mechanical cooling lands in Scope 2 (purchased electricity). The same drifted sensor can do both in different seasons, so no single scope is correct year-round. Avoided-emissions basis: N/A.

Deviations

  • Single combined tolerance instead of G36’s per-sensor error bands. G36’s precise form is MAT_AVG + eps_MAT < min[(RAT_AVG − eps_RAT), (OAT_AVG − eps_OAT)]; the HVAC FDD Reference collapses that to one eps_MAT applied once and this card inherits the simplification. One combined band needs a larger true error before it fires — fewer false positives, less sensitivity to drift in any individual sensor. The G36 form would need three parameters and an offset block on each sensor path ahead of the Min.
  • Instantaneous samples instead of averaged signals. G36 compares time-averaged temperatures; this rule compares raw samples and leans on the 30-minute persist delay to reject noise. The two are not equivalent — averaging tolerates a signal that crosses the bound repeatedly while its mean stays outside, whereas persistence resets on every tick inside the envelope and can hide an oscillating MAT indefinitely. Steady drift, the fault this rule is for, reads the same either way.
  • Bound comparison rewritten as gap comparison. min − mat > tol ⟺ mat < min − tol, algebraically identical, but it keeps mat_tolerance a single positive set_param value instead of a negative offset in the graph. Same rewrite as AHU-0028.
  • Strict inequality at the threshold. GreaterThreshold is u > t, so a sensor sitting precisely at its rated accuracy stays off the alarm list; CDL Reals offers no greater-or-equal comparison, so this is also the only available spelling.
  • Suppression is declared, not encoded. AHU-0028 tests the same envelope with a shorter delay and suppresses this rule while active. The block graph cannot express that — the engine is status-blind and each rule is an independent composite — so it lives in suppressed_by and CLU-09 for the host to enforce.
  • Operating states and preconditions are frontmatter, not graph. Freeze protection and the minutes after an economizer mode change are periods when MAT legitimately disagrees with the steady-state mixture; all of it is host-enforced, per the library’s stance.
  • persist.delayOnInit = true (Modelica/CDL default is false): a violation already present at load waits out the full 30 minutes rather than alarming on the first tick after a controller restart.
  • Severity 3 (warning) comes from the reference’s chapter 9 card, its only severity statement for this fault — the §5.8.1 index carries no severity column.

Notes

The rule finds a contradiction between three sensors; it cannot say which one is lying. Direction narrows the list — a low MAT means MAT reads low or one of its bounds reads high — which is why the diagnoses mirror AHU-0003’s rather than copy them. Step 1 of the sensor-drift playbook resolves the ambiguity one sensor at a time; fix is on-site recalibration or replacement ($30–$80), and if all three check out, look at the mixing box for a leakage path onto the sensor. AHU-0028 runs a 15-minute delay against this pair’s 30, so the operator sees the integrity alarm first and this rule confirms which side of the envelope the reading fell on.

Test Vectors

9 scenarios, clock step 300 s over 7200 s.

ScenarioDescription
normal_mixingReference vector: MAT 18 °C with OAT 5 / RAT 22 sits 13 °C above the low bound; nothing to report
mat_below_envelopeReference vector: MAT 1 °C is 4 °C below min(oat, rat) = 5 °C, twice the tolerance; alarms at alarm_delay (1800 s)
within_toleranceReference vector: MAT 4 °C is 1 °C below the low bound — inside eps_MAT, so the sensor pair is still plausible
edge_gap_equals_toleranceThreshold edge: min(oat, rat) − mat = 2.0 °C exactly; the comparison is strict, so this is healthy
edge_gap_just_over_toleranceThreshold edge, other side: a gap of 2.1 °C clears the strict comparison and alarms at alarm_delay
summer_oat_above_ratCooling season with OAT above RAT: RAT 24 °C is now the low bound and MAT 20 °C sits 4 °C under it
high_excursion_is_not_this_faultMAT 26 °C is 4 °C above the envelope — a real violation, but the high side belongs to AHU-0003
transient_excursion_never_alarmsMAT drops below the bound for 1200 s (< alarm_delay) during a damper stroke and recovers; the timer resets unfired
fault_clears_on_recoverySustained low excursion alarms at 1800 s; recalibration at 3600 s drops the alarm on the same tick
vectors.json
{
  "schema": "cxf-library/vectors/v1",
  "clock": {
    "step_s": 300,
    "horizon_s": 7200
  },
  "scenarios": [
    {
      "name": "normal_mixing",
      "description": "Reference vector: MAT 18 \u00b0C with OAT 5 / RAT 22 sits 13 \u00b0C above the low bound; nothing to report",
      "inputs": {
        "oat": 5.0,
        "rat": 22.0,
        "mat": 18.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        }
      ]
    },
    {
      "name": "mat_below_envelope",
      "description": "Reference vector: MAT 1 \u00b0C is 4 \u00b0C below min(oat, rat) = 5 \u00b0C, twice the tolerance; alarms at alarm_delay (1800 s)",
      "inputs": {
        "oat": 5.0,
        "rat": 22.0,
        "mat": 1.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1500,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2100,
          "to_s": 7200,
          "equals": true
        }
      ]
    },
    {
      "name": "within_tolerance",
      "description": "Reference vector: MAT 4 \u00b0C is 1 \u00b0C below the low bound \u2014 inside eps_MAT, so the sensor pair is still plausible",
      "inputs": {
        "oat": 5.0,
        "rat": 22.0,
        "mat": 4.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        }
      ]
    },
    {
      "name": "edge_gap_equals_tolerance",
      "description": "Threshold edge: min(oat, rat) \u2212 mat = 2.0 \u00b0C exactly; the comparison is strict, so this is healthy",
      "inputs": {
        "oat": 20.0,
        "rat": 22.0,
        "mat": 18.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        }
      ]
    },
    {
      "name": "edge_gap_just_over_tolerance",
      "description": "Threshold edge, other side: a gap of 2.1 \u00b0C clears the strict comparison and alarms at alarm_delay",
      "inputs": {
        "oat": 20.0,
        "rat": 22.0,
        "mat": 17.9
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1500,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2100,
          "to_s": 7200,
          "equals": true
        }
      ]
    },
    {
      "name": "summer_oat_above_rat",
      "description": "Cooling season with OAT above RAT: RAT 24 \u00b0C is now the low bound and MAT 20 \u00b0C sits 4 \u00b0C under it",
      "inputs": {
        "oat": 30.0,
        "rat": 24.0,
        "mat": 20.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1500,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2100,
          "to_s": 7200,
          "equals": true
        }
      ]
    },
    {
      "name": "high_excursion_is_not_this_fault",
      "description": "MAT 26 \u00b0C is 4 \u00b0C above the envelope \u2014 a real violation, but the high side belongs to AHU-0003",
      "inputs": {
        "oat": 5.0,
        "rat": 22.0,
        "mat": 26.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        }
      ]
    },
    {
      "name": "transient_excursion_never_alarms",
      "description": "MAT drops below the bound for 1200 s (< alarm_delay) during a damper stroke and recovers; the timer resets unfired",
      "inputs": {
        "oat": 5.0,
        "rat": 22.0,
        "mat": [
          {
            "t": 0,
            "value": 18.0
          },
          {
            "t": 900,
            "value": 1.0
          },
          {
            "t": 2100,
            "value": 18.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        }
      ]
    },
    {
      "name": "fault_clears_on_recovery",
      "description": "Sustained low excursion alarms at 1800 s; recalibration at 3600 s drops the alarm on the same tick",
      "inputs": {
        "oat": 5.0,
        "rat": 22.0,
        "mat": [
          {
            "t": 0,
            "value": 1.0
          },
          {
            "t": 3600,
            "value": 18.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1500,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2100,
          "to_s": 3300,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 3900,
          "to_s": 7200,
          "equals": false
        }
      ]
    }
  ]
}

AHU-0003 — Mixed air temperature too high

Statusverified — engine e2ff2f8, cxf:fnv1a128:4c3231f994e9323f54b769c76ab050e6, 2026-08-17
Severity3
Methodrule
Phase1
CategoryCOMFORT_ENERGY
ConfidenceLOW
EstimationQUALITATIVE_ONLY
G36§5.16.14 FC#3
ClustersCLU-09
Suppresses
Suppressed byAHU-0028
RelatedAHU-0002, AHU-0028
Playbookssensor-drift
SourceHVAC FDD Reference v1.0 §9, AHU-0003; G36 §5.16.14 FC#3; PNNL-25985 (EEM-01, sensor recalibration); Bushby et al. 2001 (NIST/CEC PIER Project 2.3, APAR rules 26/27 — earliest form of this envelope test)
Operating statesOS 1-5

Preconditions (host-enforced): Supply fan running — MAT means nothing in still air. The host must not evaluate during coil freeze-protection or within a few minutes of an economizer mode transition, when MAT lags the mixture it is supposed to report, and must silence this rule while AHU-0028 is active. When a gate is unmet the verdict is NO_EVAL, not healthy.

Points: oat, rat, mat

Outputs:

  • yFault — True while MAT has stayed more than mat_tolerance above max(oat, rat) for at least alarm_delay

Parameters:

NameDefaultUnitCXF pathDescription
mat_tolerance2.0°CgapBig.tCombined sensor accuracy allowance; MAT may sit this far above the upper envelope bound before it counts as a fault. The 2.0 degC default is the reference ch.9 card’s own eps_MAT (4 degF); G36-2018 Table 5.16.14.5 publishes eps_MAT = 3 degC — retune to 3.0 for strict G36 conformance
alarm_delay1800.0spersist.delayTimeContinuous fault persistence required before the alarm asserts (30 min)

Description

Mixed air is a blend of outdoor and return air, so it cannot be warmer than both streams. When MAT reads more than combined sensor accuracy above the warmer of OAT and RAT, the reading is impossible and something is wrong with the measurement or the sensor’s surroundings: a sensor out of calibration, or one mounted where sunlight or a warm surface reaches it instead of the mixed stream.

This is G36 §5.16.14 FC#3, the high half of the mixed-air envelope check. AHU-0002 is its mirror on the low side; both sit in cluster CLU-09 (Sensor Integrity Failure), whose trigger AHU-0028 tests the envelope in both directions at once. Roughly 15% of buildings have at least one AHU with a temperature sensor this far out.

Detection Logic

yFault = mat > max(oat, rat) + mat_tolerance,
         sustained continuously for alarm_delay

Block graph (rule.cxf.jsonld):

AHU-0003 block graph

Max rather than a fixed RAT-above-OAT assumption, because which stream is warmer flips with the season. The threshold comparison is strict, so a MAT sitting exactly 2.0 °C above the bound is healthy and 2.1 °C above is not. persist requires 30 minutes of continuous violation — a MAT that spikes and recovers during a damper stroke never alarms, and any interruption restarts the timer — while recovery is immediate on the tick MAT drops back inside the bound. Nothing below the envelope can trip this rule: a MAT 4 °C under both sources violates the same physics and reads healthy here, because that case is AHU-0002’s.

Possible Diagnoses

  1. MAT sensor out of calibration, reading high
  2. OAT sensor out of calibration, reading low
  3. RAT sensor out of calibration, reading low
  4. Solar gain at the MAT sensor location — the sensor sees radiant heat or a sun-struck duct wall rather than the mixed stream

Energy Impact

COMFORT_ENERGY, LOW confidence, QUALITATIVE_ONLY. A mis-read temperature costs nothing directly; the cost is downstream. A MAT biased high reads as excess heat in the mixture, so the mixed-air loop pulls in more outdoor air than the mixture needs and the coil downstream pays for the difference — preheat in winter, mechanical cooling in summer. PNNL-25985 EEM-01 (sensor recalibration) puts the recoverable range at 0–5% of site energy across a whole sensor population, sensor-dependent and climate-neutral. Prevalence ~15% of buildings have sensor faults.

Emissions Impact

QUALITATIVE_EMISSIONS, LOW confidence; no direct emissions. Scope is 1|2 because it depends on which subsystem the bad reading distorts: excess outdoor air paid for at the preheat coil lands in Scope 1 (on-site combustion), the same excess paid for at the cooling coil lands in Scope 2 (purchased electricity). The same drifted sensor can do both in different seasons, so no single scope is correct year-round. Avoided-emissions basis: N/A.

Deviations

  • Single combined tolerance instead of G36’s per-sensor error bands. G36’s precise form is MAT_AVG − eps_MAT > max[(RAT_AVG + eps_RAT), (OAT_AVG + eps_OAT)]; the HVAC FDD Reference collapses that to one eps_MAT applied once and this card inherits the simplification. One combined band needs a larger true error before it fires — fewer false positives, less sensitivity to drift in any individual sensor. The G36 form would need three parameters and an offset block on each sensor path ahead of the Max.
  • Instantaneous samples instead of averaged signals. G36 compares time-averaged temperatures; this rule compares raw samples and leans on the 30-minute persist delay to reject noise. The two are not equivalent — averaging tolerates a signal that crosses the bound repeatedly while its mean stays outside, whereas persistence resets on every tick inside the envelope and can hide an oscillating MAT indefinitely. Steady drift, the fault this rule is for, reads the same either way.
  • Bound comparison rewritten as gap comparison. mat − max > tol ⟺ mat > max + tol, algebraically identical, but it keeps mat_tolerance a single positive set_param value. Matches AHU-0002 and AHU-0028 spelling for spelling.
  • Strict inequality at the threshold. GreaterThreshold is u > t, so a sensor sitting precisely at its rated accuracy stays off the alarm list; CDL Reals offers no greater-or-equal comparison, so this is also the only available spelling.
  • Suppression is declared, not encoded. AHU-0028 tests the same envelope with a shorter delay and suppresses this rule while active. The block graph cannot express that — the engine is status-blind and each rule is an independent composite — so it lives in suppressed_by and CLU-09 for the host to enforce.
  • Operating states and preconditions are frontmatter, not graph. Freeze protection and the minutes after an economizer mode change are periods when MAT legitimately disagrees with the steady-state mixture; all of it is host-enforced, per the library’s stance.
  • persist.delayOnInit = true (Modelica/CDL default is false): a violation already present at load waits out the full 30 minutes rather than alarming on the first tick after a controller restart.
  • Severity 3 (warning) comes from the reference’s chapter 9 card, its only severity statement for this fault — the §5.8.1 index carries no severity column.

Notes

The rule finds a contradiction between three sensors; it cannot say which one is lying. Direction narrows the list — a high MAT means MAT reads high or one of its bounds reads low — and solar gain earns a place on this side only: a sun-struck sensor reads high, never low. Step 1 of the sensor-drift playbook resolves the ambiguity one sensor at a time; fix is on-site recalibration or replacement ($30–$80), and relocating a MAT probe out of a sunlit spot fixes what recalibration cannot. AHU-0028 runs a 15-minute delay against this pair’s 30, so the operator sees the integrity alarm first and this rule confirms which side of the envelope the reading fell on.

Test Vectors

9 scenarios, clock step 300 s over 7200 s.

ScenarioDescription
normal_mixingReference vector: MAT 18 °C with OAT 5 / RAT 22 sits 4 °C below the high bound; nothing to report
mat_above_envelopeReference vector: MAT 28 °C is 6 °C above max(oat, rat) = 22 °C; alarms at alarm_delay (1800 s)
within_toleranceMirror of the FC#2 within-tolerance vector: MAT 23 °C is 1 °C above the high bound — inside eps_MAT
edge_gap_equals_toleranceThreshold edge: mat − max(oat, rat) = 2.0 °C exactly; the comparison is strict, so this is healthy
edge_gap_just_over_toleranceThreshold edge, other side: a gap of 2.1 °C clears the strict comparison and alarms at alarm_delay
summer_oat_above_ratCooling season with OAT above RAT: OAT 30 °C is now the high bound and MAT 34 °C sits 4 °C over it
low_excursion_is_not_this_faultMAT 1 °C is 4 °C below the envelope — a real violation, but the low side belongs to AHU-0002
transient_excursion_never_alarmsMAT rises above the bound for 1200 s (< alarm_delay) during a damper stroke and recovers; the timer resets unfired
fault_clears_on_recoverySustained high excursion alarms at 1800 s; recalibration at 3600 s drops the alarm on the same tick
vectors.json
{
  "schema": "cxf-library/vectors/v1",
  "clock": {
    "step_s": 300,
    "horizon_s": 7200
  },
  "scenarios": [
    {
      "name": "normal_mixing",
      "description": "Reference vector: MAT 18 \u00b0C with OAT 5 / RAT 22 sits 4 \u00b0C below the high bound; nothing to report",
      "inputs": {
        "oat": 5.0,
        "rat": 22.0,
        "mat": 18.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        }
      ]
    },
    {
      "name": "mat_above_envelope",
      "description": "Reference vector: MAT 28 \u00b0C is 6 \u00b0C above max(oat, rat) = 22 \u00b0C; alarms at alarm_delay (1800 s)",
      "inputs": {
        "oat": 5.0,
        "rat": 22.0,
        "mat": 28.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1500,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2100,
          "to_s": 7200,
          "equals": true
        }
      ]
    },
    {
      "name": "within_tolerance",
      "description": "Mirror of the FC#2 within-tolerance vector: MAT 23 \u00b0C is 1 \u00b0C above the high bound \u2014 inside eps_MAT",
      "inputs": {
        "oat": 5.0,
        "rat": 22.0,
        "mat": 23.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        }
      ]
    },
    {
      "name": "edge_gap_equals_tolerance",
      "description": "Threshold edge: mat \u2212 max(oat, rat) = 2.0 \u00b0C exactly; the comparison is strict, so this is healthy",
      "inputs": {
        "oat": 5.0,
        "rat": 22.0,
        "mat": 24.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        }
      ]
    },
    {
      "name": "edge_gap_just_over_tolerance",
      "description": "Threshold edge, other side: a gap of 2.1 \u00b0C clears the strict comparison and alarms at alarm_delay",
      "inputs": {
        "oat": 5.0,
        "rat": 22.0,
        "mat": 24.1
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1500,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2100,
          "to_s": 7200,
          "equals": true
        }
      ]
    },
    {
      "name": "summer_oat_above_rat",
      "description": "Cooling season with OAT above RAT: OAT 30 \u00b0C is now the high bound and MAT 34 \u00b0C sits 4 \u00b0C over it",
      "inputs": {
        "oat": 30.0,
        "rat": 24.0,
        "mat": 34.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1500,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2100,
          "to_s": 7200,
          "equals": true
        }
      ]
    },
    {
      "name": "low_excursion_is_not_this_fault",
      "description": "MAT 1 \u00b0C is 4 \u00b0C below the envelope \u2014 a real violation, but the low side belongs to AHU-0002",
      "inputs": {
        "oat": 5.0,
        "rat": 22.0,
        "mat": 1.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        }
      ]
    },
    {
      "name": "transient_excursion_never_alarms",
      "description": "MAT rises above the bound for 1200 s (< alarm_delay) during a damper stroke and recovers; the timer resets unfired",
      "inputs": {
        "oat": 5.0,
        "rat": 22.0,
        "mat": [
          {
            "t": 0,
            "value": 18.0
          },
          {
            "t": 900,
            "value": 28.0
          },
          {
            "t": 2100,
            "value": 18.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        }
      ]
    },
    {
      "name": "fault_clears_on_recovery",
      "description": "Sustained high excursion alarms at 1800 s; recalibration at 3600 s drops the alarm on the same tick",
      "inputs": {
        "oat": 5.0,
        "rat": 22.0,
        "mat": [
          {
            "t": 0,
            "value": 28.0
          },
          {
            "t": 3600,
            "value": 18.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1500,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2100,
          "to_s": 3300,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 3900,
          "to_s": 7200,
          "equals": false
        }
      ]
    }
  ]
}

AHU-0004 — Excessive operating state changes per hour

Statusverified — engine e2ff2f8, cxf:fnv1a128:e8de53811e08e20ebd38bc83fa7a602f, 2026-08-17
Severity3
Methodrule
Phase1
CategoryCOMFORT_ENERGY
ConfidenceLOW
EstimationQUALITATIVE_ONLY
G36§5.16.14 FC#4
ClustersCLU-01
Suppresses
Suppressed by
RelatedAHU-0016, AHU-0022
Playbookssimultaneous-hc
SourceHVAC FDD Reference v1.0 §9, AHU-0004; G36 §5.16.14 FC#4
Operating statesOS 1–5 (all)

Preconditions (host-enforced): The host must report NO_EVAL for the first count_window (1 h) after engine start: while the moving average’s window fills, its divisor is elapsed time, so the output is an extrapolated rate rather than a completed-hour count. delayOnInit = true on persist already blocks any assertion before 3600 s, so the two windows coincide. The operating_state encoding must be stable for the life of the deployment — re-mapping the enum mid-stream registers as a transition on every point that moved. Operator activity is not a fault: periods of commissioning, manual mode forcing, or scheduled occupancy testing must be excluded host-side, since every deliberate mode change counts the same as an oscillation. When any gate is unmet the verdict is NO_EVAL, not healthy.

Points: operating_state

Outputs:

  • yFault — True while the number of operating-state transitions in the trailing count_window has stayed above os_max for at least alarm_delay

Parameters:

NameDefaultUnitCXF pathDescription
os_max7.01/hcntHigh.tTransitions per hour above which the sequence counts as unstable rather than load-following
count_window3600.0srate.deltaTrailing window the transitions are counted over (1 h). It also fixes the units of os_max; a host that shortens it must retune count_scale with it and read os_max as transitions per window rather than per hour
count_scale12.01count.kRescales the moving average of the one-tick pulse train back into a transition count: k = count_window / host tick interval in seconds. The default 12.0 is 3600/300, correct only at a 300 s tick; a host on a different tick MUST retune this or every count is wrong by the ratio of the two intervals
alarm_delay3600.0spersist.delayTimeContinuous fault persistence required before the alarm asserts (60 min — the reference ch.9 card’s own AlarmDelay for FC#4; G36-2018 Table 5.16.14.5 applies a uniform 30 min AlarmDelay to all fifteen FCs)

Description

The sequence cannot decide what it is doing. An AHU walking between heating, cooling, economizer, and off more than a few times an hour is not following load — load does not move that fast — it is chasing a changeover threshold with nothing to hold it on one side. Valves and dampers stroke, control loops restart from a new setpoint and overshoot, and air conditioned one way is conditioned the other way minutes later. The cause is nearly always a missing or undersized deadband, in the changeover logic or in the zone demand aggregation feeding it; a close second is upstream sensor noise crossing a threshold every few minutes with the sequencing logic working as written. A member fault of CLU-01 (Simultaneous Heating & Cooling).

Detection Logic

pulse  = (operating_state ≠ previous tick's operating_state)   one tick wide
count  = MovingAverage(pulse, count_window) × count_scale       transitions in the trailing hour
yFault = count > os_max, sustained continuously for alarm_delay

Block graph (rule.cxf.jsonld):

AHU-0004 block graph

Reals.MovingAverage is a continuous-time integral mean: it accumulates u·dt and divides by the window, so a one-tick pulse of height 1.0 encloses one tick interval of area and n transitions in the trailing hour give rate = n · dt / count_window. count_scale = count_window / dt = 3600/300 = 12 recovers n, which couples the parameter to the host’s tick interval — the one thing about this rule that can be got wrong silently (see Deviations). Only whether the state moved is consumed; chg.up and chg.down are declared and left unconnected, since the destination state says nothing about oscillation. The threshold is strict, so exactly seven transitions an hour reads clear and eight alarms; the arithmetic is exact at that boundary in IEEE-754, not approximately exact. persist then requires the count to stay above os_max for a full hour, so roughly two hours of genuine thrashing elapse before anything is reported, and any interruption restarts the timer.

Possible Diagnoses

  1. Deadband between modes too narrow — the sequence flips back as soon as it has finished acting, because the condition that ended the last mode is the condition that starts the next one
  2. Fluctuating zone demands near a changeover threshold — the aggregated demand signal sits on the boundary and the unit follows every wobble in it
  3. Sensor noise causing mode oscillation — one intermittent or poorly located sensor crosses the threshold repeatedly and the sequencing logic faithfully obeys

Energy Impact

COMFORT_ENERGY, LOW confidence, QUALITATIVE_ONLY. The rule sees a state index and nothing else, so it cannot say what any transition cost. The reference puts the loss at 1–3% of AHU energy, split between actuator wear and the transitions themselves, where a coil is charged and then abandoned before the air stream has settled. Confidence is LOW because no controlled study isolates cycling losses from the deadband change that fixes them and no PNNL measure covers sequencing stability. Climate-neutral. Size the opportunity per Energy Impact Reference §4.4 (unstable hours × AHU coil and fan power); this rule contributes the hours.

Emissions Impact

Scope 1 + 2, QUALITATIVE_EMISSIONS, LOW confidence; on the order of 5–15 kg CO₂e/yr from cycling losses. Both scopes appear because the transitions being counted cross between them — gas at the boiler and electricity at the chiller for the same hour of indecision. The magnitude is small enough that the number is an order of magnitude, not an estimate. Avoided-emissions basis: N/A.

Deviations

  • The reference card names no points; operating_state is our choice. Its chapter 9 card has no Required Points table, only the logic and tunables. Only transitions are consumed and no value is ever interpreted, so any stable enumeration binds; the dictionary recommends the G36 §5.16.14 OS#1–OS#5 index and requires only that the encoding not change under the rule’s feet.
  • Rolling count built from a moving average, because the block set has no windowed counter. Integers.OnCounter counts monotonically from a reset and has no window, so it would need a host-driven hourly reset — turning the rolling count into a tumbling one whose verdict depends on where the hour boundary fell.
  • count_scale is coupled to the host’s tick interval. k = count_window / dt; the default 12.0 is correct only at a 300 s tick. A host ticking every 60 s must set 60.0, and leaving 12.0 reports a fifth of the true count so the rule never fires. Same family of deployment constraint as AHU-0022’s minimum sample interval, but this one fails quietly: a mis-set scale produces a plausible-looking number.
  • Minimum tick interval, from the same block. Each MovingAverage keeps a fixed 64-checkpoint ring and silently drops the oldest in-window sample past that. A window spanning n ticks retains n + 1 checkpoints, so it may span at most 63 ticks: dt ≥ count_window/63 = 57.15 s, and a legal deployment has count_scale = 3600/dt ≤ 63. At the default 300 s tick the window holds 12 samples.
  • Startup artifact (a): the first-tick pulse is inert. Integers.Change compares against pre_u_start on the first tick, so a unit loading in OS#3 registers a change at t = 0. It encloses no area because dt is zero there, so it never reaches the count. chg.pre_u_start is written explicitly as 0 rather than left to the engine default (also 0) and is not exposed as a card parameter, since nothing it can do survives tick 0.
  • Startup artifact (b): the first hour reads as a rate, not a count. While t < count_window the moving average divides by elapsed time, so two changes in the first ten minutes read as 12/hr — the pace, extrapolated. Defensible, but not the reference’s completed-hour count. delayOnInit = true blocks any assertion before 3600 s and the frontmatter precondition requires the host to report NO_EVAL over the same hour, so the artifact cannot reach a verdict.
  • The counting window is half-open. rate compares the accumulated integral now against its value one count_window ago, so a transition exactly count_window old has just left the window. The reference is silent; it matters only right at the threshold, and it errs toward silence.
  • alarm_delay equals count_window. Both come from the reference (60 min AlarmDelay, per-hour count), but the interaction is worth stating: the count must hold above os_max for a full hour after first crossing it, so a burst that ends inside that hour never reports.
  • related adds AHU-0022. The reference lists AHU-0016 only; AHU-0022 (SAT hunting) already names this fault from its side, so the link is made reciprocal.
  • Severity 3 (warning) and method rule, per the reference’s chapter 9 card; its §5.8.1 index carries no severity column. Operating states OS 1–5 are declared, not gated — the reference marks the fault applicable in every state.
  • persist.delayOnInit = true (Modelica/CDL default is false), the library’s standing choice: a count already above os_max at load waits out the full hour instead of alarming on the first tick after a controller restart.

Notes

The fix is a deadband, and it is remote and free. Step 2.1 of the simultaneous-hc playbook gives the numbers for the heating/cooling case — G36 §5.16’s 2.8 °C (5 °F) minimum — and the same reasoning applies to whatever pair of states this unit oscillates between: the condition that leaves a mode must not be the condition that re-enters it. Confirm first that the demand signal is not itself the problem (diagnosis 3). This rule and AHU-0022 are the same pathology at two altitudes, and both tripping together is the strongest evidence for that diagnosis.

Test Vectors

9 scenarios, clock step 300 s over 14400 s.

ScenarioDescription
stable_stateThe unit holds one operating state for the whole run. operating_state equals chg.pre_u_start, so Integers.Change never pulses and the rolling count sits at zero — the baseline every other scenario departs from
startup_pulse_is_inertStartup artifact (a): operating_state is a constant 3, which differs from chg.pre_u_start (0), so Integers.Change emits a spurious pulse on tick 0. It costs nothing — MovingAverage integrates u·dt and dt is zero on the first tick, so the pulse encloses no area and count stays at 0.0 for the whole run
four_transitions_per_hourThe sequence walks OS 1→2→3→4→5→1 every 900 s: four transitions an hour, ordinary for a unit tracking load across a day. count holds at 4.0, and the warm-up window agrees rather than overshooting — one change in the first 900 s extrapolates to the same 4/hr pace
sustained_thrashoperating_state alternates every tick — 12 transitions an hour, the signature of a changeover threshold with no deadband. count reads 12 from the first pulse (during the first hour that is an extrapolated pace, and here the pace is the truth), cntHigh latches at t=300, and yFault follows one alarm_delay later at t=3900, the earliest instant this rule can assert
seven_per_hour_stays_clearBoundary, low side. The unit is quiet for the first hour, then transitions seven times an hour indefinitely — seven changes per twelve ticks, so every trailing-hour window holds exactly seven. count lands on exactly 7.0 (12.0 × 7 × 300 / 3600 is exact in IEEE-754) and os_max is a strict >, so seven transitions an hour stays clear
eight_per_hour_tripsBoundary, high side. The same shape with eight transitions an hour. The eighth pulse enters the trailing window at t=6000, count steps to 8.0, and yFault asserts one alarm_delay later at t=9600
thrash_stops_before_delayTransient: ten transitions between t=3900 and t=6600, then the sequence settles. count crosses 7 at t=6000, but the burst is already over and the count decays as pulses age out, reaching 7.0 at t=8100. cntHigh held for 1800 s — half the alarm_delay — so nothing asserts
burst_ages_out_of_windowA two-hour thrash (t=3900 to t=10800) is long enough: count crosses at t=6000 and yFault asserts at t=9600. When the thrash stops the count does not drop at once — it decays as pulses leave the trailing hour, reaching 7.0 at t=12300, and the alarm clears on that tick because TrueDelay has no off-delay
warmup_rate_never_assertsStartup artifact (b): two transitions inside the first 10 minutes read as a 12/hr pace, because the growing-window divisor is elapsed time rather than count_window. cntHigh is true from t=300 to t=900, then the pace collapses as the window fills — count is 6.0 at t=1200 and 2.0 by the end of the hour. delayOnInit keeps yFault false throughout, which is the reason the frontmatter puts the first count_window under host NO_EVAL
vectors.json
{
  "schema": "cxf-library/vectors/v1",
  "clock": {
    "step_s": 300,
    "horizon_s": 14400
  },
  "scenarios": [
    {
      "name": "stable_state",
      "description": "The unit holds one operating state for the whole run. operating_state equals chg.pre_u_start, so Integers.Change never pulses and the rolling count sits at zero \u2014 the baseline every other scenario departs from",
      "inputs": {
        "operating_state": 0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 14400,
          "equals": false
        }
      ]
    },
    {
      "name": "startup_pulse_is_inert",
      "description": "Startup artifact (a): operating_state is a constant 3, which differs from chg.pre_u_start (0), so Integers.Change emits a spurious pulse on tick 0. It costs nothing \u2014 MovingAverage integrates u\u00b7dt and dt is zero on the first tick, so the pulse encloses no area and count stays at 0.0 for the whole run",
      "inputs": {
        "operating_state": 3
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 14400,
          "equals": false
        }
      ]
    },
    {
      "name": "four_transitions_per_hour",
      "description": "The sequence walks OS 1\u21922\u21923\u21924\u21925\u21921 every 900 s: four transitions an hour, ordinary for a unit tracking load across a day. count holds at 4.0, and the warm-up window agrees rather than overshooting \u2014 one change in the first 900 s extrapolates to the same 4/hr pace",
      "inputs": {
        "operating_state": [
          {
            "t": 0,
            "value": 1
          },
          {
            "t": 900,
            "value": 2
          },
          {
            "t": 1800,
            "value": 3
          },
          {
            "t": 2700,
            "value": 4
          },
          {
            "t": 3600,
            "value": 5
          },
          {
            "t": 4500,
            "value": 1
          },
          {
            "t": 5400,
            "value": 2
          },
          {
            "t": 6300,
            "value": 3
          },
          {
            "t": 7200,
            "value": 4
          },
          {
            "t": 8100,
            "value": 5
          },
          {
            "t": 9000,
            "value": 1
          },
          {
            "t": 9900,
            "value": 2
          },
          {
            "t": 10800,
            "value": 3
          },
          {
            "t": 11700,
            "value": 4
          },
          {
            "t": 12600,
            "value": 5
          },
          {
            "t": 13500,
            "value": 1
          },
          {
            "t": 14400,
            "value": 2
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 14400,
          "equals": false
        }
      ]
    },
    {
      "name": "sustained_thrash",
      "description": "operating_state alternates every tick \u2014 12 transitions an hour, the signature of a changeover threshold with no deadband. count reads 12 from the first pulse (during the first hour that is an extrapolated pace, and here the pace is the truth), cntHigh latches at t=300, and yFault follows one alarm_delay later at t=3900, the earliest instant this rule can assert",
      "inputs": {
        "operating_state": [
          {
            "t": 0,
            "value": 1
          },
          {
            "t": 300,
            "value": 2
          },
          {
            "t": 600,
            "value": 1
          },
          {
            "t": 900,
            "value": 2
          },
          {
            "t": 1200,
            "value": 1
          },
          {
            "t": 1500,
            "value": 2
          },
          {
            "t": 1800,
            "value": 1
          },
          {
            "t": 2100,
            "value": 2
          },
          {
            "t": 2400,
            "value": 1
          },
          {
            "t": 2700,
            "value": 2
          },
          {
            "t": 3000,
            "value": 1
          },
          {
            "t": 3300,
            "value": 2
          },
          {
            "t": 3600,
            "value": 1
          },
          {
            "t": 3900,
            "value": 2
          },
          {
            "t": 4200,
            "value": 1
          },
          {
            "t": 4500,
            "value": 2
          },
          {
            "t": 4800,
            "value": 1
          },
          {
            "t": 5100,
            "value": 2
          },
          {
            "t": 5400,
            "value": 1
          },
          {
            "t": 5700,
            "value": 2
          },
          {
            "t": 6000,
            "value": 1
          },
          {
            "t": 6300,
            "value": 2
          },
          {
            "t": 6600,
            "value": 1
          },
          {
            "t": 6900,
            "value": 2
          },
          {
            "t": 7200,
            "value": 1
          },
          {
            "t": 7500,
            "value": 2
          },
          {
            "t": 7800,
            "value": 1
          },
          {
            "t": 8100,
            "value": 2
          },
          {
            "t": 8400,
            "value": 1
          },
          {
            "t": 8700,
            "value": 2
          },
          {
            "t": 9000,
            "value": 1
          },
          {
            "t": 9300,
            "value": 2
          },
          {
            "t": 9600,
            "value": 1
          },
          {
            "t": 9900,
            "value": 2
          },
          {
            "t": 10200,
            "value": 1
          },
          {
            "t": 10500,
            "value": 2
          },
          {
            "t": 10800,
            "value": 1
          },
          {
            "t": 11100,
            "value": 2
          },
          {
            "t": 11400,
            "value": 1
          },
          {
            "t": 11700,
            "value": 2
          },
          {
            "t": 12000,
            "value": 1
          },
          {
            "t": 12300,
            "value": 2
          },
          {
            "t": 12600,
            "value": 1
          },
          {
            "t": 12900,
            "value": 2
          },
          {
            "t": 13200,
            "value": 1
          },
          {
            "t": 13500,
            "value": 2
          },
          {
            "t": 13800,
            "value": 1
          },
          {
            "t": 14100,
            "value": 2
          },
          {
            "t": 14400,
            "value": 1
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 4200,
          "to_s": 14400,
          "equals": true
        }
      ]
    },
    {
      "name": "seven_per_hour_stays_clear",
      "description": "Boundary, low side. The unit is quiet for the first hour, then transitions seven times an hour indefinitely \u2014 seven changes per twelve ticks, so every trailing-hour window holds exactly seven. count lands on exactly 7.0 (12.0 \u00d7 7 \u00d7 300 / 3600 is exact in IEEE-754) and os_max is a strict `>`, so seven transitions an hour stays clear",
      "inputs": {
        "operating_state": [
          {
            "t": 0,
            "value": 1
          },
          {
            "t": 3900,
            "value": 2
          },
          {
            "t": 4200,
            "value": 1
          },
          {
            "t": 4500,
            "value": 2
          },
          {
            "t": 4800,
            "value": 1
          },
          {
            "t": 5100,
            "value": 2
          },
          {
            "t": 5400,
            "value": 1
          },
          {
            "t": 5700,
            "value": 2
          },
          {
            "t": 7500,
            "value": 1
          },
          {
            "t": 7800,
            "value": 2
          },
          {
            "t": 8100,
            "value": 1
          },
          {
            "t": 8400,
            "value": 2
          },
          {
            "t": 8700,
            "value": 1
          },
          {
            "t": 9000,
            "value": 2
          },
          {
            "t": 9300,
            "value": 1
          },
          {
            "t": 11100,
            "value": 2
          },
          {
            "t": 11400,
            "value": 1
          },
          {
            "t": 11700,
            "value": 2
          },
          {
            "t": 12000,
            "value": 1
          },
          {
            "t": 12300,
            "value": 2
          },
          {
            "t": 12600,
            "value": 1
          },
          {
            "t": 12900,
            "value": 2
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 14400,
          "equals": false
        }
      ]
    },
    {
      "name": "eight_per_hour_trips",
      "description": "Boundary, high side. The same shape with eight transitions an hour. The eighth pulse enters the trailing window at t=6000, count steps to 8.0, and yFault asserts one alarm_delay later at t=9600",
      "inputs": {
        "operating_state": [
          {
            "t": 0,
            "value": 1
          },
          {
            "t": 3900,
            "value": 2
          },
          {
            "t": 4200,
            "value": 1
          },
          {
            "t": 4500,
            "value": 2
          },
          {
            "t": 4800,
            "value": 1
          },
          {
            "t": 5100,
            "value": 2
          },
          {
            "t": 5400,
            "value": 1
          },
          {
            "t": 5700,
            "value": 2
          },
          {
            "t": 6000,
            "value": 1
          },
          {
            "t": 7500,
            "value": 2
          },
          {
            "t": 7800,
            "value": 1
          },
          {
            "t": 8100,
            "value": 2
          },
          {
            "t": 8400,
            "value": 1
          },
          {
            "t": 8700,
            "value": 2
          },
          {
            "t": 9000,
            "value": 1
          },
          {
            "t": 9300,
            "value": 2
          },
          {
            "t": 9600,
            "value": 1
          },
          {
            "t": 11100,
            "value": 2
          },
          {
            "t": 11400,
            "value": 1
          },
          {
            "t": 11700,
            "value": 2
          },
          {
            "t": 12000,
            "value": 1
          },
          {
            "t": 12300,
            "value": 2
          },
          {
            "t": 12600,
            "value": 1
          },
          {
            "t": 12900,
            "value": 2
          },
          {
            "t": 13200,
            "value": 1
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 9300,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 9900,
          "to_s": 14400,
          "equals": true
        }
      ]
    },
    {
      "name": "thrash_stops_before_delay",
      "description": "Transient: ten transitions between t=3900 and t=6600, then the sequence settles. count crosses 7 at t=6000, but the burst is already over and the count decays as pulses age out, reaching 7.0 at t=8100. cntHigh held for 1800 s \u2014 half the alarm_delay \u2014 so nothing asserts",
      "inputs": {
        "operating_state": [
          {
            "t": 0,
            "value": 1
          },
          {
            "t": 3900,
            "value": 2
          },
          {
            "t": 4200,
            "value": 1
          },
          {
            "t": 4500,
            "value": 2
          },
          {
            "t": 4800,
            "value": 1
          },
          {
            "t": 5100,
            "value": 2
          },
          {
            "t": 5400,
            "value": 1
          },
          {
            "t": 5700,
            "value": 2
          },
          {
            "t": 6000,
            "value": 1
          },
          {
            "t": 6300,
            "value": 2
          },
          {
            "t": 6600,
            "value": 1
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 14400,
          "equals": false
        }
      ]
    },
    {
      "name": "burst_ages_out_of_window",
      "description": "A two-hour thrash (t=3900 to t=10800) is long enough: count crosses at t=6000 and yFault asserts at t=9600. When the thrash stops the count does not drop at once \u2014 it decays as pulses leave the trailing hour, reaching 7.0 at t=12300, and the alarm clears on that tick because TrueDelay has no off-delay",
      "inputs": {
        "operating_state": [
          {
            "t": 0,
            "value": 1
          },
          {
            "t": 3900,
            "value": 2
          },
          {
            "t": 4200,
            "value": 1
          },
          {
            "t": 4500,
            "value": 2
          },
          {
            "t": 4800,
            "value": 1
          },
          {
            "t": 5100,
            "value": 2
          },
          {
            "t": 5400,
            "value": 1
          },
          {
            "t": 5700,
            "value": 2
          },
          {
            "t": 6000,
            "value": 1
          },
          {
            "t": 6300,
            "value": 2
          },
          {
            "t": 6600,
            "value": 1
          },
          {
            "t": 6900,
            "value": 2
          },
          {
            "t": 7200,
            "value": 1
          },
          {
            "t": 7500,
            "value": 2
          },
          {
            "t": 7800,
            "value": 1
          },
          {
            "t": 8100,
            "value": 2
          },
          {
            "t": 8400,
            "value": 1
          },
          {
            "t": 8700,
            "value": 2
          },
          {
            "t": 9000,
            "value": 1
          },
          {
            "t": 9300,
            "value": 2
          },
          {
            "t": 9600,
            "value": 1
          },
          {
            "t": 9900,
            "value": 2
          },
          {
            "t": 10200,
            "value": 1
          },
          {
            "t": 10500,
            "value": 2
          },
          {
            "t": 10800,
            "value": 1
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 9300,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 9900,
          "to_s": 11700,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 12600,
          "to_s": 14400,
          "equals": false
        }
      ]
    },
    {
      "name": "warmup_rate_never_asserts",
      "description": "Startup artifact (b): two transitions inside the first 10 minutes read as a 12/hr pace, because the growing-window divisor is elapsed time rather than count_window. cntHigh is true from t=300 to t=900, then the pace collapses as the window fills \u2014 count is 6.0 at t=1200 and 2.0 by the end of the hour. delayOnInit keeps yFault false throughout, which is the reason the frontmatter puts the first count_window under host NO_EVAL",
      "inputs": {
        "operating_state": [
          {
            "t": 0,
            "value": 1
          },
          {
            "t": 300,
            "value": 2
          },
          {
            "t": 600,
            "value": 1
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 14400,
          "equals": false
        }
      ]
    }
  ]
}

AHU-0005 — SAT too low vs MAT in heating

Statusverified — engine e2ff2f8, cxf:fnv1a128:23c2dc854e3fd583e7f98f40f298a24a, 2026-08-17
Severity3
Methodrule
Phase1
CategoryEXCESS_CONSUMPTION
ConfidenceMEDIUM
EstimationPROXY_ESTIMATION
G36§5.16.14 FC#5
ClustersCLU-01
Suppresses
Suppressed byAHU-0028
RelatedAHU-0016, AHU-0007, AHU-0028
Playbookssimultaneous-hc
SourceHVAC FDD Reference v1.0 §5.8.1 (index; card abbreviated); G36 §5.16.14 FC#5 (text per Addendum u public review); NISTIR 7365 (defaults provenance)
Operating statesOS#1 (heating) — host-gated

Preconditions (host-enforced): The unit must have a MAT sensor; G36 omits FC#5 where there is none. Evaluate only in OS#1, whose actuator signature per G36 Table 5.16.14.2 is heating coil > 0, cooling coil = 0, OA damper at minimum position — outside it a SAT below MAT is the intended result of cooling, not a fault. Supply fan running, since neither MAT nor SAT means anything in still air. Suspend evaluation for ModeDelay (30 min) after any operating-state change, while the coils and damper are still stroking and the sensors still report the previous state’s mixture. Silence this rule while AHU-0028 is active: MAT is an input here and FC-062 is its integrity gate. When any gate is unmet the verdict is NO_EVAL, not healthy.

Points: mat, sat

Outputs:

  • yFault — True while SAT has stayed more than mat_sat_gap_threshold below MAT for at least alarm_delay

Parameters:

NameDefaultUnitCXF pathDescription
mat_sat_gap_threshold3.0°CgapBig.tShortfall of SAT below MAT that counts as heat being removed rather than sensor and fan-rise uncertainty. Composed from the G36 §5.16.14 internal variables as eSAT + eMAT − dTSF = 1 + 3 − 1; a site that retunes any of the three recomputes the sum (a larger measured fan rise lowers the threshold, a looser sensor band raises it)
alarm_delay1800.0spersist.delayTimeContinuous fault persistence required before the alarm asserts (G36 AlarmDelay, 30 min)

Description

In heating, air leaves the unit warmer than it arrived: it crosses the supply fan, which adds about a degree of shaft work, and then a heating coil the sequence has called for. SAT sitting several degrees below MAT under those conditions is not a control error — something in the unit is taking heat back out. A cooling coil valve leaking or stuck open, a DX circuit stuck on, or a sensor pair that disagrees by more than either instrument is rated for.

This is G36 §5.16.14 FC#5, applicable in OS#1 (heating) only; elsewhere a SAT below MAT is what the sequence asked for. The leaking-cooling-coil diagnosis is simultaneous heating and cooling seen from the air side, which is why the fault sits in CLU-01 behind AHU-0016: FC-050 catches two valve commands overlapping, this rule catches one device passing water or refrigerant with no command raised.

Detection Logic

gap    = mat − sat
yFault = gap > mat_sat_gap_threshold,
         sustained continuously for alarm_delay

Block graph (rule.cxf.jsonld):

AHU-0005 block graph

The threshold is G36’s SAT_AVG + eSAT <= MAT_AVG − eMAT + dTSF rearranged so one positive number carries the whole allowance (see Deviations). It is not a sensor tolerance: it is the two sensor bands minus the fan rise the air gets for free, so the rule fires on heat removal that neither instrument error nor the fan can explain. The comparison is strict, so a gap sitting exactly on 3.0 °C reads healthy where G36’s <= would call it a fault. persist requires 30 minutes of continuous violation — enough to ride out a mode change, a hot-water loop coming up to temperature, or a valve stroking through its span — and any interruption restarts the timer.

Nothing about the size of the heating call reaches this rule. A unit whose heating coil does nothing at all still shows SAT about a fan rise above MAT, so the gap runs slightly negative and this rule reads healthy; that failure belongs to the inactive-coil rules.

Possible Diagnoses

G36 §5.16.14 FC#5, transcribed:

  1. SAT sensor error
  2. MAT sensor error
  3. Cooling coil valve leaking or stuck open
  4. Heating coil valve stuck closed or actuator failure
  5. Fouled or undersized heating coil
  6. HW temperature too low or HW unavailable
  7. Gas or electric heat unavailable
  8. DX cooling stuck on

Read the list as two groups. Either the sensors are lying (1, 2), or a heat sink is running against the heating call (3, 8) — and a dead coil behind it (4–7) makes the gap wide enough to clear the threshold sooner. Diagnoses 4 through 7 explain a missing rise on their own, but not a rise of the wrong sign, so they reach this rule only in company.

Energy Impact

EXCESS_CONSUMPTION, MEDIUM confidence, PROXY_ESTIMATION, all three from the reference’s §5.8.1 index row. The waste is a cancellation: the heating source pays to warm air and a leaking cooling coil or stuck DX circuit immediately unpays it, so both sides bill.

waste_kw >= supply_airflow_m3s × 1.2 × 1.005 × ((mat + dTSF) − sat)

A floor, not an equality — it credits the heating coil with no rise at all, and design airflow stands in for a measured one. The index puts the recoverable range at 1–4% of site energy against PNNL-25985 EEM-05 (supply air temperature reset), the nearest catalog measure rather than a study of this failure, so treat it as an order-of-magnitude bound. Heating-dominant: the rule is only evaluable in OS#1, so its hours are heating hours.

Emissions Impact

PROXY_EMISSIONS, MEDIUM confidence. Scope 1+2 because the two halves of the exchange run at once and land in different inventories: hot water from a gas boiler or furnace section is Scope 1, electric resistance or a heat pump is Scope 2, and the cooling side eating the heat is Scope 2 in nearly every building. Avoided-emissions basis: marginal operating emissions rate (MOER) for the electric half, static combustion factor for the fuel half.

Deviations

  • The reference card is abbreviated; G36 is the normative source. HVAC FDD Reference v1.0 carries AHU-0005 as a §5.8.1 index row only — no equation, tunables, diagnoses, operating-state applicability, or severity. Detection Logic and Possible Diagnoses are transcribed from ASHRAE Guideline 36 §5.16.14 FC#5 as it appears in Addendum u to Guideline 36-2018 (first public review, 2021), with the Table 5.16.14.5 defaults (NISTIR 7365 provenance, which the addendum notes are biased toward minimizing false alarms).
  • Severity 3 is the library’s. No reference card states one and the §5.8.1 index carries no severity column; the value matches this chapter’s scaffold row and every other G36 comparison rule here.
  • The energy profile is the index row’s; the emissions split is the library’s. category, confidence, estimation_method, and savings_range are copied from §5.8.1. The index has no emissions column, so scope: 1+2 follows AHU-0016’s convention for simultaneous heating-and-cooling waste (matching mirror card AHU-0012), and the runtime formula is mirrored from AHU-0016’s waste term.
  • Combined-epsilon threshold. MAT − SAT >= eSAT + eMAT − dTSF binds one positive threshold to one CXF path: 1 + 3 − 1 = 3.0 °C at the Table 5.16.14.5 defaults. A site that retunes any input recomputes the sum rather than the parameter — a measured 2 K fan rise drops the threshold to 2.0 °C, a looser eMAT of 4 K for a poorly mixed plenum raises it to 4.0 °C. Same rearrangement as AHU-0028 and AHU-0001.
  • G36’s <= becomes a strict >. CDL Reals offers no greater-or-equal comparison; the disagreement has measure zero on a real temperature pair and errs toward silence.
  • Instantaneous samples instead of averaged signals. G36 computes every signal as a five-minute rolling average of one-minute samples; this rule compares raw samples and leans on the 30-minute persist delay. The two are not equivalent — averaging tolerates a signal that keeps crossing back while its mean stays outside the band, whereas persistence resets on every compliant tick, so an oscillating gap can hide indefinitely. A steady offset, which is what a leaking valve or drifted sensor produces, reads the same either way. Same note as AHU-0002.
  • Operating-state applicability and ModeDelay are frontmatter, not graph. G36 scopes FC#5 to OS#1 and suspends every fault condition for 30 minutes after a mode change; both are host concerns under this library’s stance (precedent AHU-0029), and a verdict outside OS#1 or inside the transition window is NO_EVAL, never healthy. The OS#1 signature the host gates on is G36 Table 5.16.14.2’s: heating coil > 0, cooling coil = 0, OA damper at minimum.
  • Suppression is declared, not encoded. MAT is an input, so AHU-0028 — the mixing-box integrity gate — silences this rule while active. The engine is status-blind, so it lives in suppressed_by for the host to enforce.
  • persist.delayOnInit = true (Modelica/CDL default is false), the library’s standing choice: a gap already present at load waits out the full 30 minutes rather than alarming on the first tick after a controller restart.

Notes

This rule and AHU-0007 are the two G36 heating-side SAT tests, and they fail differently on purpose: FC-007 says the unit cannot make the air it was asked for, this one says the unit is making air colder than it found it. A fouled coil or dead boiler trips FC-007 alone; a leaking cooling valve trips both, which points at diagnosis 3 or 8 before anything else. Order of work: confirm MAT first — AHU-0028 runs a shorter delay and suppresses this rule — then take the leaking-valve half of the list to the simultaneous-hc playbook.

Test Vectors

10 scenarios, clock step 300 s over 5400 s.

ScenarioDescription
heating_air_leaves_warmer_than_it_arrivedHealthy OS#1 operation: 15 °C mixed air leaves at 25 °C after the fan and an active heating coil. The gap runs negative, which no > against a positive threshold can satisfy
sensor_spread_absorbed_by_thresholdSAT reads 2 °C below MAT — inside the combined eSAT + eMAT − dTSF band, so a pair of sensors at their rated accuracy plus a fan that adds less rise than assumed cannot fire the rule
cooling_coil_leaking_through_heatingSAT 6 °C below MAT while the unit is in heating: a leaking or stuck-open cooling coil valve is stripping more heat than the fan and heating coil put in. Alarms one alarm_delay (1800 s) after the condition appears
dx_stuck_on_deep_gapSAT 10 °C below MAT — DX cooling stuck on against a heating call. Same verdict as a leaking valve; the rule reports the contradiction, not which device caused it
gap_exactly_at_thresholdThreshold edge: mat − sat = 3.0 °C exactly. gapBig is a strict >, so a gap sitting precisely on mat_sat_gap_threshold reads healthy — G36’s <= would call this a fault
gap_just_over_thresholdThreshold edge, other side: mat − sat = 3.1 °C clears the strict comparison and alarms after alarm_delay
no_temperature_rise_is_not_this_faultSAT equals MAT — the heating coil is contributing nothing, which is a real problem and not this one. The gap is zero, so this rule stays silent and the finding belongs to the inactive-coil rules
transient_gap_clears_before_delayTransient: a 6 °C gap opens at load and closes at t = 1200 s as the hot-water loop comes up to temperature. Twenty minutes is short of alarm_delay, so nothing alarms
oscillating_gap_never_persistsHonesty case for the instantaneous-sampling deviation: SAT alternates every 600 s between 6 °C below MAT and 2 °C above it. The mean sits outside the band, so G36’s 5-minute rolling average would fault; persistence resets on every compliant tick and this rule never does
fault_clears_on_recoveryA sustained 6 °C gap alarms at 1800 s; the leaking cooling valve is isolated at t = 3000 s and SAT climbs above MAT, dropping the alarm on that tick — TrueDelay has no off-delay
vectors.json
{
  "schema": "cxf-library/vectors/v1",
  "clock": {
    "step_s": 300,
    "horizon_s": 5400
  },
  "scenarios": [
    {
      "name": "heating_air_leaves_warmer_than_it_arrived",
      "description": "Healthy OS#1 operation: 15 \u00b0C mixed air leaves at 25 \u00b0C after the fan and an active heating coil. The gap runs negative, which no `>` against a positive threshold can satisfy",
      "inputs": {
        "mat": 15.0,
        "sat": 25.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        }
      ]
    },
    {
      "name": "sensor_spread_absorbed_by_threshold",
      "description": "SAT reads 2 \u00b0C below MAT \u2014 inside the combined eSAT + eMAT \u2212 dTSF band, so a pair of sensors at their rated accuracy plus a fan that adds less rise than assumed cannot fire the rule",
      "inputs": {
        "mat": 20.0,
        "sat": 18.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        }
      ]
    },
    {
      "name": "cooling_coil_leaking_through_heating",
      "description": "SAT 6 \u00b0C below MAT while the unit is in heating: a leaking or stuck-open cooling coil valve is stripping more heat than the fan and heating coil put in. Alarms one alarm_delay (1800 s) after the condition appears",
      "inputs": {
        "mat": 20.0,
        "sat": 14.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1500,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2100,
          "to_s": 5400,
          "equals": true
        }
      ]
    },
    {
      "name": "dx_stuck_on_deep_gap",
      "description": "SAT 10 \u00b0C below MAT \u2014 DX cooling stuck on against a heating call. Same verdict as a leaking valve; the rule reports the contradiction, not which device caused it",
      "inputs": {
        "mat": 18.0,
        "sat": 8.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1500,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2100,
          "to_s": 5400,
          "equals": true
        }
      ]
    },
    {
      "name": "gap_exactly_at_threshold",
      "description": "Threshold edge: mat \u2212 sat = 3.0 \u00b0C exactly. `gapBig` is a strict `>`, so a gap sitting precisely on mat_sat_gap_threshold reads healthy \u2014 G36's `<=` would call this a fault",
      "inputs": {
        "mat": 20.0,
        "sat": 17.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        }
      ]
    },
    {
      "name": "gap_just_over_threshold",
      "description": "Threshold edge, other side: mat \u2212 sat = 3.1 \u00b0C clears the strict comparison and alarms after alarm_delay",
      "inputs": {
        "mat": 20.0,
        "sat": 16.9
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1500,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2100,
          "to_s": 5400,
          "equals": true
        }
      ]
    },
    {
      "name": "no_temperature_rise_is_not_this_fault",
      "description": "SAT equals MAT \u2014 the heating coil is contributing nothing, which is a real problem and not this one. The gap is zero, so this rule stays silent and the finding belongs to the inactive-coil rules",
      "inputs": {
        "mat": 18.0,
        "sat": 18.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        }
      ]
    },
    {
      "name": "transient_gap_clears_before_delay",
      "description": "Transient: a 6 \u00b0C gap opens at load and closes at t = 1200 s as the hot-water loop comes up to temperature. Twenty minutes is short of alarm_delay, so nothing alarms",
      "inputs": {
        "mat": 20.0,
        "sat": [
          {
            "t": 0,
            "value": 14.0
          },
          {
            "t": 1200,
            "value": 22.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        }
      ]
    },
    {
      "name": "oscillating_gap_never_persists",
      "description": "Honesty case for the instantaneous-sampling deviation: SAT alternates every 600 s between 6 \u00b0C below MAT and 2 \u00b0C above it. The mean sits outside the band, so G36's 5-minute rolling average would fault; persistence resets on every compliant tick and this rule never does",
      "inputs": {
        "mat": 20.0,
        "sat": [
          {
            "t": 0,
            "value": 14.0
          },
          {
            "t": 600,
            "value": 22.0
          },
          {
            "t": 1200,
            "value": 14.0
          },
          {
            "t": 1800,
            "value": 22.0
          },
          {
            "t": 2400,
            "value": 14.0
          },
          {
            "t": 3000,
            "value": 22.0
          },
          {
            "t": 3600,
            "value": 14.0
          },
          {
            "t": 4200,
            "value": 22.0
          },
          {
            "t": 4800,
            "value": 14.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        }
      ]
    },
    {
      "name": "fault_clears_on_recovery",
      "description": "A sustained 6 \u00b0C gap alarms at 1800 s; the leaking cooling valve is isolated at t = 3000 s and SAT climbs above MAT, dropping the alarm on that tick \u2014 TrueDelay has no off-delay",
      "inputs": {
        "mat": 20.0,
        "sat": [
          {
            "t": 0,
            "value": 14.0
          },
          {
            "t": 3000,
            "value": 22.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1500,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2100,
          "to_s": 2700,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 3300,
          "to_s": 5400,
          "equals": false
        }
      ]
    }
  ]
}

AHU-0006 — OA fraction deviation

Statusverified — engine e2ff2f8, cxf:fnv1a128:89896c77a2581ab67082184d505bc7db, 2026-08-17
Severity3
Methodrule
Phase1
CategoryEXCESS_CONSUMPTION
ConfidenceMEDIUM
EstimationDIRECT_MEASUREMENT
G36§5.16.14 FC#6
Clusters
Suppresses
Suppressed byAHU-0028
RelatedAHU-0021, AHU-0030, AHU-0017, AHU-0028
Playbookseconomizer-failure
SourceHVAC FDD Reference v1.0 §5.8.1 (index; card abbreviated); G36 §5.16.14 FC#6 (text per Addendum u public review); NISTIR 7365 (defaults provenance); PNNL-25985 EEM-06 (OA damper faults), PNNL EEM-17 (demand control ventilation)
Operating statesOS#1 and OS#4 (minimum outdoor air states) — host-gated

Preconditions (host-enforced): Supply fan running, and the unit in one of the two minimum-OA operating states G36 defines by actuator signature: OS#1 (HC > 0, CC = 0, OA damper at minimum) or OS#4 (HC = 0, CC > 0, OA damper at minimum). The rule must not be evaluated in OS#2 or OS#3, where the outdoor-air fraction is supposed to exceed the minimum — economizing looks identical to a stuck damper from these three temperatures — nor in OS#5, where no damper position is defined at all. Suspend evaluation for ModeDelay (30 min) after any mode or operating-state change in a zone group the AHU serves, while the dampers are still stroking and the mixing box has not settled. Silence the rule while AHU-0028 is active: the fraction is a ratio of temperature differences, so a MAT outside the OAT/RAT envelope moves it directly. min_oa_fraction must be retuned to track the active minimum-OA setpoint whenever the ventilation reset moves it (see Deviations). The temperature-difference conjunct is signalled in-rule by yTempDeltaOk; when it is false the verdict is NO_EVAL, not healthy, and the same is true whenever any of the gates above is unmet. Additionally suspend evaluation (NO_EVAL) while demand-controlled ventilation or a ventilation-demand override holds outdoor-air flow above the minimum-OA state — at VAV turndown a constant ventilation flow becomes a large OA fraction and this rule fires on healthy operation (fleet-validated FP mechanism; see the validation block).

Points: mat, rat, oat

Outputs:

  • yFault — True while the outdoor air fraction has stayed further than oa_fraction_tolerance from min_oa_fraction, in either direction, for at least alarm_delay, with the outdoor-to-return temperature difference large enough to evaluate
  • yTempDeltaOk — Evaluability signal — true when |rat − oat| exceeds delta_min (G36’s dTMIN conjunct); false means NO_EVAL and the host must ignore yFault

Parameters:

NameDefaultUnitCXF pathDescription
min_oa_fraction0.151minConst.kThe %OAmin the fraction is compared against — G36’s active minimum-OA setpoint divided by actual total airflow, bound here as a static design minimum (0-1). Hosts that can read the ventilation reset should retune this through set_param as the active setpoint moves
oa_fraction_tolerance0.31devBig.tHow far the fraction may sit from min_oa_fraction in either direction before it counts as a fault. Default 0.30 is G36’s eF (airflow error threshold, 30%), a NISTIR 7365 value the addendum notes is intentionally biased toward minimizing false alarms
delta_min6.0°CdeltaOk.tMinimum
alarm_delay1800.0spersist.delayTimeContinuous fault persistence required before the alarm asserts (G36 AlarmDelay, 30 min)

Description

The unit should be sitting at its minimum outdoor-air setpoint, and the mixing-box energy balance says it is not. Too much outdoor air costs money: every extra cubic metre is heated or cooled to supply temperature for no ventilation benefit. Too little has no energy signature at all — the building is under-ventilated, an indoor-air-quality finding rather than a waste one. G36 tests both directions with one absolute value, so this rule alarms on either.

This is G36 §5.16.14 FC#6, evaluated in the two operating states where the damper is supposed to be at minimum — OS#1 (heating) and OS#4 (mechanical cooling on minimum outdoor air). In OS#2 and OS#3 the damper is opening deliberately and the same three temperatures would read as a fault, so the host gate matters more here than the equation does. The fraction is inferred from three temperatures rather than measured, which is what makes the diagnostic cheap and what makes it conditional: when outdoor and return air are close, the quotient’s denominator collapses and sensor error becomes an arbitrary answer.

Detection Logic

oaf          = (mat − rat) / (oat − rat)                      (G36 %OA)
yTempDeltaOk = |rat − oat| > delta_min                        (false ⇒ host reports NO_EVAL)
yFault       = (|oaf − min_oa_fraction| > oa_fraction_tolerance) AND yTempDeltaOk,
               sustained for alarm_delay

Block graph (rule.cxf.jsonld):

AHU-0006 block graph

minConst holds %OAmin in a Reals.Sources.Constant rather than folded into the threshold, so min_oa_fraction and oa_fraction_tolerance stay independent single-value set_param paths — which matters here because the setpoint is the parameter a host is expected to move at runtime.

The dTMIN branch goes to two places: into gate as G36’s second conjunct, and out of the block as yTempDeltaOk. A host that ignores the output still gets the right verdict; a host that reads it learns the difference between “not faulted” and “cannot tell”. That arrangement is also what makes the division safe. CDL Divide follows IEEE-754, so oat = rat yields ±∞ or NaN rather than an error, and a near-zero denominator inflates sensor noise into a fraction of any magnitude. NaN compares false everywhere and can never raise devBig; ±∞ and a noise-inflated finite fraction both can, and gate stops them, because a denominator small enough to misbehave is by construction below delta_min. Garbage arithmetic cannot assert a fault — only report the rule unevaluable.

Both comparisons are strict. The fraction is signed consistently across the year (winter, both differences negative; summer, both positive), so no seasonal branch is needed. persist requires 30 continuous minutes and any interruption restarts the timer; recovery is immediate on the tick the deviation falls back inside tolerance.

Possible Diagnoses

Transcribed from G36 §5.16.14 FC#6:

  1. RAT sensor error
  2. MAT sensor error
  3. OAT sensor error
  4. Leaking or stuck economizer damper or actuator

Three of the four are sensor errors, and the reason is arithmetic: a 1 °C bias in MAT moves the apparent outdoor air by 5 percentage points across a 20 °C outdoor-to-return spread, and by 17 points on a spread sitting at dTMIN. A sensor within its rated accuracy can account for most of a deviation this rule reports.

Energy Impact

EXCESS_CONSUMPTION, MEDIUM confidence, DIRECT_MEASUREMENT, savings 2–10% of the subsystem — the §5.8.1 index row, the only energy statement the reference makes here. The high branch is directly computable from live data, with the excess fraction already on the wire as dev.y:

excess_oa_kw = (oaf − min_oa_fraction) × supply_airflow_m3s × 1.2 × 1.005 × |oat − rat|

Payback lands in the upper half of the range in heating-dominant climates, where every extra cubic metre costs most. The index maps the fault to PNNL-25985 EEM-06 (OA damper faults) and EEM-17 (demand control ventilation). MEDIUM rather than AHU-0021’s HIGH on the same quotient, because a low-side alarm has no energy term at all — under-ventilating saves energy while failing the occupants, so a host must check the sign of dev.y before banking anything — and because three of the four diagnoses are sensor errors, which waste nothing.

Emissions Impact

Scope 1 + 2, PROXY_EMISSIONS, MEDIUM confidence. On the high branch the split follows the season, as for AHU-0021: excess outdoor air burns Scope 1 fuel at the heating coil in winter, draws Scope 2 electricity at the chiller in summer, and collapses to Scope 2 on an all-electric unit. The low branch has nothing to attribute. PROXY where the index says DIRECT because the fraction is measured but the mass flow it must be multiplied by is not — this rule is worth deploying precisely on units with three temperature sensors and no airflow station. Avoided-emissions basis: marginal operating emissions rate (MOER) for the electric half, static combustion factor for the fuel half.

Deviations

  • The reference card is abbreviated; G36 is the normative text. The HVAC FDD Reference carries AHU-0006 only as a §5.8.1 index row — a name and an energy profile. Detection logic, internal-variable defaults, and the diagnosis list are transcribed from ASHRAE Guideline 36 §5.16.14 FC#6 as it appears in Addendum u to Guideline 36-2018 (First Public Review, 2021).
  • %OAmin is a static parameter, not the active setpoint. G36 defines it as the active minimum-OA setpoint divided by actual total airflow, which a VAV system recomputes continuously; binding it as a constant (as AHU-0021 does) keeps this a three-temperature diagnostic instead of one needing a flow station or zone-flow rollup. Hosts should retune min_oa_fraction through set_param as the reset moves it: left static, an active setpoint above the design minimum makes a correctly ventilating unit read high and measures an under-ventilating one against too low a bar. G36’s defaults absorb up to 0.30 of fraction before either error surfaces.
  • The low branch is nearly unreachable at G36’s defaults, and that is G36’s arithmetic. With %OAmin = 0.15 and eF = 0.30 the low-side alarm point is a fraction below −0.15, which no physical mixing box can produce — not even a fully shut damper delivering zero ventilation. It fires only when the inferred fraction goes negative (MAT outside the OAT/RAT interval, so a sensor is lying) or when a host has retuned min_oa_fraction above the tolerance. A real under-ventilation alarm at a 15% minimum needs oa_fraction_tolerance well below eF, and the false-alarm rate NISTIR 7365 chose eF to avoid.
  • G36’s on the dTMIN conjunct becomes a strict >. FC#6 mixes inequalities (|RAT − OAT| ≥ dTMIN, |%OA − %OAmin| > eF) and CDL Reals offers only strict comparisons, so the deviation term transcribes exactly while a spread of exactly 6.000 °C is evaluable to G36 and unevaluable here. Measure zero on a real signal, and it errs toward silence on the one term whose purpose is to suppress meaningless verdicts.
  • The high-side deviation boundary is not representable in binary. A nominal 0.45 fraction against a 0.15 setpoint and 0.30 tolerance computes one ulp above 0.30, so the rule alarms where decimal arithmetic says it should not; no double gives a deviation of exactly 0.30 on the high side. The low side is exact, because the double nearest 0.15 doubles exactly into the double nearest 0.30. Read nothing into a fraction sitting on the threshold, especially with coarsely quantized temperatures.
  • Instantaneous samples instead of 5-minute rolling averages. G36 defines rolling averages for measured points and writes the dTMIN conjunct on them, but writes %OA on unsubscripted MAT, RAT, and OAT in the same clause; this library consumes instantaneous points and lets the 30-minute AlarmDelay stand in. Not equivalent — persistence resets on every compliant tick, so an oscillating deviation (a hunting damper) can hide indefinitely, while a stuck damper or drifted sensor reads the same either way. The gap is wider here than on a single-signal rule: averaging the three inputs and averaging the quotient give different numbers.
  • Operating states and ModeDelay are host-side preconditions. G36 scopes FC#6 to OS#1 and OS#4 and suspends evaluation for ModeDelay (30 min) after a mode change in a served zone group; none of it is in the graph, per the library’s stance (precedent AHU-0029). The stakes are higher here than on most rules — a high outdoor-air fraction is the correct answer in OS#2 and OS#3, so a host that evaluates while the unit is economizing gets a sustained fault of its own making.
  • Severity 3 is the library’s. No chapter card exists to state one and the §5.8.1 index carries no severity column. G36 §5.16.14 makes every reported fault condition a Level 3 alarm, but that is an alarm-priority scheme rather than this library’s 1–4 severity scale, so it corroborates without supplying.
  • The energy profile is the index row’s; the emissions block and runtime formula are the library’s. category, confidence, estimation_method, savings_range, and the EEM mapping are copied from §5.8.1; scope 1+2 and PROXY_EMISSIONS follow AHU-0021’s reading of the same physics, and the formula is mirrored from AHU-0021 with the sign check added.
  • persist.delayOnInit = true (Modelica/CDL default is false), the library’s standing choice: a deviation already present at load waits out the full 30 minutes instead of alarming on the first tick after a controller restart.

Notes

This rule and AHU-0021 measure the same quotient and are not redundant. AHU-0021 is a one-sided energy test with a 0.10 tolerance in any non-economizing occupied hour; AHU-0006 is G36’s symmetric test in the two minimum-OA states, and its contribution is the low branch — which at G36’s defaults catches a fraction gone negative, a sensor or mixing-box contradiction that AHU-0028 tests directly with a shorter delay. Expect the pair to fire together and read AHU-0028 first; a genuine under-ventilation alarm needs CO₂ or a measured outdoor-air flow. Check the minimum position setpoint before sending anyone to the roof — a minimum dialled up during a ventilation complaint is the most common high-side cause and a $0 desk fix, ahead of the economizer-failure playbook’s damper steps. If commanding the damper closed does not move the fraction, measure building pressure: an oversized exhaust fan reads the same as a stuck damper from these three points.

Test Vectors

16 scenarios, clock step 300 s over 5400 s.

ScenarioDescription
at_minimum_ventilation_setpointWinter minimum-OA operation: 2 °C outdoor air, 22 °C return, and a 19 °C mixture. The energy balance puts %OA at 0.15, exactly the setpoint, so the deviation is zero and the 20 °C spread makes the inference meaningful
excess_outdoor_air_alarmsThe mixture reads 12 °C against the same 2/22 °C pair, so %OA is 0.50 — 0.35 above the setpoint, past the 0.30 tolerance. The high branch: outdoor air being heated for no ventilation benefit. Alarms one alarm_delay (1800 s) after the deviation appears
summer_excess_oat_above_ratSame 0.50 fraction with the temperature gradient reversed — 35 °C outdoor, 24 °C return, 29.5 °C mixed. Both differences change sign together and the quotient is unchanged, so no seasonal branch is needed and the rule alarms exactly as it does in winter
damper_shut_no_ventilation_stays_silentMAT equals RAT: the unit is on full recirculation and delivering no outdoor air at all. %OA is 0.00, a deviation of 0.15, which is only half of G36’s eF = 0.30 tolerance — so a total ventilation failure sits inside the band and the rule reports nothing. This is the false-negative documented in Deviations, and the reason the low branch only bites on units whose active minimum-OA setpoint exceeds the tolerance
high_deviation_inside_toleranceTolerance band, high side: a 13.2 °C mixture puts %OA at 0.44, a deviation of 0.29. Inside the tolerance, so no fault however long it lasts
high_deviation_outside_toleranceTolerance band, high side, one hundredth further out: 12.8 °C gives %OA 0.46 and a deviation of 0.31, which clears the strict comparison and alarms after alarm_delay
high_nominal_boundary_rounds_into_alarmThe nominal high boundary: 13.0 °C gives %OA 0.45, a deviation of exactly 0.30 in decimal, which the strict > should read as healthy. It alarms instead. (13 − 22)/(2 − 22) rounds to the double just above 0.45, and subtracting the double nearest 0.15 lands one ulp above 0.30. The boundary is not representable in binary — see Deviations — so this pins where the rounding actually falls rather than pretending the decimal edge is testable
low_deviation_exactly_at_toleranceStrict-comparison pin, inclusive side: a 25 °C mixture against 22 °C return and 2 °C outdoor puts %OA at −0.15, a deviation of exactly −0.30. On the low side the arithmetic is exact — the double nearest 0.15 doubles exactly into the double nearest 0.30 — so this is the one place the decimal boundary is genuinely testable. devBig is a strict >, so it reads healthy
low_deviation_outside_toleranceThe low branch, which AHU-0021 structurally cannot produce: 25.2 °C mixed air against 22 °C return and 2 °C outdoor gives %OA −0.16, a deviation of −0.31. A mixture warmer than both streams is not mixing, so the ventilation the unit reports cannot be trusted — G36’s diagnosis list leads with the three sensor errors for exactly this reason. Alarms after alarm_delay
delta_exactly_at_dtmin_holds_fault_downNO_EVAL pin: 16 °C outdoor against 22 °C return is a spread of exactly dTMIN = 6.0 °C, and the 16.6 °C mixture reads 0.90 outdoor air — a damper stuck wide open in a minimum-OA state, a deviation of 0.75. deltaOk is a strict >, so yTempDeltaOk is false and the gate holds yFault down for the whole horizon. False here means unknown, not healthy
delta_just_over_dtmin_alarmsThe other side of the same boundary, one tenth of a degree colder outdoors: 15.9 °C against 22 °C is a 6.1 °C spread, enough to evaluate. The 16.5 °C mixture reads 0.90 outdoor air and the same stuck damper now alarms after alarm_delay
small_delta_wild_fraction_silentA 2 °C outdoor-to-return spring spread with a 12 °C mixture: the quotient reads 5.0, five hundred percent outdoor air, a deviation of 4.85. Nothing is wrong with the unit — a small denominator has amplified ordinary sensor error into an arbitrary fraction. yTempDeltaOk is false and yFault stays down, which is the whole purpose of the dTMIN conjunct
zero_denominator_infinite_fraction_silentThe degenerate case: outdoor and return air at the same 22 °C, so the denominator is exactly zero. CDL Divide follows IEEE-754 and returns −∞ rather than raising,
transient_excess_clears_before_alarm_delayTransient: the fraction sits at 0.50 for 1200 s while the outdoor damper strokes back down from a purge, then returns to 0.15. Shorter than alarm_delay, so the deviation never reaches the operator
fault_clears_on_recoveryA sustained 0.50 fraction alarms at 1800 s; the minimum-position setpoint is corrected at t = 3000 and the fraction drops to 0.15. TrueDelay has no off-delay, so the alarm releases on that tick
oscillating_deviation_never_alarmsA hunting damper swings the mixture between 12.0 °C and 13.2 °C on a 10-minute period, so the deviation alternates 0.35 (outside the tolerance) and 0.29 (inside). Its mean, 0.32, is outside — an averaged form of the test would report the fault. Persistence restarts on every compliant tick and this rule stays silent, which is the cost of substituting TrueDelay for G36’s rolling averages (see Deviations)
vectors.json
{
  "schema": "cxf-library/vectors/v1",
  "clock": {
    "step_s": 300,
    "horizon_s": 5400
  },
  "scenarios": [
    {
      "name": "at_minimum_ventilation_setpoint",
      "description": "Winter minimum-OA operation: 2 \u00b0C outdoor air, 22 \u00b0C return, and a 19 \u00b0C mixture. The energy balance puts %OA at 0.15, exactly the setpoint, so the deviation is zero and the 20 \u00b0C spread makes the inference meaningful",
      "inputs": {
        "mat": 19.0,
        "rat": 22.0,
        "oat": 2.0
      },
      "expect": [
        {
          "output": "yTempDeltaOk",
          "from_s": 0,
          "to_s": 5400,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        }
      ]
    },
    {
      "name": "excess_outdoor_air_alarms",
      "description": "The mixture reads 12 \u00b0C against the same 2/22 \u00b0C pair, so %OA is 0.50 \u2014 0.35 above the setpoint, past the 0.30 tolerance. The high branch: outdoor air being heated for no ventilation benefit. Alarms one alarm_delay (1800 s) after the deviation appears",
      "inputs": {
        "mat": 12.0,
        "rat": 22.0,
        "oat": 2.0
      },
      "expect": [
        {
          "output": "yTempDeltaOk",
          "from_s": 0,
          "to_s": 5400,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1500,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2100,
          "to_s": 5400,
          "equals": true
        }
      ]
    },
    {
      "name": "summer_excess_oat_above_rat",
      "description": "Same 0.50 fraction with the temperature gradient reversed \u2014 35 \u00b0C outdoor, 24 \u00b0C return, 29.5 \u00b0C mixed. Both differences change sign together and the quotient is unchanged, so no seasonal branch is needed and the rule alarms exactly as it does in winter",
      "inputs": {
        "mat": 29.5,
        "rat": 24.0,
        "oat": 35.0
      },
      "expect": [
        {
          "output": "yTempDeltaOk",
          "from_s": 0,
          "to_s": 5400,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1500,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2100,
          "to_s": 5400,
          "equals": true
        }
      ]
    },
    {
      "name": "damper_shut_no_ventilation_stays_silent",
      "description": "MAT equals RAT: the unit is on full recirculation and delivering no outdoor air at all. %OA is 0.00, a deviation of 0.15, which is only half of G36's eF = 0.30 tolerance \u2014 so a total ventilation failure sits inside the band and the rule reports nothing. This is the false-negative documented in Deviations, and the reason the low branch only bites on units whose active minimum-OA setpoint exceeds the tolerance",
      "inputs": {
        "mat": 22.0,
        "rat": 22.0,
        "oat": 2.0
      },
      "expect": [
        {
          "output": "yTempDeltaOk",
          "from_s": 0,
          "to_s": 5400,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        }
      ]
    },
    {
      "name": "high_deviation_inside_tolerance",
      "description": "Tolerance band, high side: a 13.2 \u00b0C mixture puts %OA at 0.44, a deviation of 0.29. Inside the tolerance, so no fault however long it lasts",
      "inputs": {
        "mat": 13.2,
        "rat": 22.0,
        "oat": 2.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        }
      ]
    },
    {
      "name": "high_deviation_outside_tolerance",
      "description": "Tolerance band, high side, one hundredth further out: 12.8 \u00b0C gives %OA 0.46 and a deviation of 0.31, which clears the strict comparison and alarms after alarm_delay",
      "inputs": {
        "mat": 12.8,
        "rat": 22.0,
        "oat": 2.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1500,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2100,
          "to_s": 5400,
          "equals": true
        }
      ]
    },
    {
      "name": "high_nominal_boundary_rounds_into_alarm",
      "description": "The nominal high boundary: 13.0 \u00b0C gives %OA 0.45, a deviation of exactly 0.30 in decimal, which the strict `>` should read as healthy. It alarms instead. (13 \u2212 22)/(2 \u2212 22) rounds to the double just above 0.45, and subtracting the double nearest 0.15 lands one ulp above 0.30. The boundary is not representable in binary \u2014 see Deviations \u2014 so this pins where the rounding actually falls rather than pretending the decimal edge is testable",
      "inputs": {
        "mat": 13.0,
        "rat": 22.0,
        "oat": 2.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1500,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2100,
          "to_s": 5400,
          "equals": true
        }
      ]
    },
    {
      "name": "low_deviation_exactly_at_tolerance",
      "description": "Strict-comparison pin, inclusive side: a 25 \u00b0C mixture against 22 \u00b0C return and 2 \u00b0C outdoor puts %OA at \u22120.15, a deviation of exactly \u22120.30. On the low side the arithmetic is exact \u2014 the double nearest 0.15 doubles exactly into the double nearest 0.30 \u2014 so this is the one place the decimal boundary is genuinely testable. `devBig` is a strict `>`, so it reads healthy",
      "inputs": {
        "mat": 25.0,
        "rat": 22.0,
        "oat": 2.0
      },
      "expect": [
        {
          "output": "yTempDeltaOk",
          "from_s": 0,
          "to_s": 5400,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        }
      ]
    },
    {
      "name": "low_deviation_outside_tolerance",
      "description": "The low branch, which AHU-0021 structurally cannot produce: 25.2 \u00b0C mixed air against 22 \u00b0C return and 2 \u00b0C outdoor gives %OA \u22120.16, a deviation of \u22120.31. A mixture warmer than both streams is not mixing, so the ventilation the unit reports cannot be trusted \u2014 G36's diagnosis list leads with the three sensor errors for exactly this reason. Alarms after alarm_delay",
      "inputs": {
        "mat": 25.2,
        "rat": 22.0,
        "oat": 2.0
      },
      "expect": [
        {
          "output": "yTempDeltaOk",
          "from_s": 0,
          "to_s": 5400,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1500,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2100,
          "to_s": 5400,
          "equals": true
        }
      ]
    },
    {
      "name": "delta_exactly_at_dtmin_holds_fault_down",
      "description": "NO_EVAL pin: 16 \u00b0C outdoor against 22 \u00b0C return is a spread of exactly dTMIN = 6.0 \u00b0C, and the 16.6 \u00b0C mixture reads 0.90 outdoor air \u2014 a damper stuck wide open in a minimum-OA state, a deviation of 0.75. `deltaOk` is a strict `>`, so yTempDeltaOk is false and the gate holds yFault down for the whole horizon. False here means unknown, not healthy",
      "inputs": {
        "mat": 16.6,
        "rat": 22.0,
        "oat": 16.0
      },
      "expect": [
        {
          "output": "yTempDeltaOk",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        }
      ]
    },
    {
      "name": "delta_just_over_dtmin_alarms",
      "description": "The other side of the same boundary, one tenth of a degree colder outdoors: 15.9 \u00b0C against 22 \u00b0C is a 6.1 \u00b0C spread, enough to evaluate. The 16.5 \u00b0C mixture reads 0.90 outdoor air and the same stuck damper now alarms after alarm_delay",
      "inputs": {
        "mat": 16.5,
        "rat": 22.0,
        "oat": 15.9
      },
      "expect": [
        {
          "output": "yTempDeltaOk",
          "from_s": 0,
          "to_s": 5400,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1500,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2100,
          "to_s": 5400,
          "equals": true
        }
      ]
    },
    {
      "name": "small_delta_wild_fraction_silent",
      "description": "A 2 \u00b0C outdoor-to-return spring spread with a 12 \u00b0C mixture: the quotient reads 5.0, five hundred percent outdoor air, a deviation of 4.85. Nothing is wrong with the unit \u2014 a small denominator has amplified ordinary sensor error into an arbitrary fraction. yTempDeltaOk is false and yFault stays down, which is the whole purpose of the dTMIN conjunct",
      "inputs": {
        "mat": 12.0,
        "rat": 22.0,
        "oat": 20.0
      },
      "expect": [
        {
          "output": "yTempDeltaOk",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        }
      ]
    },
    {
      "name": "zero_denominator_infinite_fraction_silent",
      "description": "The degenerate case: outdoor and return air at the same 22 \u00b0C, so the denominator is exactly zero. CDL `Divide` follows IEEE-754 and returns \u2212\u221e rather than raising, |\u2212\u221e \u2212 0.15| clears any finite tolerance, and `devBig` goes true. The gate is what stops it reaching the operator \u2014 garbage arithmetic can make the rule report itself unevaluable but can never assert a fault",
      "inputs": {
        "mat": 18.0,
        "rat": 22.0,
        "oat": 22.0
      },
      "expect": [
        {
          "output": "yTempDeltaOk",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        }
      ]
    },
    {
      "name": "transient_excess_clears_before_alarm_delay",
      "description": "Transient: the fraction sits at 0.50 for 1200 s while the outdoor damper strokes back down from a purge, then returns to 0.15. Shorter than alarm_delay, so the deviation never reaches the operator",
      "inputs": {
        "mat": [
          {
            "t": 0,
            "value": 12.0
          },
          {
            "t": 1200,
            "value": 19.0
          }
        ],
        "rat": 22.0,
        "oat": 2.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        }
      ]
    },
    {
      "name": "fault_clears_on_recovery",
      "description": "A sustained 0.50 fraction alarms at 1800 s; the minimum-position setpoint is corrected at t = 3000 and the fraction drops to 0.15. `TrueDelay` has no off-delay, so the alarm releases on that tick",
      "inputs": {
        "mat": [
          {
            "t": 0,
            "value": 12.0
          },
          {
            "t": 3000,
            "value": 19.0
          }
        ],
        "rat": 22.0,
        "oat": 2.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1500,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2100,
          "to_s": 2700,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 3300,
          "to_s": 5400,
          "equals": false
        }
      ]
    },
    {
      "name": "oscillating_deviation_never_alarms",
      "description": "A hunting damper swings the mixture between 12.0 \u00b0C and 13.2 \u00b0C on a 10-minute period, so the deviation alternates 0.35 (outside the tolerance) and 0.29 (inside). Its mean, 0.32, is outside \u2014 an averaged form of the test would report the fault. Persistence restarts on every compliant tick and this rule stays silent, which is the cost of substituting TrueDelay for G36's rolling averages (see Deviations)",
      "inputs": {
        "mat": [
          {
            "t": 0,
            "value": 12.0
          },
          {
            "t": 600,
            "value": 13.2
          },
          {
            "t": 1200,
            "value": 12.0
          },
          {
            "t": 1800,
            "value": 13.2
          },
          {
            "t": 2400,
            "value": 12.0
          },
          {
            "t": 3000,
            "value": 13.2
          },
          {
            "t": 3600,
            "value": 12.0
          },
          {
            "t": 4200,
            "value": 13.2
          },
          {
            "t": 4800,
            "value": 12.0
          }
        ],
        "rat": 22.0,
        "oat": 2.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        }
      ]
    }
  ]
}

AHU-0007 — SAT too low at full heating

Statusverified — engine e2ff2f8, cxf:fnv1a128:c388701604d442eb0bf2425055d91402, 2026-08-17
Severity3
Methodrule
Phase1
CategoryEXCESS_CONSUMPTION
ConfidenceMEDIUM
EstimationPROXY_ESTIMATION
G36§5.16.14 FC#7
Clusters
Suppresses
Suppressed by
RelatedAHU-0005, AHU-0013, AHU-0023, AHU-0032, AHU-0033
Playbooks
SourceHVAC FDD Reference v1.0 §5.8.1 (index; card abbreviated); G36 §5.16.14 FC#7 (text per Addendum u public review); NISTIR 7365 (defaults provenance)
Operating statesOS#1 (heating) — host-gated

Preconditions (host-enforced): The unit must have a heating coil; G36 omits FC#7 where there is none. Evaluate only in OS#1, whose actuator signature per G36 Table 5.16.14.2 is heating coil > 0, cooling coil = 0, OA damper at minimum position — in any cooling state SAT is meant to sit below its heating setpoint. Supply fan running, since SAT means nothing in still air. Suspend evaluation for ModeDelay (30 min) after any operating-state change, while the coil is still stroking and SAT still reflects the previous state. sat_sp must be the setpoint the unit is actively controlling to: a host that binds a design constant while the sequence follows a reset schedule is comparing SAT against a number nothing is chasing. When any gate is unmet the verdict is NO_EVAL, not healthy.

Points: sat, sat_sp, htg_vlv_cmd

Outputs:

  • yFault — True while SAT has stayed more than sat_error_threshold below setpoint with the heating coil commanded above hc_full_threshold, for at least alarm_delay

Parameters:

NameDefaultUnitCXF pathDescription
sat_error_threshold1.0°CspMiss.tShortfall below the supply air temperature setpoint that counts as a real miss rather than sensor error; the G36 §5.16.14 internal variable eSAT at its Table 5.16.14.5 default
hc_full_threshold99.0%htgFull.tHeating coil command above which the coil is treated as having no capacity left (G36 HC >= 99%). Hosts whose valve command is quantized to whole percent should retune to 98.9, since the comparison is strict
alarm_delay1800.0spersist.delayTimeContinuous fault persistence required before the alarm asserts (G36 AlarmDelay, 30 min)

Description

The heating coil is wide open and the air is still coming out cold. Two signals settle it: the coil command has reached its stop, so the control loop has already asked for everything it has, and SAT is below setpoint by more than the sensor could be wrong about. What is left is a capacity, supply, or sensing problem — the coil cannot deliver, the heat source is not there to deliver, or the measurement is lying about what did get delivered. None of it is a tuning question, which is what makes the saturated-valve half of the test worth carrying: SAT below setpoint at 60% valve command is an ordinary loop working through a load change. This is G36 §5.16.14 FC#7, applicable in OS#1 (heating) only, and G36 omits it entirely on units with no heating coil.

Detection Logic

sp_gap = sat_sp − sat
yFault = (sp_gap       > sat_error_threshold)   SAT short of setpoint by more than sensor error
     AND (htg_vlv_cmd  > hc_full_threshold)     coil has no capacity left
         sustained continuously for alarm_delay

Block graph (rule.cxf.jsonld):

AHU-0007 block graph

The gap form is G36’s SAT_AVG < SATSP − eSAT rearranged so the tolerance stays a positive number on one CXF path (see Deviations). Both comparisons are strict, so an error sitting exactly on 1.0 °C and a coil reported at exactly 99.0% both read healthy. persist requires 30 minutes of continuous violation and any interruption restarts the timer, which separates a broken coil from a morning warm-up, a step in the setpoint reset schedule, or the recovery after a mode change.

The rule reads the coil command, not a position feedback, and that is deliberate: a valve commanded to 100% while its actuator has failed closed is diagnosis 3, and reading the command is what catches it. The cost is that the rule cannot distinguish a coil genuinely wide open and starved from one that never moved.

Possible Diagnoses

G36 §5.16.14 FC#7, transcribed:

  1. SAT sensor error
  2. Cooling coil valve leaking or stuck open
  3. Heating coil valve stuck closed or actuator failure
  4. Fouled or undersized heating coil
  5. HW temperature too low or HW unavailable
  6. Gas or electric heat unavailable
  7. DX cooling stuck on
  8. Leaking or stuck economizer damper or actuator

The list spans three failure modes that look identical from these three points: the heat never arrives (3–6), something removes it after it arrives (2, 7, 8), or it arrived and the sensor did not see it (1). Trending MAT alongside SAT splits the first two groups — that comparison is AHU-0005.

Energy Impact

EXCESS_CONSUMPTION, MEDIUM confidence, PROXY_ESTIMATION, all three from the reference’s §5.8.1 index row, which puts the range at 2–5% of AHU energy and maps no PNNL measure. The waste depends on the cause and the three points cannot tell the causes apart, which is why the estimate is a proxy. Where something removes heat (diagnoses 2, 7, 8) the unit pays twice and AHU-0016’s simultaneous heating and cooling term sizes it. Where the coil is starved or fouled (3–6) the AHU wastes little on its own and the deficit moves downstream: makeup_kw = supply_airflow_m3s × 1.2 × 1.005 × (sat_sp − sat), delivered by terminal reheat at worse efficiency than the central coil, or not at all in zones with no terminal heat. Heating-dominant, since the rule is only evaluable in OS#1.

Emissions Impact

PROXY_EMISSIONS, MEDIUM confidence. Scope 1|2 because the inventory follows the heat source: hot water from a gas boiler, a gas furnace section, or a steam coil is Scope 1, while electric resistance, a heat pump, and the terminal reheat making up the deficit are usually Scope 2. A site with a gas boiler and electric reheat pays into both, and the cooling-side diagnoses (2, 7) add a Scope 2 term of their own. Avoided-emissions basis: static combustion factor for the fuel half, marginal operating emissions rate (MOER) for the electric half.

Deviations

  • The reference card is abbreviated; G36 is the normative source. HVAC FDD Reference v1.0 carries AHU-0007 as a §5.8.1 index row only — no equation, tunables, diagnoses, operating-state applicability, or severity. Detection Logic and Possible Diagnoses are transcribed from ASHRAE Guideline 36 §5.16.14 FC#7 as it appears in Addendum u to Guideline 36-2018 (first public review, 2021), with the Table 5.16.14.5 defaults (NISTIR 7365 provenance, which the addendum notes are biased toward minimizing false alarms).
  • Severity 3 is the library’s. No reference card states one and the §5.8.1 index carries no severity column; the value matches this chapter’s scaffold row and the other G36 comparison rules here.
  • The energy profile is the index row’s; the emissions split is the library’s. category, confidence, estimation_method, and savings_range are copied from §5.8.1. The index has no emissions column, so scope: 1|2 follows AHU-0020’s convention for a fault whose fuel depends on which diagnosis is true; the cause-dependent runtime formula is the library’s, its simultaneous-heating-and-cooling branch AHU-0016’s term.
  • SAT_AVG < SATSP − eSAT rewritten in gap form. Subtracting first and testing sat_sp − sat > eSAT keeps the tolerance the positive number G36 publishes, retunable at one CXF path, instead of a negative offset ahead of the comparison. Same rearrangement as AHU-0001 and AHU-0028, with the same one-ulp caveat that no temperature sensor resolves.
  • HC >= 99% becomes a strict >. CDL Reals has only strict comparisons, so a coil command parked at exactly 99.000% reads as not-at-full-capacity and the rule stays silent — the same deviation AHU-0001 documents for fan speed. A host whose valve command is quantized to whole percent should retune hc_full_threshold to 98.9.
  • Instantaneous samples instead of averaged signals. G36 computes every signal as a five-minute rolling average of one-minute samples; this rule compares raw samples and leans on the 30-minute persist delay. Not equivalent — persistence resets on every compliant tick, so an oscillating SAT can hide indefinitely, while the steady shortfall of a starved or fouled coil reads the same either way. Same note as AHU-0002; a SAT swinging across the band rather than sitting under it is AHU-0022’s finding.
  • Operating-state applicability and ModeDelay are frontmatter, not graph. G36 scopes FC#7 to OS#1 and suspends every fault condition for 30 minutes after a mode change; both are host concerns under this library’s stance (precedent AHU-0029), and a verdict outside OS#1 or inside the transition window is NO_EVAL, never healthy. The graph’s htg_vlv_cmd > 99 test is not that gate — it is the saturation half of the fault condition, and OS#1 admits any heating command above zero.
  • persist.delayOnInit = true (Modelica/CDL default is false), the library’s standing choice: a shortfall already present at load waits out the full 30 minutes rather than alarming on the first tick after a controller restart.

Notes

Check the setpoint before the coil. A SAT setpoint that never resets (AHU-0023) can hold a heating call the unit was never sized to meet, and a reset schedule stepping up faster than the coil can follow produces this signature for a few minutes at every step. Then work the supply side: hot water temperature and pump status cost nothing to read, and diagnoses 5 and 6 take a whole building’s heating rather than one unit’s. AHU-0013 is this fault mirrored into cooling; AHU-0005 is its companion in the same state, and FC-007 with FC-005 points at a heat sink running against the heating call. No playbook covers coil capacity and heat-supply work yet.

Test Vectors

11 scenarios, clock step 300 s over 5400 s.

ScenarioDescription
part_load_heating_holding_setpointHealthy OS#1 operation: the coil is at 60% and SAT is 0.5 °C under a 21.5 °C setpoint. Neither half of the conjunction is true — the loop has capacity left and the error is inside eSAT
full_heating_still_on_setpointThe valve is wide open on a design-day morning but SAT is only 0.5 °C short — the unit is sized close to its design point, not broken. Saturation alone is not a fault
part_load_missing_setpointSAT is 6 °C short of setpoint with the valve at 60% — that is a control-loop finding (tuning, or a reset the loop is chasing), not a capacity finding, and this rule stays silent by design
full_heating_missing_setpointThe reportable case: valve at 100% and SAT 6 °C below setpoint. The coil is saturated and still losing, so the shortfall is capacity, supply, or sensing. Alarms one alarm_delay (1800 s) after the condition appears
error_exactly_at_thresholdThreshold edge: sat_sp − sat = 1.0 °C exactly with the valve at 100%. spMiss is a strict >, so an error sitting precisely on eSAT reads healthy
error_just_over_thresholdThreshold edge, other side: sat_sp − sat = 1.1 °C clears the strict comparison and alarms after alarm_delay
valve_exactly_at_full_thresholdThreshold edge: the valve reports exactly 99.0% with a 6 °C error present. htgFull is a strict >, so G36’s HC >= 99% reads as not-full here — the case a host with integer-quantized valve feedback must retune hc_full_threshold to 98.9 to catch
valve_just_over_full_thresholdThreshold edge, other side: 99.5% clears the strict comparison, so the same 6 °C error alarms after alarm_delay
morning_warmup_clears_before_delayTransient: the coil pins at 100% and SAT sits 6 °C short during warm-up, then reaches 20.5 °C at t = 1200 s. Twenty minutes is short of alarm_delay, so a cold start never alarms
valve_modulates_off_before_delayTransient on the other input: the 6 °C error holds but the valve backs off to 70% at t = 1200 s, which means the loop was never out of capacity. Both halves must be true at once, so persistence restarts and no alarm follows
fault_clears_on_recoveryA sustained shortfall alarms at 1800 s; hot water returns at t = 3000 s and SAT reaches 20.5 °C, dropping the alarm on that tick — TrueDelay has no off-delay
vectors.json
{
  "schema": "cxf-library/vectors/v1",
  "clock": {
    "step_s": 300,
    "horizon_s": 5400
  },
  "scenarios": [
    {
      "name": "part_load_heating_holding_setpoint",
      "description": "Healthy OS#1 operation: the coil is at 60% and SAT is 0.5 \u00b0C under a 21.5 \u00b0C setpoint. Neither half of the conjunction is true \u2014 the loop has capacity left and the error is inside eSAT",
      "inputs": {
        "sat": 21.0,
        "sat_sp": 21.5,
        "htg_vlv_cmd": 60.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        }
      ]
    },
    {
      "name": "full_heating_still_on_setpoint",
      "description": "The valve is wide open on a design-day morning but SAT is only 0.5 \u00b0C short \u2014 the unit is sized close to its design point, not broken. Saturation alone is not a fault",
      "inputs": {
        "sat": 20.5,
        "sat_sp": 21.0,
        "htg_vlv_cmd": 100.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        }
      ]
    },
    {
      "name": "part_load_missing_setpoint",
      "description": "SAT is 6 \u00b0C short of setpoint with the valve at 60% \u2014 that is a control-loop finding (tuning, or a reset the loop is chasing), not a capacity finding, and this rule stays silent by design",
      "inputs": {
        "sat": 15.0,
        "sat_sp": 21.0,
        "htg_vlv_cmd": 60.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        }
      ]
    },
    {
      "name": "full_heating_missing_setpoint",
      "description": "The reportable case: valve at 100% and SAT 6 \u00b0C below setpoint. The coil is saturated and still losing, so the shortfall is capacity, supply, or sensing. Alarms one alarm_delay (1800 s) after the condition appears",
      "inputs": {
        "sat": 15.0,
        "sat_sp": 21.0,
        "htg_vlv_cmd": 100.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1500,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2100,
          "to_s": 5400,
          "equals": true
        }
      ]
    },
    {
      "name": "error_exactly_at_threshold",
      "description": "Threshold edge: sat_sp \u2212 sat = 1.0 \u00b0C exactly with the valve at 100%. `spMiss` is a strict `>`, so an error sitting precisely on eSAT reads healthy",
      "inputs": {
        "sat": 20.0,
        "sat_sp": 21.0,
        "htg_vlv_cmd": 100.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        }
      ]
    },
    {
      "name": "error_just_over_threshold",
      "description": "Threshold edge, other side: sat_sp \u2212 sat = 1.1 \u00b0C clears the strict comparison and alarms after alarm_delay",
      "inputs": {
        "sat": 19.9,
        "sat_sp": 21.0,
        "htg_vlv_cmd": 100.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1500,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2100,
          "to_s": 5400,
          "equals": true
        }
      ]
    },
    {
      "name": "valve_exactly_at_full_threshold",
      "description": "Threshold edge: the valve reports exactly 99.0% with a 6 \u00b0C error present. `htgFull` is a strict `>`, so G36's `HC >= 99%` reads as not-full here \u2014 the case a host with integer-quantized valve feedback must retune hc_full_threshold to 98.9 to catch",
      "inputs": {
        "sat": 15.0,
        "sat_sp": 21.0,
        "htg_vlv_cmd": 99.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        }
      ]
    },
    {
      "name": "valve_just_over_full_threshold",
      "description": "Threshold edge, other side: 99.5% clears the strict comparison, so the same 6 \u00b0C error alarms after alarm_delay",
      "inputs": {
        "sat": 15.0,
        "sat_sp": 21.0,
        "htg_vlv_cmd": 99.5
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1500,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2100,
          "to_s": 5400,
          "equals": true
        }
      ]
    },
    {
      "name": "morning_warmup_clears_before_delay",
      "description": "Transient: the coil pins at 100% and SAT sits 6 \u00b0C short during warm-up, then reaches 20.5 \u00b0C at t = 1200 s. Twenty minutes is short of alarm_delay, so a cold start never alarms",
      "inputs": {
        "sat": [
          {
            "t": 0,
            "value": 15.0
          },
          {
            "t": 1200,
            "value": 20.5
          }
        ],
        "sat_sp": 21.0,
        "htg_vlv_cmd": 100.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        }
      ]
    },
    {
      "name": "valve_modulates_off_before_delay",
      "description": "Transient on the other input: the 6 \u00b0C error holds but the valve backs off to 70% at t = 1200 s, which means the loop was never out of capacity. Both halves must be true at once, so persistence restarts and no alarm follows",
      "inputs": {
        "sat": 15.0,
        "sat_sp": 21.0,
        "htg_vlv_cmd": [
          {
            "t": 0,
            "value": 100.0
          },
          {
            "t": 1200,
            "value": 70.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        }
      ]
    },
    {
      "name": "fault_clears_on_recovery",
      "description": "A sustained shortfall alarms at 1800 s; hot water returns at t = 3000 s and SAT reaches 20.5 \u00b0C, dropping the alarm on that tick \u2014 TrueDelay has no off-delay",
      "inputs": {
        "sat": [
          {
            "t": 0,
            "value": 15.0
          },
          {
            "t": 3000,
            "value": 20.5
          }
        ],
        "sat_sp": 21.0,
        "htg_vlv_cmd": 100.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1500,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2100,
          "to_s": 2700,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 3300,
          "to_s": 5400,
          "equals": false
        }
      ]
    }
  ]
}

AHU-0008 — SAT ≠ MAT in economizer mode

Statusverified — engine e2ff2f8, cxf:fnv1a128:9a1a1d3f2efeeaa385ba4336b64530a2, 2026-08-17
Severity3
Methodrule
Phase1
CategoryCOMFORT_ENERGY
ConfidenceLOW
EstimationQUALITATIVE_ONLY
G36§5.16.14 FC#8
Clusters
Suppresses
Suppressed byAHU-0028
RelatedAHU-0002, AHU-0003, AHU-0010, AHU-0028
Playbookssensor-drift
SourceHVAC FDD Reference v1.0 §5.8.1 (index; card abbreviated); G36 §5.16.14 FC#8 (text per Addendum u public review); NISTIR 7365 (defaults provenance); PNNL-25985 (EEM-01, sensor recalibration)
Operating statesOS#2 (free cooling, modulating OA) — host-gated

Preconditions (host-enforced): The unit must have a MAT sensor; G36 omits FC#8 where there is none. Evaluate only in OS#2, which G36 Table 5.16.14.2 defines by actuator signature: heating coil = 0, cooling coil = 0, minimum OA position < OA damper < 100%. Outside that state a coil is legitimately working and SAT is supposed to differ from MAT, so the equation means nothing. Supply fan running, since neither temperature describes a stream that is not moving. Per §5.16.14.11 the host suspends evaluation while the AHU is off and for ModeDelay (30 min) after a mode change in any zone group the AHU serves, while the coils and damper are still stroking. Silence this rule while AHU-0028 is active: MAT is an input here and FC-062 is its integrity gate. When any gate is unmet the verdict is NO_EVAL, not healthy.

Points: sat, mat

Outputs:

  • yFault — True while |sat − fan_rise − mat| has stayed above combined_error for at least alarm_delay

Parameters:

NameDefaultUnitCXF pathDescription
fan_rise1.0°CfanConst.kTemperature rise across the supply fan (G36 dTSF). Subtracted from the raw SAT − MAT difference so the comparison is about coil and sensor behavior rather than shaft work. A site that measures its own fan rise — a high-static or direct-drive plenum fan can be well off 1 °C — sets this to the measured value; the threshold is unaffected, since fan rise centers the band and sensor error sizes it.
combined_error3.1623°CdevBig.tHalf-width of the band the fan-heat-corrected difference may occupy, composed as the root-sum-square of the two sensor error bands: sqrt(eSAT² + eMAT²) = sqrt(1² + 3²) = sqrt(10) = 3.1623 °C with the G36 Table 5.16.14.5 defaults. Errors add in quadrature rather than linearly because two independent sensors are unlikely to be wrong in the same direction at once. A site with a different MAT accuracy recomputes the root-sum-square: eMAT = 4 °C gives sqrt(1 + 16) = sqrt(17) = 4.1231 °C.
alarm_delay1800.0spersist.delayTimeContinuous fault persistence required before the alarm asserts (G36 AlarmDelay, 30 min)

Description

In OS#2 the air handler runs as a modulating economizer: both coils are commanded shut, so the only thing between the mixed-air sensor and the supply-air sensor doing any work is the supply fan. Air should leave at the temperature it arrived plus the degree or so the fan puts in. A disagreement larger than the two sensors’ combined error after that fan rise is taken out means something in between is adding or removing heat, or a sensor is not reporting the stream it is mounted in.

This is the one state where the two sensors can be audited against each other at no cost — everywhere else a coil is doing work and SAT is supposed to differ from MAT. The fault is deliberately non-committal about direction: air leaving too warm points at hot water leaking through a seated heating valve, too cold at chilled water doing the same. Either way the plant is serving a coil nobody commanded, which no command-following check can see.

Detection Logic

G36 §5.16.14 FC#8, applies to OS#2 (omitted if the unit has no MAT sensor):

    | SAT_AVG − dTSF − MAT_AVG | > sqrt(eSAT² + eMAT²)

with the Table 5.16.14.5 defaults:

    | sat − 1.0 − mat | > sqrt(1² + 3²) = 3.1623 °C

yFault = (|sat − fan_rise − mat| > combined_error), sustained for alarm_delay

Block graph (rule.cxf.jsonld):

AHU-0008 block graph

Fan rise and sensor error do different jobs, which is why they are separate parameters: fan_rise positions the center of the acceptance band — the difference the unit is expected to show — and combined_error sets its half-width. Retuning one does not imply retuning the other. The fan-heat term is a constant source into a Subtract rather than an AddParameter with a negative parameter, so fanConst.k holds the physical quantity with its physical sign and a host that measures a 1.4 °C rise can set 1.4 (precedent: AHU-0021’s designConst).

G36’s comparison is already strict, so GreaterThreshold reproduces it exactly and a deviation of exactly 3.1623 °C reads healthy in both. persist requires 30 minutes of continuous violation and any interruption restarts the timer.

Possible Diagnoses

Per G36 §5.16.14 Table 5.16.14.8, FC#8:

  1. SAT sensor error — cheapest to rule out with a hand-held reference in the supply duct
  2. MAT sensor error — harder, since a mixed-air sensor can be in calibration and still read a stratified slice of the plenum instead of the mixture (AHU-0028 catches the gross version)
  3. Cooling coil valve leaking or stuck open — chilled water through a valve commanded to 0 shows up here and nowhere in the command stream
  4. Heating coil valve leaking or stuck open — the mirror case, and the more expensive one in this state: the unit chose free cooling because outdoor air was useful, and boiler energy is being spent to undo it

Energy Impact

COMFORT_ENERGY, LOW confidence, QUALITATIVE_ONLY — the grades in the reference’s §5.8.1 index row, which maps the fault to PNNL-25985 EEM-01 (sensor recalibration) and publishes savings as sensor-dependent. Nothing is estimated from the rule’s inputs: two temperatures and no airflow give no power term, and the difference is ambiguous between a lying sensor and a leaking valve. Those have very different costs — a drifted sensor burns nothing directly, while a valve leaking through in free cooling burns plant energy continuously, and worse than the same leak elsewhere because the unit entered OS#2 on the finding that outdoor air could do the job unaided. Climate-neutral.

Emissions Impact

QUALITATIVE_EMISSIONS, LOW confidence; the block is library-assigned, as the §5.8.1 index carries no emissions column. Scope 1|2 because the equation cannot tell which coil is leaking: a hot water valve passing flow lands in Scope 1 (Scope 2 for electric resistance or heat pump heat), a chilled water valve or stuck DX circuit in Scope 2. The sensor-error diagnoses have no direct emissions. Avoided-emissions basis: N/A — no quantity is estimated.

Deviations

  • The reference card is an index row, so this card is built from G36. §5.8.1 gives the code, the name, and the energy grades, with no equation, vectors, or severity. The equation, OS#2 applicability, four diagnoses, and internal-variable defaults are transcribed from ASHRAE Guideline 36 §5.16.14 as it appears in Addendum u to Guideline 36-2018 (first public review, 2021).
  • Severity 3 is library-assigned. The index has no severity column, and G36’s Level 3 alarm grading (§5.16.14.16) is a reporting priority rather than a ranking. The value matches every other G36 001-range card here.
  • Energy profile follows the §5.8.1 index row (COMFORT_ENERGY / LOW / QUAL, EEM-01, savings “sensor-dependent”); the emissions block is library-assigned.
  • Root-sum-square threshold shipped as one number. G36 writes the bound as sqrt(eSAT² + eMAT²); the graph carries the evaluated 3.1623 °C in devBig.t, so a host retunes one parameter and no square root runs at runtime. The composition is spelled out in the parameter description because the arithmetic is not linear: a 4 °C mixed-air sensor gives sqrt(1 + 16) = 4.1231 °C, not 1 + 4.
  • No boundary deviation for this fault. FC#8’s comparison is already strict (>), unlike the / forms elsewhere in Table 5.16.14.8 (FC#5, FC#12, FC#14, FC#15), so no measure-zero rewrite is involved. Same finding as AHU-0009 and AHU-0011.
  • Fan heat enters as a constant signal, not a negative parameter. An AddParameter with p = −1.0 computes the same deviation but stores the fan rise with an inverted sign, so a host setting a measured value through set_param would double the error instead of correcting it.
  • Instantaneous samples instead of averaged signals. G36 compares 5-minute rolling averages sampled at 1-minute intervals; this rule compares raw samples and leans on the 30-minute persist delay. Not equivalent — persistence resets on every compliant tick, so an oscillating difference can hide indefinitely, while a drifted sensor and a leaking valve are steady offsets and read the same either way. (Honesty note carried from AHU-0002.)
  • Suppression is declared, not encoded. AHU-0028 gates MAT integrity and silences this rule while active; the engine is status-blind, so the relationship lives in suppressed_by for the host to enforce.
  • Operating-state gating and NO_EVAL are frontmatter, not graph. G36 scopes FC#8 to OS#2 (§5.16.14.9b), suspends evaluation for ModeDelay after a mode change, and suspends it entirely when the AHU is off (§5.16.14.11). A host that evaluates in OS#1 or OS#4 will see this rule assert continuously — correctly by the equation and meaninglessly in fact, because a working coil is exactly what the difference is measuring.
  • persist.delayOnInit = true (Modelica/CDL default is false): a deviation already present at load waits out the full 30 minutes rather than alarming on the first tick after a controller restart. Library-wide choice, per AHU-0016.

Notes

This rule and AHU-0010 are the G36 pair testing whether two temperatures that ought to be equal actually are, and both are close cousins of AHU-0028. FC-062 tests containment — MAT inside the OAT–RAT envelope — a law that holds in every operating state; these two test equality, which holds in one state each, and size their bands as the root-sum-square of two specific sensors’ error. The pair also splits the AHU at the fan: FC-010 compares MAT against OAT, both upstream, and carries no fan-heat term. Run the sensor-drift playbook before touching a valve — a hand-held reference settles the SAT question in minutes, and a sensor runs $30–$80.

Test Vectors

10 scenarios, clock step 300 s over 7200 s.

ScenarioDescription
sat_is_mat_plus_fan_riseTextbook OS#2: 13.0 °C mixed air arrives at the supply sensor as 14.0 °C, the whole difference being the 1 °C the supply fan puts in. dev = sat − mat − dTSF = 0 exactly, which is what the fanConst term exists to produce. No coil is doing anything and neither sensor is lying.
sat_equals_mat_no_fan_riseSAT and MAT read identically, so dev = −1.0 °C: the fan heat G36 expects is missing. That is well inside the 3.1623 °C root-sum-square band — a fan that adds less than its nameplate rise, or a pair of sensors each off by half a degree, is not a fault.
heating_valve_leaking_sat_too_warm13.0 °C mixed air leaves at 18.0 °C: dev = +4.0 °C, past the threshold. In OS#2 the heating coil is commanded shut, so the only ways air gains four degrees more than fan heat are hot water leaking through a closed valve or a sensor reading wrong. Alarms once the 30 min delay expires.
cooling_valve_leaking_sat_too_cold14.0 °C mixed air leaves at 10.0 °C: dev = −5.0 °C. Negative disagreement pins the Abs — without it the rule would only see air that got too warm. Physically this is chilled water leaking through a closed valve, cooling nobody asked for and nobody is paying attention to, since the valve command reads 0.
edge_positive_dev_equals_thresholdStrict boundary, clear side: sat − mat − 1.0 = 3.1623 °C exactly, the full root-sum-square of eSAT and eMAT. GreaterThreshold is u > t, so a unit sitting precisely on its combined sensor budget reads healthy.
edge_positive_dev_just_over_thresholdStrict boundary, fault side: the same mixed air with SAT 0.04 °C warmer puts dev at 3.2 °C, which clears u > t and alarms after the delay. Pins the edge from the other direction.
edge_negative_dev_equals_thresholdSame boundary on the cold side: dev = −3.1623 °C exactly,
edge_negative_dev_just_over_thresholdCold side, fault: dev = −3.2 °C.
transient_disagreement_never_alarmsSAT jumps to 18 °C for 1200 s — shorter than the 1800 s alarm delay — then returns. A damper stroke or a load step moves the mixed-air stream past the SAT sensor before the MAT sensor settles, and the timer resets on the first compliant tick, so nothing is reported.
fault_clears_when_valve_seatsA leaking heating valve holds SAT at 18 °C, the alarm asserts at 1800 s, and at 3600 s the valve seats and SAT falls back to 14 °C. The alarm drops on that tick: assertion waits out the delay, recovery does not.
vectors.json
{
  "schema": "cxf-library/vectors/v1",
  "clock": {
    "step_s": 300,
    "horizon_s": 7200
  },
  "scenarios": [
    {
      "name": "sat_is_mat_plus_fan_rise",
      "description": "Textbook OS#2: 13.0 \u00b0C mixed air arrives at the supply sensor as 14.0 \u00b0C, the whole difference being the 1 \u00b0C the supply fan puts in. dev = sat \u2212 mat \u2212 dTSF = 0 exactly, which is what the fanConst term exists to produce. No coil is doing anything and neither sensor is lying.",
      "inputs": {
        "sat": 14.0,
        "mat": 13.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        }
      ]
    },
    {
      "name": "sat_equals_mat_no_fan_rise",
      "description": "SAT and MAT read identically, so dev = \u22121.0 \u00b0C: the fan heat G36 expects is missing. That is well inside the 3.1623 \u00b0C root-sum-square band \u2014 a fan that adds less than its nameplate rise, or a pair of sensors each off by half a degree, is not a fault.",
      "inputs": {
        "sat": 13.0,
        "mat": 13.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        }
      ]
    },
    {
      "name": "heating_valve_leaking_sat_too_warm",
      "description": "13.0 \u00b0C mixed air leaves at 18.0 \u00b0C: dev = +4.0 \u00b0C, past the threshold. In OS#2 the heating coil is commanded shut, so the only ways air gains four degrees more than fan heat are hot water leaking through a closed valve or a sensor reading wrong. Alarms once the 30 min delay expires.",
      "inputs": {
        "sat": 18.0,
        "mat": 13.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1500,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2100,
          "to_s": 7200,
          "equals": true
        }
      ]
    },
    {
      "name": "cooling_valve_leaking_sat_too_cold",
      "description": "14.0 \u00b0C mixed air leaves at 10.0 \u00b0C: dev = \u22125.0 \u00b0C. Negative disagreement pins the Abs \u2014 without it the rule would only see air that got too warm. Physically this is chilled water leaking through a closed valve, cooling nobody asked for and nobody is paying attention to, since the valve command reads 0.",
      "inputs": {
        "sat": 10.0,
        "mat": 14.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1500,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2100,
          "to_s": 7200,
          "equals": true
        }
      ]
    },
    {
      "name": "edge_positive_dev_equals_threshold",
      "description": "Strict boundary, clear side: sat \u2212 mat \u2212 1.0 = 3.1623 \u00b0C exactly, the full root-sum-square of eSAT and eMAT. GreaterThreshold is u > t, so a unit sitting precisely on its combined sensor budget reads healthy.",
      "inputs": {
        "sat": 17.2623,
        "mat": 13.1
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        }
      ]
    },
    {
      "name": "edge_positive_dev_just_over_threshold",
      "description": "Strict boundary, fault side: the same mixed air with SAT 0.04 \u00b0C warmer puts dev at 3.2 \u00b0C, which clears u > t and alarms after the delay. Pins the edge from the other direction.",
      "inputs": {
        "sat": 17.3,
        "mat": 13.1
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1500,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2100,
          "to_s": 7200,
          "equals": true
        }
      ]
    },
    {
      "name": "edge_negative_dev_equals_threshold",
      "description": "Same boundary on the cold side: dev = \u22123.1623 \u00b0C exactly, |dev| = t, clear. Together with the positive edge this shows the band is symmetric about the fan-heat-corrected zero rather than about zero itself.",
      "inputs": {
        "sat": 10.8377,
        "mat": 13.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        }
      ]
    },
    {
      "name": "edge_negative_dev_just_over_threshold",
      "description": "Cold side, fault: dev = \u22123.2 \u00b0C. |dev| clears the threshold and the alarm asserts after 30 min, at the same magnitude the positive case does.",
      "inputs": {
        "sat": 10.8,
        "mat": 13.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1500,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2100,
          "to_s": 7200,
          "equals": true
        }
      ]
    },
    {
      "name": "transient_disagreement_never_alarms",
      "description": "SAT jumps to 18 \u00b0C for 1200 s \u2014 shorter than the 1800 s alarm delay \u2014 then returns. A damper stroke or a load step moves the mixed-air stream past the SAT sensor before the MAT sensor settles, and the timer resets on the first compliant tick, so nothing is reported.",
      "inputs": {
        "sat": [
          {
            "t": 0,
            "value": 14.0
          },
          {
            "t": 600,
            "value": 18.0
          },
          {
            "t": 1800,
            "value": 14.0
          }
        ],
        "mat": 13.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        }
      ]
    },
    {
      "name": "fault_clears_when_valve_seats",
      "description": "A leaking heating valve holds SAT at 18 \u00b0C, the alarm asserts at 1800 s, and at 3600 s the valve seats and SAT falls back to 14 \u00b0C. The alarm drops on that tick: assertion waits out the delay, recovery does not.",
      "inputs": {
        "sat": [
          {
            "t": 0,
            "value": 18.0
          },
          {
            "t": 3600,
            "value": 14.0
          }
        ],
        "mat": 13.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1500,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2100,
          "to_s": 3300,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 3900,
          "to_s": 7200,
          "equals": false
        }
      ]
    }
  ]
}

AHU-0009 — OAT too high for free cooling

Statusverified — engine e2ff2f8, cxf:fnv1a128:cc6083da19e505bdcd1e46dd56352962, 2026-08-17
Severity3
Methodrule
Phase1
CategoryCOMFORT_ENERGY
ConfidenceLOW
EstimationQUALITATIVE_ONLY
G36§5.16.14 FC#9
ClustersCLU-03
Suppresses
Suppressed by
RelatedAHU-0017, AHU-0011
Playbookseconomizer-failure
SourceHVAC FDD Reference v1.0 §5.8.1 (index; card abbreviated); G36 §5.16.14 FC#9 (text per Addendum u public review); NISTIR 7365 (defaults provenance)
Operating statesOS#2 (free cooling, modulating OA) — host-gated

Preconditions (host-enforced): Evaluate only in G36 OS#2, which Table 5.16.14.2 defines by actuator signature: heating coil = 0, cooling coil = 0, minimum OA position < OA damper < 100%. Per §5.16.14.11 the host suspends evaluation while the AHU is not operating and for ModeDelay (30 min) after a mode change in any zone group the AHU serves; per §5.16.14.12 fault conditions not applicable to the current OS are not evaluated at all. Verdicts outside OS#2, and verdicts inside a transition window, are NO_EVAL — never healthy. sat_sp must be the setpoint the economizer is controlling to (G36’s SATsp for heating-coil and economizer control), not the cooling-coil setpoint SATsp-C. The 1.0 °C default threshold assumes a local OAT sensor at the unit; a site on a global OAT sensor must retune it before trusting the verdict.

Points: oat, sat_sp

Outputs:

  • yFault — True while outdoor air has stayed more than oat_excess_threshold above the supply air setpoint continuously for at least alarm_delay

Parameters:

NameDefaultUnitCXF pathDescription
oat_excess_threshold1.0°CgapBig.tAmount by which oat may exceed sat_sp before free cooling counts as unable to reach setpoint. The default composes the G36 §5.16.14 internal variables as eOAT + eSAT − dTSF = 1 + 1 − 1 = 1.0 °C, using the local-sensor eOAT. Retuning any one term means recomputing the sum: a global OAT sensor (eOAT = 3 °C) gives 3.0 °C; a measured 2 °C fan rise gives 0.0 °C.
alarm_delay1800.0spersist.delayTimeContinuous fault persistence required before the alarm asserts (G36 AlarmDelay, 30 min)

Description

The unit is running as a modulating economizer while outdoor air is too warm to reach the supply setpoint. In OS#2 both coils are shut and outdoor air is the only cooling in the machine; it enters at OAT and picks up roughly 1 °C crossing the supply fan, so once OAT is above SATSP − dTSF no damper position gets supply air down to setpoint. The unit belongs in OS#3, with mechanical cooling on top of a fully open damper.

The fault names a changeover that did not happen: the high-limit logic should have handed off and did not, or the OAT the logic reads is not the OAT the unit is breathing, or something is quietly supplying cooling that keeps the SAT loop looking satisfied. Within CLU-03 (Economizer Failure) this is the mirror of the trigger AHU-0017 — that rule catches an economizer that will not open when outdoor air is useful, this one catches one still open after it stopped being.

Detection Logic

G36 §5.16.14 FC#9, applies to OS#2:

    OAT_AVG − eOAT > SATSP − dTSF + eSAT

rearranged to gap form:

    oat − sat_sp > eOAT + eSAT − dTSF = 1 + 1 − 1 = 1.0 °C

yFault = (oat − sat_sp > oat_excess_threshold), sustained for alarm_delay

Block graph (rule.cxf.jsonld):

AHU-0009 block graph

Everything G36 spreads across both sides of the inequality — two sensor error bands and the fan-heat correction — collapses into the single positive gapBig.t, so a host retunes one parameter instead of reasoning about which side each term lives on. The gap is signed, and its sign is the physics: negative means free cooling has headroom, positive means the damper is out of moves, and the threshold sets how far past zero the gap must go before sensor error stops being a plausible explanation.

G36’s comparison is already strict, so GreaterThreshold reproduces it exactly and a gap of exactly 1.0 °C reads healthy in both. persist requires 30 minutes of continuous violation and any interruption restarts the timer; recovery is immediate on the tick the gap falls back under the threshold. Either input can open the gap — a setpoint reset walking toward a colder target crosses the same line a warming afternoon does.

Possible Diagnoses

Per G36 §5.16.14 Table 5.16.14.8, FC#9:

  1. SAT sensor error — the SAT loop chases a reading that is not the supply air and holds the unit in economizer mode at a setpoint outdoor air cannot reach
  2. OAT sensor error — a sensor reading low keeps the changeover logic convinced free cooling is viable; cheapest of the three to rule out
  3. Cooling coil valve leaking or stuck open — invisible to the command in OS#2, but real cooling, and it can hold SAT near setpoint where outdoor air alone never could, so the unit has no reason to change over

Energy Impact

COMFORT_ENERGY, LOW confidence, QUALITATIVE_ONLY — the reference’s §5.8.1 index row, which maps the fault to PNNL-25985 EEM-06 (OA damper faults). The immediate symptom is lost cooling capacity: the unit cannot make setpoint, zones drift warm, and VAV boxes open chasing supply air that never gets cold enough. No waste term is computable from two temperatures with no airflow, coil state, or counterfactual mode. The secondary cost depends on the diagnosis — a leaking cooling valve runs a chiller against a coil nobody commanded, while a delayed changeover mostly costs comfort until mechanical cooling engages. Cooling-dominant by construction.

Emissions Impact

QUALITATIVE_EMISSIONS, LOW confidence, Scope 2. Every path out of this fault runs on purchased electricity — chiller or DX capacity engaging late, fan energy moving air that is not cold enough, and in the leaking-valve case compressor work nobody asked for. No on-site combustion is involved: OS#2 has the heating coil commanded shut, and a heating valve leaking in this state is FC#15’s finding, not this one’s. Avoided-emissions basis: N/A.

Deviations

  • The reference card is an index row, so this card is built from G36. §5.8.1 gives the code and the name and nothing else. The equation, OS#2 applicability, three diagnoses, and internal-variable defaults are transcribed from ASHRAE Guideline 36 §5.16.14 as it appears in Addendum u to Guideline 36-2018 (first public review, 2021).
  • Severity 3 is library-assigned. The reference publishes none; the value matches this chapter’s scaffold row and every other G36 001-range card here. G36’s own Level 3 alarm grading (§5.16.14.16) is a reporting priority rather than a ranking, but it points the same direction.
  • Energy profile matches the §5.8.1 index row (COMFORT_ENERGY / LOW / QUAL, EEM-06, savings “sensor-dependent”), mirroring AHU-0002 and AHU-0003; the emissions block is library-assigned. Scope 2 is narrower than the 1|2 those cards use, because unlike a mis-read MAT this fault cannot drive heating: OS#2 has the heating coil shut by definition.
  • Combined-epsilon threshold. G36 puts eOAT on the measured side and dTSF and eSAT on the setpoint side, which would bind one card parameter to three block parameters with two signs. oat − sat_sp > eOAT + eSAT − dTSF puts one positive number on one CXF path, algebraically identical, with the composition recorded in the parameter description. Same move as AHU-0028 and AHU-0002.
  • The default assumes a local OAT sensor. G36 gives eOAT as 1 °C at the unit and 3 °C for a global sensor, and the library ships the local value. A site feeding this rule from a campus or weather-service OAT must set oat_excess_threshold to 3.0 °C (3 + 1 − 1); leaving it at 1.0 makes the rule fire on sensor disagreement G36 considers within tolerance.
  • No boundary deviation for this fault. FC#9’s comparison is already strict (>), unlike the / forms elsewhere in Table 5.16.14.8 (FC#5, FC#12, FC#14, FC#15), so no measure-zero rewrite is involved.
  • Instantaneous samples instead of averaged signals. G36 compares 5-minute rolling averages sampled at 1-minute intervals; this rule compares raw samples and leans on the 30-minute persist delay. Not equivalent — persistence resets on every compliant tick, so an oscillating OAT can hide indefinitely, while the steady offset this rule is for reads the same either way. (Honesty note carried from AHU-0002.)
  • Operating-state gating and NO_EVAL are frontmatter, not graph. G36 scopes FC#9 to OS#2 (§5.16.14.9b), suspends evaluation for ModeDelay after a mode change, and suspends it entirely when the AHU is off (§5.16.14.11). The engine is status-blind and the graph computes fault-given-valid-data only (precedent AHU-0029). A host that evaluates in OS#3 will see this rule assert on every warm afternoon, correctly by the equation and meaninglessly in fact.
  • persist.delayOnInit = true (Modelica/CDL default is false): a gap already open at load waits out the full 30 minutes rather than alarming on the first tick after a controller restart. Library-wide choice, per AHU-0016.

Notes

The instructive property of this rule is only visible next to its pair. AHU-0009 and AHU-0011 test the same two points against the same epsilons and both account for the same 1 °C fan rise, but FC#9 subtracts fan heat (threshold 1.0 °C) while FC#11 adds it (3.0 °C): fan heat narrows the usable free-cooling band from the top and widens it from the bottom, so the too-warm test fires sooner and the too-cold test waits longer. A site on a global OAT sensor recomputes both — 3.0 °C here, 5.0 °C for AHU-0011 — and they do not scale together. Check the OAT sensor before anyone edits changeover logic (step 1.2 of the economizer-failure playbook): a sensor reading low produces this exact signature with the sequence working.

Test Vectors

8 scenarios, clock step 300 s over 7200 s.

ScenarioDescription
favorable_free_coolingNormal OS#2: 10 °C outdoor air against a 13 °C setpoint. Even after the 1 °C fan rise the air arrives at 11 °C, so the damper has room to modulate and the changeover is correct.
oat_well_above_setpoint20 °C outdoor air against a 13 °C setpoint: the gap is 7 °C, far past the 1.0 °C threshold. Free cooling cannot reach setpoint at any damper position, so the alarm asserts once the 30 min delay expires.
edge_gap_equals_thresholdStrict boundary, clear side: oat − sat_sp = 1.0 °C exactly. GreaterThreshold is u > t, so sitting precisely on the composed epsilon budget is not a fault.
edge_gap_just_over_thresholdStrict boundary, fault side: oat − sat_sp = 1.1 °C clears u > t and alarms after the delay. Pins the same edge as edge_gap_equals_threshold from the other direction.
fan_heat_narrows_the_bandTeaching case for the sign of dTSF: a 1.5 °C gap faults because fan heat is subtracted from the epsilon budget (1 + 1 − 1 = 1.0). Had the threshold been sensor error alone (2.0 °C), this unit would read healthy while outdoor air 0.5 °C above setpoint gains another 1 °C crossing the fan.
setpoint_drop_opens_the_gapOutdoor air holds at 13.5 °C while the SAT reset walks the setpoint from 14 °C down to 12 °C at t = 600 s. Nothing outdoors changed; the unit simply now demands colder supply air than free cooling can deliver. The delay runs from the setpoint step, so the alarm lands at 2400 s.
transient_warm_spell_never_alarmsA 1200 s excursion to 20 °C — shorter than the 1800 s alarm delay — then back to 10 °C. The persistence timer resets on the first compliant tick and nothing is reported.
fault_clears_on_recoverySustained 7 °C gap alarms at 1800 s; outdoor air falls back to 10 °C at 3600 s and the alarm drops on that tick. Recovery has no delay — only the assertion does.
vectors.json
{
  "schema": "cxf-library/vectors/v1",
  "clock": {
    "step_s": 300,
    "horizon_s": 7200
  },
  "scenarios": [
    {
      "name": "favorable_free_cooling",
      "description": "Normal OS#2: 10 \u00b0C outdoor air against a 13 \u00b0C setpoint. Even after the 1 \u00b0C fan rise the air arrives at 11 \u00b0C, so the damper has room to modulate and the changeover is correct.",
      "inputs": {
        "oat": 10.0,
        "sat_sp": 13.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        }
      ]
    },
    {
      "name": "oat_well_above_setpoint",
      "description": "20 \u00b0C outdoor air against a 13 \u00b0C setpoint: the gap is 7 \u00b0C, far past the 1.0 \u00b0C threshold. Free cooling cannot reach setpoint at any damper position, so the alarm asserts once the 30 min delay expires.",
      "inputs": {
        "oat": 20.0,
        "sat_sp": 13.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1500,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2100,
          "to_s": 7200,
          "equals": true
        }
      ]
    },
    {
      "name": "edge_gap_equals_threshold",
      "description": "Strict boundary, clear side: oat \u2212 sat_sp = 1.0 \u00b0C exactly. GreaterThreshold is u > t, so sitting precisely on the composed epsilon budget is not a fault.",
      "inputs": {
        "oat": 14.0,
        "sat_sp": 13.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        }
      ]
    },
    {
      "name": "edge_gap_just_over_threshold",
      "description": "Strict boundary, fault side: oat \u2212 sat_sp = 1.1 \u00b0C clears u > t and alarms after the delay. Pins the same edge as edge_gap_equals_threshold from the other direction.",
      "inputs": {
        "oat": 14.1,
        "sat_sp": 13.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1500,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2100,
          "to_s": 7200,
          "equals": true
        }
      ]
    },
    {
      "name": "fan_heat_narrows_the_band",
      "description": "Teaching case for the sign of dTSF: a 1.5 \u00b0C gap faults because fan heat is subtracted from the epsilon budget (1 + 1 \u2212 1 = 1.0). Had the threshold been sensor error alone (2.0 \u00b0C), this unit would read healthy while outdoor air 0.5 \u00b0C above setpoint gains another 1 \u00b0C crossing the fan.",
      "inputs": {
        "oat": 14.5,
        "sat_sp": 13.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1500,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2100,
          "to_s": 7200,
          "equals": true
        }
      ]
    },
    {
      "name": "setpoint_drop_opens_the_gap",
      "description": "Outdoor air holds at 13.5 \u00b0C while the SAT reset walks the setpoint from 14 \u00b0C down to 12 \u00b0C at t = 600 s. Nothing outdoors changed; the unit simply now demands colder supply air than free cooling can deliver. The delay runs from the setpoint step, so the alarm lands at 2400 s.",
      "inputs": {
        "oat": 13.5,
        "sat_sp": [
          {
            "t": 0,
            "value": 14.0
          },
          {
            "t": 600,
            "value": 12.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2100,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2700,
          "to_s": 7200,
          "equals": true
        }
      ]
    },
    {
      "name": "transient_warm_spell_never_alarms",
      "description": "A 1200 s excursion to 20 \u00b0C \u2014 shorter than the 1800 s alarm delay \u2014 then back to 10 \u00b0C. The persistence timer resets on the first compliant tick and nothing is reported.",
      "inputs": {
        "oat": [
          {
            "t": 0,
            "value": 10.0
          },
          {
            "t": 600,
            "value": 20.0
          },
          {
            "t": 1800,
            "value": 10.0
          }
        ],
        "sat_sp": 13.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        }
      ]
    },
    {
      "name": "fault_clears_on_recovery",
      "description": "Sustained 7 \u00b0C gap alarms at 1800 s; outdoor air falls back to 10 \u00b0C at 3600 s and the alarm drops on that tick. Recovery has no delay \u2014 only the assertion does.",
      "inputs": {
        "oat": [
          {
            "t": 0,
            "value": 20.0
          },
          {
            "t": 3600,
            "value": 10.0
          }
        ],
        "sat_sp": 13.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1500,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2100,
          "to_s": 3300,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 3900,
          "to_s": 7200,
          "equals": false
        }
      ]
    }
  ]
}

AHU-0010 — OAT ≠ MAT in mechanical + economizer cooling

Statusverified — engine e2ff2f8, cxf:fnv1a128:5eecdfa05c333292682916fecbc5d7f1, 2026-08-17
Severity3
Methodrule
Phase1
CategoryCOMFORT_ENERGY
ConfidenceLOW
EstimationQUALITATIVE_ONLY
G36§5.16.14 FC#10
Clusters
Suppresses
Suppressed byAHU-0028
RelatedAHU-0002, AHU-0003, AHU-0008, AHU-0021, AHU-0028
Playbookssensor-drift
SourceHVAC FDD Reference v1.0 §5.8.1 (index; card abbreviated); G36 §5.16.14 FC#10 (text per Addendum u public review); NISTIR 7365 (defaults provenance); PNNL-25985 (EEM-01, sensor recalibration)
Operating statesOS#3 (mechanical + economizer cooling, damper at 100%) — host-gated

Preconditions (host-enforced): The unit must have a MAT sensor; G36 omits FC#10 where there is none. Evaluate only in OS#3, which G36 Table 5.16.14.2 defines by actuator signature: heating coil = 0, cooling coil > 0, OA damper at 100%. At any smaller damper position return air is supposed to be in the mixture and the equation is simply false. Supply fan running, since neither temperature describes a stream that is not moving. Per §5.16.14.11 the host suspends evaluation while the AHU is off and for ModeDelay (30 min) after a mode change in any zone group the AHU serves — a damper still stroking toward 100% leaves return air in the plenum that no fault caused. Silence this rule while AHU-0028 is active: MAT is an input here and FC-062 is its integrity gate. When any gate is unmet the verdict is NO_EVAL, not healthy.

Points: mat, oat

Outputs:

  • yFault — True while |mat − oat| has stayed above combined_error for at least alarm_delay

Parameters:

NameDefaultUnitCXF pathDescription
combined_error3.1623°CdevBig.tLargest disagreement between mixed and outdoor air that sensor accuracy alone can explain, composed as the root-sum-square of the two error bands: sqrt(eMAT² + eOAT²) = sqrt(3² + 1²) = sqrt(10) = 3.1623 °C with the G36 Table 5.16.14.5 defaults. Errors add in quadrature rather than linearly because two independent sensors are unlikely to be wrong in the same direction at once. The eOAT default is location-dependent — 1 °C for a sensor at the unit, 3 °C for a shared or global one — so a site reading campus or weather-service outdoor air recomputes sqrt(9 + 9) = 4.2426 °C.
alarm_delay1800.0spersist.delayTimeContinuous fault persistence required before the alarm asserts (G36 AlarmDelay, 30 min)

Description

In OS#3 the unit has opened the outdoor air damper to 100% and closed the return damper, and is running mechanical cooling on top. Mixed air is then not a mixture at all — it is outdoor air, one plenum downstream — so the two sensors are measuring the same stream at two points and should agree to within their combined accuracy.

When they do not, either a sensor is lying or the return damper is not where the command says it is. The second case is the expensive one: return air in a building that needs cooling is warmer than the outdoor air the economizer just chose, so every degree of leak-through arrives at the coil as load the unit did not need to buy, and the damper feedback will not show it. This is the outdoor-air-side counterpart to AHU-0008, which audits the supply and mixed-air sensors in free cooling; between them the two G36 equality tests cover most of the unit’s temperature instrumentation using only operating states the sequence already visits.

Detection Logic

G36 §5.16.14 FC#10, applies to OS#3 (omitted if the unit has no MAT sensor):

    | MAT_AVG − OAT_AVG | > sqrt(eMAT² + eOAT²)

with the Table 5.16.14.5 defaults and a local OAT sensor:

    | mat − oat | > sqrt(3² + 1²) = 3.1623 °C

yFault = (|mat − oat| > combined_error), sustained for alarm_delay

Block graph (rule.cxf.jsonld):

AHU-0010 block graph

There is no fan-heat term, and its absence is physics rather than omission: both sensors sit upstream of the supply fan, so whatever the fan adds is added after the comparison and cancels out of it. AHU-0008 straddles the fan and must correct for it.

The sign of dev carries the diagnosis even though the rule discards it. Positive — mixed air warmer than outdoor — is the leaking return damper in cooling weather. Negative is a sensor story: no amount of recirculated building air pulls a mixture below the outdoor stream when the building is warmer than outdoors. A host that wants the direction keeps the two temperatures alongside the verdict.

G36’s comparison is already strict, so GreaterThreshold reproduces it exactly and a deviation of exactly 3.1623 °C reads healthy in both. persist requires 30 minutes of continuous violation and any interruption restarts the timer.

Possible Diagnoses

Per G36 §5.16.14 Table 5.16.14.8, FC#10:

  1. MAT sensor error — out of calibration, or reading a stratified slice of the plenum rather than the stream (AHU-0028 catches the gross version)
  2. OAT sensor error — cheapest to rule out, and usually placement: a sensor on a sun-struck wall or above a condenser reads high all afternoon
  3. Leaking or stuck economizer damper or actuator — the return damper is not sealing, or the outdoor damper never reached the 100% it reports

Energy Impact

COMFORT_ENERGY, LOW confidence, QUALITATIVE_ONLY — the grades in the reference’s §5.8.1 index row, which maps the fault to PNNL-25985 EEM-01 (sensor recalibration) and publishes savings as sensor-dependent. Two temperatures and no airflow give no power term, and the difference is ambiguous between a lying sensor and a leaking damper. On the damper diagnosis the waste is direct and continuous: recirculated return air arrives at the coil warmer than the outdoor air the economizer selected, and the chiller pays for as long as the leak lasts. On the sensor diagnoses the cost is indirect — the same bad reading feeds AHU-0021, AHU-0030, and the changeover logic. Cooling-dominant, since OS#3 exists only when the unit is making cold air.

Emissions Impact

QUALITATIVE_EMISSIONS, LOW confidence; the block is library-assigned, as the §5.8.1 index carries no emissions column. Scope 2: every path out of this fault lands on the cooling plant — chiller or DX work spent on recirculated air, and fan energy moving it. No on-site combustion is involved, since OS#3 has the heating coil commanded shut and a heating valve leaking in this state reports as FC#15. That is why this card is narrower than AHU-0008’s 1|2, which cannot tell which coil is leaking. Avoided-emissions basis: N/A.

Deviations

  • The reference card is an index row, so this card is built from G36. §5.8.1 gives the code, the name, and the energy grades, with no equation, vectors, or severity. The equation, OS#3 applicability, three diagnoses, and internal-variable defaults are transcribed from ASHRAE Guideline 36 §5.16.14 as it appears in Addendum u to Guideline 36-2018 (first public review, 2021).
  • Severity 3 is library-assigned. The index has no severity column, and G36’s Level 3 alarm grading (§5.16.14.16) is a reporting priority rather than a ranking. The value matches every other G36 001-range card here.
  • Energy profile follows the §5.8.1 index row (COMFORT_ENERGY / LOW / QUAL, EEM-01, savings “sensor-dependent”); the emissions block is library-assigned.
  • Root-sum-square threshold shipped as one number. G36 writes the bound as sqrt(eMAT² + eOAT²); the graph carries the evaluated 3.1623 °C in devBig.t, so a host retunes one parameter and no square root runs at runtime. The composition is spelled out in the parameter description because the arithmetic is not linear.
  • The default assumes a local OAT sensor. G36 gives eOAT as 1 °C at the unit and 3 °C for a global one. A site feeding this rule from a campus sensor or weather service must set combined_error to sqrt(9 + 9) = 4.2426 °C; leaving it at the local value makes the rule fire on disagreement G36 considers within tolerance. The two G36 equality tests do not retune together — AHU-0008’s threshold does not move at all, since it contains no OAT term.
  • No boundary deviation for this fault. FC#10’s comparison is already strict (>), unlike the / forms elsewhere in Table 5.16.14.8 (FC#5, FC#12, FC#14, FC#15), so no measure-zero rewrite is involved. Same finding as AHU-0009 and AHU-0011.
  • Instantaneous samples instead of averaged signals. G36 compares 5-minute rolling averages sampled at 1-minute intervals; this rule compares raw samples and leans on the 30-minute persist delay. Not equivalent — persistence resets on every compliant tick, so an oscillating difference can hide indefinitely, while a drifted sensor and a leaking damper are steady offsets and read the same either way. (Honesty note carried from AHU-0002.)
  • Suppression is declared, not encoded. AHU-0028 gates MAT integrity and silences this rule while active; the engine is status-blind, so the relationship lives in suppressed_by for the host to enforce.
  • Operating-state gating and NO_EVAL are frontmatter, not graph. G36 scopes FC#10 to OS#3 (§5.16.14.9c), suspends evaluation for ModeDelay after a mode change, and suspends it entirely when the AHU is off (§5.16.14.11). A host that evaluates at minimum outdoor air will see this rule assert on every cold morning, correctly by the equation and meaninglessly in fact, because mixing return air is what the damper is for.
  • persist.delayOnInit = true (Modelica/CDL default is false): a deviation already present at load waits out the full 30 minutes rather than alarming on the first tick after a controller restart. Library-wide choice, per AHU-0016.

Notes

This rule and AHU-0008 are the G36 pair testing whether two temperatures that ought to be equal actually are, and both are close cousins of AHU-0028. FC-062 tests containment — MAT inside the OAT–RAT envelope — a law that holds in every operating state; these two test equality, which holds in one state each. A unit whose MAT sits inside the envelope and still disagrees with outdoor air by 4 °C on full outdoor air passes the gate and fails here, which is the point of running both. The outdoor air fraction AHU-0021 and AHU-0030 compute puts MAT and OAT in numerator and denominator at once, so an error big enough to trip this rule moves that ratio further still. Run the sensor-drift playbook before anyone opens the mixing box, and check where the outdoor sensor is mounted — sun on the housing produces this signature with a calibrated element inside.

Test Vectors

9 scenarios, clock step 300 s over 7200 s.

ScenarioDescription
mat_tracks_oatHealthy OS#3: the OA damper is at 100%, the return damper is shut, and 15.5 °C outdoor air reaches the mixed-air sensor at 15.8 °C. The 0.3 °C residual is plenum warming and sensor spread, far inside the 3.1623 °C band.
return_air_leaking_past_shut_damper15.0 °C outdoor air, 20.0 °C mixed air: 5 °C of warm return air is finding its way into a plenum that is supposed to be on 100% outdoor air. The return damper reads closed, so only the temperature pair can see it. Alarms after the 30 min delay.
oat_sensor_reads_highMixed air 4 °C below outdoor air. Return air cannot cool a mixture below the outdoor stream, so this direction is a sensor story: an OAT sensor in afternoon sun, or a MAT sensor drifted low. Negative disagreement pins the Abs — without it the rule would be blind to half its diagnoses.
edge_positive_dev_equals_thresholdStrict boundary, clear side: mat − oat = 3.1623 °C exactly, the full root-sum-square of eMAT and eOAT. GreaterThreshold is u > t, so a unit sitting precisely on its combined sensor budget reads healthy.
edge_positive_dev_just_over_thresholdStrict boundary, fault side: the same outdoor air with MAT 0.04 °C warmer puts the deviation at 3.2 °C, which clears u > t and alarms after the delay. Pins the edge from the other direction.
edge_negative_dev_equals_thresholdSame boundary on the cold side: mat − oat = −3.1623 °C exactly,
edge_negative_dev_just_over_thresholdCold side, fault: mat − oat = −3.2 °C.
transient_disagreement_never_alarmsMAT rises to 20 °C for 1200 s — shorter than the 1800 s alarm delay — then settles back. A damper still stroking toward 100% leaves return air in the plenum for a few minutes; the timer resets on the first compliant tick and nothing is reported.
fault_clears_when_damper_seatsA leaking return damper holds MAT at 20 °C against 15 °C outdoor air; the alarm asserts at 1800 s. At 3600 s the damper seats and MAT falls to 15.3 °C, and the alarm drops on that tick — assertion waits out the delay, recovery does not.
vectors.json
{
  "schema": "cxf-library/vectors/v1",
  "clock": {
    "step_s": 300,
    "horizon_s": 7200
  },
  "scenarios": [
    {
      "name": "mat_tracks_oat",
      "description": "Healthy OS#3: the OA damper is at 100%, the return damper is shut, and 15.5 \u00b0C outdoor air reaches the mixed-air sensor at 15.8 \u00b0C. The 0.3 \u00b0C residual is plenum warming and sensor spread, far inside the 3.1623 \u00b0C band.",
      "inputs": {
        "mat": 15.8,
        "oat": 15.5
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        }
      ]
    },
    {
      "name": "return_air_leaking_past_shut_damper",
      "description": "15.0 \u00b0C outdoor air, 20.0 \u00b0C mixed air: 5 \u00b0C of warm return air is finding its way into a plenum that is supposed to be on 100% outdoor air. The return damper reads closed, so only the temperature pair can see it. Alarms after the 30 min delay.",
      "inputs": {
        "mat": 20.0,
        "oat": 15.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1500,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2100,
          "to_s": 7200,
          "equals": true
        }
      ]
    },
    {
      "name": "oat_sensor_reads_high",
      "description": "Mixed air 4 \u00b0C below outdoor air. Return air cannot cool a mixture below the outdoor stream, so this direction is a sensor story: an OAT sensor in afternoon sun, or a MAT sensor drifted low. Negative disagreement pins the Abs \u2014 without it the rule would be blind to half its diagnoses.",
      "inputs": {
        "mat": 15.0,
        "oat": 19.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1500,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2100,
          "to_s": 7200,
          "equals": true
        }
      ]
    },
    {
      "name": "edge_positive_dev_equals_threshold",
      "description": "Strict boundary, clear side: mat \u2212 oat = 3.1623 \u00b0C exactly, the full root-sum-square of eMAT and eOAT. GreaterThreshold is u > t, so a unit sitting precisely on its combined sensor budget reads healthy.",
      "inputs": {
        "mat": 18.5623,
        "oat": 15.4
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        }
      ]
    },
    {
      "name": "edge_positive_dev_just_over_threshold",
      "description": "Strict boundary, fault side: the same outdoor air with MAT 0.04 \u00b0C warmer puts the deviation at 3.2 \u00b0C, which clears u > t and alarms after the delay. Pins the edge from the other direction.",
      "inputs": {
        "mat": 18.6,
        "oat": 15.4
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1500,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2100,
          "to_s": 7200,
          "equals": true
        }
      ]
    },
    {
      "name": "edge_negative_dev_equals_threshold",
      "description": "Same boundary on the cold side: mat \u2212 oat = \u22123.1623 \u00b0C exactly, |dev| = t, clear. The band is symmetric, so both signs have to be pinned.",
      "inputs": {
        "mat": 14.8377,
        "oat": 18.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        }
      ]
    },
    {
      "name": "edge_negative_dev_just_over_threshold",
      "description": "Cold side, fault: mat \u2212 oat = \u22123.2 \u00b0C. |dev| clears the threshold and the alarm asserts at the same magnitude the positive case does.",
      "inputs": {
        "mat": 14.8,
        "oat": 18.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1500,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2100,
          "to_s": 7200,
          "equals": true
        }
      ]
    },
    {
      "name": "transient_disagreement_never_alarms",
      "description": "MAT rises to 20 \u00b0C for 1200 s \u2014 shorter than the 1800 s alarm delay \u2014 then settles back. A damper still stroking toward 100% leaves return air in the plenum for a few minutes; the timer resets on the first compliant tick and nothing is reported.",
      "inputs": {
        "mat": [
          {
            "t": 0,
            "value": 15.3
          },
          {
            "t": 600,
            "value": 20.0
          },
          {
            "t": 1800,
            "value": 15.3
          }
        ],
        "oat": 15.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        }
      ]
    },
    {
      "name": "fault_clears_when_damper_seats",
      "description": "A leaking return damper holds MAT at 20 \u00b0C against 15 \u00b0C outdoor air; the alarm asserts at 1800 s. At 3600 s the damper seats and MAT falls to 15.3 \u00b0C, and the alarm drops on that tick \u2014 assertion waits out the delay, recovery does not.",
      "inputs": {
        "mat": [
          {
            "t": 0,
            "value": 20.0
          },
          {
            "t": 3600,
            "value": 15.3
          }
        ],
        "oat": 15.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1500,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2100,
          "to_s": 3300,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 3900,
          "to_s": 7200,
          "equals": false
        }
      ]
    }
  ]
}

AHU-0011 — OAT too low for mechanical cooling

Statusverified — engine e2ff2f8, cxf:fnv1a128:c58377cb68e22f9e5e9d4f66cc906bba, 2026-08-17
Severity3
Methodrule
Phase1
CategoryCOMFORT_ENERGY
ConfidenceLOW
EstimationQUALITATIVE_ONLY
G36§5.16.14 FC#11
ClustersCLU-03
Suppresses
Suppressed by
RelatedAHU-0017, AHU-0009
Playbookseconomizer-failure
SourceHVAC FDD Reference v1.0 §5.8.1 (index; card abbreviated); G36 §5.16.14 FC#11 (text per Addendum u public review); NISTIR 7365 (defaults provenance)
Operating statesOS#3 (mechanical + economizer cooling) — host-gated

Preconditions (host-enforced): Evaluate only in G36 OS#3, which Table 5.16.14.2 defines by actuator signature: heating coil = 0, cooling coil > 0, OA damper = 100%. Per §5.16.14.11 the host suspends evaluation while the AHU is not operating and for ModeDelay (30 min) after a mode change in any zone group the AHU serves; per §5.16.14.12 fault conditions not applicable to the current OS are not evaluated at all. Verdicts outside OS#3, and verdicts inside a transition window, are NO_EVAL — never healthy. sat_sp must be the setpoint the economizer is controlling to (G36’s SATsp for heating-coil and economizer control), not the cooling-coil setpoint SATsp-C. The 3.0 °C default threshold assumes a local OAT sensor at the unit; a site on a global OAT sensor must retune it before trusting the verdict.

Points: oat, sat_sp

Outputs:

  • yFault — True while outdoor air has stayed more than oat_deficit_threshold below the supply air setpoint continuously for at least alarm_delay

Parameters:

NameDefaultUnitCXF pathDescription
oat_deficit_threshold3.0°CgapBig.tAmount by which oat may fall below sat_sp before mechanical cooling counts as unnecessary. The default composes the G36 §5.16.14 internal variables as eOAT + eSAT + dTSF = 1 + 1 + 1 = 3.0 °C, using the local-sensor eOAT. Retuning any one term means recomputing the sum: a global OAT sensor (eOAT = 3 °C) gives 5.0 °C; a measured 2 °C fan rise gives 4.0 °C.
alarm_delay1800.0spersist.delayTimeContinuous fault persistence required before the alarm asserts (G36 AlarmDelay, 30 min)

Description

The unit is running mechanical cooling on top of a fully open outdoor air damper while outdoor air is already colder than the supply setpoint by more than the fan will add back. In OS#3 the dampers sit at 100% and the chiller trims what the economizer cannot deliver, which is legitimate — until OAT is more than dTSF below setpoint, at which point free cooling alone would overshoot and the unit belongs in OS#2 modulating back toward a blend.

Something is holding the machine in the wrong state: the sequence never changed over, a sensor is lying about which side of setpoint the air is on, or a heating coil is leaking warmth into a stream the chiller then removes — the expensive version, two subsystems paying to cancel each other. Within CLU-03 (Economizer Failure) this is the third face of the same changeover problem: AHU-0017 catches a damper that stays shut when outdoor air is useful, AHU-0009 an economizer still modulating after outdoor air stopped being useful, and this one mechanical cooling still running when the economizer alone had it covered.

Detection Logic

G36 §5.16.14 FC#11, applies to OS#3:

    OAT_AVG + eOAT < SATSP − dTSF − eSAT

rearranged to gap form:

    sat_sp − oat > eOAT + eSAT + dTSF = 1 + 1 + 1 = 3.0 °C

yFault = (sat_sp − oat > oat_deficit_threshold), sustained for alarm_delay

Block graph (rule.cxf.jsonld):

AHU-0011 block graph

G36 spreads the two sensor error bands and the fan-heat correction across both sides of the inequality; the rearrangement collapses them into the single positive gapBig.t, so a host retunes one parameter instead of tracking which side each term sits on. Note the operand order: gap is sat_sp − oat, the reverse of AHU-0009’s, because the two rules look at opposite ends of the same band and each wants its own end positive.

G36’s comparison is already strict, so the rearranged GreaterThreshold reproduces it exactly and a gap of exactly 3.0 °C reads healthy in both. persist requires 30 minutes of continuous violation and any interruption restarts the timer; recovery is immediate on the tick the gap falls back under the threshold. Either input can open the gap — raising the supply setpoint does it as surely as a cold front.

Possible Diagnoses

Per G36 §5.16.14 Table 5.16.14.8, FC#11:

  1. SAT sensor error — a supply reading biased high keeps the cooling loop calling for capacity the air does not need
  2. OAT sensor error — a sensor reading high keeps the changeover logic from recognizing that free cooling would now overshoot
  3. Heating coil valve leaking or stuck open — warmth upstream of the cooling coil is heat the chiller then removes, and the reason the unit needs mechanical cooling on air that arrived cold. The most expensive diagnosis here, and the one that also lights up FC#15 where that rule is deployed
  4. Leaking or stuck economizer damper or actuator — a damper reported at 100% but physically short of it, or a return damper that will not close, delivers a warmer mixture than OAT implies

Energy Impact

COMFORT_ENERGY, LOW confidence, QUALITATIVE_ONLY — the reference’s §5.8.1 index row, which maps the fault to PNNL-25985 EEM-06 (OA damper faults). Mechanical cooling is doing free cooling’s job: every kilowatt at the chiller buys a temperature drop the outdoor air had already made. The waste is real and continuous but not computable here — two temperatures carry no airflow, no coil load, and no counterfactual for the correct state. An upper bound is the cooling energy spent over the fault’s duration; a host with airflow and coil data can substitute the direct measurement AHU-0017 uses. Cooling-dominant, and most common in shoulder seasons when outdoor air swings across the changeover point.

Emissions Impact

QUALITATIVE_EMISSIONS, LOW confidence, Scope 2. The waste is chiller or DX compressor electricity plus fan energy already being spent. Diagnosis 3 adds on-site combustion at the boiler, which would be Scope 1 — but that heat is inferred rather than observed, and the coil it feeds is commanded shut in OS#3, so the emissions this rule can honestly claim are the purchased-electricity ones. Avoided-emissions basis: N/A.

Deviations

  • The reference card is an index row, so this card is built from G36. §5.8.1 gives the code and the name and nothing else. The equation, OS#3 applicability, four diagnoses, and internal-variable defaults are transcribed from ASHRAE Guideline 36 §5.16.14 as it appears in Addendum u to Guideline 36-2018 (first public review, 2021).
  • Severity 3 is library-assigned. The reference publishes none; the value matches this chapter’s scaffold row and every other G36 001-range card here. G36’s Level 3 alarm grading (§5.16.14.16) is a reporting priority rather than a ranking, but it points the same direction.
  • Energy profile follows the §5.8.1 index row (COMFORT_ENERGY / LOW / QUAL, EEM-06, savings “sensor-dependent”), and the published row wins where one exists. EXCESS_CONSUMPTION is a defensible alternative — setpoint is being met, so the unit is comfortable and wasteful rather than uncomfortable — and is recorded, not adopted. The emissions block is library-assigned.
  • Combined-epsilon threshold. G36 puts eOAT on the measured side and dTSF and eSAT on the setpoint side, which would bind one card parameter to three block parameters. sat_sp − oat > eOAT + eSAT + dTSF puts one positive number on one CXF path, algebraically identical, with the composition recorded in the parameter description. Same move as AHU-0028 and AHU-0009.
  • The default assumes a local OAT sensor. G36 gives eOAT as 1 °C at the unit and 3 °C for a global sensor. A site feeding this rule from a campus or weather-service OAT must set oat_deficit_threshold to 5.0 °C (3 + 1 + 1); leaving it at 3.0 makes the rule fire on sensor disagreement G36 considers within tolerance.
  • No boundary deviation for this fault. FC#11’s comparison is already strict (<), unlike the / forms elsewhere in Table 5.16.14.8 (FC#5, FC#12, FC#14, FC#15), so GreaterThreshold on the rearranged gap reproduces the source exactly.
  • Instantaneous samples instead of averaged signals. G36 compares 5-minute rolling averages sampled at 1-minute intervals; this rule compares raw samples and leans on the 30-minute persist delay. Not equivalent — persistence resets on every compliant tick, so an oscillating OAT can hide indefinitely, while the steady offset this rule is for reads the same either way. (Honesty note carried from AHU-0002.)
  • Operating-state gating and NO_EVAL are frontmatter, not graph. G36 scopes FC#11 to OS#3 (§5.16.14.9c), suspends evaluation for ModeDelay after a mode change, and suspends it entirely when the AHU is off (§5.16.14.11). The engine is status-blind and the graph computes fault-given-valid-data only (precedent AHU-0029). A host that evaluates in OS#2 will see this rule assert on every cold morning — cold outdoor air with the dampers modulating is free cooling working.
  • persist.delayOnInit = true (Modelica/CDL default is false): a gap already open at load waits out the full 30 minutes rather than alarming on the first tick after a controller restart. Library-wide choice, per AHU-0016.

Notes

The instructive property of this rule is only visible next to its pair. AHU-0009 and AHU-0011 test the same two points against the same epsilons and both account for the same 1 °C fan rise, but FC#9 subtracts fan heat (threshold 1.0 °C) while FC#11 adds it (3.0 °C): fan heat narrows the usable free-cooling band from the top and widens it from the bottom. A site on a global OAT sensor recomputes both — 5.0 °C here, 3.0 °C for AHU-0009 — and they do not scale together. The two cannot fire at once, since one needs OS#2 and the other OS#3; a unit that alternates between them across a day is saying the changeover point is in the wrong place or the OAT sensor is unreliable. Check that sensor before anyone edits changeover logic — step 1.2 of the economizer-failure playbook.

Test Vectors

8 scenarios, clock step 300 s over 7200 s.

ScenarioDescription
mech_cooling_with_mild_oaNormal OS#3: 16 °C outdoor air against a 13 °C setpoint. Outdoor air is warmer than the supply target, so the dampers at 100% plus mechanical cooling on top is exactly the right arrangement.
oat_far_below_setpoint5 °C outdoor air against a 13 °C setpoint: the gap is 8 °C, well past the 3.0 °C threshold. Free cooling alone would overshoot, so the chiller is removing heat from air that was already cold enough. Alarms after the 30 min delay.
edge_gap_equals_thresholdStrict boundary, clear side: sat_sp − oat = 3.0 °C exactly. GreaterThreshold is u > t, so sitting precisely on the composed epsilon-plus-fan-heat budget is not a fault.
edge_gap_just_over_thresholdStrict boundary, fault side: sat_sp − oat = 3.1 °C clears u > t and alarms after the delay. Pins the same edge as edge_gap_equals_threshold from the other direction.
fan_heat_widens_the_bandTeaching case for the sign of dTSF: a 2.5 °C gap reads healthy because fan heat is added to the epsilon budget (1 + 1 + 1 = 3.0). Outdoor air 2.5 °C below setpoint arrives only 1.5 °C below it after the fan, which mechanical cooling can legitimately still be trimming. An epsilon-only threshold of 2.0 °C would have called this a fault.
setpoint_raised_opens_the_gapOutdoor air holds at 11 °C while the setpoint is raised from 13 °C to 15 °C at t = 600 s. Weather did not change; the unit now asks for supply air that free cooling alone would overshoot. The delay runs from the setpoint step, so the alarm lands at 2400 s.
transient_cold_snap_never_alarmsA 1200 s excursion to 5 °C — shorter than the 1800 s alarm delay — then back to 16 °C. The persistence timer resets on the first compliant tick and nothing is reported.
fault_clears_on_recoverySustained 8 °C gap alarms at 1800 s; outdoor air warms back to 16 °C at 3600 s and the alarm drops on that tick. Recovery has no delay — only the assertion does.
vectors.json
{
  "schema": "cxf-library/vectors/v1",
  "clock": {
    "step_s": 300,
    "horizon_s": 7200
  },
  "scenarios": [
    {
      "name": "mech_cooling_with_mild_oa",
      "description": "Normal OS#3: 16 \u00b0C outdoor air against a 13 \u00b0C setpoint. Outdoor air is warmer than the supply target, so the dampers at 100% plus mechanical cooling on top is exactly the right arrangement.",
      "inputs": {
        "oat": 16.0,
        "sat_sp": 13.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        }
      ]
    },
    {
      "name": "oat_far_below_setpoint",
      "description": "5 \u00b0C outdoor air against a 13 \u00b0C setpoint: the gap is 8 \u00b0C, well past the 3.0 \u00b0C threshold. Free cooling alone would overshoot, so the chiller is removing heat from air that was already cold enough. Alarms after the 30 min delay.",
      "inputs": {
        "oat": 5.0,
        "sat_sp": 13.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1500,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2100,
          "to_s": 7200,
          "equals": true
        }
      ]
    },
    {
      "name": "edge_gap_equals_threshold",
      "description": "Strict boundary, clear side: sat_sp \u2212 oat = 3.0 \u00b0C exactly. GreaterThreshold is u > t, so sitting precisely on the composed epsilon-plus-fan-heat budget is not a fault.",
      "inputs": {
        "oat": 10.0,
        "sat_sp": 13.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        }
      ]
    },
    {
      "name": "edge_gap_just_over_threshold",
      "description": "Strict boundary, fault side: sat_sp \u2212 oat = 3.1 \u00b0C clears u > t and alarms after the delay. Pins the same edge as edge_gap_equals_threshold from the other direction.",
      "inputs": {
        "oat": 9.9,
        "sat_sp": 13.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1500,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2100,
          "to_s": 7200,
          "equals": true
        }
      ]
    },
    {
      "name": "fan_heat_widens_the_band",
      "description": "Teaching case for the sign of dTSF: a 2.5 \u00b0C gap reads healthy because fan heat is added to the epsilon budget (1 + 1 + 1 = 3.0). Outdoor air 2.5 \u00b0C below setpoint arrives only 1.5 \u00b0C below it after the fan, which mechanical cooling can legitimately still be trimming. An epsilon-only threshold of 2.0 \u00b0C would have called this a fault.",
      "inputs": {
        "oat": 10.5,
        "sat_sp": 13.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        }
      ]
    },
    {
      "name": "setpoint_raised_opens_the_gap",
      "description": "Outdoor air holds at 11 \u00b0C while the setpoint is raised from 13 \u00b0C to 15 \u00b0C at t = 600 s. Weather did not change; the unit now asks for supply air that free cooling alone would overshoot. The delay runs from the setpoint step, so the alarm lands at 2400 s.",
      "inputs": {
        "oat": 11.0,
        "sat_sp": [
          {
            "t": 0,
            "value": 13.0
          },
          {
            "t": 600,
            "value": 15.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2100,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2700,
          "to_s": 7200,
          "equals": true
        }
      ]
    },
    {
      "name": "transient_cold_snap_never_alarms",
      "description": "A 1200 s excursion to 5 \u00b0C \u2014 shorter than the 1800 s alarm delay \u2014 then back to 16 \u00b0C. The persistence timer resets on the first compliant tick and nothing is reported.",
      "inputs": {
        "oat": [
          {
            "t": 0,
            "value": 16.0
          },
          {
            "t": 600,
            "value": 5.0
          },
          {
            "t": 1800,
            "value": 16.0
          }
        ],
        "sat_sp": 13.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        }
      ]
    },
    {
      "name": "fault_clears_on_recovery",
      "description": "Sustained 8 \u00b0C gap alarms at 1800 s; outdoor air warms back to 16 \u00b0C at 3600 s and the alarm drops on that tick. Recovery has no delay \u2014 only the assertion does.",
      "inputs": {
        "oat": [
          {
            "t": 0,
            "value": 5.0
          },
          {
            "t": 3600,
            "value": 16.0
          }
        ],
        "sat_sp": 13.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1500,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2100,
          "to_s": 3300,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 3900,
          "to_s": 7200,
          "equals": false
        }
      ]
    }
  ]
}

AHU-0012 — SAT too high vs MAT in cooling

Statusverified — engine e2ff2f8, cxf:fnv1a128:d771011dc1df5622a20da08a347948cc, 2026-08-17
Severity3
Methodrule
Phase1
CategoryEXCESS_CONSUMPTION
ConfidenceMEDIUM
EstimationPROXY_ESTIMATION
G36§5.16.14 FC#12
ClustersCLU-01
Suppresses
Suppressed byAHU-0028
RelatedAHU-0016, AHU-0005, AHU-0013, AHU-0028, AHU-0032
Playbookssimultaneous-hc
SourceHVAC FDD Reference v1.0 §5.8.1 (index; card abbreviated); G36 §5.16.14 FC#12 (text per Addendum u public review); NISTIR 7365 (defaults provenance)
Operating statesOS#2-#4 (any cooling-side state) — host-gated

Preconditions (host-enforced): Supply fan running, and the unit in one of the cooling-side operating states G36 defines by actuator signature: OS#2 (HC = 0, CC = 0, minimum < OA damper < 100%), OS#3 (HC = 0, CC > 0, OA damper = 100%), or OS#4 (HC = 0, CC > 0, OA damper at minimum). Omit the rule on a unit with no MAT sensor — G36 marks FC#12 omit if no MAT sensor, and a MAT inferred from a mixing model rather than measured makes the comparison circular. Suspend evaluation for ModeDelay (30 min) after any mode or operating-state change in a zone group the AHU serves, while actuators are still stroking to their new positions. Silence the rule while AHU-0028 is active: a MAT outside the OAT/RAT envelope is not a number to compare anything against. When any gate is unmet the verdict is NO_EVAL, not healthy.

Points: sat, mat

Outputs:

  • yFault — True while SAT has stayed more than sat_mat_gap_threshold above MAT for at least alarm_delay

Parameters:

NameDefaultUnitCXF pathDescription
sat_mat_gap_threshold5.0°CgapBig.tHow far SAT may exceed MAT before the air is warming for a reason no cooling-side state explains. Default 5.0 °C is G36’s composition eSAT + eMAT + dTSF = 1 + 3 + 1: the supply-air and mixed-air sensor allowances plus the temperature rise across the supply fan. A site retuning a sensor allowance recomputes that sum — a local calibrated MAT sensor lowers eMAT, and a measured fan rise replaces dTSF
alarm_delay1800.0spersist.delayTimeContinuous fault persistence required before the alarm asserts (G36 AlarmDelay, 30 min)

Description

Air crossing an air handler in a cooling-side state can gain a little heat from the supply fan and nothing else. When SAT reads more than the combined sensor and fan-heat allowance above MAT, the stream is picking up heat from a source that should not be running: a gas or electric stage stuck on, a heating valve leaking through, or a heating coil that never closed when the unit left OS#1. The alternative is that one of the two sensors is lying.

This is G36 §5.16.14 FC#12, AHU-0005 read in the mirror; both are statements about the sign of the temperature change across the unit, each evaluated only in the states where its sign is the expected one. A heating source active while the unit is cooling is simultaneous heating and cooling under another name, which puts this rule in CLU-01 behind AHU-0016. The difference is what each can see: AHU-0016 needs both valve commands and catches the conflict at the command layer, while this rule reads two temperatures and catches it at the air stream — including the case AHU-0016 misses, where the heating command reads zero and heat arrives anyway.

Detection Logic

gap    = sat − mat
yFault = gap > sat_mat_gap_threshold,
         sustained continuously for alarm_delay

Block graph (rule.cxf.jsonld):

AHU-0012 block graph

G36 writes the test as SAT_AVG − eSAT − ΔTSF ≥ MAT_AVG + eMAT; moving the constants to one side gives SAT − MAT ≥ eSAT + eMAT + ΔTSF, one subtraction against the composed 5.0 °C threshold. The comparison is strict, so a gap sitting exactly on 5.0 °C reads healthy where G36 would report the fault. persist requires 30 minutes of continuous violation and any interruption restarts the timer, which separates a stuck heat source from a heating stage finishing its off-cycle purge; recovery is immediate on the tick the gap falls back inside the allowance.

Nothing below the threshold can trip this rule, including a gap of −20 °C. A cooling coil that has stopped cooling shows up here only once the air is actively being heated; the case where SAT merely fails to reach setpoint with the valve wide open is AHU-0013’s.

Possible Diagnoses

Transcribed from G36 §5.16.14 FC#12:

  1. SAT sensor error
  2. MAT sensor error
  3. Cooling coil valve stuck closed or actuator failure
  4. Fouled or undersized cooling coil
  5. CHW temperature too high or CHW unavailable
  6. DX cooling unavailable
  7. Gas or electric heat stuck on
  8. Heating coil valve leaking or stuck open

Diagnoses 7 and 8 are what make this a waste fault rather than a capacity fault: they are the only entries that put energy into the stream. Air merely failing to be cooled lands about one fan-heat rise above MAT, not five degrees, so the cooling-side entries usually reach this rule in combination — a leaking heating coil that a working chilled-water coil had been masking becomes visible the moment the cooling capacity goes away.

Energy Impact

EXCESS_CONSUMPTION, MEDIUM confidence, PROXY_ESTIMATION, savings 2–5% of AHU energy — the §5.8.1 index row, the only energy statement the reference makes here (no EEM mapped). The waste has two halves, the heat someone paid to add and the cooling paid to remove it again:

waste_kw = supply_airflow_m3s × 1.2 × 1.005 × (sat − (mat + dTSF))

That sizes the first half; the second is a further charge of roughly the same magnitude at the plant’s efficiency. Design airflow substituting for a measured one keeps the term a proxy, and MEDIUM rather than HIGH because the rule cannot separate its waste diagnoses from its sensor diagnoses — a mis-calibrated SAT sensor draws the identical trace and wastes nothing. Cooling-dominant, since the rule is evaluated only in cooling-side states.

Emissions Impact

PROXY_EMISSIONS, MEDIUM confidence. Scope 1+2, following AHU-0016: when the fault is real both inventories are paid into at the same moment — gas heat adding energy (Scope 1) and purchased electricity driving the chiller that removes it (Scope 2). The 1 is contingent on the heat being combustion; on an all-electric unit the exchange collapses to Scope 2. The heating-side mirror AHU-0005 records the same exchange as 1|2 for that contingency, so the pair differs on notation rather than physics. When the cause is a sensor there is nothing to attribute. Avoided-emissions basis: marginal operating emissions rate (MOER) for the electric half, static combustion factor for the fuel half.

Deviations

  • The reference card is abbreviated; G36 is the normative text. The HVAC FDD Reference carries AHU-0012 only as a §5.8.1 index row — no equation, internal variables, vectors, severity, diagnoses, or preconditions. Detection logic and the diagnosis list are transcribed from ASHRAE Guideline 36 §5.16.14 FC#12 as it appears in Addendum u to Guideline 36-2018 (First Public Review, 2021).
  • Combined threshold instead of three separate allowances. eSAT = 1 °C, eMAT = 3 °C, and ΔTSF = 1 °C (NISTIR 7365 defaults the addendum notes are “intentionally biased toward minimizing false alarms”) compose into one positive 5.0 °C threshold on one CXF path, so a host retunes one number instead of three. Recompute as eSAT + eMAT + ΔTSF: a local calibrated MAT sensor at eMAT = 1 °C gives 3.0, a measured 2 °C fan rise gives 6.0. Same rearrangement as AHU-0028 and AHU-0001.
  • G36’s becomes a strict >. CDL Reals offers only strict comparisons, so a gap of exactly 5.000 °C reads healthy where G36 reports the fault. Measure zero on a real temperature signal, and it errs toward silence. A host binding coarsely quantized temperatures (integer °C, or a BAS that rounds to 0.5) should retune sat_mat_gap_threshold to 4.9.
  • Instantaneous samples instead of 5-minute rolling averages. G36 computes every §5.16.14 signal as a 5-minute rolling average with 1-minute sampling; this library consumes instantaneous points and lets the 30-minute AlarmDelay stand in. Not equivalent — persistence resets on every compliant tick, so an oscillating gap can hide indefinitely, while the steady offset of a stuck heat source or a drifted sensor reads the same either way. (Honesty note from AHU-0002.)
  • Operating states and ModeDelay are host-side preconditions. G36 scopes FC#12 to OS#2–#4, suspends evaluation for ModeDelay (30 min) after a mode change in a served zone group, and suspends it entirely while the AHU is off. None of it is in the graph, per the library’s stance (precedent AHU-0029). G36’s omit if no MAT sensor qualifier is a deployment decision of the same kind and lives in preconditions too.
  • OS#2 is included on the addendum’s own authority. The published FC#12 row applies to OS#3–#4; Addendum u shows the applicability edited to OS#2–#4, which is what is transcribed here — free cooling with a modulating damper is a cooling-side state like the other two. A host running the unedited 2018 text gates OS#3–#4 only; the graph is identical either way.
  • Severity 3 is the library’s. No chapter card states one and the §5.8.1 index carries no severity column. G36’s Level 3 alarm grading is a reporting priority rather than this library’s 1–4 scale, so it corroborates without supplying.
  • The energy profile is the index row’s; the runtime formula and scope are the library’s. category, confidence, estimation_method, and savings_range are copied from §5.8.1. The proxy formula is mirrored from AHU-0005 with the sign flipped — that rule counts heat removed from air the heating coil paid to warm, this one heat added to air the cooling coil pays to remove.
  • persist.delayOnInit = true (Modelica/CDL default is false), the library’s standing choice: a violation already present at load waits out the full 30 minutes instead of alarming on the first tick after a controller restart.

Notes

The threshold asymmetry with AHU-0005 is worth understanding before either number is retuned. The heating-side rule tests MAT − SAT against eSAT + eMAT − ΔTSF = 3.0 °C; this one tests SAT − MAT against eSAT + eMAT + ΔTSF = 5.0 °C. The sensor allowances are identical and the fan heat is what differs: expected warming excuses SAT running warm and indicts SAT running cold. Start with the two sensor diagnoses — cheapest to eliminate and most likely to be right — and note that an active AHU-0028 should already be suppressing this rule. If the sensors check out, the simultaneous-hc playbook covers the rest: the heat source is on, and what remains is whether the command, the valve, or the sequence is responsible.

Test Vectors

9 scenarios, clock step 300 s over 5400 s.

ScenarioDescription
cooling_coil_doing_its_jobMinimum-OA mechanical cooling on a warm day: a 24 °C mixture leaves the coil at 13 °C. SAT is 11 °C below MAT, the sign the rule expects in every cooling-side state, so there is nothing to report
fan_heat_with_no_cooling_demandSAT sits 1.5 °C above MAT with the coil idle — about what the supply fan alone adds (dTSF = 1 °C) plus a fraction of the sensor allowances. The 5 °C threshold is built to absorb exactly this case, so the rule stays silent
gap_exactly_at_thresholdThreshold edge: SAT − MAT = 5.0 °C exactly, the full eSAT + eMAT + dTSF allowance. gapBig is a strict >, so a gap sitting precisely on the composed allowance reads healthy
gap_just_over_thresholdThreshold edge, other side: SAT − MAT = 5.1 °C clears the strict comparison and the alarm asserts one alarm_delay (1800 s) later
heat_stuck_on_during_mechanical_coolingThe unit is in a cooling state and the air still gains 9 °C crossing the AHU — a gas or electric heating stage stuck on, or a heating valve leaking through. Air cannot warm itself, so this is the CLU-01 waste case: energy added and removed on the same stream
mat_sensor_reading_lowA plausible SAT of 18 °C against a MAT of 12 °C on a day with no 12 °C air anywhere in the building — the same 6 °C violation, but here the mixed-air sensor is what is wrong. The rule reports the contradiction; it cannot say which of the two sensors is lying
transient_clears_before_alarm_delayTransient: the gap holds at 9 °C for 1200 s while a heating stage finishes its off-cycle purge, then SAT drops to 15 °C. Anything shorter than alarm_delay never reaches the operator
fault_clears_on_recoveryA sustained 9 °C gap alarms at 1800 s; the stuck heating stage is switched off at t = 3000 and SAT falls to 14 °C. TrueDelay has no off-delay, so the alarm drops on that tick
oscillating_gap_never_alarmsA staging heater cycling on a 10-minute period drives the gap between 9 °C and 0 °C. Its mean is well outside the threshold and G36’s 5-minute rolling average would report the fault, but persistence restarts on every compliant tick, so this rule stays silent — the limit of substituting TrueDelay for averaging (see Deviations)
vectors.json
{
  "schema": "cxf-library/vectors/v1",
  "clock": {
    "step_s": 300,
    "horizon_s": 5400
  },
  "scenarios": [
    {
      "name": "cooling_coil_doing_its_job",
      "description": "Minimum-OA mechanical cooling on a warm day: a 24 \u00b0C mixture leaves the coil at 13 \u00b0C. SAT is 11 \u00b0C below MAT, the sign the rule expects in every cooling-side state, so there is nothing to report",
      "inputs": {
        "sat": 13.0,
        "mat": 24.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        }
      ]
    },
    {
      "name": "fan_heat_with_no_cooling_demand",
      "description": "SAT sits 1.5 \u00b0C above MAT with the coil idle \u2014 about what the supply fan alone adds (dTSF = 1 \u00b0C) plus a fraction of the sensor allowances. The 5 \u00b0C threshold is built to absorb exactly this case, so the rule stays silent",
      "inputs": {
        "sat": 25.5,
        "mat": 24.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        }
      ]
    },
    {
      "name": "gap_exactly_at_threshold",
      "description": "Threshold edge: SAT \u2212 MAT = 5.0 \u00b0C exactly, the full eSAT + eMAT + dTSF allowance. `gapBig` is a strict `>`, so a gap sitting precisely on the composed allowance reads healthy",
      "inputs": {
        "sat": 29.0,
        "mat": 24.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        }
      ]
    },
    {
      "name": "gap_just_over_threshold",
      "description": "Threshold edge, other side: SAT \u2212 MAT = 5.1 \u00b0C clears the strict comparison and the alarm asserts one alarm_delay (1800 s) later",
      "inputs": {
        "sat": 29.1,
        "mat": 24.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1500,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2100,
          "to_s": 5400,
          "equals": true
        }
      ]
    },
    {
      "name": "heat_stuck_on_during_mechanical_cooling",
      "description": "The unit is in a cooling state and the air still gains 9 \u00b0C crossing the AHU \u2014 a gas or electric heating stage stuck on, or a heating valve leaking through. Air cannot warm itself, so this is the CLU-01 waste case: energy added and removed on the same stream",
      "inputs": {
        "sat": 31.0,
        "mat": 22.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1500,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2100,
          "to_s": 5400,
          "equals": true
        }
      ]
    },
    {
      "name": "mat_sensor_reading_low",
      "description": "A plausible SAT of 18 \u00b0C against a MAT of 12 \u00b0C on a day with no 12 \u00b0C air anywhere in the building \u2014 the same 6 \u00b0C violation, but here the mixed-air sensor is what is wrong. The rule reports the contradiction; it cannot say which of the two sensors is lying",
      "inputs": {
        "sat": 18.0,
        "mat": 12.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1500,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2100,
          "to_s": 5400,
          "equals": true
        }
      ]
    },
    {
      "name": "transient_clears_before_alarm_delay",
      "description": "Transient: the gap holds at 9 \u00b0C for 1200 s while a heating stage finishes its off-cycle purge, then SAT drops to 15 \u00b0C. Anything shorter than alarm_delay never reaches the operator",
      "inputs": {
        "sat": [
          {
            "t": 0,
            "value": 31.0
          },
          {
            "t": 1200,
            "value": 15.0
          }
        ],
        "mat": 22.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        }
      ]
    },
    {
      "name": "fault_clears_on_recovery",
      "description": "A sustained 9 \u00b0C gap alarms at 1800 s; the stuck heating stage is switched off at t = 3000 and SAT falls to 14 \u00b0C. `TrueDelay` has no off-delay, so the alarm drops on that tick",
      "inputs": {
        "sat": [
          {
            "t": 0,
            "value": 31.0
          },
          {
            "t": 3000,
            "value": 14.0
          }
        ],
        "mat": 22.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1500,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2100,
          "to_s": 2700,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 3300,
          "to_s": 5400,
          "equals": false
        }
      ]
    },
    {
      "name": "oscillating_gap_never_alarms",
      "description": "A staging heater cycling on a 10-minute period drives the gap between 9 \u00b0C and 0 \u00b0C. Its mean is well outside the threshold and G36's 5-minute rolling average would report the fault, but persistence restarts on every compliant tick, so this rule stays silent \u2014 the limit of substituting TrueDelay for averaging (see Deviations)",
      "inputs": {
        "sat": [
          {
            "t": 0,
            "value": 31.0
          },
          {
            "t": 600,
            "value": 22.0
          },
          {
            "t": 1200,
            "value": 31.0
          },
          {
            "t": 1800,
            "value": 22.0
          },
          {
            "t": 2400,
            "value": 31.0
          },
          {
            "t": 3000,
            "value": 22.0
          },
          {
            "t": 3600,
            "value": 31.0
          },
          {
            "t": 4200,
            "value": 22.0
          },
          {
            "t": 4800,
            "value": 31.0
          }
        ],
        "mat": 22.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        }
      ]
    }
  ]
}

AHU-0013 — SAT too high at full cooling

Statusverified — engine e2ff2f8, cxf:fnv1a128:52a1f486bef61ee338ec4a5ec1338a05, 2026-08-17
Severity3
Methodrule
Phase1
CategoryEXCESS_CONSUMPTION
ConfidenceMEDIUM
EstimationPROXY_ESTIMATION
G36§5.16.14 FC#13
Clusters
Suppresses
Suppressed by
RelatedAHU-0012, AHU-0007, AHU-0023, AHU-0032, AHU-0033
Playbooks
SourceHVAC FDD Reference v1.0 §5.8.1 (index; card abbreviated); G36 §5.16.14 FC#13 (text per Addendum u public review); NISTIR 7365 (defaults provenance)
Operating statesOS#3-#4 (mechanical cooling) — host-gated

Preconditions (host-enforced): Supply fan running, and the unit in one of the two mechanical-cooling operating states G36 defines by actuator signature: OS#3 (HC = 0, CC > 0, OA damper = 100%) or OS#4 (HC = 0, CC > 0, OA damper at minimum). Suspend evaluation for ModeDelay (30 min) after any mode or operating-state change in a zone group the AHU serves, while actuators are still stroking and the coil has not caught up. clg_vlv_cmd must be the command the AHU controller is issuing, not a position feedback: this rule asks whether the loop has run out of capacity to ask for, and a feedback that disagrees with its command is a stuck-actuator finding (AHU-0020), not this one. sat_sp must be the active setpoint, including any reset — comparing against a design value the sequence is no longer holding produces a fault every mild afternoon. When any gate is unmet the verdict is NO_EVAL, not healthy.

Points: sat, sat_sp, clg_vlv_cmd

Outputs:

  • yFault — True while SAT has stayed more than sat_error_threshold above its setpoint with the cooling valve commanded above cc_full_threshold, for at least alarm_delay

Parameters:

NameDefaultUnitCXF pathDescription
sat_error_threshold1.0°CspMiss.tAmount by which SAT may exceed its setpoint before the miss is real rather than sensor error. Default 1.0 °C is G36’s eSAT, the supply-air sensor accuracy allowance (NISTIR 7365). A site with a calibrated SAT sensor may lower it; raising it to quiet a hunting loop hides AHU-0022 instead of fixing it
cc_full_threshold99.0%clgFull.tCooling coil command above which the loop is treated as having no capacity left to ask for (G36 CC >= 99%)
alarm_delay1800.0spersist.delayTimeContinuous fault persistence required before the alarm asserts (G36 AlarmDelay, 30 min)

Description

The cooling valve is wide open and the supply air is still above setpoint. The control loop has already asked for everything it has, so whatever is wrong is not tuning: either the coil cannot deliver, the chilled water or refrigerant behind it cannot deliver, or the sensor reporting the miss is wrong. Downstream the effect is the ordinary one — zones that cannot get cold enough, VAV boxes opening toward maximum flow, and a fan pushing more air to make up the degrees the coil failed to remove.

This is G36 §5.16.14 FC#13, the exact mirror of AHU-0007 on the heating side. Both rules make the same statement: an actuator at its stop with the controlled variable still on the wrong side of its target is evidence of a defect, and until the actuator saturates it is evidence of nothing.

Detection Logic

sp_gap = sat − sat_sp
yFault = (sp_gap      > sat_error_threshold)   SAT above setpoint by more than sensor accuracy
     AND (clg_vlv_cmd > cc_full_threshold)     cooling loop has nothing left to ask for
         sustained continuously for alarm_delay

Block graph (rule.cxf.jsonld):

AHU-0013 block graph

The gap form is G36’s SAT_AVG > SATSP + eSAT rearranged so the allowance stays a single positive number at one CXF path. clgFull is the half that gives the miss its meaning: SAT above setpoint at a part-open valve is a control loop doing its job, and only a loop that has run out of coil is evidence of a defect. Both comparisons are strict, so a miss sitting exactly on 1.0 °C and a command parked exactly on 99.0% both read healthy. persist requires 30 minutes of continuous violation and any interruption restarts the timer, which separates a failed coil from a morning pulldown or the minutes after a large block of zones opens at once.

Possible Diagnoses

Transcribed from G36 §5.16.14 FC#13:

  1. SAT sensor error
  2. Cooling coil valve stuck closed or actuator failure
  3. Fouled or undersized cooling coil
  4. CHW temperature too high or CHW unavailable
  5. DX cooling unavailable
  6. Gas or electric heat stuck on
  7. Heating coil valve leaking or stuck open

The list is FC#12’s minus the MAT sensor, which this rule does not read. Entries 6 and 7 are heat sources fighting the coil, so a unit that trips this rule and AHU-0012 together is pointing at those two rather than at the coil.

Energy Impact

EXCESS_CONSUMPTION, MEDIUM confidence, PROXY_ESTIMATION, savings 2–5% of AHU energy — the §5.8.1 index row, the only energy statement the reference makes here (no EEM mapped). The estimate is branched because the rule does not say which branch you are in. If a heat source is fighting the coil, the waste is immediate and AHU-0016’s term sizes it. If the coil, the chilled water, or the compressor is simply not delivering, the AHU wastes nothing at the coil and the cost lands downstream: shortfall_kw = supply_airflow_m3s × 1.2 × 1.005 × (sat − sat_sp), made up by extra airflow at fan power if it is made up at all. Design airflow substituting for a measured one keeps it a proxy. Cooling-dominant, since the rule is evaluated only in mechanical-cooling states.

Emissions Impact

PROXY_EMISSIONS, MEDIUM confidence. Scope 2: everything this fault spends is purchased electricity — the chiller or DX compressor running longer, and the fan power moving extra air. Diagnoses 6 and 7 can put a combustion stream behind the fault, but that is heat this rule cannot see; AHU-0012 measures it and carries the Scope 1 half. Emissions can rise rather than fall when the fault is fixed, since a coil restored to capacity finally delivers the cooling the building has been asking for — this rule buys comfort and diagnosis, and the avoided-emissions claim belongs to whatever waste the repair uncovers. Avoided-emissions basis: marginal operating emissions rate (MOER), applicable only to the fighting-heat-source branch.

Deviations

  • The reference card is abbreviated; G36 is the normative text. The HVAC FDD Reference carries AHU-0013 only as a §5.8.1 index row — no equation, internal variables, vectors, severity, diagnoses, or preconditions. Detection logic and the diagnosis list are transcribed from ASHRAE Guideline 36 §5.16.14 FC#13 as it appears in Addendum u to Guideline 36-2018 (First Public Review, 2021).
  • Setpoint comparison rewritten as gap comparison. Subtracting first and testing sat − sat_sp > eSAT keeps the allowance the positive number G36 publishes at one CXF path, instead of an offset added to the setpoint ahead of a two-signal comparison. Same rearrangement as AHU-0001 and AHU-0028. The threshold is a single G36 constant rather than a composition — eSAT = 1 °C, the NISTIR 7365 supply-air sensor accuracy — so recalibrating that sensor changes sat_error_threshold directly, with no arithmetic.
  • CC >= 99% becomes a strict > 99.0. CDL Reals offers only strict comparisons, so a command parked at exactly 99.000% reads as not-saturated and the rule stays silent where G36 would evaluate it. Same deviation and retune hint as AHU-0001 and AHU-0007: a host binding a coarsely quantized command should set cc_full_threshold to 98.9 rather than rely on the signal overshooting.
  • Instantaneous samples instead of 5-minute rolling averages. G36 computes every §5.16.14 signal as a 5-minute rolling average with 1-minute sampling; this library consumes instantaneous points and lets the 30-minute AlarmDelay stand in. Not equivalent — persistence resets on every compliant tick, so an oscillating signal can hide indefinitely; the realistic instance here is a hunting SAT loop, which is AHU-0022’s fault to report. A steady miss against a saturated valve reads the same either way. (Honesty note from AHU-0002.)
  • Operating states and ModeDelay are host-side preconditions. G36 scopes FC#13 to OS#3–#4, suspends evaluation for ModeDelay (30 min) after a mode change in a served zone group, and suspends it entirely while the AHU is off; none of it is in the graph, per the library’s stance (precedent AHU-0029). CC > 0 is part of both applicable state definitions, so the host’s gate already implies a cooling call — the graph’s clgFull test is the stronger statement that the call has saturated.
  • Severity 3 is the library’s. No chapter card states one and the §5.8.1 index carries no severity column. G36’s Level 3 alarm grading is a reporting priority rather than this library’s 1–4 scale, so it corroborates without supplying.
  • The energy profile is the index row’s; the runtime formula and scope are the library’s. category, confidence, estimation_method, and savings_range are copied from §5.8.1 — the identical row the reference gives AHU-0007, this rule’s heating-side mirror. The branched formula is mirrored from FC-007’s, but the scope departs from it: FC-007 records 1|2 because the heat making up its deficit may be gas or electric, while nothing on the cooling side burns fuel, so this card records 2 and leaves the Scope 1 half of the shared diagnoses to AHU-0012.
  • persist.delayOnInit = true (Modelica/CDL default is false), the library’s standing choice: a violation already present at load waits out the full 30 minutes instead of alarming on the first tick after a controller restart.

Notes

The setpoint this rule compares against is the one the sequence is actually holding, which makes it quietly dependent on the reset strategy. Where SAT reset has been disabled or never commissioned — the condition AHU-0023 detects — the active setpoint may be a design-day value, and a coil that cannot reach it in August is being asked for capacity nobody budgeted. Read this rule together with AHU-0012: every diagnosis here appears there, and what differs is that FC#12 finds heat entering the stream while FC#13 finds heat failing to leave it. A coil that has lost capacity trips this rule alone; a heat source fighting the coil trips both, which is the cheapest discriminator available before anyone opens an access panel.

Test Vectors

10 scenarios, clock step 300 s over 5400 s.

ScenarioDescription
coil_modulating_on_setpointOrdinary mechanical cooling: SAT is on its 13 °C setpoint with the valve at 65%. The loop has reserve in both directions, which is what a healthy cooling coil looks like
valve_wide_open_holding_setpointThe valve is at 100% and SAT is 0.5 °C high — a coil sized close to its design point on a design day. Half a degree is inside eSAT, so this is a unit at capacity, not a unit that has lost capacity
sat_error_exactly_at_thresholdThreshold edge: SAT − SATSP = 1.0 °C exactly, the whole eSAT allowance, with the valve at 100%. spMiss is a strict >, so an error sitting precisely on the sensor allowance reads healthy
sat_error_just_over_thresholdThreshold edge, other side: 1.1 °C above setpoint at 100% valve clears the strict comparison, and the alarm asserts one alarm_delay (1800 s) later
valve_exactly_at_full_thresholdThreshold edge: a 5 °C setpoint miss with the valve parked on exactly 99.0%. clgFull is a strict >, so a command sitting on cc_full_threshold does not count as saturated and the rule stays silent
valve_just_over_full_thresholdThreshold edge, other side: the same 5 °C miss with the valve at 99.5% clears the strict comparison and alarms after alarm_delay
coil_saturated_and_missing_setpointThe valve is wide open and SAT is stuck 5 °C above setpoint — the control loop has asked for everything and the temperature has not moved. Chilled water too warm, a fouled or undersized coil, or a valve that reports open and is not: capacity, supply, or sensor, never tuning
morning_pulldown_clears_before_delayTransient: the valve pins at 100% and SAT runs 5 °C high while the coil pulls the building down after a warm night, reaching setpoint at t = 1200. A pulldown is shorter than alarm_delay, so nothing alarms
fault_clears_when_chilled_water_returnsA saturated valve missing setpoint alarms at 1800 s; the chiller comes back at t = 3000 and SAT reaches setpoint. TrueDelay has no off-delay, so the alarm drops on that tick
valve_backs_off_before_delayTransient: SAT stays 5 °C high but the valve modulates back to 85% at t = 1200. A loop with reserve left is a loop still working, so persistence restarts and no alarm follows — the saturation half is what makes the setpoint miss evidence of a defect
vectors.json
{
  "schema": "cxf-library/vectors/v1",
  "clock": {
    "step_s": 300,
    "horizon_s": 5400
  },
  "scenarios": [
    {
      "name": "coil_modulating_on_setpoint",
      "description": "Ordinary mechanical cooling: SAT is on its 13 \u00b0C setpoint with the valve at 65%. The loop has reserve in both directions, which is what a healthy cooling coil looks like",
      "inputs": {
        "sat": 13.0,
        "sat_sp": 13.0,
        "clg_vlv_cmd": 65.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        }
      ]
    },
    {
      "name": "valve_wide_open_holding_setpoint",
      "description": "The valve is at 100% and SAT is 0.5 \u00b0C high \u2014 a coil sized close to its design point on a design day. Half a degree is inside eSAT, so this is a unit at capacity, not a unit that has lost capacity",
      "inputs": {
        "sat": 13.5,
        "sat_sp": 13.0,
        "clg_vlv_cmd": 100.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        }
      ]
    },
    {
      "name": "sat_error_exactly_at_threshold",
      "description": "Threshold edge: SAT \u2212 SATSP = 1.0 \u00b0C exactly, the whole eSAT allowance, with the valve at 100%. `spMiss` is a strict `>`, so an error sitting precisely on the sensor allowance reads healthy",
      "inputs": {
        "sat": 14.0,
        "sat_sp": 13.0,
        "clg_vlv_cmd": 100.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        }
      ]
    },
    {
      "name": "sat_error_just_over_threshold",
      "description": "Threshold edge, other side: 1.1 \u00b0C above setpoint at 100% valve clears the strict comparison, and the alarm asserts one alarm_delay (1800 s) later",
      "inputs": {
        "sat": 14.1,
        "sat_sp": 13.0,
        "clg_vlv_cmd": 100.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1500,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2100,
          "to_s": 5400,
          "equals": true
        }
      ]
    },
    {
      "name": "valve_exactly_at_full_threshold",
      "description": "Threshold edge: a 5 \u00b0C setpoint miss with the valve parked on exactly 99.0%. `clgFull` is a strict `>`, so a command sitting on cc_full_threshold does not count as saturated and the rule stays silent",
      "inputs": {
        "sat": 18.0,
        "sat_sp": 13.0,
        "clg_vlv_cmd": 99.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        }
      ]
    },
    {
      "name": "valve_just_over_full_threshold",
      "description": "Threshold edge, other side: the same 5 \u00b0C miss with the valve at 99.5% clears the strict comparison and alarms after alarm_delay",
      "inputs": {
        "sat": 18.0,
        "sat_sp": 13.0,
        "clg_vlv_cmd": 99.5
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1500,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2100,
          "to_s": 5400,
          "equals": true
        }
      ]
    },
    {
      "name": "coil_saturated_and_missing_setpoint",
      "description": "The valve is wide open and SAT is stuck 5 \u00b0C above setpoint \u2014 the control loop has asked for everything and the temperature has not moved. Chilled water too warm, a fouled or undersized coil, or a valve that reports open and is not: capacity, supply, or sensor, never tuning",
      "inputs": {
        "sat": 18.0,
        "sat_sp": 13.0,
        "clg_vlv_cmd": 100.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1500,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2100,
          "to_s": 5400,
          "equals": true
        }
      ]
    },
    {
      "name": "morning_pulldown_clears_before_delay",
      "description": "Transient: the valve pins at 100% and SAT runs 5 \u00b0C high while the coil pulls the building down after a warm night, reaching setpoint at t = 1200. A pulldown is shorter than alarm_delay, so nothing alarms",
      "inputs": {
        "sat": [
          {
            "t": 0,
            "value": 18.0
          },
          {
            "t": 1200,
            "value": 13.2
          }
        ],
        "sat_sp": 13.0,
        "clg_vlv_cmd": 100.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        }
      ]
    },
    {
      "name": "fault_clears_when_chilled_water_returns",
      "description": "A saturated valve missing setpoint alarms at 1800 s; the chiller comes back at t = 3000 and SAT reaches setpoint. `TrueDelay` has no off-delay, so the alarm drops on that tick",
      "inputs": {
        "sat": [
          {
            "t": 0,
            "value": 18.0
          },
          {
            "t": 3000,
            "value": 13.0
          }
        ],
        "sat_sp": 13.0,
        "clg_vlv_cmd": 100.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1500,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2100,
          "to_s": 2700,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 3300,
          "to_s": 5400,
          "equals": false
        }
      ]
    },
    {
      "name": "valve_backs_off_before_delay",
      "description": "Transient: SAT stays 5 \u00b0C high but the valve modulates back to 85% at t = 1200. A loop with reserve left is a loop still working, so persistence restarts and no alarm follows \u2014 the saturation half is what makes the setpoint miss evidence of a defect",
      "inputs": {
        "sat": 18.0,
        "sat_sp": 13.0,
        "clg_vlv_cmd": [
          {
            "t": 0,
            "value": 100.0
          },
          {
            "t": 1200,
            "value": 85.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        }
      ]
    }
  ]
}

AHU-0014 — Inactive cooling coil temperature drop

Statusverified — engine e2ff2f8, cxf:fnv1a128:9fb8c27579429e23f93c2f5a34e84d40, 2026-08-17
Severity2
Methodrule
Phase1
CategoryCRITICAL_WASTE
ConfidenceHIGH
EstimationDIRECT_MEASUREMENT
G36§5.16.14 FC#14
Clusters
Suppresses
Suppressed byAHU-0028
RelatedAHU-0016, AHU-0020, AHU-0015, AHU-0005, AHU-0028
Playbooksstuck-actuator
SourceHVAC FDD Reference v1.0 §5.8.1 (index; card abbreviated); G36 §5.16.14 FC#14 (text per Addendum u public review); NISTIR 7365 (defaults provenance); PNNL EEM-03 (leaking coil valves; the §5.8.1 index row’s EEM mapping)
Operating statesOS#1-#2 (cooling coil commanded off) — host-gated

Preconditions (host-enforced): Supply fan running, and the unit in one of the two states where G36 requires the cooling coil to be off: OS#1 heating (Table 5.16.14.2: HC > 0, CC = 0, OA damper at minimum) or OS#2 free cooling (HC = 0, CC = 0, minimum < OA damper < 100%). In OS#3 and OS#4 the coil is commanded to cool and a drop across it is the intended result, not a fault. Suspend evaluation for ModeDelay (30 min) after any mode or operating-state change in a zone group the AHU serves, and whenever the AHU is not operating (G36 §5.16.14.11) — a coil coasting down still shows its drop. This binding reads the coil through MAT and SAT, so a unit with no MAT sensor cannot run the rule as shipped: install dedicated coil sensors and rebind, or omit. Silence the rule while AHU-0028 is active: a MAT outside the OAT/RAT envelope is not a coil entering temperature. When any gate is unmet the verdict is NO_EVAL, not healthy.

Points: mat, sat

Outputs:

  • yFault — True while mat has stayed more than coil_drop_threshold above sat for at least alarm_delay

Parameters:

NameDefaultUnitCXF pathDescription
coil_drop_threshold4.1623°CdropBig.tTemperature drop across the cooling coil that stops being sensor noise and starts being cooling. Composed from the G36 §5.16.14 internal variables as sqrt(eCCET² + eCCLT²) + dTSF = sqrt(3² + 1²) + 1 = 4.1623, using the proxied epsilons Table 5.16.14.5 prescribes when the coil is read through MAT and SAT (eCCET = eMAT = 3 °C, eCCLT = eSAT = 1 °C) and the fan-heat term dTSF = 1 °C, which belongs here because the supply fan sits between the two sensors. Retunes: dedicated sensors bracketing the coil with the fan outside the pair drop the dTSF term and their own epsilons, giving sqrt(2)·1 ≈ 1.41 for a matched ±1 °C pair or 3.1623 if the entering sensor keeps a 3 °C band; keeping the mat/sat binding but testing the true coil drop against G36’s noise floor alone gives sqrt(10) − 1 = 2.1623
alarm_delay1800.0spersist.delayTimeContinuous fault persistence required before the alarm asserts (G36 AlarmDelay, 30 min)

Description

In OS#1 and OS#2 the cooling coil is closed by definition — G36 identifies both states partly by CC = 0. Air crossing the unit meets the supply fan and nothing else, so it reaches the supply sensor about a degree warmer than it left the mixing box. When SAT instead reads several degrees below MAT, something is pulling heat out of the stream: chilled water past a valve that reports itself shut, or a DX circuit that never got the message to stop.

The waste is worst in OS#1, where every kilowatt the leaking coil removes is a kilowatt the heating coil is paid to put back — the AHU-0016 failure arriving through a different door. In OS#2 there is no heating bill, but chilled water is still being made and pumped for air the economizer was cooling for nothing. This rule and AHU-0015 are AHU-0016’s silent siblings: that rule reads the two valve commands and needs both past 5% open, so a valve reporting 0% and flowing anyway is invisible to it. This pair reads the temperature signature and does not care what the command says.

Detection Logic

drop   = mat − sat
yFault = drop > coil_drop_threshold,
         sustained continuously for alarm_delay

Block graph (rule.cxf.jsonld):

AHU-0014 block graph

G36 writes the test as CCET_AVG − CCLT_AVG ≥ sqrt(eCCET² + eCCLT²) + ΔTSF*, footnoting the fan-heat factor as included or not depending on where the coil sensors sit. This library binds CCET := mat and CCLT := sat — the instrumentation most air handlers actually have — which brings the proxied epsilons (3 °C mixed-air, 1 °C supply-air, root-sum-square 3.1623 °C) and puts the supply fan inside the measurement, so the ΔTSF term applies: 3.1623 + 1 = 4.1623 °C.

Follow that arithmetic, because for a drop the fan works against the signal. Fan heat and coil cooling move the air in opposite directions, so mat − sat measures the true coil drop minus one dTSF and a measured 4.1623 °C is a real drop of about 5.16 °C. The shipped default is therefore doubly conservative — the direction the addendum says its defaults are chosen for — and sites that want the sharper test retune (see Deviations).

The comparison is strict, so a drop sitting exactly on 4.1623 °C reads healthy where G36 would report the fault. persist requires 30 continuous minutes and any interruption restarts the timer, which separates a leaking valve from a coil giving up the chilled water still standing in it after a state change.

Possible Diagnoses

Transcribed from G36 §5.16.14 FC#14:

  1. CCET sensor error
  2. CCLT sensor error
  3. Cooling coil valve stuck open or leaking
  4. DX cooling stuck on

Under this library’s binding, diagnoses 1 and 2 read as MAT and SAT sensor error, and they are the cheap ones to eliminate first. Diagnosis 3 dominates in the field and is why the card carries the stuck-actuator playbook: a two-way valve whose seat has eroded, or an actuator that has lost its close position, passes water at a command of 0% and no command-based rule will ever see it. Diagnosis 4 is the DX equivalent — a stuck contactor or a compressor a local safety has latched on.

Energy Impact

CRITICAL_WASTE, HIGH confidence, DIRECT_MEASUREMENT, savings 0.5–5% of site energy mapped to PNNL EEM-03 (leaking coil valves) — the §5.8.1 index row, the only energy statement the reference makes here. DIRECT_MEASUREMENT is honest in a way it is not for the abbreviated comparison rules: the two temperatures the rule already reads are the measurement.

waste_kw = supply_airflow_m3s × 1.2 × 1.005 × ((mat − sat) + dTSF)

The fan’s rise is added back because the measured drop under-reports the coil’s work by that much; design airflow is the one substitution. HIGH confidence because a sustained drop across a coil commanded shut has no benign explanation other than a sensor, and the sensor case shows up as a drop that does not move with load. Heating-dominant despite being a cooling fault, following the operating states: OS#1 is a heating state, and the hours a leaking chilled-water valve does the most damage are the hours a heating coil is fighting it.

Emissions Impact

PROXY_EMISSIONS, scope 1+2, both library-assigned since the §5.8.1 index publishes no emissions column. The unwanted cooling is purchased electricity at the chiller or DX compressor (Scope 2), and in OS#1 the heating that cancels it follows whatever the plant burns — Scope 1 for gas, Scope 2 for electric resistance or a heat pump. On an all-electric site the exchange collapses to Scope 2, and when the cause is a sensor there is nothing to attribute. Avoided-emissions basis: marginal operating emissions rate (MOER) for the electric half, static combustion factor for the fuel half.

Deviations

  • The reference card is abbreviated; G36 is the normative text. The HVAC FDD Reference carries AHU-0014 only as a §5.8.1 index row — no equation, internal variables, vectors, severity, diagnoses, or preconditions. Detection logic and the diagnosis list are transcribed from ASHRAE Guideline 36 §5.16.14 FC#14 as it appears in Addendum u to Guideline 36-2018 (First Public Review, 2021).
  • CCET and CCLT are bound to MAT and SAT. G36 leaves the instrumentation open (§5.16.14.5) and Table 5.16.14.5 supplies the proxied epsilons. The consequence is that the rule sees the whole air path from the mixing box to the supply sensor: the fan is inside the measurement (handled by dTSF) and so is any duct heat gain between coil and sensor (not handled — it biases the drop downward and makes the rule quieter still). A site with dedicated coil sensors rebinds the two boundary inputs at deployment and retunes coil_drop_threshold with its own sensor errors; because the fan is then outside the pair, that retune also drops the dTSF term.
  • The fan-heat term is included, and for this fault it works against the signal. A measured 4.1623 °C at the threshold is a true coil drop of 5.16 °C, where the sensor bands alone would justify reporting at 3.16 °C — the shipped default demands a leak 63% larger than the noise floor does, and the cost is real misses of modest leaks. Two worked retunes for sites that want the sharper test: dedicated sensors bracketing the coil with the fan outside the pair drop the term entirely (3.1623 with a 3 °C entering band, ≈1.41 with a matched ±1 °C pair); keeping the mat/sat binding but accepting G36’s noise floor on the true drop gives sqrt(10) − 1 = 2.1623.
  • G36’s becomes a strict >. CDL Reals offers only strict comparisons, so a drop of exactly 4.1623 °C reads healthy where G36 reports the fault. Measure zero on a real temperature signal, and it errs toward silence. A host binding coarsely quantized temperatures should retune the threshold down rather than rely on the signal overshooting.
  • The threshold is a rounded constant, not a root-sum-square computed in the graph. sqrt(3² + 1²) + 1 = 4.16227766…, shipped as 4.1623 — high by 2.2 × 10⁻⁵ °C, four orders of magnitude below the resolution of the sensors feeding it, and one number to retune instead of three.
  • Instantaneous samples instead of 5-minute rolling averages. G36 computes every §5.16.14 signal as a 5-minute rolling average with 1-minute sampling; this library consumes instantaneous points and lets the 30-minute AlarmDelay stand in. Not equivalent — persistence resets on every compliant tick, so an oscillating drop (a short-cycling DX stage) can hide indefinitely, while the steady leak of a failed valve seat reads the same either way. (Honesty note from AHU-0002.)
  • Operating states, ModeDelay, and the not-operating suspension are host-side preconditions. G36 scopes FC#14 to OS#1–#2 and suspends evaluation after a mode change in a served zone group and whenever the AHU is off; none of it is in the graph, per the library’s stance. G36 attaches no “omit if no MAT sensor” qualifier to FC#14 — it contemplates dedicated coil sensors — but this library’s binding needs MAT, so the qualifier applies to the shipped rule and lives in preconditions.
  • Severity 2 is the library’s. The §5.8.1 index carries no severity column. Severity 2 puts this fault with AHU-0016 and AHU-0020 rather than the 001-range comparison rules at 3, which is where CRITICAL_WASTE and HIGH confidence point. G36’s Level 3 alarm grading is a priority scheme, not this library’s 1–4 scale.
  • The energy profile is the index row’s; the runtime formula, climate sensitivity, and emissions block are the library’s, reasoned from the operating states the fault is evaluated in.
  • persist.delayOnInit = true (Modelica/CDL default is false), the library’s standing choice: a violation already present at load waits out the full 30 minutes instead of alarming on the first tick after a controller restart.

Notes

In OS#1 this rule overlaps AHU-0005, which tests the same two sensors in the same direction against a narrower 3.0 °C threshold and therefore alarms first on any leak large enough to trip both; the value of this rule there is its diagnosis list, which names the cooling coil and the DX circuit directly. It stands alone in OS#2. The threshold asymmetry between the two is about how sensor bands compose — linearly for AHU-0005, in quadrature here — and the fan-heat term then moves opposite ways, leaving this rule the quieter despite the tighter bands. Start at the sensors, since a MAT reading high or a SAT reading low produces this trace with nothing wrong in the mechanical room; an active AHU-0028 should already be suppressing the rule. Then isolate the coil and watch the drop disappear, and let the stuck-actuator playbook separate a failed actuator from an eroded seat.

Test Vectors

9 scenarios, clock step 300 s over 5400 s.

ScenarioDescription
heating_state_with_the_cooling_coil_offOS#1: a 18 °C mixture leaves the unit at 19 °C. The only thing the air crossed between the two sensors was the supply fan, so it gained dTSF = 1 °C and the cooling coil took nothing back. drop = mat − sat = −1 °C, the healthy signature of an inactive cooling coil on a draw-through unit
drop_exactly_at_thresholdThreshold edge: mat − sat = 4.1623 °C exactly, the composed sqrt(eCCET² + eCCLT²) + dTSF allowance. dropBig is a strict >, so a drop sitting precisely on the allowance reads healthy — G36’s would report it (see Deviations)
drop_just_over_thresholdThreshold edge, other side: mat − sat = 4.2623 °C clears the strict comparison on the first tick and the alarm asserts one alarm_delay (1800 s) later
chilled_water_valve_leaking_during_heatingOS#1 with the cooling valve commanded 0%: a 20 °C mixture arrives at the supply sensor at 12 °C. Adding the fan’s own 1 °C rise back, the coil is pulling roughly 9 °C out of air the heating coil is simultaneously paying to warm. AHU-0016 cannot see this — the cooling command reads zero and the water is flowing anyway
sat_sensor_reading_lowSame 6 °C signature with the chilled-water plant off for the season and the valve verified shut: nothing is removing heat, and the supply sensor is what is wrong. The rule reports the contradiction and cannot say which of the two sensors is lying — G36 lists both sensor errors ahead of the valve in its diagnosis order
modest_drop_below_shipped_defaultA real but small leak: measured mat − sat = 3.5 °C, which after adding the fan’s 1 °C rise back is a true coil drop of about 4.5 °C. The shipped 4.1623 °C threshold is measured against mat − sat, so it demands a true drop of ~5.16 °C and stays silent here. A site that retunes to sqrt(10) − 1 = 2.1623 (keeping the mat/sat binding but dropping the fan-heat term from the allowance) catches this leak; the shipped default is deliberately the quieter one
transient_clears_before_alarm_delayTransient: an 8 °C drop persists for 1200 s while chilled water still standing in a just-closed coil boils off, then SAT returns to mat + fan heat. Anything shorter than alarm_delay never reaches the operator
fault_clears_on_recoveryA sustained 8 °C drop alarms at 1800 s; the leaking valve is isolated at t = 3000 and SAT climbs back to mat + fan heat. TrueDelay has no off-delay, so the alarm drops on that tick
oscillating_drop_never_alarmsA DX stage short-cycling on a 10-minute period swings the drop between 8 °C and −1 °C. Its mean is well outside the allowance and G36’s 5-minute rolling average would report the fault, but persistence restarts on every compliant tick, so this rule stays silent — the limit of substituting TrueDelay for averaging (see Deviations)
vectors.json
{
  "schema": "cxf-library/vectors/v1",
  "clock": {
    "step_s": 300,
    "horizon_s": 5400
  },
  "scenarios": [
    {
      "name": "heating_state_with_the_cooling_coil_off",
      "description": "OS#1: a 18 \u00b0C mixture leaves the unit at 19 \u00b0C. The only thing the air crossed between the two sensors was the supply fan, so it gained dTSF = 1 \u00b0C and the cooling coil took nothing back. `drop` = mat \u2212 sat = \u22121 \u00b0C, the healthy signature of an inactive cooling coil on a draw-through unit",
      "inputs": {
        "mat": 18.0,
        "sat": 19.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        }
      ]
    },
    {
      "name": "drop_exactly_at_threshold",
      "description": "Threshold edge: mat \u2212 sat = 4.1623 \u00b0C exactly, the composed sqrt(eCCET\u00b2 + eCCLT\u00b2) + dTSF allowance. `dropBig` is a strict `>`, so a drop sitting precisely on the allowance reads healthy \u2014 G36's `\u2265` would report it (see Deviations)",
      "inputs": {
        "mat": 20.0,
        "sat": 15.8377
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        }
      ]
    },
    {
      "name": "drop_just_over_threshold",
      "description": "Threshold edge, other side: mat \u2212 sat = 4.2623 \u00b0C clears the strict comparison on the first tick and the alarm asserts one alarm_delay (1800 s) later",
      "inputs": {
        "mat": 20.0,
        "sat": 15.7377
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1500,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2100,
          "to_s": 5400,
          "equals": true
        }
      ]
    },
    {
      "name": "chilled_water_valve_leaking_during_heating",
      "description": "OS#1 with the cooling valve commanded 0%: a 20 \u00b0C mixture arrives at the supply sensor at 12 \u00b0C. Adding the fan's own 1 \u00b0C rise back, the coil is pulling roughly 9 \u00b0C out of air the heating coil is simultaneously paying to warm. AHU-0016 cannot see this \u2014 the cooling command reads zero and the water is flowing anyway",
      "inputs": {
        "mat": 20.0,
        "sat": 12.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1500,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2100,
          "to_s": 5400,
          "equals": true
        }
      ]
    },
    {
      "name": "sat_sensor_reading_low",
      "description": "Same 6 \u00b0C signature with the chilled-water plant off for the season and the valve verified shut: nothing is removing heat, and the supply sensor is what is wrong. The rule reports the contradiction and cannot say which of the two sensors is lying \u2014 G36 lists both sensor errors ahead of the valve in its diagnosis order",
      "inputs": {
        "mat": 21.0,
        "sat": 15.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1500,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2100,
          "to_s": 5400,
          "equals": true
        }
      ]
    },
    {
      "name": "modest_drop_below_shipped_default",
      "description": "A real but small leak: measured mat \u2212 sat = 3.5 \u00b0C, which after adding the fan's 1 \u00b0C rise back is a true coil drop of about 4.5 \u00b0C. The shipped 4.1623 \u00b0C threshold is measured against mat \u2212 sat, so it demands a true drop of ~5.16 \u00b0C and stays silent here. A site that retunes to sqrt(10) \u2212 1 = 2.1623 (keeping the mat/sat binding but dropping the fan-heat term from the allowance) catches this leak; the shipped default is deliberately the quieter one",
      "inputs": {
        "mat": 20.0,
        "sat": 16.5
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        }
      ]
    },
    {
      "name": "transient_clears_before_alarm_delay",
      "description": "Transient: an 8 \u00b0C drop persists for 1200 s while chilled water still standing in a just-closed coil boils off, then SAT returns to mat + fan heat. Anything shorter than alarm_delay never reaches the operator",
      "inputs": {
        "mat": 20.0,
        "sat": [
          {
            "t": 0,
            "value": 12.0
          },
          {
            "t": 1200,
            "value": 19.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        }
      ]
    },
    {
      "name": "fault_clears_on_recovery",
      "description": "A sustained 8 \u00b0C drop alarms at 1800 s; the leaking valve is isolated at t = 3000 and SAT climbs back to mat + fan heat. `TrueDelay` has no off-delay, so the alarm drops on that tick",
      "inputs": {
        "mat": 20.0,
        "sat": [
          {
            "t": 0,
            "value": 12.0
          },
          {
            "t": 3000,
            "value": 19.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1500,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2100,
          "to_s": 2700,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 3300,
          "to_s": 5400,
          "equals": false
        }
      ]
    },
    {
      "name": "oscillating_drop_never_alarms",
      "description": "A DX stage short-cycling on a 10-minute period swings the drop between 8 \u00b0C and \u22121 \u00b0C. Its mean is well outside the allowance and G36's 5-minute rolling average would report the fault, but persistence restarts on every compliant tick, so this rule stays silent \u2014 the limit of substituting TrueDelay for averaging (see Deviations)",
      "inputs": {
        "mat": 20.0,
        "sat": [
          {
            "t": 0,
            "value": 12.0
          },
          {
            "t": 600,
            "value": 21.0
          },
          {
            "t": 1200,
            "value": 12.0
          },
          {
            "t": 1800,
            "value": 21.0
          },
          {
            "t": 2400,
            "value": 12.0
          },
          {
            "t": 3000,
            "value": 21.0
          },
          {
            "t": 3600,
            "value": 12.0
          },
          {
            "t": 4200,
            "value": 21.0
          },
          {
            "t": 4800,
            "value": 12.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        }
      ]
    }
  ]
}

AHU-0015 — Inactive heating coil temperature rise

Statusverified — engine e2ff2f8, cxf:fnv1a128:2e4a701f33e4a7b3245b96c9e5b6922a, 2026-08-17
Severity2
Methodrule
Phase1
CategoryCRITICAL_WASTE
ConfidenceHIGH
EstimationDIRECT_MEASUREMENT
G36§5.16.14 FC#15
Clusters
Suppresses
Suppressed byAHU-0028
RelatedAHU-0016, AHU-0020, AHU-0014, AHU-0012, AHU-0028
Playbooksstuck-actuator
SourceHVAC FDD Reference v1.0 §5.8.1 (index; card abbreviated); G36 §5.16.14 FC#15 (text per Addendum u public review); NISTIR 7365 (defaults provenance); PNNL EEM-03 (leaking coil valves; the §5.8.1 index row’s EEM mapping)
Operating statesOS#2-#4 (heating coil commanded off) — host-gated

Preconditions (host-enforced): Supply fan running, and the unit in one of the three states where G36 requires the heating coil to be off (Table 5.16.14.2): OS#2 free cooling (HC = 0, CC = 0, minimum < OA damper < 100%), OS#3 mechanical plus economizer cooling (HC = 0, CC > 0, OA damper = 100%), or OS#4 mechanical cooling on minimum OA (HC = 0, CC > 0, OA damper at minimum). In OS#1 the coil is commanded to heat and a rise across it is the intended result, not a fault. Suspend evaluation for ModeDelay (30 min) after any mode or operating-state change in a zone group the AHU serves, and whenever the AHU is not operating (G36 §5.16.14.11) — a coil coasting down still shows its rise, and a preheat coil in freeze protection shows one on purpose. This binding reads the coil through MAT and SAT, so a unit with no MAT sensor cannot run the rule as shipped: install dedicated coil sensors and rebind, or omit. Silence the rule while AHU-0028 is active: a MAT outside the OAT/RAT envelope is not a coil entering temperature. When any gate is unmet the verdict is NO_EVAL, not healthy.

Points: sat, mat

Outputs:

  • yFault — True while sat has stayed more than coil_rise_threshold above mat for at least alarm_delay

Parameters:

NameDefaultUnitCXF pathDescription
coil_rise_threshold4.1623°CriseBig.tTemperature rise across the heating coil that stops being fan heat and sensor noise and starts being heat. Composed from the G36 §5.16.14 internal variables as sqrt(eHCET² + eHCLT²) + dTSF = sqrt(3² + 1²) + 1 = 4.1623, using the proxied epsilons Table 5.16.14.5 prescribes when the coil is read through MAT and SAT (eHCET = eMAT = 3 °C, eHCLT = eSAT = 1 °C) and the fan-heat term dTSF = 1 °C, which belongs here because the supply fan sits between the two sensors and its rise would otherwise be charged to the coil. Retunes: dedicated sensors bracketing the coil with the fan outside the pair drop the dTSF term and their own epsilons, giving sqrt(2)·1 ≈ 1.41 for a matched ±1 °C pair or 3.1623 if the entering sensor keeps a 3 °C band; a site that measures a fan rise other than 1 °C substitutes it directly
alarm_delay1800.0spersist.delayTimeContinuous fault persistence required before the alarm asserts (G36 AlarmDelay, 30 min)

Description

In OS#2, OS#3, and OS#4 the heating coil is closed by definition — G36 identifies all three states partly by HC = 0. Air crossing the unit should pick up the supply fan’s degree of shaft work and nothing more. When SAT reads several degrees above MAT anyway, hot water is moving through a valve that reports itself shut, or an electric or gas stage is energized that nothing called for.

In OS#3 and OS#4 the cooling coil is running at the same time, so every kilowatt the leaking coil adds is a kilowatt the chiller is paid to remove — the AHU-0016 failure arriving through a different door. In OS#2 the leak spends the economizer’s savings by warming the outdoor air that was supposed to do the cooling, often pushing the unit into mechanical cooling it did not need. This rule and AHU-0014 are AHU-0016’s silent siblings: that rule reads the two valve commands and needs both past 5% open, so a valve reporting 0% and flowing anyway is invisible to it.

Detection Logic

rise   = sat − mat
yFault = rise > coil_rise_threshold,
         sustained continuously for alarm_delay

Block graph (rule.cxf.jsonld):

AHU-0015 block graph

G36 writes the test as HCLT_AVG − HCET_AVG ≥ sqrt(eHCET² + eHCLT²) + ΔTSF*, footnoting the fan-heat factor as included or not depending on where the coil sensors sit. This library binds HCET := mat and HCLT := sat — the instrumentation most air handlers actually have — which brings the proxied epsilons (3 °C mixed-air, 1 °C supply-air, root-sum-square 3.1623 °C) and puts the supply fan inside the measurement, so the ΔTSF term applies: 3.1623 + 1 = 4.1623 °C.

Here the term is doing physical work rather than bookkeeping. A healthy draw-through unit with both coils shut already reads sat − mat = +1.0 °C. Drop the term and the test charges that degree to the coil: the threshold falls to 3.1623 °C, a true coil rise of 2.17 °C reports as a leak, and on a unit whose fan adds more than 3.16 °C the alarm never clears. With the term the shipped test fires precisely when the coil’s own contribution exceeds the 3.1623 °C sensor floor.

The comparison is strict, so a rise sitting exactly on 4.1623 °C reads healthy where G36 would report the fault. persist requires 30 continuous minutes and any interruption restarts the timer, which separates a leaking valve from a coil giving up the hot water still standing in it after a state change.

Possible Diagnoses

Transcribed from G36 §5.16.14 FC#15:

  1. HCET sensor error
  2. HCLT sensor error
  3. Heating coil valve stuck open or leaking

The single-zone version of the same row (§5.18.14, the SZVAV table in Addendum u) adds “gas or electric heat stuck on”, which applies to any unit with a non-hydronic heat source even though the VAV row omits it.

Under this library’s binding, diagnoses 1 and 2 read as MAT and SAT sensor error, and they are the cheap ones to eliminate first. Diagnosis 3 dominates in the field and is why the card carries the stuck-actuator playbook: a two-way valve whose seat has eroded, or an actuator that has lost its close position, passes water at a command of 0% and no command-based rule will ever see it.

Energy Impact

CRITICAL_WASTE, HIGH confidence, DIRECT_MEASUREMENT, savings 0.5–5% of site energy mapped to PNNL EEM-03 (leaking coil valves) — the §5.8.1 index row, the only energy statement the reference makes here. DIRECT_MEASUREMENT is honest in a way it is not for the abbreviated comparison rules: the two temperatures the rule already reads are the measurement.

waste_kw = supply_airflow_m3s × 1.2 × 1.005 × ((sat − mat) − dTSF)

Subtracting the fan rise is not a rounding detail — at the threshold it is 1.0 of a 4.16 °C measured rise, so crediting it to the coil would overstate the waste by about a third, and more on any smaller leak. Design airflow is the one substitution. HIGH confidence because a sustained rise across a coil commanded shut has no benign explanation other than a sensor, and the sensor case shows up as a rise that does not move with load. Cooling-dominant, following the operating states: all three are cooling-side, and in OS#3–#4 the leak is paid for twice, once at the boiler and once at the chiller.

Emissions Impact

PROXY_EMISSIONS, scope 1+2, both library-assigned since the §5.8.1 index publishes no emissions column. The leaked heat is Scope 1 or Scope 2 depending on the plant (gas boiler versus electric resistance or a heat pump), and the cooling that removes it again in OS#3–#4 is purchased electricity, Scope 2. On an all-electric site the exchange collapses to Scope 2, and when the cause is a sensor there is nothing to attribute. Avoided-emissions basis: marginal operating emissions rate (MOER) for the electric half, static combustion factor for the fuel half.

Deviations

  • The reference card is abbreviated; G36 is the normative text. The HVAC FDD Reference carries AHU-0015 only as a §5.8.1 index row — no equation, internal variables, vectors, severity, diagnoses, or preconditions. Detection logic and the diagnosis list are transcribed from ASHRAE Guideline 36 §5.16.14 FC#15 as it appears in Addendum u to Guideline 36-2018 (First Public Review, 2021); the fourth diagnosis comes from the addendum’s single-zone table (§5.18.14) and is marked as such.
  • HCET and HCLT are bound to MAT and SAT. G36 leaves the instrumentation open (§5.16.14.5) and Table 5.16.14.5 supplies the proxied epsilons. The consequence is that the rule sees the whole air path from the mixing box to the supply sensor: the fan is inside the measurement (handled by dTSF) and so is any duct heat gain between coil and sensor (not handled — on this fault it biases the rise upward and makes the rule slightly louder, the opposite of its effect on AHU-0014). A site with dedicated coil sensors rebinds the two boundary inputs at deployment and retunes coil_rise_threshold with its own sensor errors; because the fan is then outside the pair, that retune also drops the dTSF term.
  • The fan-heat term is included, and here it is what keeps healthy units quiet. Fan heat and coil heat both raise SAT, so the measured rise is the true coil rise plus one dTSF and the threshold must discount the fan’s own contribution before charging anything to the coil. Unlike its cooling-side twin this rule is not additionally desensitized by the binding, so there is no sharper retune of the same kind to offer; sites with a measured fan rise other than 1 °C substitute it in the sum directly.
  • G36’s becomes a strict >. CDL Reals offers only strict comparisons, so a rise of exactly 4.1623 °C reads healthy where G36 reports the fault. Measure zero on a real temperature signal, and it errs toward silence. A host binding coarsely quantized temperatures should retune the threshold down rather than rely on the signal overshooting.
  • The threshold is a rounded constant, not a root-sum-square computed in the graph. sqrt(3² + 1²) + 1 = 4.16227766…, shipped as 4.1623 — high by 2.2 × 10⁻⁵ °C, four orders of magnitude below the resolution of the sensors feeding it, and one number to retune instead of three.
  • Instantaneous samples instead of 5-minute rolling averages. G36 computes every §5.16.14 signal as a 5-minute rolling average with 1-minute sampling; this library consumes instantaneous points and lets the 30-minute AlarmDelay stand in. Not equivalent — persistence resets on every compliant tick, so an oscillating rise (a short-cycling electric stage) can hide indefinitely, while the steady leak of a failed valve seat reads the same either way. (Honesty note from AHU-0002.)
  • Operating states, ModeDelay, and the not-operating suspension are host-side preconditions. G36 scopes FC#15 to OS#2–#4 and suspends evaluation after a mode change in a served zone group and whenever the AHU is off; none of it is in the graph, per the library’s stance. Freeze protection deserves its own mention there: a preheat coil driven open to protect itself produces this exact signature while doing its job. G36 attaches no “omit if no MAT sensor” qualifier to FC#15, but this library’s binding needs MAT, so the qualifier applies to the shipped rule and lives in preconditions.
  • Severity 2 is the library’s. The §5.8.1 index carries no severity column. Severity 2 puts this fault with AHU-0016 and AHU-0020 rather than the 001-range comparison rules at 3, which is where CRITICAL_WASTE and HIGH confidence point. G36’s Level 3 alarm grading is a priority scheme, not this library’s 1–4 scale.
  • The energy profile is the index row’s; the runtime formula, climate sensitivity, and emissions block are the library’s, reasoned from the operating states the fault is evaluated in.
  • persist.delayOnInit = true (Modelica/CDL default is false), the library’s standing choice: a violation already present at load waits out the full 30 minutes instead of alarming on the first tick after a controller restart.

Notes

This rule and AHU-0012 test the same sign, on the same two sensors, in overlapping states, and both exist on purpose. AHU-0012 thresholds at eSAT + eMAT + ΔTSF = 5.0 °C — bands added linearly, worst case — while this one adds them in quadrature to 4.1623 °C, the sharper composition when the errors are independent, so this rule alarms first by 0.84 °C of rise. FC#12’s diagnosis list is broad and includes cooling-side capacity failures; FC#15 names the heating coil and its sensors and nothing else, so a host that wants one alarm should keep this one. Start at the sensors, since a MAT reading low or a SAT reading high produces this trace with nothing wrong in the mechanical room; an active AHU-0028 should already be suppressing the rule. Then isolate the coil and watch the rise collapse to fan heat — or, on electric or gas heat, check the stage’s contactor or safety interlock.

Test Vectors

8 scenarios, clock step 300 s over 5400 s.

ScenarioDescription
healthy_unit_shows_fan_heat_onlyThe pin this rule exists to keep clear: a draw-through unit with the heating coil shut still warms its air by dTSF = 1 °C crossing the supply fan, so sat − mat = +1.0 °C in every healthy OS#2-#4 hour. The 4.1623 °C threshold carries the fan-heat term precisely so this reads healthy forever; without it every healthy AHU in the fleet would alarm
rise_exactly_at_thresholdThreshold edge: sat − mat = 4.1623 °C exactly, the composed sqrt(eHCET² + eHCLT²) + dTSF allowance. riseBig is a strict >, so a rise sitting precisely on the allowance reads healthy — G36’s would report it (see Deviations)
rise_just_over_thresholdThreshold edge, other side: sat − mat = 4.2623 °C clears the strict comparison on the first tick and the alarm asserts one alarm_delay (1800 s) later
heating_valve_leaking_during_free_coolingOS#2 on a mild morning: the economizer mixes to 16 °C and the air reaches the supply sensor at 23 °C with the heating valve commanded 0%. Take the fan’s 1 °C back out and the coil is adding about 6 °C to air the economizer was brought in to cool — hot water flowing through a valve that reports itself shut, which is exactly the leak AHU-0016 is structurally blind to
mat_sensor_reading_lowSame 6 °C signature in a season when the hot-water plant is locked out and the valve has been verified shut: no coil can be adding heat, and the mixed-air sensor is what is wrong. The rule reports the contradiction; G36 lists both sensor errors ahead of the valve in its diagnosis order for this reason
transient_clears_before_alarm_delayTransient: a 7 °C rise persists for 1200 s while hot water standing in a just-closed coil gives up its residual heat, then SAT settles back to mat + fan heat. Anything shorter than alarm_delay never reaches the operator
fault_clears_on_recoveryA sustained 7 °C rise alarms at 1800 s; the leaking valve is isolated at t = 3000 and SAT falls back to mat + fan heat. TrueDelay has no off-delay, so the alarm drops on that tick
oscillating_rise_never_alarmsAn electric heating stage short-cycling on a 10-minute period swings the rise between 7 °C and 1 °C. Its mean is well outside the allowance and G36’s 5-minute rolling average would report the fault, but persistence restarts on every compliant tick, so this rule stays silent — the limit of substituting TrueDelay for averaging (see Deviations)
vectors.json
{
  "schema": "cxf-library/vectors/v1",
  "clock": {
    "step_s": 300,
    "horizon_s": 5400
  },
  "scenarios": [
    {
      "name": "healthy_unit_shows_fan_heat_only",
      "description": "The pin this rule exists to keep clear: a draw-through unit with the heating coil shut still warms its air by dTSF = 1 \u00b0C crossing the supply fan, so sat \u2212 mat = +1.0 \u00b0C in every healthy OS#2-#4 hour. The 4.1623 \u00b0C threshold carries the fan-heat term precisely so this reads healthy forever; without it every healthy AHU in the fleet would alarm",
      "inputs": {
        "mat": 18.0,
        "sat": 19.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        }
      ]
    },
    {
      "name": "rise_exactly_at_threshold",
      "description": "Threshold edge: sat \u2212 mat = 4.1623 \u00b0C exactly, the composed sqrt(eHCET\u00b2 + eHCLT\u00b2) + dTSF allowance. `riseBig` is a strict `>`, so a rise sitting precisely on the allowance reads healthy \u2014 G36's `\u2265` would report it (see Deviations)",
      "inputs": {
        "mat": 15.8377,
        "sat": 20.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        }
      ]
    },
    {
      "name": "rise_just_over_threshold",
      "description": "Threshold edge, other side: sat \u2212 mat = 4.2623 \u00b0C clears the strict comparison on the first tick and the alarm asserts one alarm_delay (1800 s) later",
      "inputs": {
        "mat": 15.7377,
        "sat": 20.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1500,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2100,
          "to_s": 5400,
          "equals": true
        }
      ]
    },
    {
      "name": "heating_valve_leaking_during_free_cooling",
      "description": "OS#2 on a mild morning: the economizer mixes to 16 \u00b0C and the air reaches the supply sensor at 23 \u00b0C with the heating valve commanded 0%. Take the fan's 1 \u00b0C back out and the coil is adding about 6 \u00b0C to air the economizer was brought in to cool \u2014 hot water flowing through a valve that reports itself shut, which is exactly the leak AHU-0016 is structurally blind to",
      "inputs": {
        "mat": 16.0,
        "sat": 23.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1500,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2100,
          "to_s": 5400,
          "equals": true
        }
      ]
    },
    {
      "name": "mat_sensor_reading_low",
      "description": "Same 6 \u00b0C signature in a season when the hot-water plant is locked out and the valve has been verified shut: no coil can be adding heat, and the mixed-air sensor is what is wrong. The rule reports the contradiction; G36 lists both sensor errors ahead of the valve in its diagnosis order for this reason",
      "inputs": {
        "mat": 14.0,
        "sat": 20.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1500,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2100,
          "to_s": 5400,
          "equals": true
        }
      ]
    },
    {
      "name": "transient_clears_before_alarm_delay",
      "description": "Transient: a 7 \u00b0C rise persists for 1200 s while hot water standing in a just-closed coil gives up its residual heat, then SAT settles back to mat + fan heat. Anything shorter than alarm_delay never reaches the operator",
      "inputs": {
        "mat": 16.0,
        "sat": [
          {
            "t": 0,
            "value": 23.0
          },
          {
            "t": 1200,
            "value": 17.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        }
      ]
    },
    {
      "name": "fault_clears_on_recovery",
      "description": "A sustained 7 \u00b0C rise alarms at 1800 s; the leaking valve is isolated at t = 3000 and SAT falls back to mat + fan heat. `TrueDelay` has no off-delay, so the alarm drops on that tick",
      "inputs": {
        "mat": 16.0,
        "sat": [
          {
            "t": 0,
            "value": 23.0
          },
          {
            "t": 3000,
            "value": 17.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1500,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2100,
          "to_s": 2700,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 3300,
          "to_s": 5400,
          "equals": false
        }
      ]
    },
    {
      "name": "oscillating_rise_never_alarms",
      "description": "An electric heating stage short-cycling on a 10-minute period swings the rise between 7 \u00b0C and 1 \u00b0C. Its mean is well outside the allowance and G36's 5-minute rolling average would report the fault, but persistence restarts on every compliant tick, so this rule stays silent \u2014 the limit of substituting TrueDelay for averaging (see Deviations)",
      "inputs": {
        "mat": 16.0,
        "sat": [
          {
            "t": 0,
            "value": 23.0
          },
          {
            "t": 600,
            "value": 17.0
          },
          {
            "t": 1200,
            "value": 23.0
          },
          {
            "t": 1800,
            "value": 17.0
          },
          {
            "t": 2400,
            "value": 23.0
          },
          {
            "t": 3000,
            "value": 17.0
          },
          {
            "t": 3600,
            "value": 23.0
          },
          {
            "t": 4200,
            "value": 17.0
          },
          {
            "t": 4800,
            "value": 23.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        }
      ]
    }
  ]
}

AHU-0016 — Simultaneous heating and cooling

Statusverified — engine e2ff2f8, cxf:fnv1a128:31aa24f4af35117444e0227b5a021b99, 2026-08-17
Severity2
Methodrule
Phase1
CategoryCRITICAL_WASTE
ConfidenceHIGH
EstimationDIRECT_MEASUREMENT
G36
ClustersCLU-01
Suppresses
Suppressed by
RelatedAHU-0004, AHU-0020, AHU-0025
Playbookssimultaneous-hc
SourceHVAC FDD Reference v1.0 §9, AHU-0016; PNNL-27338 retuning measures (EEM-38)
Operating statesall

Preconditions (host-enforced): Equipment is in occupied mode or supply fan is running. Host gates evaluation; when unmet, the verdict is NO_EVAL, not healthy.

Points: htg_vlv_cmd, clg_vlv_cmd

Outputs:

  • yFault — True while both valves have been open beyond their thresholds for at least alarm_delay

Parameters:

NameDefaultUnitCXF pathDescription
htg_vlv_threshold5.0%htgThr.tMinimum heating valve position considered open
clg_vlv_threshold5.0%clgThr.tMinimum cooling valve position considered open
alarm_delay900.0spersist.delayTimeContinuous fault persistence required before the alarm asserts

Description

The heating coil valve and cooling coil valve are both commanded open beyond their respective thresholds at the same time. The air stream is being heated and then cooled (or vice versa) — energy is simultaneously added and removed with no occupant benefit, so the entire overlap is waste. One of the most common and highest-impact faults in commercial buildings; the trigger rule for cluster CLU-01.

Detection Logic

yFault = (htg_vlv_cmd > htg_vlv_threshold)
     AND (clg_vlv_cmd > clg_vlv_threshold)
     sustained continuously for alarm_delay

Block graph (rule.cxf.jsonld):

AHU-0016 block graph

TrueDelay implements the alarm persistence: yFault asserts only after the combined condition has held continuously for alarm_delay seconds, and any interruption restarts the timer. The comparison is strict (>), so a valve sitting exactly at its threshold does not count as open. persist sets delayOnInit = true so a condition already present at engine start still waits out the full delay (engine-verified: the Modelica default of false passes an initially-true input through immediately, which would fire the alarm on the first tick after a controller restart).

Possible Diagnoses

  1. Control sequence error — heating and cooling loops fighting (missing interlock or overlapping deadbands)
  2. Stuck heating valve (mechanically open)
  3. Stuck cooling valve (mechanically open)
  4. Incorrect valve wiring (normally-open vs normally-closed)
  5. Poorly tuned PID loops with overlapping deadbands

Energy Impact

CRITICAL_WASTE, HIGH confidence, DIRECT_MEASUREMENT. Waste is directly computable from live points and design capacities: waste_kw = htg_vlv_cmd/100 × ahu_htg_capacity_kw + clg_vlv_cmd/100 × ahu_clg_capacity_kw. Savings range 10–30% of AHU thermal energy (PNNL-27338, EEM-38). Present in the majority of buildings; wastes energy in all seasons.

Emissions Impact

Scope 1 + 2 (gas heating waste + electric cooling waste), DIRECT_EMISSIONS, HIGH confidence. Typical range 2,000–15,000 kg CO₂e/yr. Avoided-emissions basis: marginal operating emissions rate (MOER).

Deviations

  • The reference card’s precondition (“equipment in occupied mode or fan running”) is declared in frontmatter for host enforcement rather than encoded in the block graph. The CXF computes the pure fault condition; gating its evaluation (and the NO_EVAL verdict when data is bad or the unit is off) is the host’s responsibility, matching the open-control engine’s status-blind design.
  • Threshold comparisons use zero hysteresis (h = 0 on both GreaterThreshold blocks), like the reference. If command signals chatter around the threshold in practice, set h per site rather than raising t.
  • persist.delayOnInit = true (Modelica/CDL default is false). The reference is silent on startup behavior; we require the persistence window even when the condition pre-exists at load, to avoid instant alarms after controller restarts.

Notes

Fix the trigger first: resolving this rule typically clears the CLU-01 member faults (mode mismatch, lockout issues) within 24–48 hours. Remote fix succeeds ~70% of the time (interlock/deadband/override corrections) at $0 cost.

Test Vectors

7 scenarios, clock step 60 s over 1800 s.

ScenarioDescription
normal_heatingReference vector: heating only, no fault
normal_coolingReference vector: cooling only, no fault
both_openReference vector: both valves open; fault asserts after alarm_delay (900 s)
both_below_thresholdReference vector: both valves cracked but under threshold
deadbandReference vector: both valves closed
threshold_edgeExactly at threshold is not open (strict > comparison)
transient_overlapOverlap shorter than alarm_delay never alarms; TrueDelay resets on release
vectors.json
{
  "schema": "cxf-library/vectors/v1",
  "clock": {
    "step_s": 60,
    "horizon_s": 1800
  },
  "scenarios": [
    {
      "name": "normal_heating",
      "description": "Reference vector: heating only, no fault",
      "inputs": {
        "htg_vlv_cmd": 45.0,
        "clg_vlv_cmd": 0.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        }
      ]
    },
    {
      "name": "normal_cooling",
      "description": "Reference vector: cooling only, no fault",
      "inputs": {
        "htg_vlv_cmd": 0.0,
        "clg_vlv_cmd": 60.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        }
      ]
    },
    {
      "name": "both_open",
      "description": "Reference vector: both valves open; fault asserts after alarm_delay (900 s)",
      "inputs": {
        "htg_vlv_cmd": 20.0,
        "clg_vlv_cmd": 30.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 840,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 960,
          "to_s": 1800,
          "equals": true
        }
      ]
    },
    {
      "name": "both_below_threshold",
      "description": "Reference vector: both valves cracked but under threshold",
      "inputs": {
        "htg_vlv_cmd": 3.0,
        "clg_vlv_cmd": 4.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        }
      ]
    },
    {
      "name": "deadband",
      "description": "Reference vector: both valves closed",
      "inputs": {
        "htg_vlv_cmd": 0.0,
        "clg_vlv_cmd": 0.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        }
      ]
    },
    {
      "name": "threshold_edge",
      "description": "Exactly at threshold is not open (strict > comparison)",
      "inputs": {
        "htg_vlv_cmd": 5.0,
        "clg_vlv_cmd": 5.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        }
      ]
    },
    {
      "name": "transient_overlap",
      "description": "Overlap shorter than alarm_delay never alarms; TrueDelay resets on release",
      "inputs": {
        "htg_vlv_cmd": 20.0,
        "clg_vlv_cmd": [
          {
            "t": 0,
            "value": 30.0
          },
          {
            "t": 600,
            "value": 0.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        }
      ]
    }
  ]
}

AHU-0017 — Economizer not operational when favorable

Statusverified — engine e2ff2f8, cxf:fnv1a128:6bd93b0a587698d63e6263031a029829, 2026-08-17
Severity3
Methodrule
Phase1
CategoryCRITICAL_WASTE
ConfidenceHIGH
EstimationDIRECT_MEASUREMENT
G36
ClustersCLU-03
Suppresses
Suppressed by
RelatedAHU-0009, AHU-0011, AHU-0034
Playbookseconomizer-failure
SourceHVAC FDD Reference v1.0 §9, AHU-0017; PNNL-27338 §3; PNNL EEM-06; Cowan 2004 (54% of RTUs)
Operating statesOS 4 (mechanical cooling)

Preconditions (host-enforced): Supply fan running. The outdoor/return comparison must be evaluable: |oat - rat| >= TMIN (PNNL-27338 uses 5 °F for its outdoor-air-fraction work), since two sensors reading within their combined error of each other cannot establish which air is cooler. Hosts also gate on OAT sensor quality — a sensor reading high produces this fault’s signature with the economizer control working correctly (diagnosis 4). When either gate is unmet the verdict is NO_EVAL, not healthy.

Points: oat, rat, clg_vlv_cmd, oa_dmpr_cmd

Outputs:

  • yFault — True while outdoor conditions have favored economizing, mechanical cooling has run, and the OA damper has stayed below econ_damper_threshold, all continuously for at least alarm_delay

Parameters:

NameDefaultUnitCXF pathDescription
econ_type_is_ddbTrueboolisDDB.kEconomizer changeover type — true = differential dry-bulb (compare oat to rat), false = fixed high-limit dry-bulb (compare oat to econ_hl_temp)
econ_hl_temp21.0°ChlConst.kFixed high-limit changeover temperature, used only when econ_type_is_ddb is false
temp_deadband1.0°CddbFav.t, hlFav.tMargin the favorable comparison must clear before economizing counts as worthwhile; binds both changeover branches
cooling_enabled_threshold10.0%clgOn.tCooling valve command above which mechanical cooling counts as active
econ_damper_threshold25.0%dmprLow.tOA damper command below which the damper counts as parked at minimum position
alarm_delay1800.0spersist.delayTimeContinuous fault persistence required before the alarm asserts (30 min)

Description

Outdoor air is cool enough to do the cooling for free, but the outdoor-air damper sits at or near its minimum position while the cooling coil runs. Every kilowatt the compressor or chiller spends in that state buys cooling the economizer was standing by to provide at fan power alone. Common and cheap to fix: Cowan’s 2004 field survey found 54% of RTU economizers carrying at least one fault, most often a disconnected damper linkage. Trigger rule for CLU-03 (Economizer Failure) — fix it first, since a damper pinned at minimum also fails the mixed-air and OA-fraction tests in AHU-0009 and AHU-0011.

Detection Logic

econ_favorable = (rat - oat)          > temp_deadband   when econ_type_is_ddb
               = (econ_hl_temp - oat) > temp_deadband   otherwise

yFault = econ_favorable
     AND clg_vlv_cmd > cooling_enabled_threshold
     AND oa_dmpr_cmd < econ_damper_threshold
     sustained continuously for alarm_delay

Block graph (rule.cxf.jsonld):

AHU-0017 block graph

Both changeover branches compute on every tick and favSel (Logical.Switch, y = u2 ? u1 : u3) picks one: isDDB true (the default) selects the differential branch (rat - oat), false the fixed high-limit branch (econ_hl_temp - oat). Thresholding the difference rather than the raw temperatures is what lets one temp_deadband serve both branches. All three comparisons are strict, so a damper parked at exactly 25%, a cooling valve at exactly 10%, or a gap of exactly 1.0 °C does not trip the rule. persist requires 30 minutes of continuous violation — long enough to ride out damper strokes and changeover transitions — and any interruption restarts the timer; delayOnInit = true makes a violation already present at engine start wait out the full window.

Possible Diagnoses

  1. Economizer control sequence disabled or misconfigured in the BAS
  2. OA damper stuck at minimum position (linkage disconnected, blades bound)
  3. Damper actuator failure — no power, no air, or a burnt-out motor
  4. OAT sensor error, reading higher than actual, which locks out changeover
  5. Economizer lockout active when it should not be (seasonal or manual)

Energy Impact

CRITICAL_WASTE, HIGH confidence, DIRECT_MEASUREMENT. The waste is the mechanical cooling that free cooling would have displaced, readable straight off the valve command: waste_kw = clg_vlv_cmd/100 × ahu_clg_capacity_kw. Correcting economizer operation saves 5–20% of cooling energy (PNNL-27338 §3; PNNL EEM-06, OA damper faults and controls). Cooling-dominant, and worth most in mild shoulder-season weather — exactly when the damper should be modulating.

Emissions Impact

Scope 2, DIRECT_EMISSIONS, HIGH confidence; typical 1,500–8,000 kg CO₂e/yr. The displaced energy is electric compressor or chiller work, so the entire impact lands in purchased electricity. Free-cooling hours cluster in mild daytime and overnight weather, when the marginal generator differs sharply from the annual average — use the marginal operating emissions rate (MOER), not an average grid factor.

Deviations

  • The reference’s econ_type enum (DDB | HL_DB, default DDB) is carried here as the boolean econ_type_is_ddb driving a Logical.Switch. Two values do not earn an enum, and a boolean is retunable through set_param on a deployed rule; enthalpy changeover needs its own rule, not a third value.
  • The evaluability gate |oat - rat| >= TMIN and the OS-4 (mechanical cooling) operating-state restriction are declared as preconditions for host enforcement rather than encoded in the block graph — gating and data quality stay out of the rule, which computes the fault condition given valid data.
  • All three comparisons are strict (>, >, <); the reference does not specify boundary behavior, so the library’s strict convention applies.
  • temp_deadband is one card parameter bound to two CXF paths (ddbFav.t, hlFav.t), matching the reference’s single deadband. Hosts must set both paths together, or flipping econ_type_is_ddb silently changes the deadband.
  • persist.delayOnInit = true (Modelica/CDL default is false), the library’s standing choice: a violation already present at load waits out the full 30 minutes instead of alarming on the first tick after a controller restart.

Notes

With default parameters the shipped vectors exercise only the DDB branch — vectors/v1 stages inputs, not parameters, so hlConst, hlGap, and hlFav are structurally verified but never reach yFault through u3. A host that sets econ_type_is_ddb = false should commission that path itself.

econ_hl_temp defaults to 21 °C, near ASHRAE 90.1’s 70 °F high limit for climate zones 4A–5A. Zones 1A–3A allow 75 °F (23.9 °C) and zones 5B–8 use 65 °F (18.3 °C) — retune per the playbook rather than accepting the default in a climate it does not fit.

Test Vectors

9 scenarios, clock step 60 s over 3600 s.

ScenarioDescription
favorable_damper_openReference vector: OAT 10 °C against RAT 22 °C with cooling at 40% — economizer is favorable and the damper is at 80%, so free cooling is being used
favorable_damper_at_minReference vector: same favorable conditions and cooling call, but the damper sits at 15% — mechanical cooling with the economizer shut; alarms after alarm_delay (1800 s)
not_favorableReference vector: OAT 30 °C is hotter than the 22 °C return air — economizing would add load, so a closed damper is correct
no_cooling_callReference vector: favorable outdoor air but the cooling valve is closed — no mechanical cooling to displace, so the minimum-position damper is correct
damper_exactly_at_thresholdEdge case: damper parked exactly at econ_damper_threshold (25%) under favorable conditions with cooling — the strict < keeps it out of the alarm
cooling_exactly_at_thresholdEdge case: cooling valve exactly at cooling_enabled_threshold (10%) with the damper at minimum — the strict > means the coil does not count as cooling
deadband_edge_then_favorableEdge case: rat − oat sits exactly on temp_deadband (1.0 °C) — no alarm; at t=1200 the return air rises to 22.5 °C, opening the gap to 1.5 °C, and the alarm follows 1800 s later
damper_opens_before_delayTransient: the economizer is shut against favorable air with cooling running, but the damper drives to 70% at t=1200 — the condition clears before alarm_delay completes, so no alarm
damper_cycle_resets_persistenceTransient: the damper opens at t=900 and falls back to minimum at t=1200 under an unbroken favorable-plus-cooling condition — persistence restarts from the reclose, so the alarm lands at 3000 s, not 1800 s
vectors.json
{
  "schema": "cxf-library/vectors/v1",
  "clock": {
    "step_s": 60,
    "horizon_s": 3600
  },
  "scenarios": [
    {
      "name": "favorable_damper_open",
      "description": "Reference vector: OAT 10 \u00b0C against RAT 22 \u00b0C with cooling at 40% \u2014 economizer is favorable and the damper is at 80%, so free cooling is being used",
      "inputs": {
        "oat": 10.0,
        "rat": 22.0,
        "clg_vlv_cmd": 40.0,
        "oa_dmpr_cmd": 80.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "favorable_damper_at_min",
      "description": "Reference vector: same favorable conditions and cooling call, but the damper sits at 15% \u2014 mechanical cooling with the economizer shut; alarms after alarm_delay (1800 s)",
      "inputs": {
        "oat": 10.0,
        "rat": 22.0,
        "clg_vlv_cmd": 40.0,
        "oa_dmpr_cmd": 15.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1740,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 1860,
          "to_s": 3600,
          "equals": true
        }
      ]
    },
    {
      "name": "not_favorable",
      "description": "Reference vector: OAT 30 \u00b0C is hotter than the 22 \u00b0C return air \u2014 economizing would add load, so a closed damper is correct",
      "inputs": {
        "oat": 30.0,
        "rat": 22.0,
        "clg_vlv_cmd": 40.0,
        "oa_dmpr_cmd": 15.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "no_cooling_call",
      "description": "Reference vector: favorable outdoor air but the cooling valve is closed \u2014 no mechanical cooling to displace, so the minimum-position damper is correct",
      "inputs": {
        "oat": 10.0,
        "rat": 22.0,
        "clg_vlv_cmd": 0.0,
        "oa_dmpr_cmd": 15.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "damper_exactly_at_threshold",
      "description": "Edge case: damper parked exactly at econ_damper_threshold (25%) under favorable conditions with cooling \u2014 the strict `<` keeps it out of the alarm",
      "inputs": {
        "oat": 10.0,
        "rat": 22.0,
        "clg_vlv_cmd": 40.0,
        "oa_dmpr_cmd": 25.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "cooling_exactly_at_threshold",
      "description": "Edge case: cooling valve exactly at cooling_enabled_threshold (10%) with the damper at minimum \u2014 the strict `>` means the coil does not count as cooling",
      "inputs": {
        "oat": 10.0,
        "rat": 22.0,
        "clg_vlv_cmd": 10.0,
        "oa_dmpr_cmd": 15.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "deadband_edge_then_favorable",
      "description": "Edge case: rat \u2212 oat sits exactly on temp_deadband (1.0 \u00b0C) \u2014 no alarm; at t=1200 the return air rises to 22.5 \u00b0C, opening the gap to 1.5 \u00b0C, and the alarm follows 1800 s later",
      "inputs": {
        "oat": 21.0,
        "rat": [
          {
            "t": 0,
            "value": 22.0
          },
          {
            "t": 1200,
            "value": 22.5
          }
        ],
        "clg_vlv_cmd": 40.0,
        "oa_dmpr_cmd": 15.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2940,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 3060,
          "to_s": 3600,
          "equals": true
        }
      ]
    },
    {
      "name": "damper_opens_before_delay",
      "description": "Transient: the economizer is shut against favorable air with cooling running, but the damper drives to 70% at t=1200 \u2014 the condition clears before alarm_delay completes, so no alarm",
      "inputs": {
        "oat": 10.0,
        "rat": 22.0,
        "clg_vlv_cmd": 40.0,
        "oa_dmpr_cmd": [
          {
            "t": 0,
            "value": 15.0
          },
          {
            "t": 1200,
            "value": 70.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "damper_cycle_resets_persistence",
      "description": "Transient: the damper opens at t=900 and falls back to minimum at t=1200 under an unbroken favorable-plus-cooling condition \u2014 persistence restarts from the reclose, so the alarm lands at 3000 s, not 1800 s",
      "inputs": {
        "oat": 10.0,
        "rat": 22.0,
        "clg_vlv_cmd": 40.0,
        "oa_dmpr_cmd": [
          {
            "t": 0,
            "value": 15.0
          },
          {
            "t": 900,
            "value": 70.0
          },
          {
            "t": 1200,
            "value": 15.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2940,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 3060,
          "to_s": 3600,
          "equals": true
        }
      ]
    }
  ]
}

AHU-0018 — Unoccupied override — equipment running during off-hours

Statusverified — engine e2ff2f8, cxf:fnv1a128:964ec962a0c3d75681dcc1c4e0f6c66b, 2026-08-17
Severity3
Methodrule
Phase1
CategoryCRITICAL_WASTE
ConfidenceHIGH
EstimationDIRECT_MEASUREMENT
G36
ClustersCLU-04, CLU-08
Suppresses
Suppressed by
RelatedSYS-0003, SYS-0004, AHU-0026
Playbooksafter-hours-operation
SourceHVAC FDD Reference v1.0 §9, AHU-0018; PNNL-27338 §2; Mazzetto 2025
Operating statesall

Preconditions (host-enforced): Occupancy schedule data available and current; the host evaluates the schedule (time zone, calendar, holidays) into the boolean occ_schedule point. When schedule provenance is unknown or stale, the verdict is NO_EVAL, not healthy.

Points: sf_status, occ_schedule, override_active

Outputs:

  • yFault — True while the supply fan has been running unoccupied, past the grace period, with no active override, for at least alarm_delay

Parameters:

NameDefaultUnitCXF pathDescription
grace_period1800.0sunoccGrace.delayTimeTime after the occupied period ends before unoccupied operation counts against the rule
alarm_delay900.0spersist.delayTimeContinuous fault persistence required before the alarm asserts

Description

The supply fan is operating outside the defined occupancy schedule when no active override or demand condition justifies it. While this fault is active, essentially the entire AHU energy draw — fan power plus any heating or cooling of outdoor air — is waste. Roughly 15% of buildings exhibit it, usually from schedule misconfiguration or a stuck override, and it is the trigger rule for both the After-Hours Operation (CLU-04) and Schedule Dysfunction (CLU-08) clusters. It is also among the fastest faults to pay back: the fix is almost always a $0 schedule or override correction.

Detection Logic

yFault = sf_status
     AND (NOT occ_schedule  sustained for grace_period)
     AND NOT override_active
     sustained continuously for alarm_delay

Block graph (rule.cxf.jsonld):

AHU-0018 block graph

unoccGrace implements the reference’s grace period: the unoccupied state must persist for grace_period before it counts, so normal operation continuing briefly past schedule end never alarms. persist then requires the full justification-free condition (fan on · unoccupied past grace · no override) to hold for alarm_delay. Any re-occupancy, fan stop, or override activation resets the corresponding timer. Worst-case time to alarm after schedule end is grace_period + alarm_delay (default 45 min).

Possible Diagnoses

  1. Schedule misconfiguration or incorrect time zone (DST mismatches are a common culprit)
  2. Stuck override in the BAS (BACnet priority array holding the fan on)
  3. Fan relay or contactor stuck closed
  4. Occupancy sensor triggering unnecessarily
  5. Night setback / morning warmup running too long

Energy Impact

CRITICAL_WASTE, HIGH confidence, DIRECT_MEASUREMENT. While active, the entire AHU draw is waste: waste_kw = ahu_fan_design_kw × (sf_speed/100)³ + active heating/cooling (cubed fan law on speed; thermal penalty typically 1.5–3× the fan energy). Savings range 3–16% of site energy (PNNL-25985 EEM-04 shortened schedules / EEM-16 night setbacks). Climate-neutral: waste scales with operating hours, not weather. Prevalence ~15%.

Emissions Impact

Scope 1 + 2, DIRECT_EMISSIONS, HIGH confidence; typical 3,000–20,000 kg CO₂e/yr (full AHU energy during unoccupied hours). After-hours waste lands in evening/overnight hours when the marginal grid generator is often coal or gas peaking — in solar-heavy regions the nighttime MOER can be 2–3× the midday value, so this fault’s emissions rank can exceed its energy-cost rank. Avoided-emissions basis: MOER.

Deviations

  • The reference’s logic calls in_occupied_schedule(current_time, occ_schedule) — a schedule-evaluation function over a schedule object. Our rule consumes the host-evaluated boolean occ_schedule point instead; schedule interpretation (time zone, calendar, holiday exceptions) is a host concern, consistent with this library’s precondition philosophy and with 223P/Brick having no schedule vocabulary to describe one (see points/ahu.points.json notes).
  • The reference lists grace_period as “minutes after schedule end to allow.” We implement it as a TrueDelay on the unoccupied signal, which grants the same grace after schedule end and equally after any occupied→unoccupied transition — equivalent for schedule-driven hosts, and safer for hosts that drive occ_schedule from occupancy sensing.
  • delayOnInit = true on both timers: a controller restart mid-condition still waits out the full grace + persistence window (same startup-alarm rationale as AHU-0016).

Notes

Remote fix succeeds ~95% of the time at $0 (schedule correction, override release, setback enablement). When this rule fires, check SYS-0003 (lighting) and SYS-0004 (exhaust fans) — they frequently share the same master-schedule root cause, which is why this rule triggers two clusters.

Test Vectors

6 scenarios, clock step 60 s over 3600 s.

ScenarioDescription
normal_occupiedReference vector: fan on during scheduled occupancy
normal_unoccupiedReference vector: fan off while unoccupied
running_after_hoursReference vector: fan on, unoccupied, no override; alarms after grace_period (1800 s) + alarm_delay (900 s)
override_justifies_runReference vector: fan on, unoccupied, but an active override justifies it
fan_stops_within_graceFan shuts down before the grace period elapses; never alarms
reoccupied_resets_persistenceOccupancy resumes after grace elapsed but before alarm_delay completes; timers reset, never alarms
vectors.json
{
  "schema": "cxf-library/vectors/v1",
  "clock": {
    "step_s": 60,
    "horizon_s": 3600
  },
  "scenarios": [
    {
      "name": "normal_occupied",
      "description": "Reference vector: fan on during scheduled occupancy",
      "inputs": {
        "sf_status": true,
        "occ_schedule": true,
        "override_active": false
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "normal_unoccupied",
      "description": "Reference vector: fan off while unoccupied",
      "inputs": {
        "sf_status": false,
        "occ_schedule": false,
        "override_active": false
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "running_after_hours",
      "description": "Reference vector: fan on, unoccupied, no override; alarms after grace_period (1800 s) + alarm_delay (900 s)",
      "inputs": {
        "sf_status": true,
        "occ_schedule": false,
        "override_active": false
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2640,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2760,
          "to_s": 3600,
          "equals": true
        }
      ]
    },
    {
      "name": "override_justifies_run",
      "description": "Reference vector: fan on, unoccupied, but an active override justifies it",
      "inputs": {
        "sf_status": true,
        "occ_schedule": false,
        "override_active": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "fan_stops_within_grace",
      "description": "Fan shuts down before the grace period elapses; never alarms",
      "inputs": {
        "sf_status": [
          {
            "t": 0,
            "value": true
          },
          {
            "t": 1500,
            "value": false
          }
        ],
        "occ_schedule": false,
        "override_active": false
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "reoccupied_resets_persistence",
      "description": "Occupancy resumes after grace elapsed but before alarm_delay completes; timers reset, never alarms",
      "inputs": {
        "sf_status": true,
        "occ_schedule": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 2000,
            "value": true
          }
        ],
        "override_active": false
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    }
  ]
}

AHU-0019 — Supply air temperature setpoint too low (over-cooling)

Statusverified — engine e2ff2f8, cxf:fnv1a128:07fe9d3b43f17351d191782e29b6577c, 2026-08-17
Severity3
Methodrule
Phase1
CategoryEXCESS_CONSUMPTION
ConfidenceHIGH
EstimationPROXY_ESTIMATION
G36
ClustersCLU-02
Suppresses
Suppressed by
RelatedVAV-0001, AHU-0022, AHU-0023
Playbooksmissing-reset
SourceHVAC FDD Reference v1.0 §9, AHU-0019; PNNL-27338; PNNL-25985 EEM-05/EEM-15
Operating statesOccupied (OS 2, 3, 4)

Preconditions (host-enforced): AHU in occupied mode and serving multiple zones; zone reheat data available and aggregated by the host into zone_reheat_fraction. When the zone data is missing, stale, or covers too few zones for the fraction to mean anything, the verdict is NO_EVAL, not healthy.

Points: sat_sp, zone_reheat_fraction

Outputs:

  • yFault — True while the SAT setpoint has stayed below sat_sp_low_limit with more than reheat_fraction_threshold of zones reheating, for at least alarm_delay

Parameters:

NameDefaultUnitCXF pathDescription
sat_sp_low_limit12.0°CspLow.tMinimum recommended SAT setpoint; below this the air is colder than any zone needs
reheat_fraction_threshold0.51rhtHigh.tFraction of served zones reheating (0-1) above which the cold air is demonstrably being reheated
alarm_delay3600.0spersist.delayTimeContinuous fault persistence required before the alarm asserts (60 min)

Description

The supply air temperature setpoint sits below the minimum recommended value while a large share of the zones served are running reheat. Air colder than any zone asked for costs chiller energy to make and then boiler or electric reheat energy to undo, so every degree of over-cooling is paid for twice — and the zones stay comfortable throughout, which is why a setpoint parked at 10 °C survives for years. SAT reset is absent in 74% of buildings (PNNL 151-building study); this is the CLU-02 member fault that shows the missing reset actually costing money.

Detection Logic

yFault = sat_sp < sat_sp_low_limit
     AND zone_reheat_fraction > reheat_fraction_threshold
     sustained continuously for alarm_delay

Block graph (rule.cxf.jsonld):

AHU-0019 block graph

Two threshold tests feed one conjunction and one timer. spLow watches the setpoint, not the measured SAT: this rule is about what the sequence asked for, while a unit that cannot hold its setpoint (AHU-0007, AHU-0013) or hunts around it (AHU-0022) is a separate finding. rhtHigh supplies the corroboration that turns “cold setpoint” into “waste” — without a reheat majority, a 10 °C setpoint may simply be serving a high-load hour. Both comparisons are strict, so a setpoint parked exactly on the 12 °C limit, or exactly half the zones reheating, does not trip the rule. persist requires 60 minutes of continuous violation — enough to ride out morning cool-down and the reheat spike after an occupied-mode transition — and delayOnInit = true holds that window across a controller restart.

Possible Diagnoses

  1. SAT setpoint too aggressive — set low at commissioning and never reset since
  2. SAT reset logic disabled or misconfigured (the CLU-02 root cause; confirm with AHU-0023)
  3. A single rogue zone dragging the AHU setpoint down through the trim-and-respond request path — one starved or mis-sensored box can hold the whole system at its minimum setpoint

Energy Impact

EXCESS_CONSUMPTION, HIGH confidence, PROXY_ESTIMATION. The waste is a reheat integral: excess_reheat_kw = Σ over reheating zones of (rht_vlv_cmd_i/100 × vav_rht_capacity_kw_i), plus the chiller energy spent making air nobody wanted. Raising the setpoint into the reset band saves 5–15% of AHU cooling plus reheat energy (PNNL-25985 EEM-05 SAT reset and EEM-15 VAV minimum flow reduction, combined — the two interact, since a high minimum flow forces reheat no setpoint change can eliminate). Heating-dominant: the reheat half of the bill grows with hours spent below balance point.

Emissions Impact

Scope 2, PROXY_EMISSIONS, HIGH confidence; typical 800–5,000 kg CO₂e/yr (excess cooling plus reheat). Sites with gas or steam reheat move that half of the inventory into scope 1; the card reports scope 2 because electric reheat and electric chilling are the common case. Avoided-emissions basis: marginal operating emissions rate (MOER).

Deviations

  • The reference’s single 50% zone-count fraction diverges from its own PNNL source: PNNL-27338 §2.2.2–2.2.3 tests two quantities — zones with reheat valves open (>10%) exceeding 25%, AND fleet-average reheat command above 50%. This card follows the reference. Sites wanting the PNNL-literal form retune reheat_fraction_threshold to 0.25; the magnitude conjunct is not expressible without a host-derived mean reheat command, which the point dictionary does not yet carry.
  • The reference counts reheating zones across a per-zone valve command array. Library v1 avoids array boundary points, so the host does the counting and feeds the scalar zone_reheat_fraction (flagged derived in the dictionary, same pattern as zone_dmpr_pos_max in AHU-0024). The reheat-active counting threshold is host configuration, not a rule parameter.
  • reheat_fraction_threshold is a fraction 0–1, not a percent: the reference states 50%, but the point compared against carries unit 1, so the parameter is 0.5. Hosts feeding a 0–100 percentage fire this rule on nearly every tick.
  • Both comparisons are strict (<, >); the reference does not specify boundary behavior, so the library’s strict convention applies.
  • The reference tags this fault for both AHU and RTU. This card is the AHU-family instance; an RTU-0004 would restate it against the RTU’s discharge setpoint and its zone group.
  • Operating-state gating (OS 2, 3, 4 — occupied) and the multi-zone precondition are declared in frontmatter for host enforcement rather than encoded in the block graph, per the library’s design stance.
  • persist.delayOnInit = true (Modelica/CDL default is false), the library’s standing choice: a violation already present at load waits out the full 60 minutes instead of alarming on the first tick after a controller restart.

Notes

AHU-0023 and this rule are the two halves of the same CLU-02 story. FC-057 is the statistical trigger — it proves the setpoint never moves. This rule is the harm case: the setpoint is parked low and the zones downstream are burning fuel to undo it. Fix order is FC-057’s fix — program the reset per G36 §5.16.2 (playbook missing-reset, step 2.2) — after which this rule should clear within one occupied day. Before raising the setpoint, check the zone minimum flows: a box with a 40% minimum will reheat at any SAT and will keep this rule firing after the reset is programmed.

Test Vectors

8 scenarios, clock step 60 s over 7200 s.

ScenarioDescription
setpoint_above_limit_light_reheatReference vector: SAT setpoint 14 °C with 20% of zones reheating — setpoint above the low limit, nothing to report
setpoint_low_but_reheat_lightReference vector: SAT setpoint 10 °C with only 30% of zones reheating — a cold setpoint the zones are not fighting, so no fault
setpoint_low_with_widespread_reheatReference vector: SAT setpoint 10 °C with 65% of zones reheating — over-cooling paid for by reheat; alarms after alarm_delay (3600 s)
setpoint_exactly_at_low_limitEdge case: SAT setpoint sits exactly on sat_sp_low_limit (12 °C) with 65% of zones reheating — the strict < keeps it out of the alarm
reheat_exactly_at_thresholdEdge case: exactly half the zones reheating (0.5) against a 10 °C setpoint — the strict > means the fraction must exceed the threshold
reheat_burst_clears_before_delayTransient: a morning reheat burst at 0.65 falls back to 0.30 at t=1800 — the condition clears inside alarm_delay, so no alarm
reset_raises_setpoint_before_delayTransient: reheat stays at 0.65 but a working SAT reset walks the setpoint from 10 °C to 13.5 °C at t=1800 — the fault condition ends before it can alarm
reheat_dip_restarts_persistenceTransient: reheat dips to 0.40 at t=900 and returns to 0.65 at t=1200 against a 10 °C setpoint — persistence restarts from the return, so the alarm lands at 4800 s, not 3600 s
vectors.json
{
  "schema": "cxf-library/vectors/v1",
  "clock": {
    "step_s": 60,
    "horizon_s": 7200
  },
  "scenarios": [
    {
      "name": "setpoint_above_limit_light_reheat",
      "description": "Reference vector: SAT setpoint 14 \u00b0C with 20% of zones reheating \u2014 setpoint above the low limit, nothing to report",
      "inputs": {
        "sat_sp": 14.0,
        "zone_reheat_fraction": 0.2
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        }
      ]
    },
    {
      "name": "setpoint_low_but_reheat_light",
      "description": "Reference vector: SAT setpoint 10 \u00b0C with only 30% of zones reheating \u2014 a cold setpoint the zones are not fighting, so no fault",
      "inputs": {
        "sat_sp": 10.0,
        "zone_reheat_fraction": 0.3
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        }
      ]
    },
    {
      "name": "setpoint_low_with_widespread_reheat",
      "description": "Reference vector: SAT setpoint 10 \u00b0C with 65% of zones reheating \u2014 over-cooling paid for by reheat; alarms after alarm_delay (3600 s)",
      "inputs": {
        "sat_sp": 10.0,
        "zone_reheat_fraction": 0.65
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3540,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 3660,
          "to_s": 7200,
          "equals": true
        }
      ]
    },
    {
      "name": "setpoint_exactly_at_low_limit",
      "description": "Edge case: SAT setpoint sits exactly on sat_sp_low_limit (12 \u00b0C) with 65% of zones reheating \u2014 the strict `<` keeps it out of the alarm",
      "inputs": {
        "sat_sp": 12.0,
        "zone_reheat_fraction": 0.65
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        }
      ]
    },
    {
      "name": "reheat_exactly_at_threshold",
      "description": "Edge case: exactly half the zones reheating (0.5) against a 10 \u00b0C setpoint \u2014 the strict `>` means the fraction must exceed the threshold",
      "inputs": {
        "sat_sp": 10.0,
        "zone_reheat_fraction": 0.5
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        }
      ]
    },
    {
      "name": "reheat_burst_clears_before_delay",
      "description": "Transient: a morning reheat burst at 0.65 falls back to 0.30 at t=1800 \u2014 the condition clears inside alarm_delay, so no alarm",
      "inputs": {
        "sat_sp": 10.0,
        "zone_reheat_fraction": [
          {
            "t": 0,
            "value": 0.65
          },
          {
            "t": 1800,
            "value": 0.3
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        }
      ]
    },
    {
      "name": "reset_raises_setpoint_before_delay",
      "description": "Transient: reheat stays at 0.65 but a working SAT reset walks the setpoint from 10 \u00b0C to 13.5 \u00b0C at t=1800 \u2014 the fault condition ends before it can alarm",
      "inputs": {
        "sat_sp": [
          {
            "t": 0,
            "value": 10.0
          },
          {
            "t": 1800,
            "value": 13.5
          }
        ],
        "zone_reheat_fraction": 0.65
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        }
      ]
    },
    {
      "name": "reheat_dip_restarts_persistence",
      "description": "Transient: reheat dips to 0.40 at t=900 and returns to 0.65 at t=1200 against a 10 \u00b0C setpoint \u2014 persistence restarts from the return, so the alarm lands at 4800 s, not 3600 s",
      "inputs": {
        "sat_sp": 10.0,
        "zone_reheat_fraction": [
          {
            "t": 0,
            "value": 0.65
          },
          {
            "t": 900,
            "value": 0.4
          },
          {
            "t": 1200,
            "value": 0.65
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 4740,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 4860,
          "to_s": 7200,
          "equals": true
        }
      ]
    }
  ]
}

AHU-0020 — Stuck or failed actuator

Statusverified — engine e2ff2f8, cxf:fnv1a128:42b5629649240985c2d9776995d8b4de, 2026-08-17
Severity2
Methodrule
Phase1
CategoryCRITICAL_WASTE
ConfidenceMEDIUM
EstimationPROXY_ESTIMATION
G36
Clusters
Suppresses
Suppressed by
RelatedAHU-0014, AHU-0015
Playbooksstuck-actuator
SourceHVAC FDD Reference v1.0 §9, AHU-0020; PNNL retuning; Bie et al. 2025
Operating statesall

Preconditions (host-enforced): Both the command and the position feedback must be available and bound to the same physical device — a feedback point wired to a different actuator than the command produces a permanent false alarm. When the feedback point is absent, stale, or its device pairing is unverified, the verdict is NO_EVAL, not healthy.

Points: actuator_cmd, actuator_pos

Outputs:

  • yFault — True while command and position feedback have differed by more than position_error_threshold continuously for stuck_duration plus alarm_delay

Parameters:

NameDefaultUnitCXF pathDescription
position_error_threshold10.0%errBig.tCommand-vs-position delta above which the actuator counts as not tracking
stuck_duration1800.0sstuck.delayTimeHow long the delta must persist before the actuator is judged stuck (30 min)
alarm_delay300.0spersist.delayTimeAdditional debounce held after stuck_duration before the alarm asserts (5 min)

Description

An actuator’s command and its measured position disagree by more than the tracking allowance and stay disagreed for half an hour. Whatever the sequence told the device to do, it is not doing: the linkage has come off, the motor has failed, the stem is seized in scale or debris, or the control signal never reaches the actuator at all. This is the chapter’s one template rule — actuator_cmd and actuator_pos are a command/feedback pair the host binds per actuated device, so a typical AHU carries three instances (outdoor air damper, heating valve, cooling valve). Severity 2 because a stuck actuator defeats whatever sequence commands it: when this fires alongside AHU-0016, AHU-0017, or AHU-0025 on the same subsystem, those rules are reporting the symptom and this one is naming the cause.

Detection Logic

yFault = |actuator_cmd − actuator_pos| > position_error_threshold
         sustained continuously for stuck_duration,
         then held for a further alarm_delay

Block graph (rule.cxf.jsonld):

AHU-0020 block graph

err takes the signed difference and absErr strips the sign, so the test is direction-blind: an actuator that will not open, one that will not close, and a reverse-wired feedback reporting 80% against a 20% command all trip the same threshold. errBig compares strictly, so a delta sitting exactly on 10% reads healthy — the threshold is a tracking allowance, and an actuator at the edge of it is within spec. The two timers chain rather than merge: stuck requires 30 minutes of continuous mistracking, longer than any real stroke (full 0–100% damper travel takes 90–150 s), and persist adds the reference’s 5-minute debounce on top, putting worst-case time to alarm at 2100 s. Either timer resets the moment feedback comes back inside the allowance, so recovery is immediate. delayOnInit = true holds the full window across a controller restart.

Possible Diagnoses

  1. Actuator mechanical failure — motor, gear train, or spring return
  2. Actuator linkage disconnected (the most common finding, and the cheapest fix)
  3. Incorrect wiring — command and feedback bound to different devices
  4. Valve or damper seized by corrosion or debris
  5. Control signal not reaching the actuator (broken wire, blown fuse, failed pneumatic transducer)

Energy Impact

CRITICAL_WASTE, MEDIUM confidence, PROXY_ESTIMATION. The waste depends on which actuator is stuck and where it stopped: a heating valve stuck at 40% burns fuel year-round; stuck closed it costs nothing in energy and shows up as a comfort complaint. Estimating the loss means falling back on the affected subsystem’s formula — AHU-0014/AHU-0015 for a coil valve, AHU-0021/AHU-0026 for an OA damper — with the stuck position read from actuator_pos rather than the command, which is why this card is PROXY_ESTIMATION. Repairing a stuck actuator returns 5–20% of the affected subsystem’s energy (PNNL retuning measures EEM-03 for leaking coil valves, EEM-06 for OA damper faults). Climate sensitivity follows the device.

Emissions Impact

PROXY_EMISSIONS, MEDIUM confidence; typical 200–2,000 kg CO₂e/yr, the wide band reflecting the same device dependence as the energy estimate. Scope is recorded as 1|2 because it follows the affected subsystem rather than the fault: a stuck heating valve wastes on-site combustion (scope 1), a stuck cooling valve or a damper feeding the chiller outdoor air wastes purchased electricity (scope 2), and a stuck OA damper in a gas-heated building can do both across a year. Hosts should attribute against the subsystem the bound device serves, not against this rule. Avoided-emissions basis: MOER.

Deviations

  • NO_EVAL is a host precondition, not an output. The reference’s data-absence case (command 50%, feedback missing) is not representable in a status-blind block graph — nothing here distinguishes “no feedback” from “feedback reads 0”, and an unbound point held at 0 against a 50% command alarms in 35 minutes. The host must confirm both points are present, fresh, and bound to the same device before interpreting yFault.
  • actuator_cmd and actuator_pos are the dictionary’s first template entries, bound per device by the host rather than forked into per-device rule variants that would triple the card count. Their entries carry no Brick or 223P class because the class differs per instance (Damper_Position_Command on one, Valve_Position_Command on another); the cost is that the CXF document alone does not say which device it watches — the host binding does.
  • stuck_duration and alarm_delay stay separate timers rather than one 2100 s TrueDelay, because the reference tunes them separately and they answer different questions: when an actuator counts as stuck, versus how much alarm-noise suppression the site wants on top.
  • errBig uses GreaterThreshold (u > t), so a 10.0% delta is healthy and 10.1% is not; the reference writes > threshold.
  • absErr implements the reference’s |actuator_cmd − actuator_pos| literally, so over-travel and reversed feedback wiring alarm on the same schedule as a jam — diagnosis 3 depends on it.
  • The reference tags this fault for AHU, RTU, VAV, and FCU. This is the AHU-family instance; the other families reuse the block graph unchanged, since the template points carry no equipment-specific semantics.
  • delayOnInit = true on both timers (Modelica/CDL default is false), the library’s standing choice: an actuator already mistracking at load waits out the full 35 minutes rather than alarming after a controller restart.

Notes

Deploy the three AHU instances together — the diagnosis often depends on which one fired. Sites without position feedback on an actuator simply do not instantiate the rule there; there is no degraded mode.

Remote fixes are limited to releasing overrides and checking for demand-limiting that clamps the command range (playbook stuck-actuator, step 2). Everything else is on-site: $0–$50 to reconnect a linkage, $200–$800 for an actuator, $500–$2,000 for a seized valve body. After the repair, stroke the device 0 → 100 → 0 and confirm feedback tracks within 5%.

Test Vectors

9 scenarios, clock step 60 s over 3600 s.

ScenarioDescription
tracking_normallyReference vector: command 50%, feedback 48% — a 2% tracking error, inside the allowance
briefly_stuck_then_recoversReference vector: command 80% against feedback 20% for 10 min, then the actuator catches up — the 60% error never reaches stuck_duration
stuck_sustainedReference vector: command 80% against feedback 20% held past 45 min — the alarm lands at stuck_duration + alarm_delay = 2100 s, well before the reference’s 2700 s observation point
error_exactly_at_thresholdEdge case: command 50%, feedback 40% — a delta of exactly position_error_threshold (10%); the strict > keeps it healthy
error_just_over_thresholdEdge case: command 50%, feedback 39.5% — a 10.5% delta clears the threshold and alarms at 2100 s
reverse_error_is_symmetricFeedback above command (20% commanded, 80% reported) — absErr makes the test direction-blind, so an over-travelled or reverse-wired actuator alarms on the same 2100 s schedule
normal_stroke_lagTransient: the command steps 20% → 80% at t=600 and the actuator takes 300 s to arrive — a 60% error for the length of the stroke, far short of stuck_duration
error_clears_during_debounceTransient: the error survives stuck_duration (timer satisfied at 1800 s) but the actuator frees itself at 1920 s, inside the 300 s debounce — no alarm ever asserts
error_returns_restarts_stuck_timerTransient: the actuator tracks briefly at t=600 and jams again at t=900 — the stuck timer restarts from the second jam, pushing the alarm to 900 + 2100 = 3000 s
vectors.json
{
  "schema": "cxf-library/vectors/v1",
  "clock": {
    "step_s": 60,
    "horizon_s": 3600
  },
  "scenarios": [
    {
      "name": "tracking_normally",
      "description": "Reference vector: command 50%, feedback 48% \u2014 a 2% tracking error, inside the allowance",
      "inputs": {
        "actuator_cmd": 50.0,
        "actuator_pos": 48.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "briefly_stuck_then_recovers",
      "description": "Reference vector: command 80% against feedback 20% for 10 min, then the actuator catches up \u2014 the 60% error never reaches stuck_duration",
      "inputs": {
        "actuator_cmd": 80.0,
        "actuator_pos": [
          {
            "t": 0,
            "value": 20.0
          },
          {
            "t": 600,
            "value": 80.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "stuck_sustained",
      "description": "Reference vector: command 80% against feedback 20% held past 45 min \u2014 the alarm lands at stuck_duration + alarm_delay = 2100 s, well before the reference's 2700 s observation point",
      "inputs": {
        "actuator_cmd": 80.0,
        "actuator_pos": 20.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2040,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2160,
          "to_s": 3600,
          "equals": true
        }
      ]
    },
    {
      "name": "error_exactly_at_threshold",
      "description": "Edge case: command 50%, feedback 40% \u2014 a delta of exactly position_error_threshold (10%); the strict `>` keeps it healthy",
      "inputs": {
        "actuator_cmd": 50.0,
        "actuator_pos": 40.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "error_just_over_threshold",
      "description": "Edge case: command 50%, feedback 39.5% \u2014 a 10.5% delta clears the threshold and alarms at 2100 s",
      "inputs": {
        "actuator_cmd": 50.0,
        "actuator_pos": 39.5
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2040,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2160,
          "to_s": 3600,
          "equals": true
        }
      ]
    },
    {
      "name": "reverse_error_is_symmetric",
      "description": "Feedback above command (20% commanded, 80% reported) \u2014 `absErr` makes the test direction-blind, so an over-travelled or reverse-wired actuator alarms on the same 2100 s schedule",
      "inputs": {
        "actuator_cmd": 20.0,
        "actuator_pos": 80.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2040,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2160,
          "to_s": 3600,
          "equals": true
        }
      ]
    },
    {
      "name": "normal_stroke_lag",
      "description": "Transient: the command steps 20% \u2192 80% at t=600 and the actuator takes 300 s to arrive \u2014 a 60% error for the length of the stroke, far short of stuck_duration",
      "inputs": {
        "actuator_cmd": [
          {
            "t": 0,
            "value": 20.0
          },
          {
            "t": 600,
            "value": 80.0
          }
        ],
        "actuator_pos": [
          {
            "t": 0,
            "value": 20.0
          },
          {
            "t": 900,
            "value": 80.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "error_clears_during_debounce",
      "description": "Transient: the error survives stuck_duration (timer satisfied at 1800 s) but the actuator frees itself at 1920 s, inside the 300 s debounce \u2014 no alarm ever asserts",
      "inputs": {
        "actuator_cmd": 80.0,
        "actuator_pos": [
          {
            "t": 0,
            "value": 20.0
          },
          {
            "t": 1920,
            "value": 80.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "error_returns_restarts_stuck_timer",
      "description": "Transient: the actuator tracks briefly at t=600 and jams again at t=900 \u2014 the stuck timer restarts from the second jam, pushing the alarm to 900 + 2100 = 3000 s",
      "inputs": {
        "actuator_cmd": 80.0,
        "actuator_pos": [
          {
            "t": 0,
            "value": 20.0
          },
          {
            "t": 600,
            "value": 80.0
          },
          {
            "t": 900,
            "value": 20.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2940,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 3060,
          "to_s": 3600,
          "equals": true
        }
      ]
    }
  ]
}

AHU-0021 — Excess outdoor air during occupied hours

Statusverified — engine e2ff2f8, cxf:fnv1a128:a9c58cb4c0e46fe131faed48a0c8efc0, 2026-08-17
Severity3
Methodrule
Phase2
CategoryEXCESS_CONSUMPTION
ConfidenceHIGH
EstimationDIRECT_MEASUREMENT
G36
Clusters
Suppresses
Suppressed byAHU-0028
RelatedAHU-0006, AHU-0017, AHU-0030
Playbookseconomizer-failure
SourceHVAC FDD Reference v1.0 §9, AHU-0021; PNNL-27338 §3; PNNL EEM-17 (demand control ventilation)
Operating statesoccupied, non-economizer operation (host-gated); reference OS 1, OS 4

Preconditions (host-enforced): Supply fan running. The host must not evaluate during economizer operation — bringing in more than the design minimum is the point of economizing, and this rule cannot tell that apart from a stuck damper. MAT must pass its integrity gate (AHU-0028, see suppressed_by): the fraction is a ratio of temperature differences, so a biased mixed-air reading moves it directly. The temperature-difference gate is signalled in-rule by yTempDeltaOk; when it is false the verdict is NO_EVAL, not healthy. Additionally suspend evaluation (NO_EVAL) while demand-controlled ventilation or a ventilation-demand override holds outdoor-air flow above the minimum-OA state — at VAV turndown a constant ventilation flow becomes a large OA fraction and this rule fires on healthy operation (fleet-validated FP mechanism; see the validation block).

Points: mat, rat, oat

Outputs:

  • yFault — True while the outdoor air fraction has stayed more than oaf_threshold above desired_oaf for at least alarm_delay, with the temperature difference large enough to evaluate
  • yTempDeltaOk — Evaluability signal — true when |oat − rat| exceeds oaf_temp_threshold; false means NO_EVAL and the host must ignore yFault

Parameters:

NameDefaultUnitCXF pathDescription
desired_oaf0.151designConst.kDesign outdoor air fraction the unit should hold at minimum ventilation (0–1)
oaf_threshold0.11marginHigh.tTolerance above the design fraction before the excess counts as a fault
oaf_temp_threshold6.0°CdeltaOk.tMinimum
alarm_delay1800.0spersist.delayTimeContinuous fault persistence required before the alarm asserts (30 min)

Description

The unit is pulling in more outdoor air than its design minimum ventilation requires, and it is not economizing — every extra cubic metre has to be heated or cooled to supply temperature for no ventilation benefit. Unlike a failed economizer, this fault is invisible from the zone: the space stays comfortable, the coils simply work harder to keep it that way, through every occupied hour. The outdoor air fraction is inferred from the mixing-box energy balance rather than measured, which makes the diagnostic cheap — three temperatures, no airflow station — and conditional, since the inference only holds when outdoor and return air differ enough to locate the fraction; hence the explicit evaluability output. AHU-0030 is the same measurement narrowed to heating operation, where the excess is most expensive. Present in roughly 15% of buildings.

Detection Logic

oaf          = (mat − rat) / (oat − rat)
yTempDeltaOk = |oat − rat| > oaf_temp_threshold     (false ⇒ host reports NO_EVAL)
yFault       = (oaf − desired_oaf > oaf_threshold) AND yTempDeltaOk,
               sustained for alarm_delay

Block graph (rule.cxf.jsonld):

AHU-0021 block graph

matRat and oatRat form the two differences, oaf divides them, and margin subtracts the design fraction so that marginHigh tests the excess against a single positive threshold. oatRat fans out a second time into absDelta and deltaOk, whose output is both the boundary output yTempDeltaOk and the second input of gate — so yFault is held down over exactly the interval the host is told to disregard it. That matters because the division is unguarded: CDL Divide follows IEEE-754, so oat = rat yields ±∞ or NaN rather than an error, and a near-zero denominator amplifies ordinary sensor noise into a fraction of any magnitude. NaN compares false everywhere, but ±∞ and a noise-inflated finite fraction can both raise marginHigh, and gate is what stops them. Both comparisons are strict: a fraction sitting exactly at desired_oaf + oaf_threshold is not a fault, and a temperature difference of exactly oaf_temp_threshold is not evaluable. The fraction is signed consistently across the year — summer both differences positive, winter both negative — so no seasonal branch is needed. persist requires 30 continuous minutes, riding out damper strokes and the mixing transient after a mode change; delayOnInit = true holds that window across a restart.

Possible Diagnoses

  1. OA damper minimum position set too high
  2. OA damper not closing to minimum — stuck, or the sequence never commands it back down after a purge or economizer period
  3. Damper actuator issue: failed actuator, slipped linkage, or a position feedback that disagrees with the blade
  4. Exhaust fan creating negative building pressure that pulls outdoor air in past the minimum position

Energy Impact

EXCESS_CONSUMPTION, HIGH confidence, DIRECT_MEASUREMENT. The waste is computable from live data: excess_oa_kw = (actual_oaf − desired_oaf) × airflow × cp × |oat − rat|, with the excess fraction already on the wire as oaf − designConst.k. Correcting minimum ventilation saves 2–10% of AHU thermal energy (PNNL-27338), the upper half of that range in heating-dominant climates. PNNL EEM-17 (demand control ventilation) is the related retrofit and this rule is its screening test: a unit already over its design fraction with the dampers at minimum will not benefit from CO₂ control until the mechanical problem is fixed.

Emissions Impact

Scope 1 + 2, DIRECT_EMISSIONS, HIGH confidence; typical 500–4,000 kg CO₂e/yr for the excess ventilation thermal load. The split follows the season: excess outdoor air in winter usually burns scope 1 fuel at the heating coil, in summer it draws scope 2 electricity at the chiller. Avoided-emissions basis: marginal operating emissions rate (MOER).

Deviations

  • The reference’s AND NOT econ_favorable term is not in the block graph. Economizer operation is an operating state, not a measurement, and this library keeps operating-state gating host-side (precedent: AHU-0017’s OS-4 restriction), so the term lives in operating_states and preconditions instead. A host that evaluates this rule during economizing will get a fault, and it will be the host’s bug.
  • The reference writes the test as oaf > (desired_oaf + oaf_threshold), which would force the two tunables into one summed threshold. Feeding desired_oaf as Reals.Sources.Constant.k and comparing the remaining margin against oaf_threshold is algebraically identical and keeps both retunable alone.
  • Evaluability is an output, not just a precondition: the |oat − rat| test is computable from this rule’s own inputs, so SCHEMA.md requires exposing it as yTempDeltaOk (PNNL-27338 uses 5 °F for the same computation; the reference’s 6 °C default is adopted). A false yFault under a false yTempDeltaOk means “unknown”, not “healthy”.
  • Both comparisons are strict (>); the reference does not specify boundary behavior, so the library’s strict convention applies.
  • persist.delayOnInit = true (Modelica/CDL default is false), the library’s standing choice: an excess already present at load waits out the full 30 minutes instead of alarming on the first tick after a controller restart.

Notes

Check the minimum position setpoint before sending anyone to the roof — the most common cause is a minimum dialled up during a ventilation complaint or a commissioning shortcut, and it is a $0 desk fix. The economizer-failure playbook’s damper and linkage steps come after that.

The rule is deliberately blind to why the fraction is high. A damper stuck at 40% and a building held under negative pressure by an oversized exhaust fan produce the same number, and the second is invisible from the AHU’s own points: if commanding the damper closed does not move the fraction, measure building pressure before replacing the actuator.

Test Vectors

8 scenarios, clock step 60 s over 3600 s.

ScenarioDescription
normal_ventilationReference vector: OAF 0.18 against a 0.25 alarm point (oat 2 °C, rat 22 °C, mat 18.4 °C) — design minimum plus normal spread
excess_outdoor_airReference vector: OAF 0.40 (oat 2 °C, rat 22 °C, mat 14.0 °C) — 0.25 above design; alarms after alarm_delay (1800 s)
margin_exactly_at_thresholdEdge case: OAF 0.25 exactly (mat 17.0 °C), so the margin equals oaf_threshold — the strict > means no fault
margin_just_over_thresholdEdge case: OAF 0.26 (mat 16.8 °C) clears the strict comparison and alarms after alarm_delay
small_delta_not_evaluableNO_EVAL:
delta_exactly_at_thresholdEdge case:
transient_excess_clears_before_delayTransient: OAF jumps to 0.40 at t=600 and falls back to 0.18 at t=1800 — 1200 s of excess, short of alarm_delay, so no alarm
summer_excess_oat_above_ratSign case: outdoor air warmer than return (oat 35 °C, rat 24 °C, mat 28.4 °C) — the fraction still reads 0.40 and the rule alarms after alarm_delay
vectors.json
{
  "schema": "cxf-library/vectors/v1",
  "clock": {
    "step_s": 60,
    "horizon_s": 3600
  },
  "scenarios": [
    {
      "name": "normal_ventilation",
      "description": "Reference vector: OAF 0.18 against a 0.25 alarm point (oat 2 \u00b0C, rat 22 \u00b0C, mat 18.4 \u00b0C) \u2014 design minimum plus normal spread",
      "inputs": {
        "mat": 18.4,
        "rat": 22.0,
        "oat": 2.0
      },
      "expect": [
        {
          "output": "yTempDeltaOk",
          "from_s": 0,
          "to_s": 3600,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "excess_outdoor_air",
      "description": "Reference vector: OAF 0.40 (oat 2 \u00b0C, rat 22 \u00b0C, mat 14.0 \u00b0C) \u2014 0.25 above design; alarms after alarm_delay (1800 s)",
      "inputs": {
        "mat": 14.0,
        "rat": 22.0,
        "oat": 2.0
      },
      "expect": [
        {
          "output": "yTempDeltaOk",
          "from_s": 0,
          "to_s": 3600,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1740,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 1860,
          "to_s": 3600,
          "equals": true
        }
      ]
    },
    {
      "name": "margin_exactly_at_threshold",
      "description": "Edge case: OAF 0.25 exactly (mat 17.0 \u00b0C), so the margin equals oaf_threshold \u2014 the strict `>` means no fault",
      "inputs": {
        "mat": 17.0,
        "rat": 22.0,
        "oat": 2.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "margin_just_over_threshold",
      "description": "Edge case: OAF 0.26 (mat 16.8 \u00b0C) clears the strict comparison and alarms after alarm_delay",
      "inputs": {
        "mat": 16.8,
        "rat": 22.0,
        "oat": 2.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1740,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 1860,
          "to_s": 3600,
          "equals": true
        }
      ]
    },
    {
      "name": "small_delta_not_evaluable",
      "description": "NO_EVAL: |oat \u2212 rat| = 2 \u00b0C (oat 20 \u00b0C, rat 22 \u00b0C) with mat 14 \u00b0C, so the raw fraction reads 4.0 \u2014 an ungated rule would alarm; yTempDeltaOk stays false and holds yFault down",
      "inputs": {
        "mat": 14.0,
        "rat": 22.0,
        "oat": 20.0
      },
      "expect": [
        {
          "output": "yTempDeltaOk",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "delta_exactly_at_threshold",
      "description": "Edge case: |oat \u2212 rat| = 6.0 \u00b0C exactly (oat 16 \u00b0C, rat 22 \u00b0C) with OAF 0.50 \u2014 the strict `>` leaves the rule not evaluable",
      "inputs": {
        "mat": 19.0,
        "rat": 22.0,
        "oat": 16.0
      },
      "expect": [
        {
          "output": "yTempDeltaOk",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "transient_excess_clears_before_delay",
      "description": "Transient: OAF jumps to 0.40 at t=600 and falls back to 0.18 at t=1800 \u2014 1200 s of excess, short of alarm_delay, so no alarm",
      "inputs": {
        "mat": [
          {
            "t": 0,
            "value": 18.4
          },
          {
            "t": 600,
            "value": 14.0
          },
          {
            "t": 1800,
            "value": 18.4
          }
        ],
        "rat": 22.0,
        "oat": 2.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "summer_excess_oat_above_rat",
      "description": "Sign case: outdoor air warmer than return (oat 35 \u00b0C, rat 24 \u00b0C, mat 28.4 \u00b0C) \u2014 the fraction still reads 0.40 and the rule alarms after alarm_delay",
      "inputs": {
        "mat": 28.4,
        "rat": 24.0,
        "oat": 35.0
      },
      "expect": [
        {
          "output": "yTempDeltaOk",
          "from_s": 0,
          "to_s": 3600,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1740,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 1860,
          "to_s": 3600,
          "equals": true
        }
      ]
    }
  ]
}

AHU-0022 — Supply air temperature hunting / oscillation

Statusverified — engine e2ff2f8, cxf:fnv1a128:4a421aa419bd1f1fe626202c9cccf494, 2026-08-17
Severity3
Methodstatistical
Phase2
CategoryCOMFORT_ENERGY
ConfidenceLOW
EstimationQUALITATIVE_ONLY
G36
Clusters
Suppresses
Suppressed by
RelatedAHU-0004, VAV-0005
Playbooks
SourceHVAC FDD Reference v1.0 §9, AHU-0022; Research-backed; PID instability detection
Operating statesall (fan running)

Preconditions (host-enforced): Supply fan running — SAT scatter means nothing in a dead air stream. No verdict within long_window (2 h) of engine start: both moving averages divide by elapsed time while their windows fill, so the baseline is not yet established and the ratio test can be satisfied by a warmup artifact — the host reports NO_EVAL for that period. SAT sensor integrity is a precondition, not a conclusion: a sensor that flatlines and then jumps produces this same scatter signature (diagnosis 4), so sensor checks clear first. When any gate is unmet the verdict is NO_EVAL, not healthy.

Points: sat

Outputs:

  • yFault — True while short-window SAT deviation has stayed above oscillation_threshold and above k × the long-window deviation for at least alarm_delay

Parameters:

NameDefaultUnitCXF pathDescription
oscillation_threshold1.2°CabsHigh.tShort-window mean absolute deviation of SAT above which scatter counts as oscillation. MAD units, not standard deviation: the reference’s 1.5 °C rolling std-dev corresponds to 1.20 MAD under a Gaussian reading (std = 1.2533 × MAD) and 1.35 under a pure-sine reading (std = 1.1107 × MAD); the default takes the lower bound
window900.0smuShort.delta, madShort.deltaShort (scatter) window, 15 min — drives both the mean stage and the deviation-averaging stage; a host must move both paths together
long_window7200.0smuLong.delta, madLong.deltaLong (baseline) window, 2 h — drives both the mean stage and the deviation-averaging stage; a host must move both paths together
k2.0ratioscaledLong.kMultiple of the long-window deviation the short window must exceed for scatter to count as a departure from the unit’s own baseline
alarm_delay1800.0spersist.delayTimeContinuous fault persistence required before the alarm asserts (30 min)

Description

Supply air temperature swings around its setpoint instead of settling on it. The signature is scatter, not offset: SAT may average exactly on setpoint while crossing it every few minutes. An oscillating loop keeps its valve or damper in continuous motion, wearing the actuator and burning coil energy on overshoot it then has to undo. The usual cause is proportional gain set too high, or integral time too short, for the coil’s actual authority — often after a valve or actuator was replaced with a differently sized part. The rule compares the unit against itself, since some AHUs simply run noisier than others: a fault requires recent scatter that is both absolutely large and several times the unit’s own baseline. Present in roughly 5% of buildings; severity 3, nothing here is unsafe.

Detection Logic

muShort  = MovingAverage(sat, window)                 muLong  = MovingAverage(sat, long_window)
madShort = MovingAverage(|sat − muShort|, window)     madLong = MovingAverage(|sat − muLong|, long_window)

yFault   = (madShort > oscillation_threshold)     absolute scatter test
       AND (madShort > k × madLong)               onset test: scatter far above this unit's baseline
           sustained continuously for alarm_delay

Block graph (rule.cxf.jsonld):

AHU-0022 block graph

Two identical chains run at two timescales. Each takes the moving average of SAT over its window (muShort, muLong), subtracts it from the live reading, takes the absolute value (errShort/devShort, errLong/devLong), and averages that deviation over the same window again (madShort, madLong) — a rolling mean absolute deviation, since the engine has no rolling standard deviation (see Deviations for the conversion). absHigh applies the absolute threshold to the short window; scaledLong and relHigh apply the ratio test. Both comparisons are strict. persist requires 30 minutes of continuous violation, which rides out one-off step disturbances — an economizer changeover or a setpoint reset spikes madShort for about one short window and then flushes out — and delayOnInit = true holds that window across a restart.

The ratio test normalizes against the unit’s own recent history, so this rule flags onset, not steady state: hunting that outlasts long_window raises madLong until madShort > k × madLong no longer holds, and the alarm clears roughly long_window/2 after onset with the loop still hunting. That is a property of the reference’s logic, not of the MAD substitution. Hosts should hold the work order open after the first assert rather than tracking yFault.

Possible Diagnoses

  1. PID loop poorly tuned (oscillating) — gain too high or integral time too short for the coil’s authority
  2. Valve or damper actuator hunting — worn linkage, sticking stem, or a positioner fighting its own feedback
  3. Conflicting control loops — two sequences acting on the same air stream (e.g. a coil loop and a face-and-bypass or mixing loop with overlapping ranges)
  4. Intermittent sensor signal — a loose SAT wire or failing transmitter reads as oscillation with no control defect present

Energy Impact

COMFORT_ENERGY, LOW confidence, QUALITATIVE_ONLY. There is no direct waste term to compute: an oscillating loop delivers roughly the right average temperature, and the loss is in the cycling itself — strokes that overshoot and correct, coil energy on excursions that cancel out, and the fan and pump work behind them. The reference puts this at 1–3% of AHU energy while the hunting lasts; confidence is LOW because no controlled study isolates oscillation losses from the tuning changes that fix them. Runtime estimation follows Energy Impact Reference §4.4 (hunting hours × AHU coil and fan power) — this rule contributes the hours, not the kilowatts. Climate-neutral.

Emissions Impact

Scope 2, QUALITATIVE_EMISSIONS, LOW confidence. Minimal in absolute terms — control-loop inefficiency, not a stuck-open coil. No avoided-emissions basis is published for this fault; a host that wants a number should apply its standard electricity factor to the fan and pump energy attributed above and treat the result as an order-of-magnitude estimate.

Deviations

  • Rolling standard deviation → rolling mean absolute deviation. The reference uses rolling_std, and the engine’s elementary block set has no variance or standard-deviation block, so MAD is computed from four MovingAverage instances plus a subtract and an absolute value per timescale. MAD and std are proportional for any fixed waveform, so the ratio test carries over unchanged — the scale factor cancels on both sides of madShort > k × madLong.
  • The absolute threshold does not cancel, so it is restated in MAD units: for Gaussian noise std = 1.2533 × MAD, for a pure sine std = 1.1107 × MAD, so the reference’s 1.5 °C std is a MAD of 1.20–1.35. The default takes the lower bound, 1.2; a site wanting the conservative end sets 1.35.
  • Reals.MovingAverage is a continuous-time integral mean, not a sample mean — the engine accumulates u·dt forward-Euler and divides by the window — so hand-computed sample statistics do not match it exactly.
  • Each MovingAverage keeps a fixed 64-checkpoint ring, so the tick interval must be ≥ long_window/64 = 112.5 s at the default windows. A host ticking faster silently shortens the baseline window: the fault still detects, but against a truncated baseline.
  • The reference’s “sufficient data in both windows” precondition is implemented host-side. During the first long_window after engine start madLong divides by elapsed time and underestimates the baseline, so the frontmatter precondition requires the host to report NO_EVAL for the first 2 h.
  • The short window is coarse at realistic tick rates — 900 s at a 300 s BAS tick is three samples — which the threshold and the 30-minute timer absorb. A faster trend interval smooths it at no cost, subject to the 112.5 s floor.
  • window and long_window each bind two CXF parameter paths (the mean stage and the deviation-averaging stage), like valve_open_threshold in AHU-0025. Hosts must set both paths of a window together; splitting them changes what the statistic means.
  • Both comparisons are strict (>). The boundary is pinned by bracketing rather than an exact-equality tick, because madShort is a computed statistic and cannot be parked exactly on 1.2 the way a staged input can.
  • The reference states its test vectors as statistical summaries rather than tick traces; each is re-expressed here as a SAT trajectory producing the stated scatter. The noisy-but-consistent case runs at a larger amplitude than the reference’s, so the absolute test passes and the ratio test is the only thing blocking the fault — at the reference’s numbers both tests fail.
  • Severity 3 (warning) and method statistical follow the reference’s chapter 9 card. This chapter’s README lists severity 4 / rule; the chapter 9 card governs and the index row needs correcting, as it did for AHU-0025.
  • The reference tags this fault for AHU and RTU; this card is the AHU-family instance, and an RTU-family sibling would restate it against the RTU’s discharge-air sensor.
  • persist.delayOnInit = true (Modelica/CDL default is false), the library’s standing choice: a violation already present at load waits out the full 30 minutes instead of alarming on the first tick after a controller restart.

Notes

A unit that has hunted for months reads as healthy here until something disturbs it — catching those needs a cross-unit or absolute-scatter comparison, which is a Phase 3 rule, not this one.

Diagnosis 4 deserves its precondition: a SAT transmitter with an intermittent connection produces textbook oscillation statistics behind a perfectly tuned loop. Read the raw trend before touching tuning parameters — control hunting is smooth and roughly periodic, a failing sensor is neither. No playbook is referenced because nothing in playbooks/ yet covers control-loop tuning.

Test Vectors

7 scenarios, clock step 150 s over 21600 s.

ScenarioDescription
stable_operationReference vector 1 (stable: short-window scatter 0.3, long-window 0.4). SAT holds 13 °C with a ±0.3 °C 15-min ripple for the whole 6 h horizon — normal PID trim, not hunting. madShort and madLong both settle at 0.3, so the absolute test (> 1.2) never fires and the ratio test never sees a raised short window. No fault at any tick.
hunting_onsetReference vector 2 (oscillating: short-window scatter 2.5 against a long-window 0.4). SAT is flat at 13 °C for 2.5 h — long enough for madLong to converge on ~0 across a full 2 h baseline — then a ±3 °C 15-min square wave starts at t=9000 s. madShort clears 1.2 at t=9450 s and both conditions hold; persist asserts 1800 s later at t=11250 s. The alarm then self-clears at t=12600 s: by then half the 2 h baseline window is itself oscillating, madLong has risen past madShort/2, and the ratio test releases. Asserted windows keep two 150 s steps of margin from both edges.
noisy_but_consistentReference vector 3 (noisy but consistent: short 1.0 vs long 0.9 — scatter present in both windows). A ±3 °C ripple runs from t=0, so madShort and madLong converge on the same 3.0 and never separate by the factor k=2. The absolute test passes from t=450 s onward; the ratio test blocks the fault for the whole horizon. Established scatter is this detector’s baseline, not its alarm.
small_oscillationThreshold edge from below: same flat-then-oscillate trajectory as hunting_onset but at ±1.0 °C. The ratio test holds from t=9000 s (the baseline is near zero) yet madShort peaks at 1.083 and settles at 1.0, both under the 1.2 threshold, so the absolute test never fires. Pins that both conditions are required — a clean ratio alone cannot raise this fault.
marginal_oscillationThreshold edge from above: the same trajectory at ±1.35 °C, just over the 1.2 threshold. madShort crosses at t=9750 s (300 s later than the ±3 case, because the short window has to fill), persist asserts at t=11550 s, and the ratio test releases at t=12600 s as in hunting_onset. The pair small_oscillation / marginal_oscillation brackets oscillation_threshold from both sides.
onset_stops_before_delayTransient: a single 15-min hunting burst (±3 °C, t=9000–9900 s) on a flat baseline — one disturbance rejection, not sustained instability. Both conditions hold from t=9450 s but madShort decays back under 1.2 at t=10650 s, 1050 s of accumulated persistence into the 1800 s timer. The timer resets and no alarm is raised.
mode_change_stepTransient: a one-time 5 °C SAT step at t=9000 s (economizer changeover or a setpoint reset), then steady operation at the new value. The step spikes madShort to 2.08 and both conditions hold from t=9150 s, but the short window flushes the step out and madShort falls back under 1.2 at t=10050 s — 750 s of persistence, well short of the 1800 s alarm delay. Pins that alarm_delay rides out a genuine step disturbance.
vectors.json
{
  "schema": "cxf-library/vectors/v1",
  "clock": {
    "step_s": 150,
    "horizon_s": 21600
  },
  "scenarios": [
    {
      "name": "stable_operation",
      "description": "Reference vector 1 (stable: short-window scatter 0.3, long-window 0.4). SAT holds 13 \u00b0C with a \u00b10.3 \u00b0C 15-min ripple for the whole 6 h horizon \u2014 normal PID trim, not hunting. madShort and madLong both settle at 0.3, so the absolute test (> 1.2) never fires and the ratio test never sees a raised short window. No fault at any tick.",
      "inputs": {
        "sat": [
          {
            "t": 0,
            "value": 13.3
          },
          {
            "t": 450,
            "value": 12.7
          },
          {
            "t": 900,
            "value": 13.3
          },
          {
            "t": 1350,
            "value": 12.7
          },
          {
            "t": 1800,
            "value": 13.3
          },
          {
            "t": 2250,
            "value": 12.7
          },
          {
            "t": 2700,
            "value": 13.3
          },
          {
            "t": 3150,
            "value": 12.7
          },
          {
            "t": 3600,
            "value": 13.3
          },
          {
            "t": 4050,
            "value": 12.7
          },
          {
            "t": 4500,
            "value": 13.3
          },
          {
            "t": 4950,
            "value": 12.7
          },
          {
            "t": 5400,
            "value": 13.3
          },
          {
            "t": 5850,
            "value": 12.7
          },
          {
            "t": 6300,
            "value": 13.3
          },
          {
            "t": 6750,
            "value": 12.7
          },
          {
            "t": 7200,
            "value": 13.3
          },
          {
            "t": 7650,
            "value": 12.7
          },
          {
            "t": 8100,
            "value": 13.3
          },
          {
            "t": 8550,
            "value": 12.7
          },
          {
            "t": 9000,
            "value": 13.3
          },
          {
            "t": 9450,
            "value": 12.7
          },
          {
            "t": 9900,
            "value": 13.3
          },
          {
            "t": 10350,
            "value": 12.7
          },
          {
            "t": 10800,
            "value": 13.3
          },
          {
            "t": 11250,
            "value": 12.7
          },
          {
            "t": 11700,
            "value": 13.3
          },
          {
            "t": 12150,
            "value": 12.7
          },
          {
            "t": 12600,
            "value": 13.3
          },
          {
            "t": 13050,
            "value": 12.7
          },
          {
            "t": 13500,
            "value": 13.3
          },
          {
            "t": 13950,
            "value": 12.7
          },
          {
            "t": 14400,
            "value": 13.3
          },
          {
            "t": 14850,
            "value": 12.7
          },
          {
            "t": 15300,
            "value": 13.3
          },
          {
            "t": 15750,
            "value": 12.7
          },
          {
            "t": 16200,
            "value": 13.3
          },
          {
            "t": 16650,
            "value": 12.7
          },
          {
            "t": 17100,
            "value": 13.3
          },
          {
            "t": 17550,
            "value": 12.7
          },
          {
            "t": 18000,
            "value": 13.3
          },
          {
            "t": 18450,
            "value": 12.7
          },
          {
            "t": 18900,
            "value": 13.3
          },
          {
            "t": 19350,
            "value": 12.7
          },
          {
            "t": 19800,
            "value": 13.3
          },
          {
            "t": 20250,
            "value": 12.7
          },
          {
            "t": 20700,
            "value": 13.3
          },
          {
            "t": 21150,
            "value": 12.7
          },
          {
            "t": 21600,
            "value": 13.3
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 21600,
          "equals": false
        }
      ]
    },
    {
      "name": "hunting_onset",
      "description": "Reference vector 2 (oscillating: short-window scatter 2.5 against a long-window 0.4). SAT is flat at 13 \u00b0C for 2.5 h \u2014 long enough for madLong to converge on ~0 across a full 2 h baseline \u2014 then a \u00b13 \u00b0C 15-min square wave starts at t=9000 s. madShort clears 1.2 at t=9450 s and both conditions hold; persist asserts 1800 s later at t=11250 s. The alarm then self-clears at t=12600 s: by then half the 2 h baseline window is itself oscillating, madLong has risen past madShort/2, and the ratio test releases. Asserted windows keep two 150 s steps of margin from both edges.",
      "inputs": {
        "sat": [
          {
            "t": 0,
            "value": 13.0
          },
          {
            "t": 9000,
            "value": 16.0
          },
          {
            "t": 9450,
            "value": 10.0
          },
          {
            "t": 9900,
            "value": 16.0
          },
          {
            "t": 10350,
            "value": 10.0
          },
          {
            "t": 10800,
            "value": 16.0
          },
          {
            "t": 11250,
            "value": 10.0
          },
          {
            "t": 11700,
            "value": 16.0
          },
          {
            "t": 12150,
            "value": 10.0
          },
          {
            "t": 12600,
            "value": 16.0
          },
          {
            "t": 13050,
            "value": 10.0
          },
          {
            "t": 13500,
            "value": 16.0
          },
          {
            "t": 13950,
            "value": 10.0
          },
          {
            "t": 14400,
            "value": 16.0
          },
          {
            "t": 14850,
            "value": 10.0
          },
          {
            "t": 15300,
            "value": 16.0
          },
          {
            "t": 15750,
            "value": 10.0
          },
          {
            "t": 16200,
            "value": 16.0
          },
          {
            "t": 16650,
            "value": 10.0
          },
          {
            "t": 17100,
            "value": 16.0
          },
          {
            "t": 17550,
            "value": 10.0
          },
          {
            "t": 18000,
            "value": 16.0
          },
          {
            "t": 18450,
            "value": 10.0
          },
          {
            "t": 18900,
            "value": 16.0
          },
          {
            "t": 19350,
            "value": 10.0
          },
          {
            "t": 19800,
            "value": 16.0
          },
          {
            "t": 20250,
            "value": 10.0
          },
          {
            "t": 20700,
            "value": 16.0
          },
          {
            "t": 21150,
            "value": 10.0
          },
          {
            "t": 21600,
            "value": 16.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 10950,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 11550,
          "to_s": 12150,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 12900,
          "to_s": 21600,
          "equals": false
        }
      ]
    },
    {
      "name": "noisy_but_consistent",
      "description": "Reference vector 3 (noisy but consistent: short 1.0 vs long 0.9 \u2014 scatter present in both windows). A \u00b13 \u00b0C ripple runs from t=0, so madShort and madLong converge on the same 3.0 and never separate by the factor k=2. The absolute test passes from t=450 s onward; the ratio test blocks the fault for the whole horizon. Established scatter is this detector's baseline, not its alarm.",
      "inputs": {
        "sat": [
          {
            "t": 0,
            "value": 16.0
          },
          {
            "t": 450,
            "value": 10.0
          },
          {
            "t": 900,
            "value": 16.0
          },
          {
            "t": 1350,
            "value": 10.0
          },
          {
            "t": 1800,
            "value": 16.0
          },
          {
            "t": 2250,
            "value": 10.0
          },
          {
            "t": 2700,
            "value": 16.0
          },
          {
            "t": 3150,
            "value": 10.0
          },
          {
            "t": 3600,
            "value": 16.0
          },
          {
            "t": 4050,
            "value": 10.0
          },
          {
            "t": 4500,
            "value": 16.0
          },
          {
            "t": 4950,
            "value": 10.0
          },
          {
            "t": 5400,
            "value": 16.0
          },
          {
            "t": 5850,
            "value": 10.0
          },
          {
            "t": 6300,
            "value": 16.0
          },
          {
            "t": 6750,
            "value": 10.0
          },
          {
            "t": 7200,
            "value": 16.0
          },
          {
            "t": 7650,
            "value": 10.0
          },
          {
            "t": 8100,
            "value": 16.0
          },
          {
            "t": 8550,
            "value": 10.0
          },
          {
            "t": 9000,
            "value": 16.0
          },
          {
            "t": 9450,
            "value": 10.0
          },
          {
            "t": 9900,
            "value": 16.0
          },
          {
            "t": 10350,
            "value": 10.0
          },
          {
            "t": 10800,
            "value": 16.0
          },
          {
            "t": 11250,
            "value": 10.0
          },
          {
            "t": 11700,
            "value": 16.0
          },
          {
            "t": 12150,
            "value": 10.0
          },
          {
            "t": 12600,
            "value": 16.0
          },
          {
            "t": 13050,
            "value": 10.0
          },
          {
            "t": 13500,
            "value": 16.0
          },
          {
            "t": 13950,
            "value": 10.0
          },
          {
            "t": 14400,
            "value": 16.0
          },
          {
            "t": 14850,
            "value": 10.0
          },
          {
            "t": 15300,
            "value": 16.0
          },
          {
            "t": 15750,
            "value": 10.0
          },
          {
            "t": 16200,
            "value": 16.0
          },
          {
            "t": 16650,
            "value": 10.0
          },
          {
            "t": 17100,
            "value": 16.0
          },
          {
            "t": 17550,
            "value": 10.0
          },
          {
            "t": 18000,
            "value": 16.0
          },
          {
            "t": 18450,
            "value": 10.0
          },
          {
            "t": 18900,
            "value": 16.0
          },
          {
            "t": 19350,
            "value": 10.0
          },
          {
            "t": 19800,
            "value": 16.0
          },
          {
            "t": 20250,
            "value": 10.0
          },
          {
            "t": 20700,
            "value": 16.0
          },
          {
            "t": 21150,
            "value": 10.0
          },
          {
            "t": 21600,
            "value": 16.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 21600,
          "equals": false
        }
      ]
    },
    {
      "name": "small_oscillation",
      "description": "Threshold edge from below: same flat-then-oscillate trajectory as hunting_onset but at \u00b11.0 \u00b0C. The ratio test holds from t=9000 s (the baseline is near zero) yet madShort peaks at 1.083 and settles at 1.0, both under the 1.2 threshold, so the absolute test never fires. Pins that both conditions are required \u2014 a clean ratio alone cannot raise this fault.",
      "inputs": {
        "sat": [
          {
            "t": 0,
            "value": 13.0
          },
          {
            "t": 9000,
            "value": 14.0
          },
          {
            "t": 9450,
            "value": 12.0
          },
          {
            "t": 9900,
            "value": 14.0
          },
          {
            "t": 10350,
            "value": 12.0
          },
          {
            "t": 10800,
            "value": 14.0
          },
          {
            "t": 11250,
            "value": 12.0
          },
          {
            "t": 11700,
            "value": 14.0
          },
          {
            "t": 12150,
            "value": 12.0
          },
          {
            "t": 12600,
            "value": 14.0
          },
          {
            "t": 13050,
            "value": 12.0
          },
          {
            "t": 13500,
            "value": 14.0
          },
          {
            "t": 13950,
            "value": 12.0
          },
          {
            "t": 14400,
            "value": 14.0
          },
          {
            "t": 14850,
            "value": 12.0
          },
          {
            "t": 15300,
            "value": 14.0
          },
          {
            "t": 15750,
            "value": 12.0
          },
          {
            "t": 16200,
            "value": 14.0
          },
          {
            "t": 16650,
            "value": 12.0
          },
          {
            "t": 17100,
            "value": 14.0
          },
          {
            "t": 17550,
            "value": 12.0
          },
          {
            "t": 18000,
            "value": 14.0
          },
          {
            "t": 18450,
            "value": 12.0
          },
          {
            "t": 18900,
            "value": 14.0
          },
          {
            "t": 19350,
            "value": 12.0
          },
          {
            "t": 19800,
            "value": 14.0
          },
          {
            "t": 20250,
            "value": 12.0
          },
          {
            "t": 20700,
            "value": 14.0
          },
          {
            "t": 21150,
            "value": 12.0
          },
          {
            "t": 21600,
            "value": 14.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 21600,
          "equals": false
        }
      ]
    },
    {
      "name": "marginal_oscillation",
      "description": "Threshold edge from above: the same trajectory at \u00b11.35 \u00b0C, just over the 1.2 threshold. madShort crosses at t=9750 s (300 s later than the \u00b13 case, because the short window has to fill), persist asserts at t=11550 s, and the ratio test releases at t=12600 s as in hunting_onset. The pair small_oscillation / marginal_oscillation brackets oscillation_threshold from both sides.",
      "inputs": {
        "sat": [
          {
            "t": 0,
            "value": 13.0
          },
          {
            "t": 9000,
            "value": 14.35
          },
          {
            "t": 9450,
            "value": 11.65
          },
          {
            "t": 9900,
            "value": 14.35
          },
          {
            "t": 10350,
            "value": 11.65
          },
          {
            "t": 10800,
            "value": 14.35
          },
          {
            "t": 11250,
            "value": 11.65
          },
          {
            "t": 11700,
            "value": 14.35
          },
          {
            "t": 12150,
            "value": 11.65
          },
          {
            "t": 12600,
            "value": 14.35
          },
          {
            "t": 13050,
            "value": 11.65
          },
          {
            "t": 13500,
            "value": 14.35
          },
          {
            "t": 13950,
            "value": 11.65
          },
          {
            "t": 14400,
            "value": 14.35
          },
          {
            "t": 14850,
            "value": 11.65
          },
          {
            "t": 15300,
            "value": 14.35
          },
          {
            "t": 15750,
            "value": 11.65
          },
          {
            "t": 16200,
            "value": 14.35
          },
          {
            "t": 16650,
            "value": 11.65
          },
          {
            "t": 17100,
            "value": 14.35
          },
          {
            "t": 17550,
            "value": 11.65
          },
          {
            "t": 18000,
            "value": 14.35
          },
          {
            "t": 18450,
            "value": 11.65
          },
          {
            "t": 18900,
            "value": 14.35
          },
          {
            "t": 19350,
            "value": 11.65
          },
          {
            "t": 19800,
            "value": 14.35
          },
          {
            "t": 20250,
            "value": 11.65
          },
          {
            "t": 20700,
            "value": 14.35
          },
          {
            "t": 21150,
            "value": 11.65
          },
          {
            "t": 21600,
            "value": 14.35
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 11250,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 11850,
          "to_s": 12150,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 12900,
          "to_s": 21600,
          "equals": false
        }
      ]
    },
    {
      "name": "onset_stops_before_delay",
      "description": "Transient: a single 15-min hunting burst (\u00b13 \u00b0C, t=9000\u20139900 s) on a flat baseline \u2014 one disturbance rejection, not sustained instability. Both conditions hold from t=9450 s but madShort decays back under 1.2 at t=10650 s, 1050 s of accumulated persistence into the 1800 s timer. The timer resets and no alarm is raised.",
      "inputs": {
        "sat": [
          {
            "t": 0,
            "value": 13.0
          },
          {
            "t": 9000,
            "value": 16.0
          },
          {
            "t": 9450,
            "value": 10.0
          },
          {
            "t": 9900,
            "value": 13.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 21600,
          "equals": false
        }
      ]
    },
    {
      "name": "mode_change_step",
      "description": "Transient: a one-time 5 \u00b0C SAT step at t=9000 s (economizer changeover or a setpoint reset), then steady operation at the new value. The step spikes madShort to 2.08 and both conditions hold from t=9150 s, but the short window flushes the step out and madShort falls back under 1.2 at t=10050 s \u2014 750 s of persistence, well short of the 1800 s alarm delay. Pins that alarm_delay rides out a genuine step disturbance.",
      "inputs": {
        "sat": [
          {
            "t": 0,
            "value": 13.0
          },
          {
            "t": 9000,
            "value": 8.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 21600,
          "equals": false
        }
      ]
    }
  ]
}

AHU-0023 — Supply air temperature reset not functioning

Statusverified — engine e2ff2f8, cxf:fnv1a128:16397a4af5f0c3617c2f555a71820b83, 2026-08-17
Severity3
Methodstatistical
Phase2
CategoryEXCESS_CONSUMPTION
ConfidenceHIGH
EstimationPROXY_ESTIMATION
G36
ClustersCLU-02
Suppresses
Suppressed by
RelatedAHU-0019, AHU-0024
Playbooksmissing-reset
SourceHVAC FDD Reference v1.0 §9, AHU-0023; PNNL RetuningOpps A03; PNNL-25985 EEM-05
Operating statesOccupied cooling modes (OS 2, 3, 4)

Preconditions (host-enforced): AHU running for the bulk of the evaluation window. Sufficient outdoor-air variation is signaled in-rule via the yOatVaried output: when it is false, the host must report NO_EVAL, not healthy.

Points: sat_sp, oat

Outputs:

  • yFault — True while the SAT setpoint has stayed flat over the evaluation window despite sufficient OAT variation, for at least alarm_delay
  • yOatVaried — Evaluability signal — true when OAT has varied enough within the evaluation window for the rule to be meaningful; false means NO_EVAL

Parameters:

NameDefaultUnitCXF pathDescription
evaluation_window604800.0sspRef.samplePeriod, oatRef.samplePeriod, spFlatHeld.delayTime, oatFlatHeld.delayTimeWindow over which setpoint flatness and OAT variation are assessed (7 days); drives both baseline sample periods and both dwell timers
sp_flat_tolerance1.0°CspFlat.tMax deviation of SAT_SP from its sampled baseline to count as flat (half the reference’s 2 °C min expected range)
oat_variation_tolerance4.0°CoatFlat.tMax deviation of OAT from its sampled baseline to still count as flat (half the reference’s 8 °C min OAT range)
alarm_delay86400.0spersist.delayTimeFault persistence before alarm (24 h)

Description

The supply air temperature setpoint remains fixed over an extended evaluation window despite varying outdoor-air and load conditions. A functioning SAT reset (G36 §5.16.2 trim-and-respond) modulates the setpoint warmer as loads decrease, cutting mechanical cooling and downstream VAV reheat. PNNL’s 151-building field study found SAT reset absent in 74% of buildings — this rule and its twin AHU-0024 are the “74% problem,” the trigger pair for cluster CLU-02, and the fix is a $0 desk-only sequence change worth ~2.5% of site energy on its own.

Detection Logic

baseline(x)  = x sampled and held every evaluation_window (7 days)
sp_flat      = |sat_sp − baseline(sat_sp)| < sp_flat_tolerance,
               continuously for evaluation_window
oat_flat     = |oat − baseline(oat)| < oat_variation_tolerance,
               continuously for evaluation_window

yOatVaried   = NOT oat_flat        (false ⇒ host reports NO_EVAL)
yFault       = sp_flat AND yOatVaried, sustained for alarm_delay

Block graph (rule.cxf.jsonld):

AHU-0023 block graph

Two symmetric chains compare each signal against a weekly sample-and-hold baseline (Discrete.Sampler, which emits the live input on its first tick — no startup artifact). spFlatHeld asserts only after the setpoint has stayed within tolerance of its baseline continuously for a full window; any reset activity ≥ 1 °C resets it. oatFlatHeld does the same for OAT, and its negation is yOatVaried: “not varied” is defined as a full window of continuous flatness. In the synchronous dataflow both dwell timers fire on the same tick in the all-flat case, so the fault conjunction is false by construction — no boundary race — and persist (24 h) filters any residual transient. Worst-case time to alarm from cold start: evaluation_window + alarm_delay (8 days).

Possible Diagnoses

  1. SAT reset sequence never programmed in the BAS
  2. SAT reset sequence disabled by an operator
  3. SAT setpoint overridden to a fixed value
  4. Trim-and-respond parameters misconfigured
  5. Zone request signals not reaching the AHU controller

Energy Impact

EXCESS_CONSUMPTION, HIGH confidence, PROXY_ESTIMATION (EEM-05). A fixed-low SAT over-cools the air stream and drives downstream VAV reheat; savings from enabling reset are 1–4.4% of site energy (2.5% national weighted median), heating-dominant by climate. Prevalence: 74% of buildings.

Emissions Impact

Scope 2, PROXY_EMISSIONS, HIGH confidence; typical 800–5,000 kg CO₂e/yr (excess cooling and reheat). Avoided-emissions basis: MOER.

Deviations

  • Windowed range → deviation from a weekly sampled baseline. The reference computes max − min over the window and CDL has no windowed min/max, so each signal is compared against a Discrete.Sampler hold refreshed once per window, with tolerances at half the reference ranges. Equivalent detection for signals that move and return, slightly conservative for monotonic drift within one window. Reals.MovingAverage was rejected: the engine implements it with a 64-checkpoint ring, so a 7-day window silently degrades at BAS tick rates, while the sampler is exact at any tick rate.
  • NO_EVAL is surfaced as the second output yOatVaried, since boolean block logic cannot express a tri-state; false means NO_EVAL, never healthy. Its inverted-flat semantics make it optimistically true during the first window after startup, when yFault cannot fire anyway because spFlatHeld needs the same full window.
  • AlarmDelay = 24 h is a TrueDelay on the fault conjunction; the evaluation window itself is enforced by the flatness dwell timers.
  • delayOnInit = true on all TrueDelays (startup conservatism per AHU-0016).

Notes

Fastest payback in the catalog together with AHU-0024: remote fix ~90%, $0, programmed per G36 §5.16.2 (start 18 °C; trim +0.2 °F when satisfied; respond −0.5 °F per cooling request; range 13–18 °C). See the missing-reset playbook.

Test Vectors

4 scenarios, clock step 300 s over 777600 s.

ScenarioDescription
reset_workingReference vector: SAT_SP modulates ~4degC with OAT swings; no fault
fixed_setpointReference vector: SAT_SP pinned at 13degC despite 15degC OAT swings; fault after evaluation_window (7d) + alarm_delay (24h)
insufficient_oat_variationReference vector (NO_EVAL): flat SAT_SP but OAT never varies; yOatVaried drops false once OAT has been flat for a full window, so the host reports NO_EVAL; yFault must never fire
reset_resumesSetpoint flat for 4 days, then reset activity resumes before the window completes; flatness timer resets, never alarms
vectors.json
{
  "schema": "cxf-library/vectors/v1",
  "clock": {
    "step_s": 300,
    "horizon_s": 777600
  },
  "scenarios": [
    {
      "name": "reset_working",
      "description": "Reference vector: SAT_SP modulates ~4degC with OAT swings; no fault",
      "inputs": {
        "sat_sp": [
          {
            "t": 0,
            "value": 13.0
          },
          {
            "t": 43200,
            "value": 17.0
          },
          {
            "t": 86400,
            "value": 13.0
          },
          {
            "t": 129600,
            "value": 17.0
          },
          {
            "t": 172800,
            "value": 13.0
          },
          {
            "t": 216000,
            "value": 17.0
          },
          {
            "t": 259200,
            "value": 13.0
          },
          {
            "t": 302400,
            "value": 17.0
          },
          {
            "t": 345600,
            "value": 13.0
          },
          {
            "t": 388800,
            "value": 17.0
          },
          {
            "t": 432000,
            "value": 13.0
          },
          {
            "t": 475200,
            "value": 17.0
          },
          {
            "t": 518400,
            "value": 13.0
          },
          {
            "t": 561600,
            "value": 17.0
          },
          {
            "t": 604800,
            "value": 13.0
          },
          {
            "t": 648000,
            "value": 17.0
          },
          {
            "t": 691200,
            "value": 13.0
          },
          {
            "t": 734400,
            "value": 17.0
          },
          {
            "t": 777600,
            "value": 13.0
          }
        ],
        "oat": [
          {
            "t": 0,
            "value": 5.0
          },
          {
            "t": 43200,
            "value": 20.0
          },
          {
            "t": 86400,
            "value": 5.0
          },
          {
            "t": 129600,
            "value": 20.0
          },
          {
            "t": 172800,
            "value": 5.0
          },
          {
            "t": 216000,
            "value": 20.0
          },
          {
            "t": 259200,
            "value": 5.0
          },
          {
            "t": 302400,
            "value": 20.0
          },
          {
            "t": 345600,
            "value": 5.0
          },
          {
            "t": 388800,
            "value": 20.0
          },
          {
            "t": 432000,
            "value": 5.0
          },
          {
            "t": 475200,
            "value": 20.0
          },
          {
            "t": 518400,
            "value": 5.0
          },
          {
            "t": 561600,
            "value": 20.0
          },
          {
            "t": 604800,
            "value": 5.0
          },
          {
            "t": 648000,
            "value": 20.0
          },
          {
            "t": 691200,
            "value": 5.0
          },
          {
            "t": 734400,
            "value": 20.0
          },
          {
            "t": 777600,
            "value": 5.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 777600,
          "equals": false
        },
        {
          "output": "yOatVaried",
          "from_s": 0,
          "to_s": 777600,
          "equals": true
        }
      ]
    },
    {
      "name": "fixed_setpoint",
      "description": "Reference vector: SAT_SP pinned at 13degC despite 15degC OAT swings; fault after evaluation_window (7d) + alarm_delay (24h)",
      "inputs": {
        "sat_sp": 13.0,
        "oat": [
          {
            "t": 0,
            "value": 5.0
          },
          {
            "t": 43200,
            "value": 20.0
          },
          {
            "t": 86400,
            "value": 5.0
          },
          {
            "t": 129600,
            "value": 20.0
          },
          {
            "t": 172800,
            "value": 5.0
          },
          {
            "t": 216000,
            "value": 20.0
          },
          {
            "t": 259200,
            "value": 5.0
          },
          {
            "t": 302400,
            "value": 20.0
          },
          {
            "t": 345600,
            "value": 5.0
          },
          {
            "t": 388800,
            "value": 20.0
          },
          {
            "t": 432000,
            "value": 5.0
          },
          {
            "t": 475200,
            "value": 20.0
          },
          {
            "t": 518400,
            "value": 5.0
          },
          {
            "t": 561600,
            "value": 20.0
          },
          {
            "t": 604800,
            "value": 5.0
          },
          {
            "t": 648000,
            "value": 20.0
          },
          {
            "t": 691200,
            "value": 5.0
          },
          {
            "t": 734400,
            "value": 20.0
          },
          {
            "t": 777600,
            "value": 5.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 688800,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 694800,
          "to_s": 777600,
          "equals": true
        },
        {
          "output": "yOatVaried",
          "from_s": 0,
          "to_s": 777600,
          "equals": true
        }
      ]
    },
    {
      "name": "insufficient_oat_variation",
      "description": "Reference vector (NO_EVAL): flat SAT_SP but OAT never varies; yOatVaried drops false once OAT has been flat for a full window, so the host reports NO_EVAL; yFault must never fire",
      "inputs": {
        "sat_sp": 13.0,
        "oat": 10.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 777600,
          "equals": false
        },
        {
          "output": "yOatVaried",
          "from_s": 0,
          "to_s": 602400,
          "equals": true
        },
        {
          "output": "yOatVaried",
          "from_s": 608400,
          "to_s": 777600,
          "equals": false
        }
      ]
    },
    {
      "name": "reset_resumes",
      "description": "Setpoint flat for 4 days, then reset activity resumes before the window completes; flatness timer resets, never alarms",
      "inputs": {
        "sat_sp": [
          {
            "t": 0,
            "value": 13.0
          },
          {
            "t": 345600,
            "value": 17.0
          },
          {
            "t": 388800,
            "value": 13.0
          },
          {
            "t": 432000,
            "value": 17.0
          },
          {
            "t": 475200,
            "value": 13.0
          },
          {
            "t": 518400,
            "value": 17.0
          },
          {
            "t": 561600,
            "value": 13.0
          },
          {
            "t": 604800,
            "value": 17.0
          },
          {
            "t": 648000,
            "value": 13.0
          },
          {
            "t": 691200,
            "value": 17.0
          },
          {
            "t": 734400,
            "value": 13.0
          },
          {
            "t": 777600,
            "value": 17.0
          }
        ],
        "oat": [
          {
            "t": 0,
            "value": 5.0
          },
          {
            "t": 43200,
            "value": 20.0
          },
          {
            "t": 86400,
            "value": 5.0
          },
          {
            "t": 129600,
            "value": 20.0
          },
          {
            "t": 172800,
            "value": 5.0
          },
          {
            "t": 216000,
            "value": 20.0
          },
          {
            "t": 259200,
            "value": 5.0
          },
          {
            "t": 302400,
            "value": 20.0
          },
          {
            "t": 345600,
            "value": 5.0
          },
          {
            "t": 388800,
            "value": 20.0
          },
          {
            "t": 432000,
            "value": 5.0
          },
          {
            "t": 475200,
            "value": 20.0
          },
          {
            "t": 518400,
            "value": 5.0
          },
          {
            "t": 561600,
            "value": 20.0
          },
          {
            "t": 604800,
            "value": 5.0
          },
          {
            "t": 648000,
            "value": 20.0
          },
          {
            "t": 691200,
            "value": 5.0
          },
          {
            "t": 734400,
            "value": 20.0
          },
          {
            "t": 777600,
            "value": 5.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 777600,
          "equals": false
        }
      ]
    }
  ]
}

AHU-0024 — Duct static pressure reset not functioning

Statusverified — engine e2ff2f8, cxf:fnv1a128:0bc49a814c1f728a54e9a9a3ecff1244, 2026-08-17
Severity3
Methodstatistical
Phase2
CategoryEXCESS_CONSUMPTION
ConfidenceHIGH
EstimationPROXY_ESTIMATION
G36
ClustersCLU-02
Suppresses
Suppressed by
RelatedAHU-0001, AHU-0023, AHU-0031, FPB-0002
Playbooksmissing-reset
SourceHVAC FDD Reference v1.0 §9, AHU-0024; PNNL RetuningOpps A01; PNNL-25985 EEM-12
Operating statesOccupied, fan running

Preconditions (host-enforced): AHU serves multiple zones; zone damper feedback available and aggregated by the host into zone_dmpr_pos_max. When zone damper data is missing or stale, the verdict is NO_EVAL, not healthy.

Points: dsp_sp, zone_dmpr_pos_max

Outputs:

  • yFault — True while the DSP setpoint has stayed flat over the evaluation window despite all zone dampers staying well below fully open, for at least alarm_delay

Parameters:

NameDefaultUnitCXF pathDescription
evaluation_window259200.0sspRef.samplePeriod, spFlatHeld.delayTime, dmprLowHeld.delayTimeWindow over which setpoint flatness and low damper demand are assessed (3 days)
sp_flat_tolerance25.0PaspFlat.tMax deviation of DSP_SP from its sampled baseline to count as flat (half the reference’s 50 Pa min expected range)
high_damper_threshold70.0%dmprLow.tMax zone damper position below which the setpoint could safely drop
alarm_delay86400.0spersist.delayTimeFault persistence before alarm (24 h)

Description

The duct static pressure setpoint remains fixed despite varying zone demand. A functioning DSP reset (G36 §5.16.1 trim-and-respond) lowers the setpoint when zone dampers are not widely open; because fan power scales with the cube of pressure, even small reductions produce outsized savings. The other half of the “74% problem” with AHU-0023 — absent in 74% of buildings (PNNL 151-building study) — and a member of cluster CLU-02 with the same $0 desk-only fix.

Detection Logic

baseline(dsp_sp) = dsp_sp sampled and held every evaluation_window (3 days)
sp_flat          = |dsp_sp − baseline(dsp_sp)| < sp_flat_tolerance,
                   continuously for evaluation_window
low_demand       = zone_dmpr_pos_max < high_damper_threshold,
                   continuously for evaluation_window

yFault = sp_flat AND low_demand, sustained for alarm_delay

Block graph (rule.cxf.jsonld):

AHU-0024 block graph

The setpoint chain mirrors AHU-0023’s sampled-baseline flatness detector. The demand condition needs no baseline at all: the reference’s max(zone_dmpr_pos_all) < 70% over the window is exactly equivalent to “the highest zone damper stays below 70% continuously,” which is one LessThreshold plus a dwell TrueDelay on the host-aggregated maximum. Worst-case time to alarm from cold start: evaluation_window + alarm_delay (4 days).

Possible Diagnoses

  1. DSP reset never programmed in the BAS
  2. DSP reset disabled by an operator
  3. Trim-and-respond parameters misconfigured
  4. Zone damper feedback not connected to the AHU controller

Energy Impact

EXCESS_CONSUMPTION, HIGH confidence, PROXY_ESTIMATION (EEM-12). Fan energy scales with the cube of duct pressure: a 20% setpoint reduction yields ~49% fan energy savings. Savings 1–3% of site energy; climate-neutral. Prevalence: 74% of buildings.

Emissions Impact

Scope 2, PROXY_EMISSIONS, HIGH confidence; typical 400–3,000 kg CO₂e/yr (excess fan energy, cubic law). Avoided-emissions basis: MOER.

Deviations

  • zone_dmpr_pos_all (array) → host-derived zone_dmpr_pos_max (scalar). The reference consumes every zone damper position; library v1 avoids array boundary points, so the host aggregates the maximum across the zones served and feeds one scalar (flagged derived in the point dictionary). The zone-side underlying points get their semantic tags when the VAV dictionary lands.
  • Windowed range → sampled-baseline flatness on the setpoint chain, as AHU-0023 (sp_flat_tolerance = min_expected_sp_range/2; same MovingAverage ring-capacity rationale). The damper condition is an exact transformation, not an approximation: max over window < tcontinuously below t.
  • delayOnInit = true on all TrueDelays (startup conservatism per AHU-0016).

Notes

Evaluate together with AHU-0023: both fire → CLU-02 confirmed, one trim-and-respond programming visit fixes both. The reference’s PNNL-27338 AIRCx corollary — DSP > 0.2 in. w.g. during unoccupied hours — is covered separately by the after-hours rules (AHU-0018 family).

Test Vectors

5 scenarios, clock step 300 s over 432000 s.

ScenarioDescription
reset_workingReference vector: DSP_SP modulates 100 Pa with demand; no fault
fixed_sp_low_demandReference vector: DSP_SP pinned while dampers well below 70%; fault after evaluation_window (3d) + alarm_delay (24h)
fixed_sp_high_demandReference vector: DSP_SP pinned but dampers near max — setpoint cannot drop, no fault
demand_rises_resetsLow demand for 2.3 days, then dampers open past threshold before the window completes; dwell resets, never alarms
damper_at_thresholdMax damper exactly at high_damper_threshold is not low demand (strict < comparison)
vectors.json
{
  "schema": "cxf-library/vectors/v1",
  "clock": {
    "step_s": 300,
    "horizon_s": 432000
  },
  "scenarios": [
    {
      "name": "reset_working",
      "description": "Reference vector: DSP_SP modulates 100 Pa with demand; no fault",
      "inputs": {
        "dsp_sp": [
          {
            "t": 0,
            "value": 350.0
          },
          {
            "t": 21600,
            "value": 450.0
          },
          {
            "t": 43200,
            "value": 350.0
          },
          {
            "t": 64800,
            "value": 450.0
          },
          {
            "t": 86400,
            "value": 350.0
          },
          {
            "t": 108000,
            "value": 450.0
          },
          {
            "t": 129600,
            "value": 350.0
          },
          {
            "t": 151200,
            "value": 450.0
          },
          {
            "t": 172800,
            "value": 350.0
          },
          {
            "t": 194400,
            "value": 450.0
          },
          {
            "t": 216000,
            "value": 350.0
          },
          {
            "t": 237600,
            "value": 450.0
          },
          {
            "t": 259200,
            "value": 350.0
          },
          {
            "t": 280800,
            "value": 450.0
          },
          {
            "t": 302400,
            "value": 350.0
          },
          {
            "t": 324000,
            "value": 450.0
          },
          {
            "t": 345600,
            "value": 350.0
          },
          {
            "t": 367200,
            "value": 450.0
          },
          {
            "t": 388800,
            "value": 350.0
          },
          {
            "t": 410400,
            "value": 450.0
          },
          {
            "t": 432000,
            "value": 350.0
          }
        ],
        "zone_dmpr_pos_max": 85.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 432000,
          "equals": false
        }
      ]
    },
    {
      "name": "fixed_sp_low_demand",
      "description": "Reference vector: DSP_SP pinned while dampers well below 70%; fault after evaluation_window (3d) + alarm_delay (24h)",
      "inputs": {
        "dsp_sp": 400.0,
        "zone_dmpr_pos_max": 45.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 343200,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 348000,
          "to_s": 432000,
          "equals": true
        }
      ]
    },
    {
      "name": "fixed_sp_high_demand",
      "description": "Reference vector: DSP_SP pinned but dampers near max \u2014 setpoint cannot drop, no fault",
      "inputs": {
        "dsp_sp": 400.0,
        "zone_dmpr_pos_max": 95.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 432000,
          "equals": false
        }
      ]
    },
    {
      "name": "demand_rises_resets",
      "description": "Low demand for 2.3 days, then dampers open past threshold before the window completes; dwell resets, never alarms",
      "inputs": {
        "dsp_sp": 400.0,
        "zone_dmpr_pos_max": [
          {
            "t": 0,
            "value": 45.0
          },
          {
            "t": 200000,
            "value": 95.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 432000,
          "equals": false
        }
      ]
    },
    {
      "name": "damper_at_threshold",
      "description": "Max damper exactly at high_damper_threshold is not low demand (strict < comparison)",
      "inputs": {
        "dsp_sp": 400.0,
        "zone_dmpr_pos_max": 70.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 432000,
          "equals": false
        }
      ]
    }
  ]
}

AHU-0025 — Heating or cooling coil lockout not active by OAT

Statusverified — engine e2ff2f8, cxf:fnv1a128:5322cb5d6f80a2e512e9d5ec26d5e4bc, 2026-08-17
Severity3
Methodrule
Phase2
CategoryCRITICAL_WASTE
ConfidenceMEDIUM
EstimationDIRECT_MEASUREMENT
G36
ClustersCLU-01
Suppresses
Suppressed by
RelatedAHU-0016
Playbookssimultaneous-hc
SourceHVAC FDD Reference v1.0 §9, AHU-0025; PNNL RetuningOpps A02; PNNL-25985
Operating statesall (fan running)

Preconditions (host-enforced): Supply fan running — a coil lockout only matters while the unit moves air. The OAT reading must be trustworthy: a biased outdoor sensor produces this exact signature with no lockout defect present (diagnosis 4), so hosts should gate on OAT data quality. When either gate is unmet the verdict is NO_EVAL, not healthy.

Points: htg_vlv_cmd, clg_vlv_cmd, oat

Outputs:

  • yFault — True while a coil has been open past valve_open_threshold on the wrong side of its OAT lockout for at least alarm_delay

Parameters:

NameDefaultUnitCXF pathDescription
valve_open_threshold5.0%htgOpen.t, clgOpen.tValve command above which a coil counts as active; binds both branches
heating_lockout_temp18.0°CoatWarm.tOAT above which the heating coil should be locked out
cooling_lockout_temp10.0°CoatCold.tOAT below which the cooling coil should be locked out
alarm_delay1800.0spersist.delayTimeContinuous fault persistence required before the alarm asserts (30 min)

Description

A coil is running on the wrong side of its outdoor-air lockout: the heating valve is open while outdoor air is warm, or the cooling valve is open while outdoor air is cold. Either way the lockout that should have disabled that coil is missing, overridden, or defeated by a valve that will not close. Out-of-season coil operation is pure waste — the load it serves is either nonexistent or better met by outdoor air — and it usually runs for months before anyone notices, because nothing about it is uncomfortable. Present in roughly 15% of buildings; a member fault of CLU-01 (Simultaneous Heating & Cooling), whose trigger is AHU-0016.

Detection Logic

yFault = (htg_vlv_cmd > valve_open_threshold AND oat > heating_lockout_temp)
      OR (clg_vlv_cmd > valve_open_threshold AND oat < cooling_lockout_temp)
      sustained continuously for alarm_delay

Block graph (rule.cxf.jsonld):

AHU-0025 block graph

The two branches are independent tests that share one persistence timer: oat fans out to oatWarm and oatCold, each valve gets its own open test at the same threshold, and anyMiss raises either finding. All three comparisons are strict, so a valve parked at exactly 5% or an OAT sitting exactly on 18 °C or 10 °C does not trip the rule. Between the two lockout temperatures — the 10–18 °C band where economizer operation belongs — neither branch can fire regardless of valve position; judging coil operation inside that band is AHU-0016’s job, not this rule’s. persist requires 30 minutes of continuous violation, which rides out mode changes, morning warmup tails, and short manual valve strokes.

Possible Diagnoses

  1. Lockout sequence never programmed in the BAS
  2. Lockout overridden or disabled (often during a comfort complaint, then left)
  3. Valve stuck open — mechanical failure, failed actuator, or a normally-open valve with no signal
  4. Incorrect OAT sensor reading (sun-exposed or wall-heated sensor reads warm; a sensor in an exhaust path reads warm year-round)

Energy Impact

CRITICAL_WASTE, MEDIUM confidence, DIRECT_MEASUREMENT. Waste is computable from the live valve command and the coil’s design capacity: heating branch waste_kw = htg_vlv_cmd/100 × ahu_htg_capacity_kw, cooling branch waste_kw = clg_vlv_cmd/100 × ahu_clg_capacity_kw. Correcting the lockout saves 5–15% of the affected subsystem’s energy while the fault is active (PNNL-25985 EEM-38). Confidence is MEDIUM rather than HIGH because the lockout temperatures are site-specific: a building with a genuine year-round reheat load or a heat-recovery scheme may legitimately hold a coil open outside the default band. Sensitive to both climates — the heating branch bites in shoulder seasons and summer, the cooling branch in winter.

Emissions Impact

Scope 1 + 2, DIRECT_EMISSIONS, MEDIUM confidence; typical 500–4,000 kg CO₂e/yr. The two branches land in different inventories: out-of-season heating is usually scope 1 gas at the boiler, out-of-season cooling is scope 2 electricity at the chiller. Avoided-emissions basis: marginal operating emissions rate (MOER).

Deviations

  • Severity 3 (warning), per the reference’s chapter 9 card — its only severity statement for this fault, since the §5.8.1 index carries no severity column. This chapter’s README previously mistranscribed it as 2, corrected alongside this card.
  • The reference tags this fault for both AHU and RTU. This card is the AHU-family instance; an RTU-family sibling would restate it against staged compressor and gas-valve commands rather than modulating valve positions.
  • All three comparisons are strict (>, >, <); the reference does not specify boundary behavior, so an OAT parked exactly on a lockout setpoint stays out of the alarm.
  • valve_open_threshold is one card parameter bound to two CXF paths (htgOpen.t, clgOpen.t), matching the reference’s single threshold. Hosts must set both together; a site needing per-coil thresholds should retune the paths individually and note the divergence.
  • Preconditions (fan running, OAT trustworthy) are declared in frontmatter for host enforcement rather than encoded in the block graph, as in AHU-0016.
  • persist.delayOnInit = true (Modelica/CDL default is false), the library’s standing choice: a violation already present at load still waits out the full 30 minutes instead of alarming on the first tick after a controller restart.

Notes

Both branches feed one persist timer, so a violation that switches branches without a gap keeps the timer running — which takes an 8 °C OAT swing inside a single tick. If both branches fire on the same day, suspect the sensor before the sequence.

Fix order within CLU-01: clear the trigger (AHU-0016) first, since a valve held open by a fighting control loop also reads as a missing lockout. When the lockout is genuinely absent, add one with hysteresis — typically disable heating above 16 °C and re-enable below 14 °C (playbook simultaneous-hc, step 2.4).

Test Vectors

9 scenarios, clock step 60 s over 3600 s.

ScenarioDescription
normal_heating_in_winterReference vector: heating valve at 40% with OAT 2 °C — below the heating lockout, so heating is legitimate
heating_when_warmReference vector: heating valve at 30% with OAT 25 °C — heating lockout is not active; alarms after alarm_delay (1800 s)
cooling_when_coldReference vector: cooling valve at 40% with OAT 2 °C — cooling lockout is not active; alarms after alarm_delay
normal_cooling_in_summerCooling valve at 60% with OAT 28 °C — above the cooling lockout, so cooling is legitimate
valve_exactly_at_thresholdEdge case: heating valve sits exactly at valve_open_threshold (5%) in 25 °C weather — the strict > means the coil does not count as open
oat_at_heating_lockout_then_aboveEdge case: OAT sits exactly at heating_lockout_temp (18 °C) with the heating valve open — no alarm; at t=1200 OAT rises to 18.1 °C and the alarm follows 1800 s later
oat_at_cooling_lockoutEdge case: OAT sits exactly at cooling_lockout_temp (10 °C) with the cooling valve open — the strict < means no alarm
warm_spell_clears_before_delayTransient: heating runs in 25 °C air, but OAT drops back to 5 °C at t=1200 — the condition clears before alarm_delay completes, so no alarm
valve_cycle_resets_persistenceTransient: cooling valve closes at t=900 and reopens at t=1200 in 2 °C air — persistence restarts from the reopen, so the alarm lands at 3000 s, not 1800 s
vectors.json
{
  "schema": "cxf-library/vectors/v1",
  "clock": {
    "step_s": 60,
    "horizon_s": 3600
  },
  "scenarios": [
    {
      "name": "normal_heating_in_winter",
      "description": "Reference vector: heating valve at 40% with OAT 2 \u00b0C \u2014 below the heating lockout, so heating is legitimate",
      "inputs": {
        "htg_vlv_cmd": 40.0,
        "clg_vlv_cmd": 0.0,
        "oat": 2.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "heating_when_warm",
      "description": "Reference vector: heating valve at 30% with OAT 25 \u00b0C \u2014 heating lockout is not active; alarms after alarm_delay (1800 s)",
      "inputs": {
        "htg_vlv_cmd": 30.0,
        "clg_vlv_cmd": 0.0,
        "oat": 25.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1740,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 1860,
          "to_s": 3600,
          "equals": true
        }
      ]
    },
    {
      "name": "cooling_when_cold",
      "description": "Reference vector: cooling valve at 40% with OAT 2 \u00b0C \u2014 cooling lockout is not active; alarms after alarm_delay",
      "inputs": {
        "htg_vlv_cmd": 0.0,
        "clg_vlv_cmd": 40.0,
        "oat": 2.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1740,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 1860,
          "to_s": 3600,
          "equals": true
        }
      ]
    },
    {
      "name": "normal_cooling_in_summer",
      "description": "Cooling valve at 60% with OAT 28 \u00b0C \u2014 above the cooling lockout, so cooling is legitimate",
      "inputs": {
        "htg_vlv_cmd": 0.0,
        "clg_vlv_cmd": 60.0,
        "oat": 28.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "valve_exactly_at_threshold",
      "description": "Edge case: heating valve sits exactly at valve_open_threshold (5%) in 25 \u00b0C weather \u2014 the strict `>` means the coil does not count as open",
      "inputs": {
        "htg_vlv_cmd": 5.0,
        "clg_vlv_cmd": 5.0,
        "oat": 25.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "oat_at_heating_lockout_then_above",
      "description": "Edge case: OAT sits exactly at heating_lockout_temp (18 \u00b0C) with the heating valve open \u2014 no alarm; at t=1200 OAT rises to 18.1 \u00b0C and the alarm follows 1800 s later",
      "inputs": {
        "htg_vlv_cmd": 30.0,
        "clg_vlv_cmd": 0.0,
        "oat": [
          {
            "t": 0,
            "value": 18.0
          },
          {
            "t": 1200,
            "value": 18.1
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2940,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 3060,
          "to_s": 3600,
          "equals": true
        }
      ]
    },
    {
      "name": "oat_at_cooling_lockout",
      "description": "Edge case: OAT sits exactly at cooling_lockout_temp (10 \u00b0C) with the cooling valve open \u2014 the strict `<` means no alarm",
      "inputs": {
        "htg_vlv_cmd": 0.0,
        "clg_vlv_cmd": 45.0,
        "oat": 10.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "warm_spell_clears_before_delay",
      "description": "Transient: heating runs in 25 \u00b0C air, but OAT drops back to 5 \u00b0C at t=1200 \u2014 the condition clears before alarm_delay completes, so no alarm",
      "inputs": {
        "htg_vlv_cmd": 30.0,
        "clg_vlv_cmd": 0.0,
        "oat": [
          {
            "t": 0,
            "value": 25.0
          },
          {
            "t": 1200,
            "value": 5.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "valve_cycle_resets_persistence",
      "description": "Transient: cooling valve closes at t=900 and reopens at t=1200 in 2 \u00b0C air \u2014 persistence restarts from the reopen, so the alarm lands at 3000 s, not 1800 s",
      "inputs": {
        "htg_vlv_cmd": 0.0,
        "clg_vlv_cmd": [
          {
            "t": 0,
            "value": 40.0
          },
          {
            "t": 900,
            "value": 0.0
          },
          {
            "t": 1200,
            "value": 40.0
          }
        ],
        "oat": 2.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2940,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 3060,
          "to_s": 3600,
          "equals": true
        }
      ]
    }
  ]
}

AHU-0026 — Outdoor air damper not closed during unoccupied periods

Statusverified — engine e2ff2f8, cxf:fnv1a128:162a0ed14faa4a16fc9bb66a7c1eed88, 2026-08-17
Severity3
Methodrule
Phase2
CategoryCRITICAL_WASTE
ConfidenceHIGH
EstimationDIRECT_MEASUREMENT
G36
ClustersCLU-04
Suppresses
Suppressed by
RelatedAHU-0018, AHU-0017
Playbooksafter-hours-operation
SourceHVAC FDD Reference v1.0 §9, AHU-0026; PNNL RetuningOpps A11; PNNL-25985 EEM-06
Operating statesall

Preconditions (host-enforced): Occupancy schedule data available and current; the host evaluates the schedule (time zone, calendar, holidays) into the boolean occ_schedule point. When schedule provenance is unknown or stale, the verdict is NO_EVAL, not healthy.

Points: sf_status, occ_schedule, oa_dmpr_cmd

Outputs:

  • yFault — True while the supply fan has run unoccupied with the OA damper commanded above oa_closed_threshold for at least alarm_delay

Parameters:

NameDefaultUnitCXF pathDescription
oa_closed_threshold5.0%dmprOpen.tDamper command at or below which the outdoor air damper counts as closed
alarm_delay900.0spersist.delayTimeContinuous fault persistence required before the alarm asserts

Description

The air handler runs outside the occupancy schedule with the outdoor air damper still commanded open. Unoccupied operation — night setback, morning warmup, a tenant override — needs no ventilation: there is nobody to ventilate for, so every cubic metre of outdoor air drawn in is heated or cooled to no purpose. A damper left at its occupied minimum (typically 15–30%) through a winter night puts the full outdoor-to-return enthalpy difference on the coils for the entire run. Prevalence is about 10%, and the fix is usually a single line in the unoccupied sequence. This is the ventilation half of the after-hours pair: AHU-0018 asks whether the fan should be running at all, this one asks whether it is dragging in outdoor air while it runs.

Detection Logic

yFault = sf_status
     AND NOT occ_schedule
     AND oa_dmpr_cmd > oa_closed_threshold
     sustained continuously for alarm_delay

Block graph (rule.cxf.jsonld):

AHU-0026 block graph

unoccRun establishes that the fan is running while the schedule says unoccupied; dmprOpen tests the damper command against the closed threshold with a strict comparison, so a damper parked at exactly 5% (leakage band, or a minimum-position parameter never zeroed but within tolerance) does not alarm. persist requires the combination to hold for alarm_delay, which rides out the damper stroke at a scheduled occupied→unoccupied transition. Any fan stop, return to occupancy, or damper close resets the timer, and a damper that reopens restarts the full 15 min; delayOnInit = true holds the window across a controller restart.

Possible Diagnoses

  1. Minimum OA position not set to 0% in the unoccupied sequence — the damper holds its occupied minimum around the clock
  2. OA damper stuck open (seized linkage, failed actuator, disconnected crank arm)
  3. Economizer logic overriding unoccupied damper control — the economizer sees a favorable outdoor condition and modulates the damper open without consulting occupancy

Energy Impact

CRITICAL_WASTE, HIGH confidence, DIRECT_MEASUREMENT. The waste is the conditioning load on unnecessary outdoor air: waste_kw = (oa_dmpr_cmd/100) × design_oa_flow × cp × |OAT − RAT|. With the damper open, 100% of ventilation energy during the unoccupied run is waste; measured against total off-hours energy the correction typically returns 3–10% (PNNL-25985 EEM-06, OA damper faults; PNNL RetuningOpps A11). Heating-dominant: the winter night is when |OAT − RAT| is largest.

Emissions Impact

Scope 1 + 2, DIRECT_EMISSIONS, HIGH confidence; typical 1,000–8,000 kg CO₂e/yr for the ventilation energy alone. The split follows the heating source — scope 1 for the gas burned to temper unnecessary outdoor air, scope 2 for the electric heat or cooling. Because the fault runs overnight, the electric share should be valued at the marginal rather than average grid rate. Avoided-emissions basis: MOER.

Deviations

  • No grace period, matching the reference, where AHU-0018 grants 30 minutes. The difference is deliberate: legitimate unoccupied fan operation — setback, warmup, an authorized override — should still run with the OA damper closed, so there is no state this rule needs to forgive.
  • The reference’s logic uses a schedule-evaluation predicate over an occupancy schedule object; as in AHU-0018 we consume the host-evaluated boolean occ_schedule point, leaving time zone, calendar, and holiday interpretation to the host (see points/ahu.points.json notes on occ_schedule).
  • Severity 3 (warning), per the reference’s chapter 9 card — its only severity statement for this fault, since the §5.8.1 index carries no severity column. This chapter’s README previously mistranscribed it as 2, corrected alongside this card.
  • The reference tags this fault for both AHU and RTU. This is the AHU-family instance; an RTU sibling would reuse the same block graph against the RTU point dictionary.
  • delayOnInit = true on persist: a controller restart mid-condition still waits out the full persistence window (same startup-alarm rationale as AHU-0016 and AHU-0018).

Notes

The remote fix is a sequence edit — zero the minimum OA position in unoccupied mode and gate the economizer on occupancy — Step 2 of the after-hours-operation playbook. Firing alongside AHU-0018 (the CLU-04 trigger), fix the schedule first: restoring the unoccupied period often closes the damper as a side effect. Firing without FC-052, the schedule is right and the damper sequence or the actuator is wrong.

Test Vectors

9 scenarios, clock step 60 s over 3600 s.

ScenarioDescription
unoccupied_damper_openReference vector: fan on, damper 30%, unoccupied; alarms after alarm_delay (900 s)
unoccupied_damper_closedReference vector: fan on unoccupied with the OA damper commanded shut — the intended night sequence
occupied_damper_openReference vector: fan on, damper 30%, occupied — minimum OA is required
fan_off_damper_openDamper left open unoccupied but the fan is off; no air is being drawn, so no ventilation waste
damper_at_thresholdThreshold edge: damper exactly at oa_closed_threshold (5%); the comparison is strict, so leakage-band positions do not alarm
damper_just_above_thresholdThreshold edge: damper 0.1% above oa_closed_threshold alarms on the same schedule as a wide-open damper
damper_closes_within_alarm_delayTransient: the unoccupied sequence drives the damper shut at t=600 s, before alarm_delay completes; never alarms
damper_reopens_restarts_persistenceTransient: damper closes at t=600 s then reopens at t=1200 s; persistence restarts from the reopen, alarming at 2100 s
occupancy_resumes_resets_persistenceTransient: the occupied period starts at t=600 s while the damper is open; the timer resets and never alarms
vectors.json
{
  "schema": "cxf-library/vectors/v1",
  "clock": {
    "step_s": 60,
    "horizon_s": 3600
  },
  "scenarios": [
    {
      "name": "unoccupied_damper_open",
      "description": "Reference vector: fan on, damper 30%, unoccupied; alarms after alarm_delay (900 s)",
      "inputs": {
        "sf_status": true,
        "oa_dmpr_cmd": 30.0,
        "occ_schedule": false
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 840,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 960,
          "to_s": 3600,
          "equals": true
        }
      ]
    },
    {
      "name": "unoccupied_damper_closed",
      "description": "Reference vector: fan on unoccupied with the OA damper commanded shut \u2014 the intended night sequence",
      "inputs": {
        "sf_status": true,
        "oa_dmpr_cmd": 0.0,
        "occ_schedule": false
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "occupied_damper_open",
      "description": "Reference vector: fan on, damper 30%, occupied \u2014 minimum OA is required",
      "inputs": {
        "sf_status": true,
        "oa_dmpr_cmd": 30.0,
        "occ_schedule": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "fan_off_damper_open",
      "description": "Damper left open unoccupied but the fan is off; no air is being drawn, so no ventilation waste",
      "inputs": {
        "sf_status": false,
        "oa_dmpr_cmd": 30.0,
        "occ_schedule": false
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "damper_at_threshold",
      "description": "Threshold edge: damper exactly at oa_closed_threshold (5%); the comparison is strict, so leakage-band positions do not alarm",
      "inputs": {
        "sf_status": true,
        "oa_dmpr_cmd": 5.0,
        "occ_schedule": false
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "damper_just_above_threshold",
      "description": "Threshold edge: damper 0.1% above oa_closed_threshold alarms on the same schedule as a wide-open damper",
      "inputs": {
        "sf_status": true,
        "oa_dmpr_cmd": 5.1,
        "occ_schedule": false
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 840,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 960,
          "to_s": 3600,
          "equals": true
        }
      ]
    },
    {
      "name": "damper_closes_within_alarm_delay",
      "description": "Transient: the unoccupied sequence drives the damper shut at t=600 s, before alarm_delay completes; never alarms",
      "inputs": {
        "sf_status": true,
        "oa_dmpr_cmd": [
          {
            "t": 0,
            "value": 30.0
          },
          {
            "t": 600,
            "value": 0.0
          }
        ],
        "occ_schedule": false
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "damper_reopens_restarts_persistence",
      "description": "Transient: damper closes at t=600 s then reopens at t=1200 s; persistence restarts from the reopen, alarming at 2100 s",
      "inputs": {
        "sf_status": true,
        "oa_dmpr_cmd": [
          {
            "t": 0,
            "value": 30.0
          },
          {
            "t": 600,
            "value": 0.0
          },
          {
            "t": 1200,
            "value": 30.0
          }
        ],
        "occ_schedule": false
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2040,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2160,
          "to_s": 3600,
          "equals": true
        }
      ]
    },
    {
      "name": "occupancy_resumes_resets_persistence",
      "description": "Transient: the occupied period starts at t=600 s while the damper is open; the timer resets and never alarms",
      "inputs": {
        "sf_status": true,
        "oa_dmpr_cmd": 30.0,
        "occ_schedule": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 600,
            "value": true
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    }
  ]
}

AHU-0027 — Manual override detected on critical control point

Statusverified — engine e2ff2f8, cxf:fnv1a128:3104e499f03189fbd6a10f3c3a9e1243, 2026-08-17
Severity4
Methodrule
Phase2
CategoryEXCESS_CONSUMPTION
ConfidenceLOW
EstimationQUALITATIVE_ONLY
G36
Clusters
Suppresses
Suppressed by
RelatedAHU-0018
Playbooksafter-hours-operation
SourceHVAC FDD Reference v1.0 §9, AHU-0027; PNNL RetuningOpps A30; PNNL-27338
Operating statesall

Preconditions (host-enforced): override_active is mapped from the BAS — a BACnet priority-array occupancy at priorities 1-8, or the vendor’s operator-override flag — on a control point that matters. The host must ensure the flag reflects operator or manual overrides only; automation modes (optimal start, demand response, smoke control) writing at their own priorities are not overrides and must not raise it.

Points: override_active

Outputs:

  • yFault — True once override_active has been held continuously for max_override_duration plus alarm_delay

Parameters:

NameDefaultUnitCXF pathDescription
max_override_duration86400.0sstaleOvr.delayTimeContinuous override duration beyond which the override counts as stale (24 h)
alarm_delay3600.0spersist.delayTimeFurther persistence required after the duration limit before the alarm asserts

Description

An override has been active on a control point for longer than any legitimate troubleshooting session. Overrides are a normal service tool — hold the valve open, watch the response, release it — but the release is the step that gets skipped. What was a ten-minute diagnostic becomes the building’s permanent control strategy, invisible to anyone reading the sequence, and it stays that way until someone audits the priority arrays. Around 10% of buildings carry at least one; they are a standard retro-commissioning finding (PNNL RetuningOpps A30, PNNL-27338). The cost is entirely a function of what was overridden — a heating valve held at 100% is expensive, a nuisance alarm limit is not — hence info severity. The value of the rule is that it turns an invisible condition into a work order.

Detection Logic

yFault = override_active
     sustained continuously for max_override_duration
     and then held a further alarm_delay

Block graph (rule.cxf.jsonld):

AHU-0027 block graph

Two delays in series measure the duration rather than reading it: staleOvr turns true only after override_active has been continuously true for max_override_duration (24 h), and persist requires a further alarm_delay (1 h) before yFault asserts — 25 h total for an override that is never released. Any release, however brief, drops both timers to false and discards the accumulated duration, so the alarm only ever describes one continuous override. An override released and re-applied restarts the full 25 h.

Possible Diagnoses

  1. Temporary override forgotten by the operator after troubleshooting
  2. Override set during commissioning and never removed — often predating the current operations staff
  3. Override set to mask an underlying equipment problem (a failed sensor, a stuck actuator), in which case releasing it will expose the real fault

Energy Impact

EXCESS_CONSUMPTION, LOW confidence, QUALITATIVE_ONLY. There is no per-fault energy model: the waste depends on which point is overridden and to what value, spanning from zero (an override matching what the sequence would command) to the full cost of a disabled control loop. Quantify it after identifying the point, using the Energy Impact Reference §4.4 framework. Prevalence ~10% of buildings (PNNL RetuningOpps A30); no PNNL EEM maps to this fault.

Emissions Impact

Scope 1 or 2, QUALITATIVE_EMISSIONS, LOW confidence, basis N/A. Which scope applies follows the overridden point: a hot-water valve, boiler enable, or gas-fired stage drives on-site combustion (scope 1); a fan, chiller, pump, or electric-heat command drives purchased electricity (scope 2); a setpoint or damper can drive both. No magnitude range until the point is known.

Deviations

  • The reference’s host-computed override_duration point is dropped: the duration is measured in-rule with a TrueDelay chain, so the rule deploys against a raw BAS flag with no host-side state machine and no derived point whose reset semantics would have to be specified and trusted. What is implemented — continuous override, any release resets — matches the reference’s intent for a stale override.
  • The trade-off is that the duration does not survive a controller restart: with delayOnInit = true an override already active for a week re-times from the restart and alarms 25 h later. That is the conservative direction; a host keeping its own override history can raise the fault sooner.
  • Two delays instead of one: the reference’s AlarmDelay is separate from max_override_duration, so we keep both parameters independently tunable even though a single 90000 s delay would behave identically at the default values. A site that wants a 4 h override limit changes one parameter.
  • The reference tags this fault for AHU, RTU, VAV, CHW, and HW. This is the AHU-family instance; the block graph is equipment-agnostic and each sibling family binds override_active from its own point dictionary.

Notes

Step 2 of the after-hours-operation playbook — release stuck overrides in the BACnet priority array — is this fault’s remote fix verbatim, which is why it shares that playbook. The two also co-occur: AHU-0018 treats an active override as justification for after-hours fan operation, so a forgotten override silences FC-052 while this rule flags the reason it is silent.

Take diagnosis 3 seriously before releasing anything. If the override was masking a failed sensor or a stuck actuator, releasing it puts the equipment back under a control loop that cannot work — trend the affected point for a shift after release.

Test Vectors

6 scenarios, clock step 300 s over 172800 s.

ScenarioDescription
no_overrideReference vector: no override anywhere in the 48 h window
override_two_hoursReference vector: a 2 h override — normal troubleshooting, well inside max_override_duration
override_forty_eight_hoursReference vector: override held continuously; alarms at max_override_duration (86400 s) + alarm_delay (3600 s) = 90000 s
release_just_before_alarmTiming edge: override released at 88800 s — past max_override_duration but 1200 s short of the alarm; never asserts
release_resets_durationTransient: a 20 h override released at 72000 s; the elapsed duration is discarded, so the remaining 28 h of the window stay clear
reoverride_restarts_durationTransient: released at 72000 s then re-applied at 75600 s; the clock restarts from the re-apply, alarming at 165600 s rather than 90000 s
vectors.json
{
  "schema": "cxf-library/vectors/v1",
  "clock": {
    "step_s": 300,
    "horizon_s": 172800
  },
  "scenarios": [
    {
      "name": "no_override",
      "description": "Reference vector: no override anywhere in the 48 h window",
      "inputs": {
        "override_active": false
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 172800,
          "equals": false
        }
      ]
    },
    {
      "name": "override_two_hours",
      "description": "Reference vector: a 2 h override \u2014 normal troubleshooting, well inside max_override_duration",
      "inputs": {
        "override_active": [
          {
            "t": 0,
            "value": true
          },
          {
            "t": 7200,
            "value": false
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 172800,
          "equals": false
        }
      ]
    },
    {
      "name": "override_forty_eight_hours",
      "description": "Reference vector: override held continuously; alarms at max_override_duration (86400 s) + alarm_delay (3600 s) = 90000 s",
      "inputs": {
        "override_active": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 89700,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 90300,
          "to_s": 172800,
          "equals": true
        }
      ]
    },
    {
      "name": "release_just_before_alarm",
      "description": "Timing edge: override released at 88800 s \u2014 past max_override_duration but 1200 s short of the alarm; never asserts",
      "inputs": {
        "override_active": [
          {
            "t": 0,
            "value": true
          },
          {
            "t": 88800,
            "value": false
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 172800,
          "equals": false
        }
      ]
    },
    {
      "name": "release_resets_duration",
      "description": "Transient: a 20 h override released at 72000 s; the elapsed duration is discarded, so the remaining 28 h of the window stay clear",
      "inputs": {
        "override_active": [
          {
            "t": 0,
            "value": true
          },
          {
            "t": 72000,
            "value": false
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 172800,
          "equals": false
        }
      ]
    },
    {
      "name": "reoverride_restarts_duration",
      "description": "Transient: released at 72000 s then re-applied at 75600 s; the clock restarts from the re-apply, alarming at 165600 s rather than 90000 s",
      "inputs": {
        "override_active": [
          {
            "t": 0,
            "value": true
          },
          {
            "t": 72000,
            "value": false
          },
          {
            "t": 75600,
            "value": true
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 165300,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 165900,
          "to_s": 172800,
          "equals": true
        }
      ]
    }
  ]
}

AHU-0028 — Mixing box damper fault — MAT outside the OAT/RAT envelope

Statusverified — engine e2ff2f8, cxf:fnv1a128:e2e36af9468f68ae62924385965827fb, 2026-08-17
Severity3
Methodrule
Phase2
CategoryCOMFORT_ENERGY
ConfidenceLOW
EstimationQUALITATIVE_ONLY
G36
ClustersCLU-09
SuppressesAHU-0002, AHU-0003, AHU-0005, AHU-0006, AHU-0008, AHU-0010, AHU-0012, AHU-0014, AHU-0015, AHU-0021, AHU-0030, RTU-0005, RTU-0006
Suppressed by
RelatedAHU-0002, AHU-0003
Playbookssensor-drift
SourceHVAC FDD Reference v1.0 §9, AHU-0028; Schein et al. 2006 (APAR Rule 1); Bushby et al. 2001 (NIST/CEC PIER Project 2.3, APAR rules 26/27 — earliest form of this envelope test); Torabi 2022; Gunay 2023
Operating statesall (supply fan running)

Preconditions (host-enforced): Supply fan running and the dampers commanded to a mixing state. The host must not evaluate during coil freeze-protection or within 2 min of an economizer mode transition, when MAT lags the mixture it is supposed to report. When the fan is off or damper state is unknown, the verdict is NO_EVAL, not healthy.

Points: oat, rat, mat

Outputs:

  • yFault — True while MAT has stayed outside the min(oat, rat)–max(oat, rat) envelope by more than sensor_tolerance for at least alarm_delay

Parameters:

NameDefaultUnitCXF pathDescription
sensor_tolerance2.0°CbelowMin.t, aboveMax.tCombined sensor accuracy allowance applied to both envelope bounds; MAT may sit this far outside min/max before it counts as a fault
alarm_delay900.0spersist.delayTimeContinuous fault persistence required before the alarm asserts (15 min)

Description

Mixed air is a blend of outdoor and return air, so its temperature must lie between the two source temperatures. When MAT falls outside the OAT–RAT envelope by more than combined sensor accuracy, the physics has been violated and one of three things is wrong: a temperature sensor is out of calibration, the MAT sensor is reading a stratified or sun-struck slice of the plenum rather than the mixture, or the mixing box itself is short-circuiting. This is APAR Rule 1 (Schein et al. 2006), the sanity check every mixed-air diagnostic is built on — the trigger of cluster CLU-09 (Sensor Integrity Failure) and the library’s first use of the suppression mechanism, since the MAT-consuming rules are running on data known to be wrong while it is active. Roughly 15% of buildings have at least one AHU with a mixed-air sensor this far out.

Detection Logic

T_min  = min(oat, rat) − sensor_tolerance
T_max  = max(oat, rat) + sensor_tolerance
yFault = (mat < T_min OR mat > T_max), sustained for alarm_delay

Block graph (rule.cxf.jsonld):

AHU-0028 block graph

The graph tests the same condition in gap form: lowGap computes min(oat, rat) − mat and highGap computes mat − max(oat, rat), and each gap is compared against sensor_tolerance by a strict GreaterThreshold — a gap of exactly 2.0 °C is not a fault, 2.1 °C is. outOfRange ORs the two sides and persist requires the excursion to hold for the full alarm_delay, so a MAT that swings out and back during a damper stroke never alarms. Recovery is immediate; delayOnInit = true holds the window across a controller restart. Which bound is which flips with the season — in cooling weather OAT is the upper bound and RAT the lower — so Min and Max are used rather than a fixed OAT-below-RAT assumption. During 100% outdoor-air or 100% return-air operation the mixture legitimately pins to one bound, which is why sensor_tolerance is a combined sensor-accuracy allowance and not a mixing-quality threshold.

Possible Diagnoses

  1. MAT sensor out of calibration or failed
  2. OAT sensor out of calibration
  3. RAT sensor out of calibration
  4. MAT sensor in a poor location — plenum stratification or solar gain on the sensor rather than the mixed stream
  5. Mixing box short-circuiting: OA bypassing the mix and striking the sensor directly

Energy Impact

COMFORT_ENERGY, LOW confidence, QUALITATIVE_ONLY. A mis-read MAT costs nothing by itself; its cost is indirect and can be large, since a biased mixed-air reading drives the economizer to the wrong damper position and can hold a coil open against outdoor air that would have done the job for free. PNNL EEM-01 (sensor recalibration) puts the recoverable range at 0–5% of site energy across an entire sensor population, sensor-dependent and climate-neutral. This rule’s value is the accuracy it restores to the rules it gates, not savings of its own.

Emissions Impact

QUALITATIVE_EMISSIONS, LOW confidence; no direct emissions — a sensor reading neither burns fuel nor draws power. Scope is recorded as 1|2 because it follows the subsystem the bad reading distorts: unnecessary preheat lands in scope 1, disabled economizing or extra mechanical cooling in scope 2, and the same drifted sensor can do both in different seasons. Avoided-emissions basis: N/A.

Deviations

  • The reference’s bound comparison is rewritten as a gap comparison. Compared directly against T_min/T_max, one card parameter would need two signed values, so a host retuning sensor_tolerance would have to negate one. Subtracting and comparing the gap against a positive threshold is algebraically identical (min − mat > tol ⟺ mat < min − tol) and leaves one number to retune, with both paths listed under params.sensor_tolerance.cxf.
  • GreaterThreshold is u > t, so a gap of exactly sensor_tolerance reads healthy, matching the reference’s strict mat < T_min / mat > T_max.
  • Suppression is declared, not encoded. A status-blind engine of independent composites cannot silence sibling rules, so the relationship lives in suppresses and CLU-09 for the host to enforce. The graph is equipment-agnostic: the two RTU entries in suppresses are gated by this rule instantiated on the RTU’s own mat/oat/rat points.
  • Freeze-protect operation and the two-minute window after an economizer mode change — periods when MAT legitimately disagrees with the steady-state mixture — are host-enforced preconditions rather than in-rule gates.
  • delayOnInit = true on persist: a controller restart mid-excursion still waits out the full 15 min before alarming (startup conservatism per AHU-0016).

Notes

The suppression contract matters more than the fault itself. While yFault is true the host must silence all thirteen rules in suppresses and report every MAT-derived verdict as NO_EVAL rather than healthy — silence is not a clean bill of health. Both halves are necessary: a rule that fires on garbage sensor data erodes operator trust, and a rule that reports “no fault” from the same garbage hides real problems. Hence Step 1.3 of the sensor-drift playbook — when several faults fire on one AHU at once, suspect a shared sensor first. The fix is on-site: recalibrate against a NIST-traceable reference, or replace ($30–$80).

Test Vectors

10 scenarios, clock step 60 s over 3600 s.

ScenarioDescription
normal_mixingReference vector: MAT 18 °C sits inside the 5–22 °C envelope
mat_below_bothReference vector: MAT 1 °C is 4 °C below min(oat, rat); alarms after alarm_delay (900 s)
mat_above_bothReference vector: MAT 26 °C is 4 °C above max(oat, rat); alarms after alarm_delay
within_toleranceReference vector: MAT 19 °C is 1 °C below min(oat, rat) — inside sensor_tolerance
edge_gap_equals_toleranceThreshold edge: min − mat = 2.0 °C exactly; the comparison is strict, so no fault
edge_gap_just_over_toleranceThreshold edge: min − mat = 2.1 °C clears the strict comparison and alarms after alarm_delay
summer_oat_above_ratCooling season, OAT above RAT: MAT 35 °C exceeds max(oat, rat) by 3 °C
full_outdoor_air_pins_to_boundEconomizer at 100% OA: MAT tracks OAT and lands 0.5 °C below it — tolerance absorbs it
transient_excursion_never_alarmsMAT dips out of range for 600 s (< alarm_delay) and recovers; persistence timer resets
fault_clears_on_recoverySustained excursion alarms at 900 s; the alarm drops on the tick MAT returns inside the envelope
vectors.json
{
  "schema": "cxf-library/vectors/v1",
  "clock": {
    "step_s": 60,
    "horizon_s": 3600
  },
  "scenarios": [
    {
      "name": "normal_mixing",
      "description": "Reference vector: MAT 18 \u00b0C sits inside the 5\u201322 \u00b0C envelope",
      "inputs": {
        "oat": 5.0,
        "rat": 22.0,
        "mat": 18.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "mat_below_both",
      "description": "Reference vector: MAT 1 \u00b0C is 4 \u00b0C below min(oat, rat); alarms after alarm_delay (900 s)",
      "inputs": {
        "oat": 5.0,
        "rat": 22.0,
        "mat": 1.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 840,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 960,
          "to_s": 3600,
          "equals": true
        }
      ]
    },
    {
      "name": "mat_above_both",
      "description": "Reference vector: MAT 26 \u00b0C is 4 \u00b0C above max(oat, rat); alarms after alarm_delay",
      "inputs": {
        "oat": 5.0,
        "rat": 22.0,
        "mat": 26.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 840,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 960,
          "to_s": 3600,
          "equals": true
        }
      ]
    },
    {
      "name": "within_tolerance",
      "description": "Reference vector: MAT 19 \u00b0C is 1 \u00b0C below min(oat, rat) \u2014 inside sensor_tolerance",
      "inputs": {
        "oat": 20.0,
        "rat": 22.0,
        "mat": 19.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "edge_gap_equals_tolerance",
      "description": "Threshold edge: min \u2212 mat = 2.0 \u00b0C exactly; the comparison is strict, so no fault",
      "inputs": {
        "oat": 20.0,
        "rat": 22.0,
        "mat": 18.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "edge_gap_just_over_tolerance",
      "description": "Threshold edge: min \u2212 mat = 2.1 \u00b0C clears the strict comparison and alarms after alarm_delay",
      "inputs": {
        "oat": 20.0,
        "rat": 22.0,
        "mat": 17.9
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 840,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 960,
          "to_s": 3600,
          "equals": true
        }
      ]
    },
    {
      "name": "summer_oat_above_rat",
      "description": "Cooling season, OAT above RAT: MAT 35 \u00b0C exceeds max(oat, rat) by 3 \u00b0C",
      "inputs": {
        "oat": 32.0,
        "rat": 24.0,
        "mat": 35.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 840,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 960,
          "to_s": 3600,
          "equals": true
        }
      ]
    },
    {
      "name": "full_outdoor_air_pins_to_bound",
      "description": "Economizer at 100% OA: MAT tracks OAT and lands 0.5 \u00b0C below it \u2014 tolerance absorbs it",
      "inputs": {
        "oat": 5.0,
        "rat": 22.0,
        "mat": 4.5
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "transient_excursion_never_alarms",
      "description": "MAT dips out of range for 600 s (< alarm_delay) and recovers; persistence timer resets",
      "inputs": {
        "oat": 5.0,
        "rat": 22.0,
        "mat": [
          {
            "t": 0,
            "value": 18.0
          },
          {
            "t": 600,
            "value": 1.0
          },
          {
            "t": 1200,
            "value": 18.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "fault_clears_on_recovery",
      "description": "Sustained excursion alarms at 900 s; the alarm drops on the tick MAT returns inside the envelope",
      "inputs": {
        "oat": 5.0,
        "rat": 22.0,
        "mat": [
          {
            "t": 0,
            "value": 1.0
          },
          {
            "t": 1800,
            "value": 18.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 840,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 960,
          "to_s": 1740,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 1860,
          "to_s": 3600,
          "equals": false
        }
      ]
    }
  ]
}

AHU-0029 — AHU operating mode mismatch

Statusverified — engine e2ff2f8, cxf:fnv1a128:502800a60ac3786128ffd6f563283d3b, 2026-08-17
Severity3
Methodrule
Phase2
CategoryCRITICAL_WASTE
ConfidenceMEDIUM
EstimationPROXY_ESTIMATION
G36
ClustersCLU-01
Suppresses
Suppressed by
RelatedAHU-0016, AHU-0025
Playbookssimultaneous-hc
SourceHVAC FDD Reference v1.0 §9, AHU-0029; Torabi et al. 2022; Gunay et al. 2022; G36 §5.16; PNNL-25985 EEM-38
Operating statesall occupied modes (host-gated)

Preconditions (host-enforced): The host derives expected_mode per G36 §5.16 from OAT, zone demands, and the occupancy schedule, and reports NO_EVAL — never healthy — whenever that derivation is unavailable, the unit is in a mode this rule does not encode (warmup, cooldown, setback, unoccupied), or the unit is inside a mode-transition window where actuators are still stroking to their new positions. The OAT sensor feeding the derivation must be trustworthy: a biased reading yields the wrong expected mode and this rule then reports a mismatch that the sequence did not commit (diagnosis 4), so OAT data quality gates this rule even though oat is not an input to the graph.

Points: expected_mode, htg_vlv_cmd, clg_vlv_cmd, oa_dmpr_cmd

Outputs:

  • yFault — True while an actuator has contradicted the expected operating mode continuously for at least alarm_delay

Parameters:

NameDefaultUnitCXF pathDescription
heating_mode_code11kHeat.kValue of expected_mode meaning HEATING
econ_mode_code21kEcon.kValue of expected_mode meaning ECONOMIZER
mech_cooling_mode_code31kMech.kValue of expected_mode meaning MECHANICAL_COOLING_MIN_OA
valve_open_threshold5.0%clgOpen.t, htgOpen.tValve command above which a coil counts as active; binds both the heating and the cooling test
econ_damper_threshold30.0%dmprEcon.tOA damper command above which the damper counts as economizing rather than sitting at its minimum position
alarm_delay1800.0spersist.delayTimeContinuous fault persistence required before the alarm asserts (30 min)

Description

Valve and damper positions contradict the operating mode the current conditions call for: the cooling coil open while the unit should be heating, the heating coil open while it should be economizing, the outdoor damper economizing while it should hold minimum outdoor air. Each actuator tracks its own loop faithfully, so nothing looks broken from the equipment side — only the sequencing that arbitrates between them is wrong, and comfort is usually maintained while two subsystems run against each other. Within CLU-01 this rule complements the trigger AHU-0016: FC-050 sees both coils fighting each other, this one sees a single subsystem fighting the mode, and a damper economizing during minimum-outdoor-air operation is invisible to FC-050 entirely.

Detection Logic

clg_open  = clg_vlv_cmd > valve_open_threshold
htg_open  = htg_vlv_cmd > valve_open_threshold
dmpr_econ = oa_dmpr_cmd > econ_damper_threshold

mismatch = (expected_mode = heating_mode_code      AND (clg_open OR dmpr_econ))
        OR (expected_mode = mech_cooling_mode_code AND (htg_open OR dmpr_econ))
        OR (expected_mode = econ_mode_code         AND (clg_open OR htg_open))

yFault = mismatch sustained continuously for alarm_delay

Block graph (rule.cxf.jsonld):

AHU-0029 block graph

expected_mode is the host’s answer to “what should this unit be doing right now”, per G36 §5.16; the graph only asks whether the actuators agree with it. Three integer constants (kHeat, kEcon, kMech) decode the mode into three mutually exclusive booleans, and three threshold tests decode the actuators. Each mode gate then ANDs its mode flag with the disjunction of the actuators that contradict it, so each test appears in exactly the two modes where it means something: an open cooling valve is wrong in HEATING and ECONOMIZER but right in MECHANICAL_COOLING_MIN_OA, and an open outdoor damper is wrong in HEATING and MECHANICAL_COOLING_MIN_OA but is the entire point of ECONOMIZER. All three comparisons are strict, so a valve parked at exactly 5% or a damper at exactly 30% is not a violation. persist requires 30 minutes of continuous mismatch, giving actuators half an hour to catch up after a mode flip; delayOnInit = true holds that window across a controller restart. If expected_mode matches none of the three codes, every mode gate is false and the rule is silent no matter what the actuators are doing — that silence is NO_EVAL territory, not a health claim (see Deviations).

Possible Diagnoses

  1. Sequencing logic error in the BAS programming — the mode arbitration itself is wrong, so the unit is executing the wrong sequence correctly
  2. Mode transition deadband too narrow: the unit hunts between modes and actuators never settle where the current mode wants them
  3. Valve stuck in the wrong position — actuator or linkage failure, or a normally-open valve with no signal
  4. Incorrect OAT sensor causing wrong mode selection: the sequence and the actuators are both fine and the derived expected mode is the thing that is wrong
  5. Manual override left on a valve or damper command (BACnet priority array)

Energy Impact

CRITICAL_WASTE, MEDIUM confidence, PROXY_ESTIMATION. There is no single waste term, because the cost depends on which subsystem is running against the mode. The reference gives 5–20% of the affected subsystem’s energy while the unit runs in the wrong mode (PNNL-25985 EEM-38), and the worst case — both coils active — is bounded by the AHU-0016 formula: waste_kw = htg_vlv_cmd/100 × ahu_htg_capacity_kw + clg_vlv_cmd/100 × ahu_clg_capacity_kw. PROXY rather than DIRECT because the rule observes commands and a derived mode, not thermal quantities, and the counterfactual is not measured. Sensitive to both climates.

Emissions Impact

Scope 1 + 2, PROXY_EMISSIONS, MEDIUM confidence; typical 500–5,000 kg CO₂e/yr. The split between inventories follows whichever subsystem is running in the wrong mode — gas at the boiler is scope 1, chiller and fan electricity are scope 2 — and a mismatch usually engages both. Avoided-emissions basis: marginal operating emissions rate (MOER).

Deviations

  • This rule consumes expected_mode where the reference lists oat. The reference’s own logic opens with expected_mode = determine_g36_mode(OAT, zone_demands, schedule) and annotates it host-side; deriving the mode needs zone demands and the schedule, neither of which is an AHU point, so the graph consumes the host’s output (precedent: AHU-0026 and occ_schedule). OAT survives as a precondition.
  • The integer encoding (1 = HEATING, 2 = ECONOMIZER, 3 = MECHANICAL_COOLING_MIN_OA) is this library’s convention — the reference names the modes without numbering them. The three codes are parameters, so a host with its own mode enum rebinds the constants instead of editing the graph.
  • An expected_mode outside the three codes leaves the rule structurally silent: all three gates go false and both coils can be wide open with no fault raised. Warmup, cooldown, setback, and unoccupied operation are states this rule cannot judge, and the host must report NO_EVAL for them. An explicit “unmapped mode” output was rejected — the host already knows which mode it derived.
  • All three comparisons are strict (>); the reference says nothing about boundary behavior, so a valve at exactly 5% and a damper on its 30% minimum-position setpoint stay out of the alarm.
  • valve_open_threshold is one card parameter bound to two CXF paths (clgOpen.t, htgOpen.t), matching the reference’s single tunable. Hosts must set both together; per-coil thresholds are a documented divergence.
  • The reference states three independent IF expected_mode = … branches; this rule ORs them behind one persistence timer, the shape every other card uses. Only one gate can be true at a time, so nothing is lost logically, but the output does not say which mode or actuator raised it — trend the four points.
  • persist.delayTime = 1800 s with delayOnInit = true (Modelica/CDL default is false), the library’s standing choice: a mismatch already present at load waits out the full 30 minutes rather than alarming on the first tick after a controller restart.
  • Severity 3 (warning) and the MEDIUM/PROXY grades come from the reference’s chapter 9 card, its only severity statement for this fault (the §5.8.1 index carries no severity column). This chapter’s README index still shows severity 2 and needs the same correction AHU-0025 received.
  • Frontmatter g36 is null even though G36 §5.16 is in source: SCHEMA.md reserves that field for the 001–049 G36-derived rules, and this is a research-backed 050-range rule citing G36 for the mode definitions only.

Notes

The rule tests only for actuators open when the mode says they should be shut. The converse — a damper stuck at minimum in ECONOMIZER, a cooling valve that will not open under mechanical cooling — is a failure to deliver, not to sequence, and belongs to AHU-0017 and the temperature-control rules. A clear yFault means no actuator is contradicting the mode, nothing more.

Check diagnosis 4 before anyone edits a sequence: this rule consumes a derived point, so a bad OAT reading arrives pre-laundered as a wrong mode and looks exactly like a programming error. AHU-0028’s envelope test is built from oat, so an outdoor sensor bad enough to select the wrong mode often shows up there too. Within CLU-01, clear the trigger (AHU-0016) first — a valve held open by a fighting control loop also contradicts whichever mode is active (playbook simultaneous-hc, steps 2.1–2.2).

Test Vectors

11 scenarios, clock step 300 s over 5400 s.

ScenarioDescription
heating_mode_consistentReference vector (oat 0 °C staged as expected_mode = 1, HEATING): heating valve at 40%, cooling valve shut, OA damper at its 10% minimum. Every actuator agrees with the mode, so nothing fires.
heating_mode_cooling_valve_openReference vector: HEATING mode with the cooling valve at 20% — the chiller is fighting the mode. Violation is present from t=0, so delayOnInit makes the alarm land 1800 s in.
econ_mode_consistentReference vector (oat 15 °C staged as expected_mode = 2, ECONOMIZER): both coils shut, OA damper at 80%. Free cooling done right — the damper test is not applied in ECONOMIZER, where an open damper is the point.
econ_mode_heating_valve_openReference vector: ECONOMIZER mode with the heating valve at 25% — the boiler is reheating air the damper just brought in for free cooling. Alarms after alarm_delay.
mech_cooling_heating_valve_openMECHANICAL_COOLING_MIN_OA with the cooling valve at 60% (legitimate) but the heating valve at 30% — classic simultaneous heating and cooling seen from the mode side. Damper at its 10% minimum is correct for this mode.
heating_mode_damper_economizingHEATING mode with the OA damper at 40% — the unit is pulling in cold outdoor air while the heating coil pays for it. Both coils are otherwise consistent with the mode; the damper alone raises the fault.
thresholds_exactly_at_edgeBoundary: in HEATING mode the cooling valve sits exactly at valve_open_threshold (5.0%) and the damper exactly at econ_damper_threshold (30.0%). Both tests are strict >, so neither counts as a violation and the rule stays clear.
thresholds_just_above_edgeBoundary from the other side: the same HEATING case with the cooling valve at 5.1% and the damper at 30.1%. Both tests now pass, so the alarm lands one alarm_delay after t=0.
unmapped_mode_stays_silentexpected_mode = 0 matches none of the three code constants, so every mode gate is false and both coils wide open with the damper at 100% raise nothing. Structural silence, not a health claim — the host owes a NO_EVAL here.
mode_switch_heals_before_delayCooling valve holds 60% while the derived mode is HEATING — a violation — until the mode flips to MECHANICAL_COOLING_MIN_OA at t=1200 s, where 60% cooling is exactly right. The violation ends 600 s short of alarm_delay, so the timer resets and no alarm is ever raised.
violation_alarms_then_clearsHEATING mode with the cooling valve at 20% asserts the alarm at 1800 s; the valve is driven shut at t=3000 s and yFault drops on that same tick, since TrueDelay only delays the rising edge.
vectors.json
{
  "schema": "cxf-library/vectors/v1",
  "clock": {
    "step_s": 300,
    "horizon_s": 5400
  },
  "scenarios": [
    {
      "name": "heating_mode_consistent",
      "description": "Reference vector (oat 0 \u00b0C staged as expected_mode = 1, HEATING): heating valve at 40%, cooling valve shut, OA damper at its 10% minimum. Every actuator agrees with the mode, so nothing fires.",
      "inputs": {
        "expected_mode": 1,
        "htg_vlv_cmd": 40.0,
        "clg_vlv_cmd": 0.0,
        "oa_dmpr_cmd": 10.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        }
      ]
    },
    {
      "name": "heating_mode_cooling_valve_open",
      "description": "Reference vector: HEATING mode with the cooling valve at 20% \u2014 the chiller is fighting the mode. Violation is present from t=0, so delayOnInit makes the alarm land 1800 s in.",
      "inputs": {
        "expected_mode": 1,
        "htg_vlv_cmd": 40.0,
        "clg_vlv_cmd": 20.0,
        "oa_dmpr_cmd": 10.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1500,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2100,
          "to_s": 5400,
          "equals": true
        }
      ]
    },
    {
      "name": "econ_mode_consistent",
      "description": "Reference vector (oat 15 \u00b0C staged as expected_mode = 2, ECONOMIZER): both coils shut, OA damper at 80%. Free cooling done right \u2014 the damper test is not applied in ECONOMIZER, where an open damper is the point.",
      "inputs": {
        "expected_mode": 2,
        "htg_vlv_cmd": 0.0,
        "clg_vlv_cmd": 0.0,
        "oa_dmpr_cmd": 80.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        }
      ]
    },
    {
      "name": "econ_mode_heating_valve_open",
      "description": "Reference vector: ECONOMIZER mode with the heating valve at 25% \u2014 the boiler is reheating air the damper just brought in for free cooling. Alarms after alarm_delay.",
      "inputs": {
        "expected_mode": 2,
        "htg_vlv_cmd": 25.0,
        "clg_vlv_cmd": 0.0,
        "oa_dmpr_cmd": 80.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1500,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2100,
          "to_s": 5400,
          "equals": true
        }
      ]
    },
    {
      "name": "mech_cooling_heating_valve_open",
      "description": "MECHANICAL_COOLING_MIN_OA with the cooling valve at 60% (legitimate) but the heating valve at 30% \u2014 classic simultaneous heating and cooling seen from the mode side. Damper at its 10% minimum is correct for this mode.",
      "inputs": {
        "expected_mode": 3,
        "htg_vlv_cmd": 30.0,
        "clg_vlv_cmd": 60.0,
        "oa_dmpr_cmd": 10.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1500,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2100,
          "to_s": 5400,
          "equals": true
        }
      ]
    },
    {
      "name": "heating_mode_damper_economizing",
      "description": "HEATING mode with the OA damper at 40% \u2014 the unit is pulling in cold outdoor air while the heating coil pays for it. Both coils are otherwise consistent with the mode; the damper alone raises the fault.",
      "inputs": {
        "expected_mode": 1,
        "htg_vlv_cmd": 45.0,
        "clg_vlv_cmd": 0.0,
        "oa_dmpr_cmd": 40.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1500,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2100,
          "to_s": 5400,
          "equals": true
        }
      ]
    },
    {
      "name": "thresholds_exactly_at_edge",
      "description": "Boundary: in HEATING mode the cooling valve sits exactly at valve_open_threshold (5.0%) and the damper exactly at econ_damper_threshold (30.0%). Both tests are strict `>`, so neither counts as a violation and the rule stays clear.",
      "inputs": {
        "expected_mode": 1,
        "htg_vlv_cmd": 40.0,
        "clg_vlv_cmd": 5.0,
        "oa_dmpr_cmd": 30.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        }
      ]
    },
    {
      "name": "thresholds_just_above_edge",
      "description": "Boundary from the other side: the same HEATING case with the cooling valve at 5.1% and the damper at 30.1%. Both tests now pass, so the alarm lands one alarm_delay after t=0.",
      "inputs": {
        "expected_mode": 1,
        "htg_vlv_cmd": 40.0,
        "clg_vlv_cmd": 5.1,
        "oa_dmpr_cmd": 30.1
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1500,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2100,
          "to_s": 5400,
          "equals": true
        }
      ]
    },
    {
      "name": "unmapped_mode_stays_silent",
      "description": "expected_mode = 0 matches none of the three code constants, so every mode gate is false and both coils wide open with the damper at 100% raise nothing. Structural silence, not a health claim \u2014 the host owes a NO_EVAL here.",
      "inputs": {
        "expected_mode": 0,
        "htg_vlv_cmd": 80.0,
        "clg_vlv_cmd": 80.0,
        "oa_dmpr_cmd": 100.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        }
      ]
    },
    {
      "name": "mode_switch_heals_before_delay",
      "description": "Cooling valve holds 60% while the derived mode is HEATING \u2014 a violation \u2014 until the mode flips to MECHANICAL_COOLING_MIN_OA at t=1200 s, where 60% cooling is exactly right. The violation ends 600 s short of alarm_delay, so the timer resets and no alarm is ever raised.",
      "inputs": {
        "expected_mode": [
          {
            "t": 0,
            "value": 1
          },
          {
            "t": 1200,
            "value": 3
          }
        ],
        "htg_vlv_cmd": 0.0,
        "clg_vlv_cmd": 60.0,
        "oa_dmpr_cmd": 10.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        }
      ]
    },
    {
      "name": "violation_alarms_then_clears",
      "description": "HEATING mode with the cooling valve at 20% asserts the alarm at 1800 s; the valve is driven shut at t=3000 s and yFault drops on that same tick, since TrueDelay only delays the rising edge.",
      "inputs": {
        "expected_mode": 1,
        "htg_vlv_cmd": 40.0,
        "clg_vlv_cmd": [
          {
            "t": 0,
            "value": 20.0
          },
          {
            "t": 3000,
            "value": 0.0
          }
        ],
        "oa_dmpr_cmd": 10.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1500,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2100,
          "to_s": 2700,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 3300,
          "to_s": 5400,
          "equals": false
        }
      ]
    }
  ]
}

AHU-0030 — Excess outdoor air during heating mode

Statusverified — engine e2ff2f8, cxf:fnv1a128:a1a61e83113bf6d634eb7702f954f32f, 2026-08-17
Severity3
Methodrule
Phase2
CategoryEXCESS_CONSUMPTION
ConfidenceHIGH
EstimationDIRECT_MEASUREMENT
G36
Clusters
Suppresses
Suppressed byAHU-0028
RelatedAHU-0028, AHU-0021, AHU-0006
Playbookseconomizer-failure
SourceHVAC FDD Reference v1.0 §9, AHU-0030; Schein et al. 2006 (APAR); PNNL-27338; Gunay 2023
Operating statesheating (htg_vlv_cmd is tested in-rule; deeper mode gating stays host-side)

Preconditions (host-enforced): Supply fan running. MAT must pass its integrity gate (AHU-0028, see suppressed_by) — the reference names that rule as this one’s prerequisite, because the outdoor air fraction is a ratio of temperature differences and a biased mixed-air reading moves it directly. The temperature-difference gate is signalled in-rule by yTempDeltaOk; when it is false the verdict is NO_EVAL, not healthy.

Points: oat, rat, mat, htg_vlv_cmd

Outputs:

  • yFault — True while the heating coil has been open and the outdoor air fraction more than oa_excess_margin above design, for at least alarm_delay, with the temperature difference large enough to evaluate
  • yTempDeltaOk — Evaluability signal — true when |oat − rat| exceeds min_delta; false means NO_EVAL and the host must ignore yFault

Parameters:

NameDefaultUnitCXF pathDescription
design_min_oa_fraction0.151designConst.kDesign minimum outdoor air fraction the unit should hold while heating (0–1)
oa_excess_margin0.151marginHigh.tTolerance above the design minimum fraction before the excess counts as a fault
min_delta6.0°CdeltaOk.tMinimum
valve_open_threshold5.0%htgOn.tHeating valve command above which the coil counts as heating
alarm_delay1800.0spersist.delayTimeContinuous fault persistence required before the alarm asserts (30 min)

Description

The heating coil is running and the unit is drawing well over its design minimum outdoor air. This is AHU-0021’s measurement narrowed to the operating state where excess ventilation costs the most: every extra cubic metre arrives at outdoor temperature and has to be lifted to supply temperature by the coil the rule is watching — in a −5 °C hour against 22 °C return air, 27 °C of lift on that share of the airflow. The classic cause is an economizer that never handed back: dampers open for free cooling in a mild afternoon, outdoor air turns cold overnight, and the sequence (or a stuck actuator, or a leaking blade seal) leaves them there while the heating coil compensates. Nothing about it is uncomfortable, so it survives until someone reads the fuel bill. Present in roughly 15% of buildings.

Detection Logic

oaf          = (mat − rat) / (oat − rat)
yTempDeltaOk = |oat − rat| > min_delta                (false ⇒ host reports NO_EVAL)
yFault       = (oaf − design_min_oa_fraction > oa_excess_margin)
               AND (htg_vlv_cmd > valve_open_threshold)
               AND yTempDeltaOk,
               sustained for alarm_delay

Block graph (rule.cxf.jsonld):

AHU-0030 block graph

The fraction chain is AHU-0021’s, unchanged: matRat and oatRat form the two differences, oaf divides them, and margin subtracts the design fraction so marginHigh tests the excess against a single positive threshold. htgOn adds the heating condition and and1 conjoins it with the excess; and2 then gates the whole finding on deltaOk, whose output is also the boundary output yTempDeltaOk. That gate is what makes the unguarded division safe: CDL Divide follows IEEE-754, so oat = rat yields ±∞ or NaN rather than an error, and a near-zero denominator turns ordinary sensor noise into a fraction of any magnitude. NaN compares false everywhere, but ±∞ and a noise-inflated finite fraction can both raise marginHigh, and and2 stops them. All three comparisons are strict: a valve parked at exactly 5%, a fraction sitting exactly at design_min_oa_fraction + oa_excess_margin, and a temperature difference of exactly min_delta all read as no-fault. persist requires 30 continuous minutes, riding out damper strokes and the mixing transient after a mode change; delayOnInit = true holds that window across a controller restart.

Possible Diagnoses

  1. OA damper stuck partially open
  2. OA damper minimum setpoint configured too high
  3. Economizer override not releasing after the transition out of free cooling
  4. Leaking OA damper seals

Energy Impact

EXCESS_CONSUMPTION, HIGH confidence, DIRECT_MEASUREMENT. The waste is computable from live data: excess_htg_kw = (actual_oaf − design_min_oa_fraction) × airflow × cp × (rat − oat), with the excess fraction already on the wire as oaf − designConst.k. Correcting it saves 3–15% of heating energy (PNNL EEM-06, OA damper faults; PNNL-27338), the top of that range in cold climates where (rat − oat) stays large for months. This is the defect AHU-0021 finds year-round, priced at its worst hour, which is why the two share a playbook.

Emissions Impact

Scope 1 + 2, DIRECT_EMISSIONS, HIGH confidence; typical 300–2,500 kg CO₂e/yr for the excess ventilation heating load. Most of it is scope 1 fuel at the boiler or furnace; the scope 2 share is whatever the site’s heating comes from electrically — heat pumps, electric resistance coils, and the extra fan energy of moving the air. Avoided-emissions basis: marginal operating emissions rate (MOER).

Deviations

  • min_delta is adopted, not transcribed: the reference states the fraction is computed only when |OAT − RAT| > min_delta but omits the parameter from its tunables table. 6.0 °C matches AHU-0021’s oaf_temp_threshold so the two rules agree on when the shared measurement is meaningful (PNNL-27338 uses 5 °F for the same computation); retune one and retune the other.
  • valve_open_threshold is likewise absent from the reference’s tunables table. 5% is what chapter 9 uses everywhere else a valve counts as open (AHU-0016, AHU-0025), so “heating” means the same thing chapter-wide.
  • The reference writes the test as oaf > (design_min_oa_fraction + oa_excess_margin), which would force the two tunables into one summed threshold. Feeding the design fraction as Reals.Sources.Constant.k and comparing the remaining margin against oa_excess_margin is algebraically identical and keeps both retunable alone.
  • Evaluability is an output, not just a precondition: the |oat − rat| test is computable from this rule’s own inputs, so SCHEMA.md requires exposing it as yTempDeltaOk. A false yFault under a false yTempDeltaOk means “unknown”, not “healthy”.
  • Heating is in-graph because htg_vlv_cmd is a measured point; everything beyond it — occupancy, unit mode, morning warmup — stays host-side per this library’s design stance, as in AHU-0017 and AHU-0021.
  • All three comparisons are strict (>); the reference does not specify boundary behavior, so the library’s strict convention applies.
  • persist.delayOnInit = true (Modelica/CDL default is false), the library’s standing choice: an excess already present at load waits out the full 30 minutes instead of alarming on the first tick after a controller restart.

Notes

This rule and AHU-0021 share the fraction core and differ in three ways, all from their respective reference cards: the htgOn term is in-graph here where FC-055 leaves its scope to the host, the excess margin is 0.15 against FC-055’s 0.10, and the energy term uses the signed (rat − oat) because in heating the sign is known. Deploying both is not redundant — this one alarms earlier in winter with a sharper cost estimate, FC-055 watches the rest of the year.

Verify AHU-0028 is clear before acting: a MAT sensor reading 3 °C low in −5 °C weather manufactures this fault out of nothing, which is why the reference names it a prerequisite. If the fraction is genuinely high, command the OA damper to minimum and watch MAT. It should climb toward return temperature within minutes; if it does not, the problem is mechanical and the economizer-failure playbook’s on-site steps apply. If it does, the sequence never commanded minimum position and the fix is at a desk.

Test Vectors

7 scenarios, clock step 60 s over 3600 s.

ScenarioDescription
normal_heating_ventilationReference vector: OAF 0.148 with the heating valve at 50% (oat −5 °C, rat 22 °C, mat 18 °C) — at design minimum, no excess
excess_oa_while_heatingReference vector: OAF 0.444 with the heating valve at 80% (oat −5 °C, rat 22 °C, mat 10 °C); alarms after alarm_delay (1800 s)
small_delta_not_evaluableReference vector:
valve_exactly_at_thresholdEdge case: heating valve sits exactly at valve_open_threshold (5%) with OAF 0.444 — the strict > means the coil does not count as heating
margin_exactly_at_thresholdEdge case: OAF 0.30 exactly (oat 2 °C, rat 22 °C, mat 16 °C), so the margin equals oa_excess_margin — the strict > means no fault
margin_just_over_thresholdEdge case: OAF 0.31 (mat 15.8 °C) clears the strict comparison and alarms after alarm_delay
valve_closes_before_delayTransient: OAF stays at 0.444 but the heating valve closes at t=1200 — 1200 s of heating, short of alarm_delay, so no alarm
vectors.json
{
  "schema": "cxf-library/vectors/v1",
  "clock": {
    "step_s": 60,
    "horizon_s": 3600
  },
  "scenarios": [
    {
      "name": "normal_heating_ventilation",
      "description": "Reference vector: OAF 0.148 with the heating valve at 50% (oat \u22125 \u00b0C, rat 22 \u00b0C, mat 18 \u00b0C) \u2014 at design minimum, no excess",
      "inputs": {
        "oat": -5.0,
        "rat": 22.0,
        "mat": 18.0,
        "htg_vlv_cmd": 50.0
      },
      "expect": [
        {
          "output": "yTempDeltaOk",
          "from_s": 0,
          "to_s": 3600,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "excess_oa_while_heating",
      "description": "Reference vector: OAF 0.444 with the heating valve at 80% (oat \u22125 \u00b0C, rat 22 \u00b0C, mat 10 \u00b0C); alarms after alarm_delay (1800 s)",
      "inputs": {
        "oat": -5.0,
        "rat": 22.0,
        "mat": 10.0,
        "htg_vlv_cmd": 80.0
      },
      "expect": [
        {
          "output": "yTempDeltaOk",
          "from_s": 0,
          "to_s": 3600,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1740,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 1860,
          "to_s": 3600,
          "equals": true
        }
      ]
    },
    {
      "name": "small_delta_not_evaluable",
      "description": "Reference vector: |oat \u2212 rat| = 2 \u00b0C (oat 20 \u00b0C, rat 22 \u00b0C, mat 21 \u00b0C, valve 20%) \u2014 the raw fraction reads 0.50, so an ungated rule would alarm; yTempDeltaOk stays false and holds yFault down",
      "inputs": {
        "oat": 20.0,
        "rat": 22.0,
        "mat": 21.0,
        "htg_vlv_cmd": 20.0
      },
      "expect": [
        {
          "output": "yTempDeltaOk",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "valve_exactly_at_threshold",
      "description": "Edge case: heating valve sits exactly at valve_open_threshold (5%) with OAF 0.444 \u2014 the strict `>` means the coil does not count as heating",
      "inputs": {
        "oat": -5.0,
        "rat": 22.0,
        "mat": 10.0,
        "htg_vlv_cmd": 5.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "margin_exactly_at_threshold",
      "description": "Edge case: OAF 0.30 exactly (oat 2 \u00b0C, rat 22 \u00b0C, mat 16 \u00b0C), so the margin equals oa_excess_margin \u2014 the strict `>` means no fault",
      "inputs": {
        "oat": 2.0,
        "rat": 22.0,
        "mat": 16.0,
        "htg_vlv_cmd": 50.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "margin_just_over_threshold",
      "description": "Edge case: OAF 0.31 (mat 15.8 \u00b0C) clears the strict comparison and alarms after alarm_delay",
      "inputs": {
        "oat": 2.0,
        "rat": 22.0,
        "mat": 15.8,
        "htg_vlv_cmd": 50.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1740,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 1860,
          "to_s": 3600,
          "equals": true
        }
      ]
    },
    {
      "name": "valve_closes_before_delay",
      "description": "Transient: OAF stays at 0.444 but the heating valve closes at t=1200 \u2014 1200 s of heating, short of alarm_delay, so no alarm",
      "inputs": {
        "oat": -5.0,
        "rat": 22.0,
        "mat": 10.0,
        "htg_vlv_cmd": [
          {
            "t": 0,
            "value": 80.0
          },
          {
            "t": 1200,
            "value": 0.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    }
  ]
}

AHU-0031 — Supply fan operating at excessive static pressure

Statusverified — engine e2ff2f8, cxf:fnv1a128:7137eb21f1dba596056b75ab058fd631, 2026-08-17
Severity3
Methodrule
Phase2
CategoryEXCESS_CONSUMPTION
ConfidenceHIGH
EstimationPROXY_ESTIMATION
G36
ClustersCLU-02
Suppresses
Suppressed by
RelatedAHU-0024, AHU-0001, FPB-0002
Playbooksmissing-reset
SourceHVAC FDD Reference v1.0 §9, AHU-0031; PNNL-25985 EEM-12/EEM-15; NIST TN 2024; PNNL-27338
Operating statesOccupied, fan running

Preconditions (host-enforced): AHU serves multiple zones; zone damper feedback available and aggregated by the host into zone_dmpr_pos_max, as AHU-0024. When zone damper data is missing or stale, the verdict is NO_EVAL, not healthy.

Points: dsp, dsp_sp, zone_dmpr_pos_max, sf_status

Outputs:

  • yFault — True while duct static pressure has held above high_sp_fraction of its setpoint with every zone damper below low_demand_damper_threshold and the fan running, for at least alarm_delay

Parameters:

NameDefaultUnitCXF pathDescription
high_sp_fraction0.951scaled.kFraction of the DSP setpoint above which the fan counts as running at the top of its pressure band
low_demand_damper_threshold50.0%dmprLow.tHighest zone damper position below which no zone is asking for the pressure being delivered
alarm_delay1800.0spersist.delayTimeContinuous fault persistence required before the alarm asserts (30 min)

Description

The fan holds duct static pressure at the top of its band while every zone damper sits well below fully open. Together those facts say the setpoint is higher than the system needs: the dampers are throttling away pressure the fan spent energy producing, and the excess leaves as noise, leakage, and heat. Fan power scales with the cube of pressure, so a setpoint 20% above what the worst zone needs costs roughly half again the fan energy — and trimming it back returns that energy immediately at no capital cost. DSP reset is absent in 74% of buildings (PNNL 151-building study); this is the CLU-02 member that catches the resulting operating symptom.

Detection Logic

yFault = dsp > dsp_sp × high_sp_fraction
     AND zone_dmpr_pos_max < low_demand_damper_threshold
     AND sf_status
     sustained continuously for alarm_delay

Block graph (rule.cxf.jsonld):

AHU-0031 block graph

scaled turns the live setpoint into the top-of-band pressure — a ratio rather than a fixed offset, so the test travels with a setpoint the reset is allowed to move. spHigh compares the measurement against that band and dmprLow against the host-aggregated damper maximum; demand requires both, which is the whole diagnostic content of the rule. Note what the pair excludes: high pressure with an open damper is a system doing its job, and low dampers at low pressure are a reset already working. gated adds the fan-running condition, since dsp and dsp_sp mean nothing with the fan off and a stale reading would otherwise alarm overnight. persist requires 30 minutes of continuous violation, riding out morning start-up and the settling period after a setpoint change; delayOnInit = true holds that window across a controller restart.

Possible Diagnoses

  1. DSP setpoint configured too high — a design-static value entered once and never revisited
  2. Trim-and-respond active but not aggressive enough (trim magnitude too small, trim interval too long, or a minimum setpoint floor set at the old fixed value)
  3. DSP reset responding to one rogue zone — a single box with a stuck damper or a failed flow sensor requests pressure continuously and holds the whole system up
  4. Zone damper feedback not wired back to the AHU controller, so the reset has nothing to respond to and parks at maximum

Energy Impact

EXCESS_CONSUMPTION, HIGH confidence, PROXY_ESTIMATION (EEM-12). Excess fan power follows the cube law: excess_fan_kw = ahu_fan_design_kw × [1 − (needed_dsp/actual_dsp)³], where needed_dsp is the pressure at which the most-open zone reaches its damper target. Savings run 5–30% of fan energy depending on how far above the true requirement the setpoint sits — the wide range is the cube law, not measurement uncertainty. Climate-neutral.

Emissions Impact

Scope 2, PROXY_EMISSIONS, HIGH confidence; typical 300–2,000 kg CO₂e/yr (excess fan energy). Fan waste runs through the whole occupied period, so it lands across the daytime grid mix rather than concentrating in any one hour. Avoided-emissions basis: marginal operating emissions rate (MOER).

Deviations

  • The reference’s dsp >= dsp_sp × high_sp_fraction becomes a strict >: CDL has no Reals.GreaterEqual, the exact-equality case has measure zero on a measured pressure signal, and the strict form errs toward silence.
  • The reference takes max() over a per-zone damper array; library v1 avoids array boundary points, so the host aggregates and feeds the scalar zone_dmpr_pos_max — the same point AHU-0024 consumes, flagged derived in the point dictionary.
  • The fan-running condition is in the block graph rather than the preconditions: the library gates operating state host-side, but the reference states sf_status = ON as part of its detection logic and sf_status is a canonical point, so it is wired as a boundary input (as in AHU-0018). The multi-zone and damper-data preconditions stay in frontmatter.
  • high_sp_fraction is dimensionless (0–1), consistent with the reference’s 0.95; low_demand_damper_threshold stays in percent because zone_dmpr_pos_max is a percent point.
  • persist.delayOnInit = true (Modelica/CDL default is false), the library’s standing choice: a violation already present at load waits out the full 30 minutes instead of alarming on the first tick after a controller restart.

Notes

This rule and AHU-0024 are the DSP half of CLU-02 from opposite sides. FC-058 watches the setpoint sit flat for three days and concludes the reset is absent; this rule does not care whether a reset exists, only that the fan is pushing pressure nobody is asking for. A misconfigured but active reset leaves FC-058 quiet and fires this rule — diagnosis 2. Both share the missing-reset playbook and the same $0 fix per G36 §5.16.1.

Before touching the setpoint, check for a rogue zone (playbook step 1.2). Healthy operation puts most zone dampers in the 50–75% band; all near 0% with pressure at maximum is this rule’s signature, and all near 100% is the opposite fault (AHU-0001, insufficient static pressure).

Test Vectors

9 scenarios, clock step 60 s over 5400 s.

ScenarioDescription
pressure_below_high_bandReference vector: 350 Pa against a 400 Pa setpoint (scaled band 380 Pa) with the highest damper at 85% — pressure is not pinned high, nothing to report
high_pressure_low_demandReference vector: 390 Pa against a 400 Pa setpoint with the highest damper at 35% — pressure held at the top of the band while no zone needs air; alarms after alarm_delay (1800 s)
high_pressure_high_demandReference vector: 390 Pa against a 400 Pa setpoint with the highest damper at 95% — the pressure is being asked for, so no fault
pressure_exactly_at_scaled_setpointEdge case: dsp sits exactly on dsp_sp × high_sp_fraction (380 Pa) with dampers at 35% — the strict > keeps the boundary out of the alarm
damper_exactly_at_thresholdEdge case: the highest damper sits exactly on low_demand_damper_threshold (50%) with pressure pinned at 390 Pa — the strict < means demand must be below the threshold, not at it
fan_off_stale_pressureGate: the fan is off while the pressure and damper readings still show the fault pattern — sf_status suppresses the rule outright
fan_cycle_restarts_persistenceTransient: the fan stops at t=600 and restarts at t=900 with the pressure pattern otherwise unchanged — persistence restarts from the restart, so the alarm lands at 2700 s, not 1800 s
reset_trims_pressure_before_delayTransient: trim-and-respond drops the setpoint to 300 Pa at t=1200 and the fan follows to 280 Pa — the condition clears inside alarm_delay, so a working reset never alarms
dampers_open_before_delayTransient: a zone opens up at t=1200 and takes the highest damper to 80% while pressure stays pinned — demand appears before the timer expires, so no alarm
vectors.json
{
  "schema": "cxf-library/vectors/v1",
  "clock": {
    "step_s": 60,
    "horizon_s": 5400
  },
  "scenarios": [
    {
      "name": "pressure_below_high_band",
      "description": "Reference vector: 350 Pa against a 400 Pa setpoint (scaled band 380 Pa) with the highest damper at 85% \u2014 pressure is not pinned high, nothing to report",
      "inputs": {
        "dsp": 350.0,
        "dsp_sp": 400.0,
        "zone_dmpr_pos_max": 85.0,
        "sf_status": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        }
      ]
    },
    {
      "name": "high_pressure_low_demand",
      "description": "Reference vector: 390 Pa against a 400 Pa setpoint with the highest damper at 35% \u2014 pressure held at the top of the band while no zone needs air; alarms after alarm_delay (1800 s)",
      "inputs": {
        "dsp": 390.0,
        "dsp_sp": 400.0,
        "zone_dmpr_pos_max": 35.0,
        "sf_status": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1740,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 1860,
          "to_s": 5400,
          "equals": true
        }
      ]
    },
    {
      "name": "high_pressure_high_demand",
      "description": "Reference vector: 390 Pa against a 400 Pa setpoint with the highest damper at 95% \u2014 the pressure is being asked for, so no fault",
      "inputs": {
        "dsp": 390.0,
        "dsp_sp": 400.0,
        "zone_dmpr_pos_max": 95.0,
        "sf_status": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        }
      ]
    },
    {
      "name": "pressure_exactly_at_scaled_setpoint",
      "description": "Edge case: dsp sits exactly on dsp_sp \u00d7 high_sp_fraction (380 Pa) with dampers at 35% \u2014 the strict `>` keeps the boundary out of the alarm",
      "inputs": {
        "dsp": 380.0,
        "dsp_sp": 400.0,
        "zone_dmpr_pos_max": 35.0,
        "sf_status": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        }
      ]
    },
    {
      "name": "damper_exactly_at_threshold",
      "description": "Edge case: the highest damper sits exactly on low_demand_damper_threshold (50%) with pressure pinned at 390 Pa \u2014 the strict `<` means demand must be below the threshold, not at it",
      "inputs": {
        "dsp": 390.0,
        "dsp_sp": 400.0,
        "zone_dmpr_pos_max": 50.0,
        "sf_status": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        }
      ]
    },
    {
      "name": "fan_off_stale_pressure",
      "description": "Gate: the fan is off while the pressure and damper readings still show the fault pattern \u2014 sf_status suppresses the rule outright",
      "inputs": {
        "dsp": 390.0,
        "dsp_sp": 400.0,
        "zone_dmpr_pos_max": 35.0,
        "sf_status": false
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        }
      ]
    },
    {
      "name": "fan_cycle_restarts_persistence",
      "description": "Transient: the fan stops at t=600 and restarts at t=900 with the pressure pattern otherwise unchanged \u2014 persistence restarts from the restart, so the alarm lands at 2700 s, not 1800 s",
      "inputs": {
        "dsp": 390.0,
        "dsp_sp": 400.0,
        "zone_dmpr_pos_max": 35.0,
        "sf_status": [
          {
            "t": 0,
            "value": true
          },
          {
            "t": 600,
            "value": false
          },
          {
            "t": 900,
            "value": true
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2640,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2760,
          "to_s": 5400,
          "equals": true
        }
      ]
    },
    {
      "name": "reset_trims_pressure_before_delay",
      "description": "Transient: trim-and-respond drops the setpoint to 300 Pa at t=1200 and the fan follows to 280 Pa \u2014 the condition clears inside alarm_delay, so a working reset never alarms",
      "inputs": {
        "dsp": [
          {
            "t": 0,
            "value": 390.0
          },
          {
            "t": 1200,
            "value": 280.0
          }
        ],
        "dsp_sp": [
          {
            "t": 0,
            "value": 400.0
          },
          {
            "t": 1200,
            "value": 300.0
          }
        ],
        "zone_dmpr_pos_max": 35.0,
        "sf_status": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        }
      ]
    },
    {
      "name": "dampers_open_before_delay",
      "description": "Transient: a zone opens up at t=1200 and takes the highest damper to 80% while pressure stays pinned \u2014 demand appears before the timer expires, so no alarm",
      "inputs": {
        "dsp": 390.0,
        "dsp_sp": 400.0,
        "zone_dmpr_pos_max": [
          {
            "t": 0,
            "value": 35.0
          },
          {
            "t": 1200,
            "value": 80.0
          }
        ],
        "sf_status": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        }
      ]
    }
  ]
}

AHU-0032 — SAT too high vs RAT in cooling

Statusverified — engine e2ff2f8, cxf:fnv1a128:f49e1d4d822154e274cde27cdf1ec318, 2026-08-18
Severity3
Methodrule
Phase2
CategoryEXCESS_CONSUMPTION
ConfidenceMEDIUM
EstimationPROXY_ESTIMATION
G36
ClustersCLU-01
Suppresses
Suppressed by
RelatedAHU-0012, AHU-0007, AHU-0013
Playbookssensor-drift, simultaneous-hc
SourceBushby, Castro, Schein, House (2001), NIST task report for CEC PIER Project 2.3 ‘Air Handling Unit and VAV Box Diagnostics’, §4.2 Table 1 rules 6, 12 and 17 — the rule expression, and Table 2’s zone-subsystem grouping recording that the three are identical; Same report, §4.2.3 — the threshold defaults: εt = 1.7 °C (3 °F) for every temperature-comparison rule, ∆Trf = 1.1 °C (2 °F) for the return-fan rise; House, Vaezi-Nejad, Whitcomb (2001), ‘An Expert Rule Set for Fault Detection in Air-Handling Units’, ASHRAE Transactions 107(1) — the paper the PIER report credits with deriving APAR; not consulted for this card; Sibling precedent: AHU-0012 (the MAT-based rise test this card mirrors onto RAT), AHU-0005 (the same comparison with the heating-mode sign), HW-0004 (library-extension framing, assembled limit); Library extension: the HVAC FDD Reference v1.0 index (§5.8.1) runs to AHU-0031 — see faults/ahu/README.md
Operating statesAPAR Modes 2-4, equivalently G36 OS#2-#4 (any cooling-side state) — host-gated

Preconditions (host-enforced): Supply fan running, the unit occupied, and the unit in a cooling-side state identified the way APAR identifies it — from the actuator signals alone: heating valve closed and either the OA damper modulating between minimum and full with both coils off (Mode 2 / OS#2), or the cooling valve open with the OA damper at 100% (Mode 3 / OS#3) or at minimum (Mode 4 / OS#4). Suspend evaluation for a mode-transition window (30 min, G36’s ModeDelay) after any change of mode or operating state, while the actuators are still stroking. rat must be the air actually coming back from the zones this AHU serves: a single return sensor on a multi-zone unit reads a flow-weighted mixture and this rule inherits that averaging, and a sensor sitting in a ceiling plenum reads roof and lighting heat rather than the space, which biases the comparison toward silence. return_fan_rise must match the installation, not the shipped 1.1 °C — set it to 0 on a unit with no return fan and on any unit whose return-air sensor is upstream of the return fan, since in both cases there is no fan heat in the reading to credit back. Both temperatures must be in °C; the rule converts nothing. Unlike its MAT-based cousins this rule needs no mixed-air sensor and is not disturbed by a faulted one, so AHU-0028 does not silence it. When any gate is unmet the verdict is NO_EVAL, not healthy.

Points: sat, rat

Outputs:

  • yFault — True while supply air has stayed more than epsilon_t above the return-air temperature corrected for return_fan_rise, continuously for at least alarm_delay. The rule’s only output — it has no evaluability flag, because it has no in-rule gate; every condition under which the verdict is NO_EVAL is a host precondition

Parameters:

NameDefaultUnitCXF pathDescription
epsilon_t1.7°CtooWarm.tTemperature-comparison allowance. 1.7 °C (3 °F) is the value APAR §4.2.3 applies flat to every one of its temperature-comparison rules, covering the combined uncertainty of the two sensors being compared. The report calls the number heuristic and names uncertainty composition (εt = εT1 + εT2) as the more rigorous replacement it had not yet adopted — a site with calibrated sensors composes its own sum, exactly as the G36-lineage cards in this chapter do
return_fan_rise1.1°Cexcess.pTemperature rise across the return fan, credited back so the comparison is against the air the zones returned rather than the air after the fan has warmed it. 1.1 °C (2 °F) is APAR §4.2.3’s typical value (∆Trf), which the report offers as a fixed stand-in for a model correlated to airflow or fan signal. SITE VALUE: set it to 0 on a unit with no return fan and on any unit whose return-air sensor is mounted upstream of the fan
alarm_delay1800.0spersist.delayTimeContinuous violation required before the alarm asserts (30 min). ADOPTED — APAR specifies no alarm persistence; it evaluates its rules on hourly data. 30 min matches the AHU comparison family in this chapter (AHU-0005, AHU-0012, AHU-0013)

Description

An air handler in a cooling mode has one job at the air stream: deliver air colder than the space it serves. Return air is the best available measure of that space, once the return fan’s own heat is taken back off the reading. When supply air is not below that corrected return temperature, the unit is running its fans and — in the mechanical modes — its coil, and the building is getting no cooling out of the exchange. The distinct thing about this rule is the sensor it does not need: nine of APAR’s 28 rules drop out entirely on a unit with no mixed-air sensor, and G36 marks the equivalent MAT test (AHU-0012) omit if no MAT sensor, so on that large population a SAT-versus-RAT comparison is the only whole-unit temperature check left. The trade is resolution — this one brackets the whole loop, return path included, so a dead coil, a heating source that never shut off, a warm-air economizer, or either sensor being wrong all land in the same alarm. Library extension: the reference’s AHU index stops at AHU-0031, so the logic and both threshold defaults come from APAR rules 6, 12 and 17 and the graph shape from AHU-0012.

Detection Logic

gap    = sat − rat
excess = gap + return_fan_rise      (= sat − (rat − return_fan_rise))
yFault = excess > epsilon_t,
         sustained continuously for alarm_delay

Block graph (rule.cxf.jsonld):

AHU-0032 block graph

APAR writes the rule as Tsa > Tra − ∆Trf + εt, a threshold on supply air that moves with the return temperature. Moving ∆Trf to the other side turns it into a threshold on a difference — (Tsa − Tra) + ∆Trf > εt — and that is what the graph computes: gap for the difference, excess to credit the return-fan rise back, tooWarm against the 1.7 °C allowance. The two source constants stay separate parameters rather than collapsing into one 0.6 °C threshold, because they are retuned for unrelated reasons: return_fan_rise is a fact about the installation and is 0 on any unit without a return fan, while epsilon_t is a sensor-uncertainty allowance. The comparison is strict, as in APAR, and at the shipped defaults the boundary is not decidable in floating point (see Deviations) — an ambiguity of femtokelvins on instruments rated to ±0.5 K. persist requires 30 continuous minutes, which separates a unit that cannot cool from a chilled-water valve still stroking after a mode change; recovery is immediate, and delayOnInit = true holds the window across a restart.

Possible Diagnoses

Library-authored. APAR detects rather than diagnoses; §4.2.2’s broad classes read against this comparison give:

  1. Cooling coil valve stuck closed, or an actuator that no longer strokes
  2. Chilled water unavailable or too warm at the coil — a plant problem, and the case where every AHU on the plant reports together
  3. DX stage or compressor not running when the sequence says it should be
  4. Coil fouled, air-bound, or too small for the load it now serves
  5. Heating source still active in a cooling mode — a leaking valve or a stage that never shut off; the simultaneous-heating-and-cooling diagnosis
  6. Economizer holding outdoor air warmer than the building (AHU-0009 tests that directly against setpoint)
  7. SAT sensor reading high, or RAT sensor reading low — nothing in the rule says which of the two moved
  8. Return-air sensor not measuring the space: ceiling-plenum mounted, or on a unit whose zones no longer return through the path it sits in

Energy Impact

EXCESS_CONSUMPTION, MEDIUM confidence, PROXY_ESTIMATION. The first cost is the whole fan energy of a unit conditioning nothing; in Modes 3 and 4 mechanical cooling is being paid for on top, and downstream VAV boxes that never see their zones satisfied drive dampers open and reheat on. undelivered_cooling_kw = supply_airflow_m3s × 1.2 × 1.005 × (sat − (rat − return_fan_rise)) sizes the sensible capacity the unit should be removing from the return stream and is instead adding to it; design airflow standing in for a measured one is what keeps it a proxy. The 2–5% savings range is carried across from AHU-0012’s reference row, since APAR publishes no savings figures. MEDIUM because the rule cannot separate its waste diagnoses from its sensor diagnoses — a SAT sensor reading 3 K high draws this trace and wastes nothing. Cooling-dominant.

Emissions Impact

Scope 2, PROXY_EMISSIONS, MEDIUM confidence. The dominant term is purchased electricity — fans moving air that does no work, plus chiller or compressor energy in the mechanical modes — so a marginal operating emissions rate is the right basis, and the load lands across occupied daytime hours where that rate is highest in most grids. Diagnosis 5 is the exception: a gas or electric heating source that never shut off gives the fault a scope 1 half, matching AHU-0012’s 1+2. The frontmatter records the scope this rule usually carries.

Deviations

  • This rule is a library extension, not a transcription: the reference’s AHU index (§5.8.1) stops at AHU-0031. The rule expression and both threshold defaults are APAR’s; the ID, name, severity, phase, category, energy figures and diagnosis list are authored here, as in HW-0004.
  • The scope is cooling-only and there is no heating mirror to write. APAR places the return-air comparison in Modes 2–4 and nowhere else, and Mode 1 has no return-air rule at all. The neighbouring coil-subsystem group (rules 1, 7, 11, 16) does flip its relational sign by mode, which makes a signed-by-mode reading of this rule a natural but wrong guess.
  • Three APAR rules, one card: rules 6, 12 and 17 differ only in the mode they are evaluated under, and mode applicability is host-side here. Precedent — AHU-0012 spans rules 11 and 16, AHU-0013 spans 13 and 19.
  • Rearranged into gap form with the two constants kept separate rather than pre-composed into a single 0.6 °C threshold (as AHU-0012 composes its three). One of the two terms is 0 for an entire population — units with no return fan, and units whose return-air sensor sits upstream of it — and asking a host to recompute 1.7 − 0.0 by hand is worse than one extra block.
  • The strict > is APAR’s own, but at the shipped defaults the boundary is not decidable: near room temperature the difference of two doubles moves in steps of ~3.6 × 10⁻¹⁵ K, so reachable values of excess straddle fl(1.7) without hitting it and a nominal 0.60 K gap reads healthy or faulted depending on which operands produced it. Both sides are pinned as vectors so the behaviour cannot change silently; the ambiguity is femtokelvin-scale.
  • Instantaneous samples with a persistence timer, against APAR’s hourly evaluation. The two are not equivalent: a supply temperature oscillating about the allowance never alarms here, because persistence restarts on every compliant tick. A steady offset — what a dead coil and a drifted sensor both produce — reads the same either way.
  • alarm_delay = 1800 s is adopted; APAR specifies no alarm persistence. 30 minutes is what AHU-0005, AHU-0012 and AHU-0013 use for the same class of comparison, and rides out a chilled-water valve stroking after a mode change.
  • Mode gating is host-side, matching the source’s own architecture: APAR classifies its five modes from the valve and damper signals alone, then selects rules by mode and evaluates them on temperatures. Nothing about the mode appears in this graph, and a verdict outside Modes 2–4, outside occupancy, or inside a transition window is NO_EVAL rather than healthy.
  • Severity 3 and phase 2 are the library’s; APAR assigns no severities. Warning matches every other temperature-comparison rule in this chapter and is honest for a finding whose most likely single cause is a sensor.
  • The energy profile is authored and its savings range borrowed: category, confidence and estimation_method are this card’s judgment, savings_range is AHU-0012’s reference row carried across — the weakest number on the card, labelled as such in its own field.
  • No evaluability output. The rule is a single comparison with no in-rule gate, so yFault is the only boundary output; everything that makes a verdict untrustworthy is a host precondition and none of it is separable in the graph.
  • APAR publishes rule expressions and threshold values, not test cases, so every scenario in vectors.json is authored.
  • The alarm-delay edge is asserted on the boundary tick rather than a step away, against SCHEMA.md’s usual margin, because Logical.TrueDelay asserts at exactly T + delayTime at the pinned engine revision and that is the fact worth pinning.
  • persist.delayOnInit = true (the Modelica/CDL default is false), the library’s standing choice: a violation already present at load waits out the full 30 minutes instead of alarming on the first tick after a controller restart.
  • clusters: [CLU-01] on the strength of diagnosis 5, the same grounds on which AHU-0012 is a member. The dominant reading remains cooling not delivered — CLU-01 groups the investigation, it does not redefine the fault.
  • playbooks cites two: sensor-drift first, because diagnoses 7 and 8 are the cheapest to eliminate and among the most likely to be right, then simultaneous-hc for diagnosis 5.
  • No suppression edge to AHU-0028. The MAT-based rules are silenced while the mixing-box rule is active; this rule never reads MAT, which is the same property that makes the card worth having.

Notes

Read this rule and AHU-0012 as one test with two instruments: FC-012 brackets the coil section and localises better, this one brackets the whole unit and covers more. A MAT fault does not disturb it, so on a unit whose mixing-box sensor has failed this rule still reports while the rest of the temperature family has gone quiet.

Check the two sensors first — a portable reference against SAT and RAT costs an hour and eliminates diagnoses 7 and 8 (sensor drift playbook). Note where the return sensor sits: plenum-mounted, it picks up lighting and roof heat, reads high, and biases this comparison toward silence. If the sensors check out, the cooling valve command discriminates — wide open with no temperature drop points at diagnoses 1–4 and the plant, closed with the air warming anyway at diagnosis 5 and the simultaneous-hc playbook.

Test Vectors

12 scenarios, clock step 300 s over 5400 s.

ScenarioDescription
cooling_air_well_below_the_spaceMinimum-OA mechanical cooling on a warm day: the space returns at 24 °C and the coil delivers 13 °C. Supply air is 11 K colder than the return, which is the sign every cooling-side mode expects, so there is nothing to report
supply_inside_the_fan_and_sensor_allowanceSupply air reads 0.5 K warmer than return air. The return-fan rise credit alone (1.1 K) more than covers that, so the comparison lands at 1.6 K against a 1.7 K allowance and the single conjunct is false — the rule’s only blocking condition, exercised alone
excess_just_inside_the_allowanceThreshold edge, healthy side: a 0.59 K gap puts the comparison at 1.69 K, one hundredth of a kelvin inside epsilon_t. Reachable as an exact double from these operands, unlike a nominal 0.60 (see the next two scenarios)
nominal_edge_operands_land_healthyA gap of nominally 0.60 K — the exact trip line for the shipped defaults — written as 24.7 − 24.1. The subtraction lands on 0.5999999999999979, the comparison on 1.699999999999998, and the strict > reads healthy. Paired with the next scenario, which is the same nominal gap on the other side of the line
nominal_edge_operands_land_faultedThe same nominal 0.60 K gap written as 24.6 − 24.0 lands on 0.6000000000000014 instead, the comparison on 1.7000000000000015, and the alarm asserts. The trip line falls between two doubles no realistic temperature pair can reach, so at exactly epsilon_t the verdict is decided by the ulp of the subtraction rather than by the rule — see Deviations
excess_just_outside_the_allowanceThreshold edge, faulted side: a 0.61 K gap puts the comparison at 1.71 K, and the alarm follows one alarm_delay later. Ten millikelvin either side of the line is decided the way the equation says
alarm_delay_edge_asserts_at_1800sBoth sides of the TrueDelay edge, pinned to the tick. A 2 K gap violates from load with delayOnInit true, so yFault is false through 1500 s and asserts at exactly 1800 s
chilled_water_lost_during_mechanical_coolingThe unit is in a cooling mode and supply air leaves 1.5 K warmer than the return — the coil is passing no useful capacity, so the fan is delivering the building’s own heat back to it. Comparison lands at 2.6 K against 1.7 K
return_sensor_reading_lowA plausible 22 °C supply against a 19 °C return in a building nowhere near 19 °C: the same 3 K violation, but here the return-air sensor is what is wrong. The rule reports the contradiction and cannot say which of the two sensors is lying
transient_clears_before_alarm_delayThe gap holds at 2 K for 1200 s while the chilled-water valve strokes open after a mode change, then supply air falls to 14 °C. Anything shorter than alarm_delay never reaches the operator
fault_clears_on_recoveryA sustained 2 K gap alarms at 1800 s; chilled water returns at t = 3000 and supply air falls to 14 °C. TrueDelay has no off-delay, so the alarm drops on that tick
oscillating_excess_never_alarmsA cooling loop hunting on a 10-minute period drives supply air between 26 °C and 20 °C. Its mean sits well outside the allowance and an hourly evaluation of averaged data would report the fault, but persistence restarts on every compliant tick, so this rule stays silent — the limit of substituting TrueDelay for a data window (see Deviations)
vectors.json
{
  "schema": "cxf-library/vectors/v1",
  "clock": {
    "step_s": 300,
    "horizon_s": 5400
  },
  "scenarios": [
    {
      "name": "cooling_air_well_below_the_space",
      "description": "Minimum-OA mechanical cooling on a warm day: the space returns at 24 \u00b0C and the coil delivers 13 \u00b0C. Supply air is 11 K colder than the return, which is the sign every cooling-side mode expects, so there is nothing to report",
      "inputs": {
        "sat": 13.0,
        "rat": 24.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        }
      ]
    },
    {
      "name": "supply_inside_the_fan_and_sensor_allowance",
      "description": "Supply air reads 0.5 K warmer than return air. The return-fan rise credit alone (1.1 K) more than covers that, so the comparison lands at 1.6 K against a 1.7 K allowance and the single conjunct is false \u2014 the rule's only blocking condition, exercised alone",
      "inputs": {
        "sat": 24.5,
        "rat": 24.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        }
      ]
    },
    {
      "name": "excess_just_inside_the_allowance",
      "description": "Threshold edge, healthy side: a 0.59 K gap puts the comparison at 1.69 K, one hundredth of a kelvin inside epsilon_t. Reachable as an exact double from these operands, unlike a nominal 0.60 (see the next two scenarios)",
      "inputs": {
        "sat": 24.59,
        "rat": 24.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        }
      ]
    },
    {
      "name": "nominal_edge_operands_land_healthy",
      "description": "A gap of nominally 0.60 K \u2014 the exact trip line for the shipped defaults \u2014 written as 24.7 \u2212 24.1. The subtraction lands on 0.5999999999999979, the comparison on 1.699999999999998, and the strict `>` reads healthy. Paired with the next scenario, which is the same nominal gap on the other side of the line",
      "inputs": {
        "sat": 24.7,
        "rat": 24.1
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        }
      ]
    },
    {
      "name": "nominal_edge_operands_land_faulted",
      "description": "The same nominal 0.60 K gap written as 24.6 \u2212 24.0 lands on 0.6000000000000014 instead, the comparison on 1.7000000000000015, and the alarm asserts. The trip line falls between two doubles no realistic temperature pair can reach, so at exactly epsilon_t the verdict is decided by the ulp of the subtraction rather than by the rule \u2014 see Deviations",
      "inputs": {
        "sat": 24.6,
        "rat": 24.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1500,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2100,
          "to_s": 5400,
          "equals": true
        }
      ]
    },
    {
      "name": "excess_just_outside_the_allowance",
      "description": "Threshold edge, faulted side: a 0.61 K gap puts the comparison at 1.71 K, and the alarm follows one alarm_delay later. Ten millikelvin either side of the line is decided the way the equation says",
      "inputs": {
        "sat": 24.61,
        "rat": 24.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1500,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2100,
          "to_s": 5400,
          "equals": true
        }
      ]
    },
    {
      "name": "alarm_delay_edge_asserts_at_1800s",
      "description": "Both sides of the TrueDelay edge, pinned to the tick. A 2 K gap violates from load with delayOnInit true, so yFault is false through 1500 s and asserts at exactly 1800 s",
      "inputs": {
        "sat": 26.0,
        "rat": 24.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1500,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 1800,
          "to_s": 5400,
          "equals": true
        }
      ]
    },
    {
      "name": "chilled_water_lost_during_mechanical_cooling",
      "description": "The unit is in a cooling mode and supply air leaves 1.5 K warmer than the return \u2014 the coil is passing no useful capacity, so the fan is delivering the building's own heat back to it. Comparison lands at 2.6 K against 1.7 K",
      "inputs": {
        "sat": 25.5,
        "rat": 24.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1500,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2100,
          "to_s": 5400,
          "equals": true
        }
      ]
    },
    {
      "name": "return_sensor_reading_low",
      "description": "A plausible 22 \u00b0C supply against a 19 \u00b0C return in a building nowhere near 19 \u00b0C: the same 3 K violation, but here the return-air sensor is what is wrong. The rule reports the contradiction and cannot say which of the two sensors is lying",
      "inputs": {
        "sat": 22.0,
        "rat": 19.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1500,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2100,
          "to_s": 5400,
          "equals": true
        }
      ]
    },
    {
      "name": "transient_clears_before_alarm_delay",
      "description": "The gap holds at 2 K for 1200 s while the chilled-water valve strokes open after a mode change, then supply air falls to 14 \u00b0C. Anything shorter than alarm_delay never reaches the operator",
      "inputs": {
        "sat": [
          {
            "t": 0,
            "value": 26.0
          },
          {
            "t": 1200,
            "value": 14.0
          }
        ],
        "rat": 24.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        }
      ]
    },
    {
      "name": "fault_clears_on_recovery",
      "description": "A sustained 2 K gap alarms at 1800 s; chilled water returns at t = 3000 and supply air falls to 14 \u00b0C. TrueDelay has no off-delay, so the alarm drops on that tick",
      "inputs": {
        "sat": [
          {
            "t": 0,
            "value": 26.0
          },
          {
            "t": 3000,
            "value": 14.0
          }
        ],
        "rat": 24.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1500,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2100,
          "to_s": 2700,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 3300,
          "to_s": 5400,
          "equals": false
        }
      ]
    },
    {
      "name": "oscillating_excess_never_alarms",
      "description": "A cooling loop hunting on a 10-minute period drives supply air between 26 \u00b0C and 20 \u00b0C. Its mean sits well outside the allowance and an hourly evaluation of averaged data would report the fault, but persistence restarts on every compliant tick, so this rule stays silent \u2014 the limit of substituting TrueDelay for a data window (see Deviations)",
      "inputs": {
        "sat": [
          {
            "t": 0,
            "value": 26.0
          },
          {
            "t": 600,
            "value": 20.0
          },
          {
            "t": 1200,
            "value": 26.0
          },
          {
            "t": 1800,
            "value": 20.0
          },
          {
            "t": 2400,
            "value": 26.0
          },
          {
            "t": 3000,
            "value": 20.0
          },
          {
            "t": 3600,
            "value": 26.0
          },
          {
            "t": 4200,
            "value": 20.0
          },
          {
            "t": 4800,
            "value": 26.0
          }
        ],
        "rat": 24.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        }
      ]
    }
  ]
}

AHU-0033 — Supply air temperature tracking error

Statusverified — engine e2ff2f8, cxf:fnv1a128:3aeb1453a7bb1cd2a02efab2f51a4722, 2026-08-18
Severity3
Methodrule
Phase2
CategoryCOMFORT_ENERGY
ConfidenceMEDIUM
EstimationPROXY_ESTIMATION
G36
Clusters
Suppresses
Suppressed by
RelatedAHU-0007, AHU-0013, AHU-0023
Playbooks
SourceBushby, Castro, Schein, House (2001), NIST/CEC PIER Project 2.3 report (Air Handling Unit and VAV Box Diagnostics), §4.2 Table 1 rule 25 — the rule expression; Same report §4.2.3 — εt = 1.7 °C (3 °F), the flat threshold APAR applies to every temperature-comparison rule; Same report §4.1 and §4.2 — Modes 1-5 derived from coil-valve and damper signals; Table 2 places rule 25 in the comfort-requirements group and scopes it to Modes 1-4; Adapted from an internal paraphrased digest of that report; the report itself is not redistributed with this library; Sibling precedent: AHU-0007 and AHU-0013 (the valve-gated SAT misses this rule deliberately drops the gate from), AHU-0010 (same four-block graph shape); Library extension: the HVAC FDD Reference v1.0 §5.8.1 indexes 31 AHU codes ending at AHU-0031 — see faults/ahu/README.md
Operating statesOccupied, in one of the four defined AHU modes — heating, cooling with outdoor air, mechanical cooling on 100% outdoor air, mechanical cooling at minimum outdoor air (APAR Modes 1-4, this library’s OS#1-#4) — host-gated. The unknown-mode case is excluded; see Deviations.

Preconditions (host-enforced): Supply fan running and the unit occupied — a supply temperature describes nothing in still air, and an unoccupied unit is not chasing a setpoint. The unit must be in one of the four defined occupied modes, which APAR derives from the coil-valve and damper commands exactly the way this library derives its operating states, and the host must suspend evaluation for the usual ModeDelay (30 min) after any mode or operating-state change, plus through morning warmup and cooldown. That gate matters more here than on AHU-0007 or AHU-0013: this rule carries no actuator conjunct, so nothing inside it distinguishes a unit still stroking toward a new mode from a unit that cannot hold setpoint. sat_sp must be the setpoint the sequence is actively holding, reset included; bind a design constant against a unit that follows a reset schedule and the rule reports a fault every hour of every day. SAT sensor integrity is a precondition, not a conclusion — a sensor reading 2 K off produces a permanent tracking error at a perfectly tuned loop, and this rule reads no second signal that could tell the two apart, so clear whatever sensor-health rule the host runs on sat before believing the verdict. The rule discards the sign of the miss; a host that wants the direction reads sat and sat_sp alongside the verdict. When any gate is unmet the verdict is NO_EVAL, not healthy.

Points: sat, sat_sp

Outputs:

  • yFault — True while |sat − sat_sp| has stayed above sat_error_threshold for at least alarm_delay. The only output — nothing in this rule is unevaluable from its own two inputs, so there is no evaluability flag and a host must not read one into it

Parameters:

NameDefaultUnitCXF pathDescription
sat_error_threshold1.7°CgapBig.tTwo-sided band around the active setpoint that SAT may stray within before the miss counts. Default 1.7 °C (3 °F) is APAR’s εt at §4.2.3, the single flat threshold that report applies to every one of its temperature-comparison rules. It is deliberately not G36’s eSAT = 1.0 °C, which AHU-0007 and AHU-0013 carry under this same parameter name: eSAT is a supply-air sensor accuracy allowance, while εt is a heuristic band the source states covers measurement error generally. A site with a calibrated SAT sensor and a loop it trusts may lower it toward 1.0 for coherence with those two cards; raising it hides the mistuning this rule exists to find.
alarm_delay3600.0spersist.delayTimeContinuous tracking error required before the alarm asserts (60 min). LIBRARY-CHOSEN — APAR specifies no per-rule persistence, and the implementation described in §4.3 evaluated its rules on hourly data, which is the nearest thing the source offers to a time constant. An hour is double the 30 min AHU-0007 and AHU-0013 use, because those two are protected by a saturated-valve conjunct that is itself rare and this rule has persistence and nothing else standing between it and every setpoint step, pulldown and load change. A site that wants the three SAT cards to alarm on the same clock retunes this to 1800.

Description

The supply air is not at its setpoint, and no actuator is at its stop. That second half is what makes this rule worth having separately. A loop whose valve has saturated is the easy case — the controller has asked for everything it has and the air is still wrong — and AHU-0007 and AHU-0013 state it, one per coil. A loop sitting 3 K off setpoint with its valve modulating around 60% is the harder case: the controller is not out of capacity, it is out of tune, or its valve has no authority left, or its actuator does not move until the ask gets large. Both sibling cards test the valve command first, so neither can ever report it. APAR states this as rule 25 and groups it with rules 3, 13 and 19 as comfort sacrificed, only the other three additionally establishing that the loop has run out of control authority. The fault is quiet by nature: zones compensate, boxes open, reheat picks up, and nobody files a ticket about a supply temperature.

Detection Logic

APAR rule 25, applicable in every defined occupied mode:

    | Tsa − Tsa,s | > εt          εt = 1.7 °C (3 °F)

as implemented:

    sp_gap = sat − sat_sp
    yFault = (|sp_gap| > sat_error_threshold), sustained continuously for alarm_delay

Block graph (rule.cxf.jsonld):

AHU-0033 block graph

Four blocks, the same shape AHU-0010 uses for its equality test: spGap subtracts the setpoint from the measurement, absGap folds the two signs together, gapBig compares the magnitude against sat_error_threshold, and persist requires 60 continuous minutes before reporting. There is no fifth block, and the absence is the whole point of the card: every other SAT rule in this library carries a second conjunct — a saturated valve on AHU-0007 and AHU-0013, a reheat fraction on AHU-0019, a baseline on AHU-0022 — and each conjunct is what makes its rule specific and also what makes it blind. Rule 25 buys generality by spending specificity: it says the unit is not delivering what it was asked for and nothing at all about why. Persistence and the host’s mode gate are the only things keeping that statement from being noise, which is why both are set conservatively; delayOnInit = true holds the hour across a controller restart. gapBig is strict, as the source’s rule 25 is, so a miss sitting exactly on 1.7 K reads healthy — though exact equality is not reachable in doubles from a realistic temperature pair (see Deviations).

Possible Diagnoses

APAR names no per-rule causes; §4.2.2’s fault classes read through rule 25 and ordered by what an ungated tracking test finds first:

  1. Control-loop tuning — a wide proportional band, a slow integral term, or a loop detuned to stop it hunting and left parked off setpoint since
  2. Valve or damper authority: a valve sized to pass design flow at 20% open has no resolution left around setpoint
  3. Actuator stiction, hysteresis, or a slipping linkage — the actuator does move, eventually, and never quite enough (AHU-0020 catches the frank case)
  4. Coil or plant capacity short of saturation: degraded enough to miss setpoint, not enough to drive the valve to its stop, so the gated cards stay silent
  5. Sequencing logic errors — a reset stepping faster than the unit can follow, two sequences writing the same coil output, a changeover leaving the unit on the previous mode’s setpoint
  6. SAT sensor error: the air is at setpoint and the reading is not. Cheapest to rule out, and the reason sensor health is a precondition here
  7. Operator intervention — a valve in hand, a coil output overridden (AHU-0027 reports the override directly)
  8. A coil fighting the other coil: simultaneous heating and cooling holds SAT off setpoint at part-open commands, which AHU-0016 names

Energy Impact

COMFORT_ENERGY, MEDIUM confidence, PROXY_ESTIMATION. The category follows the source: rule 25 is in APAR’s comfort-requirements group, and what it establishes first is that the building is not getting the air it asked for. The runtime estimate is therefore an imbalance rather than a waste — imbalance_kw = supply_airflow_m3s × 1.2 × 1.005 × |sat − sat_sp|, host-supplied airflow — and the sign decides whether it is money: air colder than a cooling setpoint is over-cooling paid once at the coil and often again at terminal reheat, air warmer is under-delivery whose cost migrates downstream to boxes at maximum flow. Both invert in heating. No savings range is published; the reference’s 2–5% belongs to the saturated-coil siblings, so treat it as a ceiling. MEDIUM because the cause — which decides the cost and even its sign — is not in the rule’s two inputs. Climate-neutral.

Emissions Impact

PROXY_EMISSIONS, MEDIUM confidence. Scope is 1|2 because the rule spans every occupied mode and cannot tell which coil is involved: a heating-side miss made up by a gas boiler or steam coil is scope 1, while a cooling-side miss, electric heating, and the fan energy moving make-up air are scope 2. Basis: static combustion factor for the fuel half, MOER for the electric half. As on AHU-0013, fixing an under-delivery instance can raise site emissions — a loop that finally holds setpoint delivers conditioning the building was going without — so the claim that survives is the over-conditioning branch plus whatever downstream compensation stops.

Deviations

  • This card is a library extension, not a transcription: the reference’s §5.8.1 index ends at AHU-0031. The detection logic is APAR rule 25 (§4.2 Table 1) with its threshold from §4.2.3; severity, phase, energy and emissions grades, persistence, the diagnosis list and the prose are authored here. The source report is personally licensed and not redistributed with this library.
  • The valve-position gate is absent on purpose, and that absence is the card. APAR pairs a SAT miss with a saturated coil in rules 3, 13 and 19 and states rule 25 with the temperature term alone. AHU-0007 and AHU-0013 argue that a SAT miss at a part-open valve is a loop working through a load change — right for a rule that fires in half an hour, wrong as a general claim, since a loop 30 minutes or six months off setpoint at 60% command is working through nothing. This rule takes the other half of the trade and pays with a longer persistence and a stricter host gate.
  • The overlap with AHU-0007 and AHU-0013 is real and not suppressed: a saturated valve missing setpoint trips this rule too, an hour later. The two findings are different statements and the gated one is more informative when both are true, so a host wanting one alarm should rank rather than silence.
  • εt = 1.7 °C ships flat as the source states it, not composed. The G36-lineage cards here do compose their bands (AHU-0010 in quadrature, AHU-0005 linearly), so this is a departure from local practice in favour of source fidelity — and the honest reading, since the band must absorb sensor error and the tracking error a healthy proportional loop shows at partial load, of which only the first half has a published budget.
  • sat_error_threshold shares its name with AHU-0007 and AHU-0013 but not its value or meaning: 1.0 °C there (G36’s eSAT, one-sided), 1.7 °C here (APAR’s εt, two-sided). The shared name keeps one vocabulary for “how far SAT may stray”; the parameter description states the difference so nobody copies a value across.
  • alarm_delay = 3600 s is library-chosen; APAR states no persistence. Its §4.3 implementation evaluated rules on hourly data, and an hour matches this chapter’s other chronic conditions. It doubles the siblings’ 30 minutes because their second conjunct is rare enough that most transients never reach their timer, whereas here the timer is the only defence.
  • No boundary rewrite: rule 25 is already strict, unlike the G36 the sibling cards convert. Exact equality is also unreachable — the double nearest 1.7 needs mantissa bits down to 2⁻⁵² and a difference of two temperatures in the 8–16 binade is a multiple of 2⁻⁴⁹ — so the vectors bracket the line instead of landing on it. Same class of finding as HW-0004’s 5.55 K trip line.
  • Mode scope follows the source’s Table 2 (Modes 1–4), not Table 1’s “all occupied modes” heading, which would include APAR’s unknown mode. That is where the report puts mode transitions and simultaneous heating and cooling, and a SAT miss there is already reported with its cause attached by AHU-0016 and AHU-0029. A host can widen the gate.
  • Mode gating is host-side, and the source agrees: APAR classifies its five modes from coil-valve and damper signals alone, then evaluates only the applicable rules — this library’s operating_states plus preconditions convention, reached independently two decades earlier. A verdict produced outside the four modes, in a transition window, or with the fan off is NO_EVAL and never healthy.
  • Instantaneous samples against an hourly source. An hourly average tolerates a signal that keeps crossing back while its mean stays outside the band; persistence does not, so SAT oscillating across the band never alarms here. That case is AHU-0022’s to report, which is the reason to deploy both.
  • The sign of the miss is computed and then discarded, because the source’s expression is a magnitude. Exposing it would add a block and an output to a rule whose value is its bluntness, and a host holding sat and sat_sp has the sign for free. Same treatment as AHU-0010.
  • outputs carries yFault alone: every evaluability question this rule has — occupancy, mode, fan status, whether the setpoint is live, whether the SAT sensor is trustworthy — needs a signal the rule does not bind, so all of them are preconditions and none qualifies for a y…Ok output.
  • Severity 3, phase 2 and the energy block are library-assigned; no reference row exists to copy. Severity 3 matches every comparison rule in this chapter, and savings_range declines to invent a number.
  • APAR publishes an expression and a threshold, not test cases, so every scenario in vectors.json is authored.
  • persist.delayOnInit = true (the CDL default is false), the library’s standing choice: a miss already present at load waits out the full hour rather than alarming on the first tick after a controller restart.
  • playbooks, clusters and both suppression lists are empty. playbooks/ has no loop-tuning or coil-capacity playbook, which is what the first four diagnoses dispatch, and sensor-drift and missing-reset each cover one diagnosis apiece — listing them would over-claim. Cluster membership is arguable but is the index owner’s edit, not this card’s.

Notes

Read this card as the complement to AHU-0007 and AHU-0013, not a replacement. The three partition the SAT-miss space by what the actuator is doing: those two cover the saturated end, where the diagnosis list is short and the fix is usually mechanical, this one everything below saturation, where the list is long and the fix is usually at a keyboard. Tripping alone, it is a tuning, authority, or sequence problem until proven otherwise; tripping with one of the gated pair, it is repeating that card’s fault an hour later.

Check the setpoint before the loop: a unit holding a design setpoint through a mild afternoon can miss it for reasons that have nothing to do with the loop (AHU-0023). This rule is only as meaningful as the setpoint it is handed — which is also the caution for any host binding a design constant to sat_sp. Then read the sign, which the rule computes and does not report: consistently warm in a cooling mode points at capacity, authority, or a coil fighting another coil, consistently cold at over-cooling (check AHU-0019), and a miss that changes sign through the day at tuning or hunting (AHU-0022).

Test Vectors

12 scenarios, clock step 300 s over 10800 s.

ScenarioDescription
sat_tracks_its_setpointHealthy occupied operation: a modulating loop holding 13.2 °C against a 13.0 °C setpoint. The 0.2 K residual is ordinary loop error, well inside the 1.7 K band, and no valve position is needed to say so.
mistuned_loop_runs_warm_at_a_part_open_valveThe case this rule exists for: SAT parked 3 K above a 13.0 °C setpoint with the cooling valve modulating around 60% and never saturating. AHU-0013 is structurally blind to it — its second conjunct needs the valve above 99% — and this rule sees it on two temperatures alone. Alarms after the 60 min delay.
mistuned_loop_runs_coldSame defect with the sign reversed: SAT 2.5 K below setpoint on a loop that never opens its heating valve far enough to trip AHU-0007. Pins the Abs — without it half the rule’s population would be invisible.
edge_positive_gap_at_the_thresholdStrict boundary, clear side. 14.7 − 13.0 is the closest a realistic temperature pair gets to APAR’s 1.7 K threshold from below: the difference evaluates to 1.6999999999999993, one ulp under the parameter, and GreaterThreshold is u > t, so it reads healthy.
edge_positive_gap_just_over_the_thresholdStrict boundary, fault side: the same setpoint with SAT 10 mK warmer puts the gap at 1.71 K, which clears u > t and alarms after the delay.
edge_negative_gap_at_the_thresholdSame boundary on the cold side: sat − sat_sp = −1.6999999999999993,
edge_negative_gap_just_over_the_thresholdCold side, fault: sat − sat_sp = −1.71 K. The magnitude clears the threshold and the alarm asserts at the same 10 mK margin the warm side does.
alarm_asserts_exactly_at_the_delayThe TrueDelay edge pinned tick by tick rather than with margin: a 3 K miss present from load, false at 3300 s, true at 3600 s. delayOnInit is true, so a miss already there when the controller restarts waits out the full hour instead of alarming on the first tick.
miss_clears_one_step_before_the_delay_expiresThe other side of the same edge: the miss holds from 0 s and ends at 3300 s, one step short of the hour. The timer resets on that tick and nothing is ever reported.
setpoint_reset_step_never_alarmsA reset schedule steps the setpoint down 3 K at 600 s and the coil takes 2400 s to catch up — 40 minutes of genuine tracking error caused by nothing but the step. Shorter than the hour, so it stays quiet. This transient is why the ungated form carries a longer delay than AHU-0007 and AHU-0013.
fault_clears_when_the_loop_is_retunedA 3 K miss alarms at 3600 s; at 7200 s the loop is corrected and SAT returns to 13.2 °C. The alarm drops on that tick — assertion waits out the delay, recovery does not.
oscillating_miss_never_alarmsPins the blind spot persistence buys. SAT swings between 16.0 °C and 13.2 °C every 20 minutes against a 13.0 °C setpoint — a hunting loop that spends half its day outside the band — and the timer resets on every compliant tick, so this rule stays silent. AHU-0022 is the rule that reports it.
vectors.json
{
  "schema": "cxf-library/vectors/v1",
  "clock": {
    "step_s": 300,
    "horizon_s": 10800
  },
  "scenarios": [
    {
      "name": "sat_tracks_its_setpoint",
      "description": "Healthy occupied operation: a modulating loop holding 13.2 \u00b0C against a 13.0 \u00b0C setpoint. The 0.2 K residual is ordinary loop error, well inside the 1.7 K band, and no valve position is needed to say so.",
      "inputs": {
        "sat": 13.2,
        "sat_sp": 13.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 10800,
          "equals": false
        }
      ]
    },
    {
      "name": "mistuned_loop_runs_warm_at_a_part_open_valve",
      "description": "The case this rule exists for: SAT parked 3 K above a 13.0 \u00b0C setpoint with the cooling valve modulating around 60% and never saturating. AHU-0013 is structurally blind to it \u2014 its second conjunct needs the valve above 99% \u2014 and this rule sees it on two temperatures alone. Alarms after the 60 min delay.",
      "inputs": {
        "sat": 16.0,
        "sat_sp": 13.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3300,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 3900,
          "to_s": 10800,
          "equals": true
        }
      ]
    },
    {
      "name": "mistuned_loop_runs_cold",
      "description": "Same defect with the sign reversed: SAT 2.5 K below setpoint on a loop that never opens its heating valve far enough to trip AHU-0007. Pins the Abs \u2014 without it half the rule's population would be invisible.",
      "inputs": {
        "sat": 10.5,
        "sat_sp": 13.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3300,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 3900,
          "to_s": 10800,
          "equals": true
        }
      ]
    },
    {
      "name": "edge_positive_gap_at_the_threshold",
      "description": "Strict boundary, clear side. 14.7 \u2212 13.0 is the closest a realistic temperature pair gets to APAR's 1.7 K threshold from below: the difference evaluates to 1.6999999999999993, one ulp under the parameter, and GreaterThreshold is u > t, so it reads healthy.",
      "inputs": {
        "sat": 14.7,
        "sat_sp": 13.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 10800,
          "equals": false
        }
      ]
    },
    {
      "name": "edge_positive_gap_just_over_the_threshold",
      "description": "Strict boundary, fault side: the same setpoint with SAT 10 mK warmer puts the gap at 1.71 K, which clears u > t and alarms after the delay.",
      "inputs": {
        "sat": 14.71,
        "sat_sp": 13.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3300,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 3900,
          "to_s": 10800,
          "equals": true
        }
      ]
    },
    {
      "name": "edge_negative_gap_at_the_threshold",
      "description": "Same boundary on the cold side: sat \u2212 sat_sp = \u22121.6999999999999993, |gap| sits on the line and reads healthy. The band is symmetric, so both signs have to be pinned.",
      "inputs": {
        "sat": 13.0,
        "sat_sp": 14.7
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 10800,
          "equals": false
        }
      ]
    },
    {
      "name": "edge_negative_gap_just_over_the_threshold",
      "description": "Cold side, fault: sat \u2212 sat_sp = \u22121.71 K. The magnitude clears the threshold and the alarm asserts at the same 10 mK margin the warm side does.",
      "inputs": {
        "sat": 13.0,
        "sat_sp": 14.71
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3300,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 3900,
          "to_s": 10800,
          "equals": true
        }
      ]
    },
    {
      "name": "alarm_asserts_exactly_at_the_delay",
      "description": "The TrueDelay edge pinned tick by tick rather than with margin: a 3 K miss present from load, false at 3300 s, true at 3600 s. delayOnInit is true, so a miss already there when the controller restarts waits out the full hour instead of alarming on the first tick.",
      "inputs": {
        "sat": 16.0,
        "sat_sp": 13.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3300,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 3600,
          "to_s": 10800,
          "equals": true
        }
      ]
    },
    {
      "name": "miss_clears_one_step_before_the_delay_expires",
      "description": "The other side of the same edge: the miss holds from 0 s and ends at 3300 s, one step short of the hour. The timer resets on that tick and nothing is ever reported.",
      "inputs": {
        "sat": [
          {
            "t": 0,
            "value": 16.0
          },
          {
            "t": 3300,
            "value": 13.2
          }
        ],
        "sat_sp": 13.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 10800,
          "equals": false
        }
      ]
    },
    {
      "name": "setpoint_reset_step_never_alarms",
      "description": "A reset schedule steps the setpoint down 3 K at 600 s and the coil takes 2400 s to catch up \u2014 40 minutes of genuine tracking error caused by nothing but the step. Shorter than the hour, so it stays quiet. This transient is why the ungated form carries a longer delay than AHU-0007 and AHU-0013.",
      "inputs": {
        "sat": [
          {
            "t": 0,
            "value": 16.2
          },
          {
            "t": 3000,
            "value": 13.2
          }
        ],
        "sat_sp": [
          {
            "t": 0,
            "value": 16.0
          },
          {
            "t": 600,
            "value": 13.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 10800,
          "equals": false
        }
      ]
    },
    {
      "name": "fault_clears_when_the_loop_is_retuned",
      "description": "A 3 K miss alarms at 3600 s; at 7200 s the loop is corrected and SAT returns to 13.2 \u00b0C. The alarm drops on that tick \u2014 assertion waits out the delay, recovery does not.",
      "inputs": {
        "sat": [
          {
            "t": 0,
            "value": 16.0
          },
          {
            "t": 7200,
            "value": 13.2
          }
        ],
        "sat_sp": 13.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3300,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 3900,
          "to_s": 6900,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 7500,
          "to_s": 10800,
          "equals": false
        }
      ]
    },
    {
      "name": "oscillating_miss_never_alarms",
      "description": "Pins the blind spot persistence buys. SAT swings between 16.0 \u00b0C and 13.2 \u00b0C every 20 minutes against a 13.0 \u00b0C setpoint \u2014 a hunting loop that spends half its day outside the band \u2014 and the timer resets on every compliant tick, so this rule stays silent. AHU-0022 is the rule that reports it.",
      "inputs": {
        "sat": [
          {
            "t": 0,
            "value": 16.0
          },
          {
            "t": 1200,
            "value": 13.2
          },
          {
            "t": 2400,
            "value": 16.0
          },
          {
            "t": 3600,
            "value": 13.2
          },
          {
            "t": 4800,
            "value": 16.0
          },
          {
            "t": 6000,
            "value": 13.2
          },
          {
            "t": 7200,
            "value": 16.0
          },
          {
            "t": 8400,
            "value": 13.2
          },
          {
            "t": 9600,
            "value": 16.0
          }
        ],
        "sat_sp": 13.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 10800,
          "equals": false
        }
      ]
    }
  ]
}

AHU-0034 — Economizing past changeover

Statusverified — engine e2ff2f8, cxf:fnv1a128:c68dcb56b390b6fa7294fb9245b6f8f2, 2026-08-18
Severity3
Methodrule
Phase2
CategoryEXCESS_CONSUMPTION
ConfidenceHIGH
EstimationDIRECT_MEASUREMENT
G36
Clusters
Suppresses
Suppressed by
RelatedAHU-0017, AHU-0030
Playbookseconomizer-failure
SourceBushby, Castro, Schein, House (2001), NIST/CEC PIER Project 2.3 report — §4.2 Rule 9 (Table 1, p. 9): Toa > Tco + εt in Mode 3; Mode 1-5 actuator signatures §4.1-4.2 (pp. 6-7); threshold defaults §4.2.3 (p. 11); House, Vaezi-Nejad, Whitcomb (2001), ASHRAE Transactions 107(1), ‘An Expert Rule Set for Fault Detection in Air-Handling Units’ — the paper the PIER report names as APAR’s derivation; PNNL-27338 §3.4 (Katipamula et al. 2018) — the same test seventeen years later: a damper-position-only ‘economizing when it should not’ check, gated on the differential dry-bulb comparison rather than a fixed changeover temperature; Both reports were read through internal paraphrased digests; neither is redistributed with this library; Sibling precedent: AHU-0017 (mirrored graph, changeover-type switch, every parameter shape); AHU-0030 (the heating-side excess-OA relative); Library extension: the HVAC FDD Reference v1.0 ch.9 specifies AHU-0001..065 and stops — see faults/ahu/README.md
Operating statesOS#3 (mechanical cooling with 100% outdoor air) — host-gated. The actuator half of that signature, a modulating cooling coil with the OA damper open, is also tested in-rule by clgOn and dmprHigh; the mode determination itself is the host’s.

Preconditions (host-enforced): Supply fan running, and the unit must have a return-air path — a 100%-outdoor-air or makeup-air unit has no changeover to miss and reads as a permanent fault. The outdoor/return comparison must be evaluable: |oat - rat| >= TMIN (APAR’s own ∆Tmin is 5.6 °C / 10 °F, §4.2.3 p. 11; AHU-0017 cites PNNL-27338’s 5 °F for the same gate), since two sensors reading within their combined error of each other cannot establish which air is warmer. Hosts also gate on OAT sensor quality — a sensor reading low produces this fault’s signature with the economizer control working correctly (diagnosis 3). When either gate is unmet the verdict is NO_EVAL, not healthy.

Points: oat, rat, clg_vlv_cmd, oa_dmpr_cmd

Outputs:

  • yFault — True while outdoor conditions have been past changeover, mechanical cooling has run, and the OA damper command has stayed above econ_damper_high_threshold, all continuously for at least alarm_delay

Parameters:

NameDefaultUnitCXF pathDescription
econ_type_is_ddbTrueboolisDDB.kChangeover type — true = differential dry-bulb (compare oat to rat), false = fixed changeover temperature (compare oat to econ_hl_temp). Same parameter, same meaning, same default as AHU-0017; a unit running both rules must carry the same value in both
econ_hl_temp21.0°ChlConst.kFixed changeover temperature, used only when econ_type_is_ddb is false. This is APAR’s Tco, the temperature at which the unit should step from 100% outdoor air to minimum outdoor air
temp_deadband1.0°CddbPast.t, hlPast.tMargin the changeover comparison must clear before economizing counts as unjustified; binds both changeover branches. APAR’s own value for this threshold is εt = 1.7 °C (§4.2.3, p. 11) — see Deviations
cooling_enabled_threshold10.0%clgOn.tCooling valve command above which mechanical cooling counts as active
econ_damper_high_threshold75.0%dmprHigh.tOA damper command above which the unit counts as still economizing rather than holding a minimum position
alarm_delay1800.0spersist.delayTimeContinuous fault persistence required before the alarm asserts (30 min)

Description

Outdoor air stopped being worth having and the dampers never found out. The unit opened wide for free cooling on a mild morning, the afternoon turned hot, and the sequence that should have stepped the dampers back to minimum did not run — so the coil carries the load plus the load the open dampers keep importing. On a 30 °C afternoon against 22 °C return air, every point of outdoor air fraction above the ventilation minimum hands the coil another 8 °C of sensible lift on that share of the airflow, and more once the outdoor air is humid. This is AHU-0017 run backwards: same four points, same three conjuncts, same graph with the temperature and damper comparisons both reversed, and because both cards use the same deadband around the same comparison they cannot assert on one unit at the same time. It is the quieter of the two failures — until the coil runs out of capacity the unit holds setpoint and looks healthy. Library extension: chapter 9 does not specify it, and the logic comes from APAR Rule 9 with the graph shape and parameter set taken from AHU-0017.

Detection Logic

past_changeover = (oat - rat)          > temp_deadband   when econ_type_is_ddb
                = (oat - econ_hl_temp) > temp_deadband   otherwise

yFault = past_changeover
     AND clg_vlv_cmd > cooling_enabled_threshold
     AND oa_dmpr_cmd > econ_damper_high_threshold
     sustained continuously for alarm_delay

Block graph (rule.cxf.jsonld):

AHU-0034 block graph

Both changeover branches are computed on every tick and pastSel (Logical.Switch, y = u2 ? u1 : u3) picks one: isDDB selects the differential branch (oat - rat, the default) or the fixed-changeover branch (oat - econ_hl_temp). The two are the two sources’ two forms — APAR Rule 9 is written against a fixed changeover temperature Tco, which is hlPast; PNNL-27338 §3.4 gates on a differential dry-bulb comparison, which is ddbPast. Thresholding the difference is what lets one temp_deadband serve both, and it is the operand order — oat on u1 in both subtractions — that makes this the reverse of AHU-0017, where oat sits on u2. clgOn and dmprHigh carry the APAR Mode-3 actuator signature in-graph, so the finding is self-evident from the rule rather than dependent on how a host classified the mode; the cooling conjunct is load-bearing, since with the coil shut there is no mechanical cooling being paid for and an open damper is a purge cycle or a comfort problem instead. All three comparisons are strict, so a damper commanded to exactly 75%, a cooling valve at exactly 10%, or an excess of exactly 1.0 °C does not trip the rule. persist requires 30 continuous minutes, long enough to ride out the damper stroke and the marginal minutes either side of the changeover point; delayOnInit = true holds that window across a controller restart.

Possible Diagnoses

APAR establishes no fault set for its rules (§4.2.2), so this list is authored from the mechanisms that raise an outdoor-air damper command past changeover:

  1. Changeover setpoint too high, or a fixed high limit left at a factory default that does not fit the climate
  2. Economizer enable logic with no disable path — the sequence opens on a favorable comparison and never re-tests it
  3. OAT sensor reading low (sun-shielded, soffit-mounted, over a warm roof, or drifted), so outdoor air still looks worth importing
  4. A changeover device — dry-bulb or enthalpy switch — failed in its “economize” state, a single point of failure with no other symptom
  5. A mixed-air low-limit or freeze-protection loop holding the damper open past changeover, its setpoint never re-tuned for cooling weather
  6. An override left in place after service (AHU-0027 finds the flag itself)

A damper commanded to minimum but mechanically stuck open never raises oa_dmpr_cmd and is invisible here — that is AHU-0020 and AHU-0028 territory, recorded as a blind spot under Deviations.

Energy Impact

EXCESS_CONSUMPTION, HIGH confidence, DIRECT_MEASUREMENT. The waste is the sensible load the unit imports above its ventilation minimum, (oa_dmpr_cmd/100 − design_min_oa_fraction) × supply_airflow × ρ·cp × (oat − rat), and every term but the design fraction and the airflow is already on this rule’s wires. It is a floor rather than a total: sensible-only, and in a humid climate the latent load of the excess outdoor air is the larger half. PNNL-27338 §3’s 5–20% of cooling energy covers economizer faults as a class, including AHU-0017’s direction and AHU-0021’s, so read savings_range as the size of the family rather than of this member. Cooling-dominant and sharply seasonal — born on a mild shoulder-season afternoon, billed in July.

Emissions Impact

Scope 2, DIRECT_EMISSIONS, HIGH confidence; typical 1,000-6,000 kg CO₂e/yr, scaled from AHU-0017’s range for the same equipment and the same mechanical cooling. The whole impact is electric compressor or chiller work, so it lands in purchased electricity, and the hours are the grid’s worst: this fault bills during hot afternoons coincident with cooling peaks, so use the marginal operating emissions rate (MOER), not an average grid factor.

Deviations

  • This rule is a library extension, not a transcription: chapter 9 specifies AHU-0001..065 and stops. The name, severity 3, phase 2 and method: rule are assigned here — severity 3 to match AHU-0017 and AHU-0030, phase 2 because the rule presupposes a site that has already configured a changeover type and threshold for AHU-0017. The graph, parameters, diagnosis list and energy claim are authored from APAR Rule 9, PNNL-27338 §3.4 and FC-051.
  • APAR Rule 9 is a Mode 3 rule, so clgOn tests cooling active, not closed: Mode 3 is mechanical cooling with 100% outdoor air, a modulating cooling coil with the damper fully open. The intuitive reading — “economizing” as Mode 2, both coils closed — is the wrong mode here and would invert the conjunct.
  • Differential dry-bulb is the shipped default even though Rule 9 is literally a fixed-changeover test (Toa > Tco + εt, which is hlPast). AHU-0017 ships DDB, a mirrored pair configured two ways is worse than either, and PNNL-27338 §3.4 gates on the differential comparison. A site running APAR literally sets econ_type_is_ddb = false and econ_hl_temp to its Tco.
  • temp_deadband ships at 1.0 °C, not APAR’s flat εt = 1.7 °C, and the reason is the pair rather than the physics: one deadband on both cards brackets a symmetric ±1.0 °C dead zone around the changeover point, where 1.0 against 1.7 would be lopsided for no gain. The cost is real — 1.0 °C is inside the combined error of two commodity sensors, which is what 1.7 °C was sized to clear — so a site with untrimmed sensors must raise it on both cards.
  • econ_damper_high_threshold = 75% sits between the two sources. APAR’s “fully open” is above 98%, which would miss every damper hanging at 80%; PNNL-27338 §3.4’s 30% would make this a damper-position restatement of AHU-0021. 75% is above any plausible minimum-position setting and mirrors AHU-0017’s 25% line. Retuning to 30% buys PNNL’s sensitivity and FC-055’s overlap with it.
  • The rule reads the damper command, not its position — what AHU-0017 reads and what APAR’s ud is — so a damper commanded to minimum and mechanically stuck open produces the whole physical fault and none of this signature. Run AHU-0020 on the OA damper alongside this rule; the pairing is the coverage.
  • The evaluability gate |oat - rat| >= ∆Tmin is a precondition, not an in-graph output: APAR attaches such a gate to Rules 2 and 18 and to Rule 9 not at all, so there is no NO_EVAL semantics to expose as a y…Ok output the way AHU-0030 does. It is declared for host enforcement with APAR’s own ∆Tmin = 5.6 °C, placed exactly as AHU-0017 places its gate.
  • All three comparisons are strict (>); neither source specifies boundary behaviour, and the engine’s Reals comparisons are strict in any case.
  • alarm_delay = 1800 s is adopted from AHU-0017; neither source specifies a persistence. APAR evaluates per sample, PNNL-27338 averages a 15–60 minute window — a different mechanism with a similar effect — and 30 minutes keeps the pair’s alarms comparable in latency.
  • persist.delayOnInit = true (Modelica/CDL default is false), the library’s standing choice: a unit already past changeover with its damper open when the controller restarts waits out the full 30 minutes instead of alarming on the first tick.
  • CLU-03 is deliberately not claimed. Its contract is that fixing the trigger (AHU-0017) clears the members within 24-48 h, and that does not hold here — a damper stuck open is not cleared by repairing one stuck closed, and the two cannot be true of one unit at once. This card shares CLU-03’s playbook and none of its clearing semantics.
  • suppresses and suppressed_by are empty. AHU-0021 is the nearest candidate, since a damper open past changeover also inflates the outdoor-air fraction it measures, but both findings are true and separately actionable, and any edge would be an index-level decision declared on both cards.
  • Neither source publishes test cases — APAR gives a rule table and threshold list, PNNL-27338 an algorithm — so every scenario in vectors.json is authored.
  • Operating states and preconditions are declared in frontmatter for host enforcement rather than encoded in the block graph, per the library’s design stance. APAR derives its five modes from coil-valve and damper signals alone with no mode sensor, which is the same host-side derivation this library’s operating_states convention already assumes.

Notes

Read this card and AHU-0017 as one policy. They bind the same four points and carry the same six parameters with the same names and defaults, differing only in the direction of the temperature and damper comparisons. Retuning one without the other is the mistake to guard against: raise temp_deadband here alone and the dead zone between the two rules goes lopsided; change econ_type_is_ddb on one and the pair answers two different questions about the same unit.

With default parameters the shipped vectors exercise only the DDB branch — vectors/v1 stages inputs, not parameters, so hlConst, hlGap and hlPast are structurally verified but never reach yFault through u3. A host setting econ_type_is_ddb = false should commission that path itself.

Do not deploy on a unit without a return-air path: a 100%-outdoor-air or makeup-air unit has its damper open by design and every conjunct will hold every hot afternoon. When the alarm is real, command the OA damper to minimum and watch MAT fall toward return temperature. If it moves, the sequence never commanded minimum and the fix is at a desk; if it does not, AHU-0020 on the OA damper is the rule that will say so. Check the OAT sensor before either — a sensor reading 4 °C low manufactures this fault out of working economizer logic.

Test Vectors

11 scenarios, clock step 60 s over 3600 s.

ScenarioDescription
past_changeover_damper_at_minHealthy changeover: OAT 30 °C against RAT 22 °C with mechanical cooling at 40% — the unit has handed back to minimum outdoor air (damper 15%), which is the correct response
past_changeover_damper_still_openThe fault: OAT 30 °C is 8 °C hotter than return air, mechanical cooling is running, and the OA damper is still at 95% — the unit never stopped economizing; alarms after alarm_delay (1800 s)
still_favorable_for_economizingTemperature conjunct blocking alone: OAT 10 °C is well below the 22 °C return air, so a wide-open damper with cooling running is free cooling doing its job
no_cooling_callCooling conjunct blocking alone: hot outdoor air and a wide-open damper, but the cooling valve is shut — no mechanical cooling is paying for the outdoor air, so there is no waste to report
damper_exactly_at_thresholdEdge case: damper parked exactly at econ_damper_high_threshold (75%) past changeover with cooling running — the strict > keeps it out of the alarm
damper_just_above_thresholdEdge case, other side: damper at 75.1% clears the threshold by a tenth of a point and the alarm follows at 1800 s
cooling_exactly_at_thresholdEdge case: cooling valve exactly at cooling_enabled_threshold (10%) with the damper wide open past changeover — the strict > means the coil does not count as cooling
cooling_just_above_thresholdEdge case, other side: cooling valve at 10.1% counts as mechanical cooling and the alarm follows at 1800 s
deadband_edge_then_past_changeoverEdge case: oat − rat sits exactly on temp_deadband (1.0 °C) — no alarm; at t=1200 the outdoor air rises to 23.5 °C, opening the gap to 1.5 °C, and the alarm follows 1800 s later
damper_closes_before_delayTransient: the economizer is wide open against hot outdoor air with cooling running, but the damper drives to 20% at t=1200 — the condition clears before alarm_delay completes, so no alarm
damper_cycle_resets_persistenceTransient: the damper closes at t=900 and drives back open at t=1200 under an unbroken past-changeover-plus-cooling condition — persistence restarts from the reopen, so the alarm lands at 3000 s, not 1800 s
vectors.json
{
  "schema": "cxf-library/vectors/v1",
  "clock": {
    "step_s": 60,
    "horizon_s": 3600
  },
  "scenarios": [
    {
      "name": "past_changeover_damper_at_min",
      "description": "Healthy changeover: OAT 30 \u00b0C against RAT 22 \u00b0C with mechanical cooling at 40% \u2014 the unit has handed back to minimum outdoor air (damper 15%), which is the correct response",
      "inputs": {
        "oat": 30.0,
        "rat": 22.0,
        "clg_vlv_cmd": 40.0,
        "oa_dmpr_cmd": 15.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "past_changeover_damper_still_open",
      "description": "The fault: OAT 30 \u00b0C is 8 \u00b0C hotter than return air, mechanical cooling is running, and the OA damper is still at 95% \u2014 the unit never stopped economizing; alarms after alarm_delay (1800 s)",
      "inputs": {
        "oat": 30.0,
        "rat": 22.0,
        "clg_vlv_cmd": 40.0,
        "oa_dmpr_cmd": 95.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1740,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 1860,
          "to_s": 3600,
          "equals": true
        }
      ]
    },
    {
      "name": "still_favorable_for_economizing",
      "description": "Temperature conjunct blocking alone: OAT 10 \u00b0C is well below the 22 \u00b0C return air, so a wide-open damper with cooling running is free cooling doing its job",
      "inputs": {
        "oat": 10.0,
        "rat": 22.0,
        "clg_vlv_cmd": 40.0,
        "oa_dmpr_cmd": 95.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "no_cooling_call",
      "description": "Cooling conjunct blocking alone: hot outdoor air and a wide-open damper, but the cooling valve is shut \u2014 no mechanical cooling is paying for the outdoor air, so there is no waste to report",
      "inputs": {
        "oat": 30.0,
        "rat": 22.0,
        "clg_vlv_cmd": 0.0,
        "oa_dmpr_cmd": 95.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "damper_exactly_at_threshold",
      "description": "Edge case: damper parked exactly at econ_damper_high_threshold (75%) past changeover with cooling running \u2014 the strict `>` keeps it out of the alarm",
      "inputs": {
        "oat": 30.0,
        "rat": 22.0,
        "clg_vlv_cmd": 40.0,
        "oa_dmpr_cmd": 75.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "damper_just_above_threshold",
      "description": "Edge case, other side: damper at 75.1% clears the threshold by a tenth of a point and the alarm follows at 1800 s",
      "inputs": {
        "oat": 30.0,
        "rat": 22.0,
        "clg_vlv_cmd": 40.0,
        "oa_dmpr_cmd": 75.1
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1740,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 1860,
          "to_s": 3600,
          "equals": true
        }
      ]
    },
    {
      "name": "cooling_exactly_at_threshold",
      "description": "Edge case: cooling valve exactly at cooling_enabled_threshold (10%) with the damper wide open past changeover \u2014 the strict `>` means the coil does not count as cooling",
      "inputs": {
        "oat": 30.0,
        "rat": 22.0,
        "clg_vlv_cmd": 10.0,
        "oa_dmpr_cmd": 95.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "cooling_just_above_threshold",
      "description": "Edge case, other side: cooling valve at 10.1% counts as mechanical cooling and the alarm follows at 1800 s",
      "inputs": {
        "oat": 30.0,
        "rat": 22.0,
        "clg_vlv_cmd": 10.1,
        "oa_dmpr_cmd": 95.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1740,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 1860,
          "to_s": 3600,
          "equals": true
        }
      ]
    },
    {
      "name": "deadband_edge_then_past_changeover",
      "description": "Edge case: oat \u2212 rat sits exactly on temp_deadband (1.0 \u00b0C) \u2014 no alarm; at t=1200 the outdoor air rises to 23.5 \u00b0C, opening the gap to 1.5 \u00b0C, and the alarm follows 1800 s later",
      "inputs": {
        "rat": 22.0,
        "oat": [
          {
            "t": 0,
            "value": 23.0
          },
          {
            "t": 1200,
            "value": 23.5
          }
        ],
        "clg_vlv_cmd": 40.0,
        "oa_dmpr_cmd": 95.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2940,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 3060,
          "to_s": 3600,
          "equals": true
        }
      ]
    },
    {
      "name": "damper_closes_before_delay",
      "description": "Transient: the economizer is wide open against hot outdoor air with cooling running, but the damper drives to 20% at t=1200 \u2014 the condition clears before alarm_delay completes, so no alarm",
      "inputs": {
        "oat": 30.0,
        "rat": 22.0,
        "clg_vlv_cmd": 40.0,
        "oa_dmpr_cmd": [
          {
            "t": 0,
            "value": 95.0
          },
          {
            "t": 1200,
            "value": 20.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "damper_cycle_resets_persistence",
      "description": "Transient: the damper closes at t=900 and drives back open at t=1200 under an unbroken past-changeover-plus-cooling condition \u2014 persistence restarts from the reopen, so the alarm lands at 3000 s, not 1800 s",
      "inputs": {
        "oat": 30.0,
        "rat": 22.0,
        "clg_vlv_cmd": 40.0,
        "oa_dmpr_cmd": [
          {
            "t": 0,
            "value": 95.0
          },
          {
            "t": 900,
            "value": 20.0
          },
          {
            "t": 1200,
            "value": 95.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2940,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 3060,
          "to_s": 3600,
          "equals": true
        }
      ]
    }
  ]
}

AHU-0035 — Supply air temperature too high for the zone population

Statusverified — engine e2ff2f8, cxf:fnv1a128:e915d6c24ba9e0afb73391fb57df7b3f, 2026-08-18
Severity3
Methodrule
Phase2
CategoryCOMFORT_ENERGY
ConfidenceMEDIUM
EstimationPROXY_ESTIMATION
G36
ClustersCLU-02
Suppresses
Suppressed by
RelatedAHU-0019, AHU-0012, AHU-0013, AHU-0023
Playbooksmissing-reset
SourcePNNL-27338 §2.3 (High SAT AIRCx) — the two-conjunct zone-population test and its published thresholds: percent_damper_threshold 60%, percent_reheat_threshold 25%, zone_high_damper_threshold 90%, reheat_on_threshold 10%, data_window 15 min; PNNL-27338 §2.3.1 — the auto-correction bounds (SAT setpoint lowered by sat_retuning = 1 °F per cycle, floored at min_sat_stpt = 50 °F), carried here as retuning guidance rather than graph; PNNL-25985 EEM-05 (SAT reset) — the savings basis shared with AHU-0019 and AHU-0023; Sibling precedent: AHU-0019, the cold half of the same PNNL SAT AIRCx pair — graph shape, occupied gating, and the fraction-unit convention are mirrored from it; AHU-0024 for a host-derived zone aggregate as a boundary point; Library extension: the HVAC FDD Reference v1.0 ch.9 carries no high-SAT population rule — see faults/ahu/README.md
Operating statesOccupied (OS 2, 3, 4) with the supply fan running — host-gated. PNNL-27338 §2.3 states the test for a running fan; dampers parked at an unoccupied position carry no cooling demand to read.

Preconditions (host-enforced): AHU serving multiple zones, and both fractions aggregated by the host over the SAME zone population from the same scan — a damper fraction counted over all boxes against a reheat fraction counted over only the reheat-capable ones compares two different denominators and biases the rule toward firing. When the zone data is missing, stale, or covers too few zones for either fraction to mean anything, the verdict is NO_EVAL, not healthy. Written for single-duct VAV AHUs whose heating happens at the terminal box (PNNL-27338 §2.3): on a unit with its own heating coil, zone reheat is no longer the whole heating story and the second conjunct loses its meaning. Hosts should also suspend evaluation through morning pull-down, which produces this exact population signature while the building is still catching up.

Points: zone_dmpr_fraction_open, zone_reheat_fraction

Outputs:

  • yFault — True while more than damper_fraction_threshold of zones have held their dampers above the host’s high-open counting threshold with fewer than reheat_fraction_threshold of zones reheating, for at least alarm_delay

Parameters:

NameDefaultUnitCXF pathDescription
damper_fraction_threshold0.61dmprHigh.tFraction of served zones (0-1) above the host’s high-open damper counting threshold beyond which the population is demonstrably starved for cooling. PNNL-27338 §2.3’s percent_damper_threshold = 60%, against a per-zone zone_high_damper_threshold of 90% that lives in host configuration
reheat_fraction_threshold0.251rhtLow.tFraction of served zones (0-1) reheating below which no zone population is asking for warmer air. PNNL-27338 §2.3’s percent_reheat_threshold = 25%; the report is explicit that above it the setpoint must not be lowered
alarm_delay1800.0spersist.delayTimeContinuous fault persistence required before the alarm asserts (30 min) — two of PNNL-27338’s 15-minute data_window cycles. See Deviations for why this rule sits between the source’s cadence and AHU-0019’s hour

Description

Most of the zones this air handler serves are holding their dampers near wide open trying to make their space setpoints, and almost none of them are reheating. Supply air that warm forces every box toward maximum flow to deliver the same cooling: the zones drift off setpoint anyway, the fan runs harder than the load requires, and the air reaching the space is wetter than it needs to be. The low reheat fraction is the corroboration — PNNL-27338 §2.3 is explicit that the setpoint must not be lowered while more than a quarter of the zones are reheating, because that population is telling the opposite story. This is the warm half of the SAT retuning pair AHU-0019 covers from the cold side, and it costs comfort and fan energy before it costs anything else.

Detection Logic

yFault = zone_dmpr_fraction_open > damper_fraction_threshold
     AND zone_reheat_fraction    < reheat_fraction_threshold
     sustained continuously for alarm_delay

Block graph (rule.cxf.jsonld):

AHU-0035 block graph

Two threshold tests feed one conjunction and one timer, the mirror of AHU-0019 with both comparisons reversed. Note what this rule does not read: no supply air temperature and no setpoint. PNNL-27338 §2.3 infers “too high” entirely from the zone population, which means the rule fires just as readily on a unit that cannot make a correct setpoint as on one whose setpoint is set wrong — AHU-0013 and AHU-0012 are the single-unit tests that split those two, and neither can see this fault, because a unit sitting exactly on a bad setpoint looks healthy to every sensor it owns. Both fractions are 0–1, not percent, and both comparisons are strict, so exactly 60% of dampers open or exactly 25% of zones reheating reads healthy. persist requires 30 continuous minutes, and delayOnInit = true serves that window in full after a controller restart.

Possible Diagnoses

  1. SAT setpoint parked too high — set for a load profile that no longer exists, or a reset whose upper bound is above what the zones can absorb. The missing-reset playbook carries this heuristic verbatim at step 2.5.
  2. The AHU cannot make its setpoint — chilled-water valve not modulating, chiller off, CHW pumps failing (PNNL-27338 §2.3 names all three). Identical population signature, entirely different work order; AHU-0013 and AHU-0012 separate it from the AHU’s own sensors, and both should be checked before anyone touches the setpoint.
  3. Genuine peak load — at design conditions, wide dampers with no reheat is correct operation. Confirm the setpoint is not already at its low limit before treating this as a retuning opportunity.
  4. Starved airflow rather than warm air — a duct static pressure setpoint too low leaves dampers open with the SAT perfectly correct (AHU-0001, and the DSP half of CLU-02 through AHU-0024).

Energy Impact

COMFORT_ENERGY, MEDIUM confidence, PROXY_ESTIMATION. There is no waste integral to compute here — nothing is being simultaneously added and removed, as it is on the cold side. The cost is airflow: every zone that has to open further to compensate for warm supply air buys fan power on the cube law, and the comfort it buys is negative, because the zones are losing the space while they do it. Correcting the setpoint recovers 1–4.4% of site energy on the EEM-05 basis (2.5% national median), partly offset by the extra chiller load colder air implies — PNNL-27338 §2.3 names that trade explicitly. Cooling-dominant, and the latent benefit of drier supply air grows with humidity.

Emissions Impact

Scope 2, PROXY_EMISSIONS, MEDIUM confidence; typical 500–3,000 kg CO₂e/yr, net of the chiller load the correction adds back. Avoided-emissions basis: marginal operating emissions rate (MOER).

Deviations

  • The auto-correction is card prose, not graph. PNNL-27338 §2.3.1 lowers the setpoint by sat_retuning = 1 °F per cycle and floors it at min_sat_stpt = 50 °F, guarding the low-limit thermostat and the economizer’s cold-air path. This library detects; those bounds belong in the retune, and the floor is the number to check first when this rule will not clear.
  • PNNL’s windowed averages are replaced by a persistence delay. The source averages percent_dmpr and percent_rht over a ≥15-minute data_window with at least 5 samples. A TrueDelay on the conjunction is the engine-native equivalent and is stricter — it requires the condition to hold every tick rather than on average — which suits a detect-only rule that must not cry wolf.
  • alarm_delay = 1800 s, between the source’s cadence and AHU-0019’s hour. One data_window (900 s) reproduces PNNL’s correction cadence, but PNNL nudges 1 °F while this card raises an alarm, and morning pull-down shows this exact signature for longer than one window. AHU-0019’s 3600 s is too slow in the other direction: warm supply air is a comfort complaint already in progress, so latency has an occupant cost the cold side does not carry. Sites wanting the literal source cadence set 900.
  • No sat_sp input, unlike AHU-0019. The source’s high-SAT test reads the setpoint array only to decide whether auto-correction is possible (§2.3.3 step 6), never as a detection term. Adding a setpoint threshold would have made the two cards symmetric and the rule wrong: a unit whose setpoint is correct and whose coil has failed is exactly the case this population test is good at.
  • Both fractions are 0–1, not percent. The source states 60% and 25%; the points compared against carry unit 1. Hosts feeding 0–100 never fire the damper conjunct and always fire the reheat one — that is, the rule goes permanently silent rather than noisy, which is the worse failure.
  • Per-zone counting thresholds stay in host configuration. The 90% damper and 10% reheat thresholds define zone_dmpr_fraction_open and zone_reheat_fraction in points/ahu.points.json, not this rule; library v1 avoids array boundary points, so the counting happens host-side (the zone_reheat_fraction precedent AHU-0019 set).
  • confidence: MEDIUM, against AHU-0019’s HIGH. The population evidence is unambiguous about the symptom and the source publishes every threshold, but three unrelated causes produce it (diagnoses 2–4) and the graph cannot rank them. severity: 3 and category: COMFORT_ENERGY follow the mirror asymmetry: the cold side burns fuel to undo cooling, the warm side loses the space first and the fan bill second.
  • Strict comparisons (>, <); the source does not specify boundary behavior, so the library’s strict convention applies.
  • Occupied/fan-on gating and the multi-zone precondition are frontmatter, for host enforcement rather than block graph, per the library’s design stance.
  • persist.delayOnInit = true (CDL default false), the library’s standing choice: a condition already present at load waits out the full 30 minutes instead of alarming on the first tick after a restart.

Notes

Bound to missing-reset rather than a playbook of its own: step 2.5 of that playbook already carries both PNNL heuristics, this one included, and the fix is the same trim-and-respond programming AHU-0023 asks for — from the other end of the reset band. Run the pair. AHU-0019 firing on the same unit at a different hour is not a contradiction; it is a reset whose band is wrong in both directions, and CLU-02’s trigger is the rule to fix. Membership in CLU-02 is declared here and belongs in clusters/clusters.json alongside AHU-0019.

Test Vectors

10 scenarios, clock step 60 s over 5400 s.

ScenarioDescription
dampers_open_few_reheatingReference vector: 75% of zones above the high-open damper threshold with 10% reheating — cooling starvation with nobody reheating; alarms after alarm_delay (1800 s), served in full from load because delayOnInit is true
dampers_moderate_few_reheatingReference vector: 45% of zones wide open with 10% reheating — light reheat alone is not a finding; the damper population never clears its threshold
dampers_open_widespread_reheatReference vector: 75% of zones wide open but 40% reheating — a mixed-load hour, not warm supply air; PNNL-27338 forbids lowering the setpoint here
damper_fraction_exactly_at_thresholdEdge case: the open-damper fraction sits exactly on damper_fraction_threshold (0.60) with 10% reheating — the strict > keeps it out of the alarm
reheat_fraction_exactly_at_thresholdEdge case: exactly a quarter of zones reheating (0.25) against 75% wide-open dampers — the strict < means the fraction must fall below the threshold
morning_pulldown_clears_before_delayTransient: the pull-down signature — every damper open, nothing reheating — relaxes to 0.40 open at t=1200, inside alarm_delay, so no alarm
reheat_returns_before_delayTransient: dampers stay at 0.75 but the reheating fraction climbs back to 0.35 at t=1200 — the corroborating conjunct drops out before the alarm can assert
damper_dip_restarts_persistenceTransient: the open-damper fraction dips to 0.50 at t=900 and returns to 0.75 at t=1200 — persistence restarts from the return, so the alarm lands at 3000 s, not 1800 s
reheat_gate_opens_lateGate edge: dampers held at 0.75 throughout while the reheating fraction falls from 0.40 to 0.10 at t=900 — persistence starts at the reheat crossing, so the alarm lands at 2700 s
both_gates_arrive_togetherGate edge: both conjuncts start false (0.45 open, 0.40 reheating) and both cross at t=600 to 0.80 open and 0.05 reheating — the alarm lands at 2400 s
vectors.json
{
  "schema": "cxf-library/vectors/v1",
  "clock": {
    "step_s": 60,
    "horizon_s": 5400
  },
  "scenarios": [
    {
      "name": "dampers_open_few_reheating",
      "description": "Reference vector: 75% of zones above the high-open damper threshold with 10% reheating \u2014 cooling starvation with nobody reheating; alarms after alarm_delay (1800 s), served in full from load because delayOnInit is true",
      "inputs": {
        "zone_dmpr_fraction_open": 0.75,
        "zone_reheat_fraction": 0.1
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1740,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 1860,
          "to_s": 5400,
          "equals": true
        }
      ]
    },
    {
      "name": "dampers_moderate_few_reheating",
      "description": "Reference vector: 45% of zones wide open with 10% reheating \u2014 light reheat alone is not a finding; the damper population never clears its threshold",
      "inputs": {
        "zone_dmpr_fraction_open": 0.45,
        "zone_reheat_fraction": 0.1
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        }
      ]
    },
    {
      "name": "dampers_open_widespread_reheat",
      "description": "Reference vector: 75% of zones wide open but 40% reheating \u2014 a mixed-load hour, not warm supply air; PNNL-27338 forbids lowering the setpoint here",
      "inputs": {
        "zone_dmpr_fraction_open": 0.75,
        "zone_reheat_fraction": 0.4
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        }
      ]
    },
    {
      "name": "damper_fraction_exactly_at_threshold",
      "description": "Edge case: the open-damper fraction sits exactly on damper_fraction_threshold (0.60) with 10% reheating \u2014 the strict `>` keeps it out of the alarm",
      "inputs": {
        "zone_dmpr_fraction_open": 0.6,
        "zone_reheat_fraction": 0.1
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        }
      ]
    },
    {
      "name": "reheat_fraction_exactly_at_threshold",
      "description": "Edge case: exactly a quarter of zones reheating (0.25) against 75% wide-open dampers \u2014 the strict `<` means the fraction must fall below the threshold",
      "inputs": {
        "zone_dmpr_fraction_open": 0.75,
        "zone_reheat_fraction": 0.25
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        }
      ]
    },
    {
      "name": "morning_pulldown_clears_before_delay",
      "description": "Transient: the pull-down signature \u2014 every damper open, nothing reheating \u2014 relaxes to 0.40 open at t=1200, inside alarm_delay, so no alarm",
      "inputs": {
        "zone_dmpr_fraction_open": [
          {
            "t": 0,
            "value": 0.75
          },
          {
            "t": 1200,
            "value": 0.4
          }
        ],
        "zone_reheat_fraction": 0.1
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        }
      ]
    },
    {
      "name": "reheat_returns_before_delay",
      "description": "Transient: dampers stay at 0.75 but the reheating fraction climbs back to 0.35 at t=1200 \u2014 the corroborating conjunct drops out before the alarm can assert",
      "inputs": {
        "zone_dmpr_fraction_open": 0.75,
        "zone_reheat_fraction": [
          {
            "t": 0,
            "value": 0.1
          },
          {
            "t": 1200,
            "value": 0.35
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        }
      ]
    },
    {
      "name": "damper_dip_restarts_persistence",
      "description": "Transient: the open-damper fraction dips to 0.50 at t=900 and returns to 0.75 at t=1200 \u2014 persistence restarts from the return, so the alarm lands at 3000 s, not 1800 s",
      "inputs": {
        "zone_dmpr_fraction_open": [
          {
            "t": 0,
            "value": 0.75
          },
          {
            "t": 900,
            "value": 0.5
          },
          {
            "t": 1200,
            "value": 0.75
          }
        ],
        "zone_reheat_fraction": 0.1
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2940,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 3060,
          "to_s": 5400,
          "equals": true
        }
      ]
    },
    {
      "name": "reheat_gate_opens_late",
      "description": "Gate edge: dampers held at 0.75 throughout while the reheating fraction falls from 0.40 to 0.10 at t=900 \u2014 persistence starts at the reheat crossing, so the alarm lands at 2700 s",
      "inputs": {
        "zone_dmpr_fraction_open": 0.75,
        "zone_reheat_fraction": [
          {
            "t": 0,
            "value": 0.4
          },
          {
            "t": 900,
            "value": 0.1
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2640,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2760,
          "to_s": 5400,
          "equals": true
        }
      ]
    },
    {
      "name": "both_gates_arrive_together",
      "description": "Gate edge: both conjuncts start false (0.45 open, 0.40 reheating) and both cross at t=600 to 0.80 open and 0.05 reheating \u2014 the alarm lands at 2400 s",
      "inputs": {
        "zone_dmpr_fraction_open": [
          {
            "t": 0,
            "value": 0.45
          },
          {
            "t": 600,
            "value": 0.8
          }
        ],
        "zone_reheat_fraction": [
          {
            "t": 0,
            "value": 0.4
          },
          {
            "t": 600,
            "value": 0.05
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2340,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2460,
          "to_s": 5400,
          "equals": true
        }
      ]
    }
  ]
}

AHU-0036 — Duct static pressure too low for the zone population

Statusverified — engine e2ff2f8, cxf:fnv1a128:f9559a3e8059a405d2ac854fab19db5a, 2026-08-18
Severity3
Methodrule
Phase2
CategoryCOMFORT_ENERGY
ConfidenceMEDIUM
EstimationQUALITATIVE_ONLY
G36
Clusters
Suppresses
Suppressed by
RelatedAHU-0001, AHU-0031, AHU-0024
Playbooksmissing-reset
SourcePNNL-27338 §2.6, §2.6.2-2.6.3 (low duct static pressure AIRCx: the sorted-population test and its 90% / 25% thresholds), §2.6.1 and §2.6.3 step 6 (auto-correction, its increment and its cap); Library-authored: the HVAC FDD Reference has no card for this fault. Name, severity, category and the persistence form are argued here; Library precedent: AHU-0019 (host-derived fleet aggregate as an ordinary Real input), AHU-0031 (fan-status conjunct in the graph)
Operating statesOccupied, fan running

Preconditions (host-enforced): AHU serves multiple zones under duct static pressure control; zone damper feedback available and aggregated by the host into the sorted pair zone_dmpr_high_avg / zone_dmpr_low_avg. The population must cover the zones this AHU actually serves and be large enough for halves to mean anything — below about four reporting zones the two averages stop being distinguishable. Morning warm-up is excluded (the reference’s main process holds every diagnostic off until warm_up_time has elapsed after fan start), since pull-down drives every damper open legitimately. When the zone data is missing, stale, or too thin, the verdict is NO_EVAL, not healthy.

Points: zone_dmpr_high_avg, zone_dmpr_low_avg, sf_status

Outputs:

  • yFault — True while both halves of the sorted zone-damper population have stayed above their thresholds with the fan running, for at least alarm_delay

Parameters:

NameDefaultUnitCXF pathDescription
zone_high_damper_threshold90.0%highOpen.tTop-half average damper command above which the busiest zones are effectively wide open
zone_low_damper_threshold25.0%lowOpen.tBottom-half average damper command above which even the quiet zones are drawing on the trunk
alarm_delay900.0spersist.delayTimeContinuous fault persistence required before the alarm asserts (15 min, the reference’s data_window)

Description

Sort every zone damper command the air handler serves, average the top half and the bottom half, and both averages come back high. The busiest zones being wide open is ordinary on a design day; the quiet zones also holding their dampers open is not, and the two together say the trunk itself is short of pressure rather than a few rooms being hot. Boxes downstream run wide open and still miss their airflow setpoints — the “starved box” condition — so zone temperatures wander and the complaints that follow get answered with overrides. This is a comfort finding, not an energy one: raising the setpoint costs fan power, and PNNL-27338 §2.6 is explicit that fixing low duct static pressure saves none.

Detection Logic

yFault = zone_dmpr_high_avg > zone_high_damper_threshold   top half of the population
     AND zone_dmpr_low_avg  > zone_low_damper_threshold    bottom half too
     AND sf_status
     sustained continuously for alarm_delay

Block graph (rule.cxf.jsonld):

AHU-0036 block graph

The host does the sort and hands the rule two ordinary Reals (see the point dictionary); the graph adds thresholds, the fan gate, and persistence. The second conjunct is the whole point. A single stuck-open damper, or a handful of zones at real load, lifts only the top half — it sits in that half by construction and cannot move the bottom-half average at all — so the population form separates a starved trunk from a busy one, which neither AHU-0001 (one pressure pair plus fan speed) nor AHU-0031 (the single zone_dmpr_pos_max) can do. Both comparisons are strict, so a half sitting exactly on its threshold reads healthy. sf_status is wired ahead of persist rather than left to the host: dampers drive open against a dead duct, so the fault signature is present every night, and the gate has to reset the timer rather than merely mask its output. persist requires 15 minutes of continuous violation and delayOnInit = true holds that window across a controller restart.

Possible Diagnoses

  1. DSP setpoint left too low — an override, a noise complaint, or an energy-saving tweak that outlived the person who made it
  2. Trim-and-respond clamped: maximum setpoint below design static, or a respond magnitude too small to answer the airflow requests arriving from the boxes
  3. Duct breach or a disconnected branch downstream of the pressure sensor — the sensor is satisfied while the trunk beyond it is not (PNNL-27338 §2.6 names failed ductwork as a cause worth inspecting above the ceiling)
  4. Fan or drive out of capacity — if AHU-0001 fires alongside this rule, the loop has already asked for everything and no setpoint change will help
  5. Zone-side inflation — oversized minimum airflow setpoints, or damper feedback scaled wrong — makes the population read open when it is not

Energy Impact

COMFORT_ENERGY, MEDIUM confidence, QUALITATIVE_ONLY. There is no savings term to publish, and the reference declines to claim one: raising duct static pressure raises fan power by the cube law, so the direct energy effect of the fix is negative. The recoverable energy is second-order — fan hours extended to chase zone temperatures, a supply air setpoint dropped to compensate for airflow that never arrives, and overrides layered on to quiet complaints. Confidence is MEDIUM rather than HIGH because the thresholds are published and the mechanism is well described, but nothing quantifies the impact and the test is only as good as the host’s zone list.

Emissions Impact

Scope 2, QUALITATIVE_EMISSIONS, MEDIUM confidence. No direct term, and the sign is not guaranteed: correcting the setpoint spends fan energy to buy back airflow. Any net credit comes from the compensating measures the starved condition provoked — extended fan schedules and a depressed SAT setpoint — both electric at the common site. Avoided-emissions basis: marginal operating emissions rate (MOER).

Deviations

  • The population sort happens host-side. The reference consumes the whole per-zone damper array; library v1 avoids array boundary points, so the host sorts once and feeds zone_dmpr_high_avg and zone_dmpr_low_avg (both flagged derived in the point dictionary), exactly as AHU-0019 consumes zone_reheat_fraction. The counting is host configuration, not a rule parameter.
  • The sort is across zones, not across time. PNNL-27338 §2.6.2 describes zn_dmpr_arr as a per-timestep average across the terminal boxes, which would make §2.6.3 step 3 a sort of the time series; step 3’s own wording (“the largest 50% of the zone terminal box damper commands”) and the Figure 2.14/2.15 discussion read it across the zone population. This card takes the across-zones reading — it is the one that makes a two-threshold test a statement about zones — and carries the time dimension in persist instead.
  • Window evaluation becomes continuous persistence. The reference tests once per 15-minute data_window; alarm_delay requires the condition continuously across the same 15 minutes, which is the stricter form (a continuous violation implies the windowed average clears the threshold, not the reverse). Sites whose host does not implement the reference’s warm-up exclusion should raise alarm_delay past their morning pull-down — AHU-0031 ships 1800 s for the same reason.
  • The fan conjunct is in the graph, against AHU-0019’s host-side choice. The reference puts fan status in its main process, so either placement is faithful; the deciding argument is timer state. With the fan off, boxes park their dampers open and the population reads exactly like a starved trunk, so a host-side gate would suppress the output while persist charged all night and asserted on the first occupied tick. Wired, the fan stop resets it (fan_cycle_restarts_persistence). Same placement as AHU-0031.
  • Auto-correction is out of scope. The reference’s AIRCx process writes the setpoint back; this library detects and reports. The retuning schedule is carried as prose (see Notes) so a host that implements the write path owns the override checks and the cap along with it.
  • No cluster membership. CLU-02 is the missing-reset syndrome, whose trigger fix (programming trim-and-respond) resolves a setpoint parked at its design value — the AHU-0031 direction. This fault is the opposite sign and is not cleared by that fix, so it stays out of the cluster while sharing its playbook.
  • missing-reset is the playbook, where AHU-0001 binds none. AHU-0001’s diagnoses are all mechanical repairs with no desk step. Here the first fix is a setpoint change and the second is trim-and-respond configuration, which is that playbook’s step 1.2 and step 2.3-2.4 verbatim.
  • Severity 3, category: COMFORT_ENERGY, estimation_method: QUALITATIVE_ONLY are library-authored, following AHU-0001’s treatment of a fault with no computable waste term. The reference’s own statement that the fix saves no fan energy is what rules out an EXCESS_CONSUMPTION framing.
  • Data sufficiency stays a precondition. The reference guards its window with no_required_data — at least five samples inside the 15 minutes — and this card adds a zone-count floor of its own, since two halves of a two-zone population say nothing. Both are host NO_EVAL tests on derived points, and neither is visible to the block graph.
  • persist.delayOnInit = true (Modelica/CDL default is false), the library’s standing choice: a violation already present at load waits out the full 15 minutes instead of alarming on the first tick after a controller restart.

Notes

Retuning is incremental by design. PNNL-27338 §2.6.1-2.6.3 raises the setpoint by 0.15 in. w.g. per diagnostic cycle (15 minutes) toward a hard cap of 2.5 in. w.g., re-evaluating the damper population after each step and stopping early once the zones settle — a rate chosen not to destabilize the fan loop. Walk the setpoint the same way by hand, and watch for the fault clearing well below the cap; reaching the cap with both halves still open is the tell for diagnosis 3 or 4, not for more pressure. Check AHU-0001 first: it and this rule firing together mean the fan is already at the stop, and the work is mechanical.

Test Vectors

10 scenarios, clock step 60 s over 3600 s.

ScenarioDescription
good_operation_dampers_50_to_75Reference vector (PNNL-27338 Figure 2.15, good operation): most dampers between 50% and 75%, so the top half averages 72% and the bottom half 55% — the bottom half clears its threshold but the top half does not, and no fault is reported
whole_population_wide_openReference vector (PNNL-27338 Figure 2.14, bad operation): nearly every damper near 100%, top half 98% and bottom half 92% — the trunk is starved and the alarm lands at alarm_delay (900 s)
busy_zones_only_bottom_half_closedThe discrimination this rule exists for: the top half averages 95% (a few zones at load, or one damper stuck open) while the bottom half sits at 15% — pressure is reaching the zones that are not asking, so the population test stays silent where a max-based test would fire
high_avg_exactly_at_thresholdEdge case: the top half sits exactly on zone_high_damper_threshold (90%) with the bottom half well above its threshold — the strict > keeps the boundary out of the alarm
low_avg_exactly_at_thresholdEdge case: the bottom half sits exactly on zone_low_damper_threshold (25%) with the top half at 97% — same strict >, so the quiet half must exceed the threshold rather than sit on it
both_halves_just_above_thresholdsEdge case, other side: 90.1% and 25.1% clear both thresholds by a tenth of a percent — the alarm still lands at 900 s
fan_off_dampers_parked_openGate: the fan is off and the boxes have driven their dampers open against a dead duct — the damper pattern reads exactly like a starved trunk, and sf_status suppresses the rule outright
fan_cycle_restarts_persistenceGate transient: the fan stops at t=300 and restarts at t=600 with the damper pattern unchanged — the timer restarts from the fan restart, so the alarm lands at 1500 s rather than 900 s
retuning_raises_pressure_before_delayTransient: the setpoint is raised at t=600 and the boxes throttle back to 70% / 45% — the condition clears inside alarm_delay, which is what a successful re-tuning cycle looks like from the rule’s side
bottom_half_dip_restarts_persistenceTransient: the bottom half drops to 20% at t=420 and returns to 92% at t=780 — the conjunction breaks and the persistence window restarts, so the alarm lands at 1680 s
vectors.json
{
  "schema": "cxf-library/vectors/v1",
  "clock": {
    "step_s": 60,
    "horizon_s": 3600
  },
  "scenarios": [
    {
      "name": "good_operation_dampers_50_to_75",
      "description": "Reference vector (PNNL-27338 Figure 2.15, good operation): most dampers between 50% and 75%, so the top half averages 72% and the bottom half 55% \u2014 the bottom half clears its threshold but the top half does not, and no fault is reported",
      "inputs": {
        "zone_dmpr_high_avg": 72.0,
        "zone_dmpr_low_avg": 55.0,
        "sf_status": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "whole_population_wide_open",
      "description": "Reference vector (PNNL-27338 Figure 2.14, bad operation): nearly every damper near 100%, top half 98% and bottom half 92% \u2014 the trunk is starved and the alarm lands at alarm_delay (900 s)",
      "inputs": {
        "zone_dmpr_high_avg": 98.0,
        "zone_dmpr_low_avg": 92.0,
        "sf_status": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 840,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 960,
          "to_s": 3600,
          "equals": true
        }
      ]
    },
    {
      "name": "busy_zones_only_bottom_half_closed",
      "description": "The discrimination this rule exists for: the top half averages 95% (a few zones at load, or one damper stuck open) while the bottom half sits at 15% \u2014 pressure is reaching the zones that are not asking, so the population test stays silent where a max-based test would fire",
      "inputs": {
        "zone_dmpr_high_avg": 95.0,
        "zone_dmpr_low_avg": 15.0,
        "sf_status": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "high_avg_exactly_at_threshold",
      "description": "Edge case: the top half sits exactly on zone_high_damper_threshold (90%) with the bottom half well above its threshold \u2014 the strict `>` keeps the boundary out of the alarm",
      "inputs": {
        "zone_dmpr_high_avg": 90.0,
        "zone_dmpr_low_avg": 60.0,
        "sf_status": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "low_avg_exactly_at_threshold",
      "description": "Edge case: the bottom half sits exactly on zone_low_damper_threshold (25%) with the top half at 97% \u2014 same strict `>`, so the quiet half must exceed the threshold rather than sit on it",
      "inputs": {
        "zone_dmpr_high_avg": 97.0,
        "zone_dmpr_low_avg": 25.0,
        "sf_status": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "both_halves_just_above_thresholds",
      "description": "Edge case, other side: 90.1% and 25.1% clear both thresholds by a tenth of a percent \u2014 the alarm still lands at 900 s",
      "inputs": {
        "zone_dmpr_high_avg": 90.1,
        "zone_dmpr_low_avg": 25.1,
        "sf_status": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 840,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 960,
          "to_s": 3600,
          "equals": true
        }
      ]
    },
    {
      "name": "fan_off_dampers_parked_open",
      "description": "Gate: the fan is off and the boxes have driven their dampers open against a dead duct \u2014 the damper pattern reads exactly like a starved trunk, and sf_status suppresses the rule outright",
      "inputs": {
        "zone_dmpr_high_avg": 98.0,
        "zone_dmpr_low_avg": 92.0,
        "sf_status": false
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "fan_cycle_restarts_persistence",
      "description": "Gate transient: the fan stops at t=300 and restarts at t=600 with the damper pattern unchanged \u2014 the timer restarts from the fan restart, so the alarm lands at 1500 s rather than 900 s",
      "inputs": {
        "zone_dmpr_high_avg": 98.0,
        "zone_dmpr_low_avg": 92.0,
        "sf_status": [
          {
            "t": 0,
            "value": true
          },
          {
            "t": 300,
            "value": false
          },
          {
            "t": 600,
            "value": true
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1440,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 1560,
          "to_s": 3600,
          "equals": true
        }
      ]
    },
    {
      "name": "retuning_raises_pressure_before_delay",
      "description": "Transient: the setpoint is raised at t=600 and the boxes throttle back to 70% / 45% \u2014 the condition clears inside alarm_delay, which is what a successful re-tuning cycle looks like from the rule's side",
      "inputs": {
        "zone_dmpr_high_avg": [
          {
            "t": 0,
            "value": 98.0
          },
          {
            "t": 600,
            "value": 70.0
          }
        ],
        "zone_dmpr_low_avg": [
          {
            "t": 0,
            "value": 92.0
          },
          {
            "t": 600,
            "value": 45.0
          }
        ],
        "sf_status": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "bottom_half_dip_restarts_persistence",
      "description": "Transient: the bottom half drops to 20% at t=420 and returns to 92% at t=780 \u2014 the conjunction breaks and the persistence window restarts, so the alarm lands at 1680 s",
      "inputs": {
        "zone_dmpr_high_avg": 98.0,
        "zone_dmpr_low_avg": [
          {
            "t": 0,
            "value": 92.0
          },
          {
            "t": 420,
            "value": 20.0
          },
          {
            "t": 780,
            "value": 92.0
          }
        ],
        "sf_status": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1620,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 1740,
          "to_s": 3600,
          "equals": true
        }
      ]
    }
  ]
}

AHU-0037 — Economizing when it should not (damper position)

Statusverified — engine e2ff2f8, cxf:fnv1a128:2188a703036ffd6c5698d42b30bea1e3, 2026-08-18
Severity3
Methodrule
Phase2
CategoryEXCESS_CONSUMPTION
ConfidenceHIGH
EstimationDIRECT_MEASUREMENT
G36
Clusters
Suppresses
Suppressed by
RelatedAHU-0021, AHU-0030, AHU-0006, AHU-0034
Playbookseconomizer-failure
SourcePNNL-27338 §3.4 (pp. 3.15-3.18) — the ‘economizing when it should not’ AIRCx process: gated on econ_condition == False, then a damper-position test with no temperature ratio in it, avg_damper_signal > excess_damper_threshold. Published parameters min_damper_sp = 20%, excess_damper_threshold = min_damper_sp × 1.5 = 30%, data_window = 30 minutes; PNNL-27338 §3.1 (p. 3.2) — econ_condition itself, the differential-dry-bulb / fixed-high-limit comparison this card encodes, with temp_deadband = 1 °F; PNNL-27338 §3.5 (pp. 3.20-3.23) — the excess-OA process, which runs this same raw-damper test alongside its OAF ratio against the same 30% line and reports both causes when both trip; Sibling precedent: AHU-0017 and AHU-0034 (the changeover-type switch and its three parameters, transcribed unchanged); AHU-0021 (feeding a published knob as a Constant so it stays retunable alone); Library extension: the HVAC FDD Reference v1.0 ch.9 specifies AHU-0001..065 and stops — see faults/ahu/README.md; PNNL-27338 is not redistributed with this library
Operating statesoccupied, supply fan running — host-gated. Whether outdoor conditions favor economizing is not a host mode here: it is tested in-rule by unfavSel from oat and rat. Unoccupied hours belong to AHU-0026, where the damper expectation is closed rather than minimum.

Preconditions (host-enforced): Supply fan running, and the unit past its start-up transient — a morning warm-up purge or a scheduled pre-occupancy flush opens the damper by design and needs a host NO_EVAL, not a fault. min_damper_sp must be set to the unit’s own commissioned minimum damper position before deployment: 20% is PNNL’s default, and a unit whose design minimum is 30% alarms continuously against it. OAT sensor quality is a host gate, since a sensor reading high manufactures this fault out of a working economizer. Do not bind the rule to a unit with no return-air path (100%-outdoor-air or makeup-air): it has no minimum position to hold. The rule reads the damper command, so it is silent on a damper commanded to minimum and stuck open — that is AHU-0020 and AHU-0028. When any gate is unmet the verdict is NO_EVAL, not healthy.

Points: oat, rat, oa_dmpr_cmd

Outputs:

  • yFault — True while outdoor conditions have not favored economizing and the OA damper command has stayed above min_damper_sp × excess_damper_multiplier, both continuously for at least alarm_delay

Parameters:

NameDefaultUnitCXF pathDescription
min_damper_sp20.0%minDmpr.kMinimum outdoor-air damper position the unit should hold when economizing is not favorable. PNNL-27338 §3.4’s own site value; retune it to the unit’s commissioned minimum, and the excess threshold follows
excess_damper_multiplier1.51excessThr.kMultiple of min_damper_sp above which the damper counts as economizing. PNNL-27338 §3.4 publishes excess_damper_threshold as min_damper_sp × 1.5 = 30%; the multiplier stays a separate knob so the threshold tracks a retuned minimum instead of being recomputed by hand
econ_type_is_ddbTrueboolisDDB.kChangeover type — true = differential dry-bulb (compare oat to rat), false = fixed changeover temperature (compare oat to econ_hl_temp). Same parameter, same meaning, same default as AHU-0017 and AHU-0034; a unit running any two of the three must carry the same value in all of them
econ_hl_temp21.0°ChlConst.kFixed changeover temperature, used only when econ_type_is_ddb is false — the outdoor temperature above which the unit should be back at minimum outdoor air
temp_deadband1.0°CddbUnfav.t, hlUnfav.tMargin by which outdoor air must beat the changeover point before economizing counts as favorable; inside it the outdoor condition is unfavorable and this rule evaluates. Binds both changeover branches. PNNL-27338 §3.1’s own value is 1 °F — see Deviations
alarm_delay1800.0spersist.delayTimeContinuous fault persistence required before the alarm asserts (30 min, the length of PNNL-27338 §3.4’s data_window)

Description

Nothing about the weather favors free cooling and the outdoor-air damper is open well past its minimum position anyway. Whatever the coils are doing, they are doing it to more outdoor air than the ventilation calculation asked for, and outdoor air no cooler than the air it displaces is a load, not a resource. This is the raw-damper half of the economizer question: it never divides one temperature difference by another, so it keeps working in the conditions where the outdoor-air fraction cannot be computed at all — which is most of the band it fires in. AHU-0021 and AHU-0030 measure how much extra air arrives; this rule asks only whether the damper had any business being open, and answers from the command the controller wrote.

Detection Logic

econ_unfavorable = (rat − oat)          < temp_deadband    when econ_type_is_ddb
                 = (econ_hl_temp − oat) < temp_deadband    otherwise

yFault = econ_unfavorable
     AND oa_dmpr_cmd > min_damper_sp × excess_damper_multiplier
     sustained continuously for alarm_delay

Block graph (rule.cxf.jsonld):

AHU-0037 block graph

Both changeover branches are computed every tick and unfavSel (Logical.Switch, y = u2 ? u1 : u3) picks one, exactly as AHU-0017 does — same subtractions, same operand order, same temp_deadband, with LessThreshold where that card has GreaterThreshold. econ_unfavorable is therefore the strict complement of AHU-0017’s econ_favorable, in both branches, which is what lets a site read the pair as one policy. minDmpr and excessThr keep PNNL’s two published numbers as two live knobs: the 30% line is computed on the wire, so retuning a site’s minimum position to 30% moves the threshold to 45% without anyone redoing the arithmetic. Both comparisons are strict, so a damper commanded to exactly 30% and a changeover gap of exactly 1.0 °C both read healthy — the second is one boundary point more conservative than PNNL’s NOT (rat − oat > temp_deadband). persist requires 30 continuous minutes, the length of PNNL’s averaging window, which rides out the damper stroke and the minutes either side of changeover; delayOnInit = true holds that window across a controller restart.

Possible Diagnoses

PNNL-27338 §3.4 emits a diagnostic message rather than a fault set, so this list is authored from the mechanisms that hold an OA damper command above minimum when nothing favors economizing:

  1. Minimum position parameter set well above the ventilation design minimum — dialled up during a complaint or a commissioning shortcut, and a $0 desk fix. Check it before anything else.
  2. Economizer enable with no disable path: the sequence opens on a favorable comparison and never re-tests it
  3. Changeover setpoint or high limit left at a factory default that does not fit the climate
  4. A changeover device — dry-bulb or enthalpy switch — failed in its “economize” state, a single point of failure with no other symptom
  5. OAT sensor reading low, so outdoor air still looks worth importing
  6. A mixed-air low-limit or freeze-protection loop holding the damper open, its setpoint never re-tuned for cooling weather
  7. An override left in place after service (AHU-0027 finds the flag itself)

Energy Impact

EXCESS_CONSUMPTION, HIGH confidence, DIRECT_MEASUREMENT. Every point of damper command above the minimum imports outdoor air with no ventilation benefit, and the rule fires only when that air is within temp_deadband of return temperature or hotter, so the waste is a cooling penalty: (oa_dmpr_cmd − min_damper_sp)/100 × supply_airflow × ρ·cp × (oat − rat), every term but the airflow already on this rule’s wires. Read it as a floor — sensible-only, and in the near-changeover band where this rule does its distinctive work the latent load of humid outdoor air is the larger half. Cooling-dominant and sharply seasonal.

Emissions Impact

Scope 2, DIRECT_EMISSIONS, HIGH confidence; typical 1,000-6,000 kg CO₂e/yr, matching AHU-0034’s range for the same equipment and the same mechanical cooling. The whole impact is electric compressor or chiller work, and the hours are the grid’s worst — hot afternoons coincident with cooling peaks — so value it at the marginal operating emissions rate (MOER) rather than an average grid factor.

Deviations

  • This card is a library extension, not a transcription: chapter 9 specifies AHU-0001..065 and stops. Name, severity 3, phase 2 and method: rule are assigned here — severity 3 to match every other economizer card, phase 2 because the rule presupposes a site that has already configured a changeover type for AHU-0017. The algorithm and every threshold are PNNL-27338 §3.4’s.
  • The economizing-unfavorable gate is in the graph, where AHU-0021 and AHU-0030 leave it to the host. Those cards gate on the unit’s economizer mode, an operating state no measurement establishes. PNNL-27338 §3.1 computes econ_condition from two temperatures at every time step, which is a measurement — the library’s standing line (AHU-0030: measured points in, modes out) — and AHU-0017 and AHU-0034 already carry that comparison in-graph. Here it is load-bearing: host-side, this rule would be a bare damper threshold.
  • The changeover-type switch is carried even though §3.4’s prose is written around the dry-bulb case. §3.1 defines both branches, and AHU-0017 and AHU-0034 ship them; a site on fixed high-limit changeover would otherwise have this rule contradicting its two siblings on the same unit. econ_type_is_ddb, econ_hl_temp and temp_deadband are their parameters unchanged.
  • min_damper_sp × excess_damper_multiplier ships as two parameters, not one 30% threshold. PNNL publishes the minimum as the site value and the excess threshold as its derivative; collapsing them makes a site with a 30% minimum remember to type 45. Feeding the minimum as Reals.Sources.Constant.k and the factor as MultiplyByParameter.k is algebraically identical and keeps both of PNNL’s published names retunable alone (AHU-0021’s precedent for desired_oaf).
  • Persistence replaces PNNL’s window average. §3.4 averages damper_signal over a 30-minute data_window (with no_required_data = 10) and tests the mean; this card tests the instantaneous comparison through a 30-minute TrueDelay. The latency matches, the semantics do not: a damper spending half the window wide open and half closed would trip the average and does not trip the delay. Persistence is the house form and the stricter of the two.
  • temp_deadband ships at 1.0 °C, not PNNL’s 1 °F (0.56 °C). The value is AHU-0017’s and AHU-0034’s, and one deadband across all three keeps their changeover geometry coherent: that pair brackets a symmetric ±1.0 °C dead zone and this card’s gate is the exact complement of AHU-0017’s. Coherence is worth more than half a degree of fidelity to a threshold PNNL itself calls adjustable. A site with untrimmed sensors must raise it on all three together.
  • No evaluability output. AHU-0021 and AHU-0030 must publish yTempDeltaOk because their quotient becomes meaningless as |oat − rat| shrinks. This rule has no quotient, so there is no in-rule condition under which its own answer is unknown, and per SCHEMA.md there is nothing to expose as a y…Ok.
  • Both comparisons are strict (> on the damper, < on the changeover gap). At rat − oat exactly equal to temp_deadband this rule stays silent where PNNL’s NOT (rat − oat > temp_deadband) would evaluate — one boundary point, in the direction the library’s strict convention always takes.
  • fan_sp is not bound. §3.4.1 lists it among the process’s required points, but it appears only in the energy estimate of Step 7, never in the detection test; fan speed belongs to the host’s energy accounting, and binding a point a rule does not read would misstate the rule’s data requirement.
  • The unoccupied case in §3.4’s prose is left to AHU-0026. PNNL extends the check to unoccupied periods, where the damper should be closed rather than at minimum; AHU-0026 already tests exactly that against occ_schedule, so this card’s operating_states restricts it to occupied hours instead of carrying a second expectation for the same actuator.
  • The rule reads the damper command, not a measured position (PNNL’s own damper_signal, from the controller). A damper commanded to minimum and mechanically stuck open produces the entire physical fault and none of this signature; run AHU-0020 on the OA damper alongside, as AHU-0034 does.
  • suppresses and suppressed_by are empty although this rule nests AHU-0034. At shipped defaults its changeover gate implies this one, its damper threshold is higher, and it adds a cooling conjunct — so every AHU-0034 assertion is also one of these, in both branches. Both findings are true and separately actionable — AHU-0034’s is the sharper diagnosis, this one the wider net — and any suppression edge would be an index-level decision declared on both cards, which single-writer authoring cannot do from here.
  • savings_range is inherited, not transcribed. PNNL-27338 publishes no annual percentage for any of its five economizer diagnostics; the band quoted is AHU-0034’s, carried so the family reads consistently, and the card’s own claim is the runtime integral.
  • persist.delayOnInit = true (Modelica/CDL default is false), the library’s standing choice: a damper already open past changeover when the controller restarts waits out the full 30 minutes instead of alarming on the first tick.
  • Every vector is authored — PNNL-27338 publishes an algorithm and a flow chart, not test cases. With default parameters they reach only the dry-bulb branch, since vectors/v1 stages inputs and not parameters, so hlConst, hlGap and hlUnfav are structurally verified but never reach yFault through u3 (as on AHU-0034); econ_type_is_ddb = false commissions itself.

Notes

The temperature spread decides which of the three excess-outdoor-air cards can speak. AHU-0021 and AHU-0030 infer the fraction from (mat − rat) / (oat − rat) and report NO_EVAL whenever |oat − rat| is under 6 °C. This rule’s gate points the other way — it needs rat − oat under 1 °C — so it owns a roughly 7 °C-wide band, from a degree below return temperature up to where the ratio becomes reliable, in which it is the only one of the three that can answer at all. Above that band both forms work and both should fire: PNNL-27338 §3.5 does exactly that, running the raw damper test beside its OAF check against the same 30% line and reporting both causes when both trip. Below it, in genuine free-cooling weather, this rule is silent by construction. The 1-to-6 °C gap where none of them speaks is honest — mild outdoor air with an unreliable ratio is when an open damper is defensible.

Check the minimum-position parameter before sending anyone to the roof, then command the damper to minimum and watch it move. If the command changes and the fault clears, the sequence never asked for minimum and the fix is at a desk (economizer-failure, remote steps). If oa_dmpr_cmd already sits at minimum while the space says otherwise, this rule was never going to see it, and AHU-0020 is the one to run.

Test Vectors

11 scenarios, clock step 60 s over 3600 s.

ScenarioDescription
damper_at_minimum_unfavorableHealthy: OAT 28 °C against RAT 22 °C leaves nothing to economize with, and the damper sits at its 20% minimum — the correct response
damper_above_minimum_unfavorableThe fault: same unfavorable outdoor condition, damper commanded to 60% — twice the 30% excess threshold; alarms after alarm_delay (1800 s)
favorable_for_economizingEcon gate off on its own: OAT 12 °C is 10 °C below return air, so a wide-open damper is free cooling doing its job and no damper position is excessive
damper_exactly_at_thresholdEdge case: damper parked exactly on min_damper_sp × excess_damper_multiplier (20 × 1.5 = 30%) — the strict > keeps it out of the alarm
damper_just_above_thresholdEdge case, other side: 30.1% clears the threshold by a tenth of a point and the alarm follows at 1800 s
deadband_exactly_at_thresholdEdge case: rat − oat sits exactly on temp_deadband (22.0 − 21.0 = 1.0 °C) with the damper wide open — the strict < leaves the outdoor condition counted as still favorable, so no alarm
deadband_just_inside_thresholdEdge case, other side: rat − oat = 0.9 °C closes the deadband, the outdoor condition counts as unfavorable, and the wide-open damper alarms at 1800 s
close_temperatures_where_the_ratio_is_blindThe complementarity case: OAT 23.0 °C against RAT 22.5 °C is a 0.5 °C spread, far inside the 6 °C evaluability gate that puts AHU-0021 and AHU-0030 into NO_EVAL — this rule still reads a 70% damper as excess and alarms at 1800 s
changeover_arrives_midrunGate edge: the damper is open at 95% throughout, but outdoor air only rises from 12 °C to 28 °C at t=600 — persistence starts from the changeover, not from t=0, so the alarm lands at 2400 s
damper_closes_before_delayTransient: the damper drives from 60% back to its 20% minimum at t=1200, clearing the condition short of alarm_delay, so no alarm
damper_cycle_resets_persistenceTransient: the damper closes at t=900 and reopens at t=1200 under an unbroken unfavorable outdoor condition — persistence restarts from the reopen, so the alarm lands at 3000 s, not 1800 s
vectors.json
{
  "schema": "cxf-library/vectors/v1",
  "clock": {
    "step_s": 60,
    "horizon_s": 3600
  },
  "scenarios": [
    {
      "name": "damper_at_minimum_unfavorable",
      "description": "Healthy: OAT 28 \u00b0C against RAT 22 \u00b0C leaves nothing to economize with, and the damper sits at its 20% minimum \u2014 the correct response",
      "inputs": {
        "oat": 28.0,
        "rat": 22.0,
        "oa_dmpr_cmd": 20.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "damper_above_minimum_unfavorable",
      "description": "The fault: same unfavorable outdoor condition, damper commanded to 60% \u2014 twice the 30% excess threshold; alarms after alarm_delay (1800 s)",
      "inputs": {
        "oat": 28.0,
        "rat": 22.0,
        "oa_dmpr_cmd": 60.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1740,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 1860,
          "to_s": 3600,
          "equals": true
        }
      ]
    },
    {
      "name": "favorable_for_economizing",
      "description": "Econ gate off on its own: OAT 12 \u00b0C is 10 \u00b0C below return air, so a wide-open damper is free cooling doing its job and no damper position is excessive",
      "inputs": {
        "oat": 12.0,
        "rat": 22.0,
        "oa_dmpr_cmd": 95.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "damper_exactly_at_threshold",
      "description": "Edge case: damper parked exactly on min_damper_sp \u00d7 excess_damper_multiplier (20 \u00d7 1.5 = 30%) \u2014 the strict `>` keeps it out of the alarm",
      "inputs": {
        "oat": 28.0,
        "rat": 22.0,
        "oa_dmpr_cmd": 30.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "damper_just_above_threshold",
      "description": "Edge case, other side: 30.1% clears the threshold by a tenth of a point and the alarm follows at 1800 s",
      "inputs": {
        "oat": 28.0,
        "rat": 22.0,
        "oa_dmpr_cmd": 30.1
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1740,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 1860,
          "to_s": 3600,
          "equals": true
        }
      ]
    },
    {
      "name": "deadband_exactly_at_threshold",
      "description": "Edge case: rat \u2212 oat sits exactly on temp_deadband (22.0 \u2212 21.0 = 1.0 \u00b0C) with the damper wide open \u2014 the strict `<` leaves the outdoor condition counted as still favorable, so no alarm",
      "inputs": {
        "oat": 21.0,
        "rat": 22.0,
        "oa_dmpr_cmd": 95.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "deadband_just_inside_threshold",
      "description": "Edge case, other side: rat \u2212 oat = 0.9 \u00b0C closes the deadband, the outdoor condition counts as unfavorable, and the wide-open damper alarms at 1800 s",
      "inputs": {
        "oat": 21.1,
        "rat": 22.0,
        "oa_dmpr_cmd": 95.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1740,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 1860,
          "to_s": 3600,
          "equals": true
        }
      ]
    },
    {
      "name": "close_temperatures_where_the_ratio_is_blind",
      "description": "The complementarity case: OAT 23.0 \u00b0C against RAT 22.5 \u00b0C is a 0.5 \u00b0C spread, far inside the 6 \u00b0C evaluability gate that puts AHU-0021 and AHU-0030 into NO_EVAL \u2014 this rule still reads a 70% damper as excess and alarms at 1800 s",
      "inputs": {
        "oat": 23.0,
        "rat": 22.5,
        "oa_dmpr_cmd": 70.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1740,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 1860,
          "to_s": 3600,
          "equals": true
        }
      ]
    },
    {
      "name": "changeover_arrives_midrun",
      "description": "Gate edge: the damper is open at 95% throughout, but outdoor air only rises from 12 \u00b0C to 28 \u00b0C at t=600 \u2014 persistence starts from the changeover, not from t=0, so the alarm lands at 2400 s",
      "inputs": {
        "oat": [
          {
            "t": 0,
            "value": 12.0
          },
          {
            "t": 600,
            "value": 28.0
          }
        ],
        "rat": 22.0,
        "oa_dmpr_cmd": 95.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2340,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2460,
          "to_s": 3600,
          "equals": true
        }
      ]
    },
    {
      "name": "damper_closes_before_delay",
      "description": "Transient: the damper drives from 60% back to its 20% minimum at t=1200, clearing the condition short of alarm_delay, so no alarm",
      "inputs": {
        "oat": 28.0,
        "rat": 22.0,
        "oa_dmpr_cmd": [
          {
            "t": 0,
            "value": 60.0
          },
          {
            "t": 1200,
            "value": 20.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "damper_cycle_resets_persistence",
      "description": "Transient: the damper closes at t=900 and reopens at t=1200 under an unbroken unfavorable outdoor condition \u2014 persistence restarts from the reopen, so the alarm lands at 3000 s, not 1800 s",
      "inputs": {
        "oat": 28.0,
        "rat": 22.0,
        "oa_dmpr_cmd": [
          {
            "t": 0,
            "value": 60.0
          },
          {
            "t": 900,
            "value": 20.0
          },
          {
            "t": 1200,
            "value": 60.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2940,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 3060,
          "to_s": 3600,
          "equals": true
        }
      ]
    }
  ]
}

AHU-0038 — Cooling coil valve-position creep (fouling / authority loss)

Statusverified — engine e2ff2f8, cxf:fnv1a128:13837aef4f6e04f01ba9f415abde75b1, 2026-08-18
Severity3
Methodstatistical
Phase2
CategoryEFFICIENCY_LOSS
ConfidenceMEDIUM
EstimationQUALITATIVE_ONLY
G36
Clusters
Suppresses
Suppressed by
RelatedERV-0001, HP-0001, CHW-0004, AHU-0033
Playbookslow-delta-t
SourcePatil, A. et al. (2022), Chemical Engineering Research and Design 180 — heat-exchanger fouling under temperature control: the loop compensates, the transferred heat rate stays essentially constant, and thermal-only detection fails. The insight this card is built on; Guelpa, E. & Verda, V. (2020), Applied Energy 259 — flow-and-temperature fouling flags on 325 district-heating substation heat exchangers, turned into a cleaning schedule worth about 1.6% of network primary energy. The field-scale precedent for maintenance-from-monitoring; Veronica, D.A. (2010), HVAC&R Research 16(5) — the cooling-coil fouling detection concept (NIST); Library-authored: the HVAC FDD Reference has no coil-fouling card. The name, severity, category, both thresholds and the in-control gate are argued here; this is the library’s first hydraulic-proxy fault; Library precedent: RTU-0007 (host-fitted baseline consumed as an ordinary Real, evaluability output, statistical method label), HP-0001 (commissioning parameters shipped as documented placeholders), AHU-0033 (the
Operating statesmechanical cooling, supply fan running, cooling coil under supply-air temperature control — one instance per AHU, each carrying its own fit

Preconditions (host-enforced): clg_vlv_baseline must be this coil’s own fit, over a KNOWN-CLEAN window, against the same load condition it is published for (see points/ahu.points.json). A baseline fitted while the coil was already fouled learns the fouling as normal and the rule goes silent forever — the SYS-0006 training caveat, and the one failure this card cannot see from inside (baseline_fitted_on_a_fouled_coil_stays_silent pins it). Refit after any coil cleaning, valve or actuator replacement, and after any change to the supply-air setpoint strategy or the load the fit was indexed on; do NOT refit while this fault is active. The host owns the fan and mode gate: with the fan off the valve is driven shut and the fault term is false on arithmetic alone, so the gate suppresses output for a condition the graph already reads healthy. The coil must also be the only thing conditioning the air stream at the sensor — a heating valve leaking downstream, or an economizer doing part of the work, moves the valve for reasons that are not fouling. Evaluability is signalled in-rule by yInControlOk; when it is false the verdict is NO_EVAL, not a clean coil.

Points: clg_vlv_cmd, clg_vlv_baseline, sat, sat_sp

Outputs:

  • yFault — True while the cooling valve command has stayed more than creep_threshold above the host’s published baseline, with the supply-air loop holding inside its band, continuously for at least alarm_delay
  • yInControlOk — Evaluability signal — true when |sat − sat_sp| is inside in_control_band, the condition under which the valve position means anything. False means NO_EVAL and the host must ignore yFault

Parameters:

NameDefaultUnitCXF pathDescription
creep_threshold10.0%creep.tValve-position points above the fitted baseline that count as hydraulic excess. COMMISSIONING PLACEHOLDER: no source publishes a portable creep band, and the right value is a property of the fit — it must clear the baseline’s own residual scatter plus the amplitude the loop hunts at, both measured on this coil. 10 points is a starting number for a well-behaved modulating loop, not a finding.
in_control_band1.0°CinCtrl.tSupply-air tracking error inside which the loop counts as compensating. COMMISSIONING PLACEHOLDER, and deliberately tighter than AHU-0033’s 1.7 K tracking-error alarm so the two rules do not both speak about the same operating point — see Deviations. Symmetric: the graph takes the magnitude, so overcooling leaves the band exactly as undercooling does.
alarm_delay3600.0spersist.delayTimeContinuous excess-at-control required before the alarm asserts (60 min). Fouling develops over months, so latency is free; the hour is what a modulating loop can realistically hold continuously inside the band, and every excursion outside it restarts the timer.

Description

A cooling coil under supply-air temperature control hides its own fouling. As the fins load up and the tubes scale, heat transfer falls — and the loop answers by opening the valve. Supply air arrives at setpoint, the heat rate is unchanged (Patil et al. 2022), and every thermal test in this library reads a healthy unit: SAT tracks, the SAT-to-MAT drop is normal, nothing is high or low. What has changed is hydraulic. The coil is buying the same duty with more water, and the excess shows up as valve position — the one signal the control loop is not holding constant. This rule reads that excess against a baseline fitted when the coil was clean, and only while the loop is genuinely compensating: a coil that has stopped meeting its setpoint is a louder, different fault.

Detection Logic

excess       = clg_vlv_cmd − clg_vlv_baseline
tracking_err = |sat − sat_sp|

yInControlOk = tracking_err < in_control_band     (false ⇒ host reports NO_EVAL)
yFault       = excess > creep_threshold AND yInControlOk,
               sustained continuously for alarm_delay

Block graph (rule.cxf.jsonld):

AHU-0038 block graph

The gate is the rule’s argument, not a detail. Valve position is evidence about a coil only while the loop is closing on setpoint; once it saturates, position is pinned at 100% and the excess arithmetic is meaningless, so yInControlOk goes false and the host reads NO_EVAL rather than a clean coil. That is why it is an evaluability flag and not a sub-condition flag. The magnitude is taken before the comparison, so an overcooling loop is out of the band the same as an undercooling one; without it, a coil running 1.5 K cold with its valve wide open would read as in control and be alarmed as fouled.

Both comparisons are strict: a valve sitting exactly on the creep line, and a loop sitting exactly on the band edge, both read healthy. persist requires the full hour continuously and delayOnInit = true holds that window across a controller restart. Any interruption of either conjunct restarts it, so the alarm follows the last excursion rather than the fault’s onset.

Possible Diagnoses

The rule detects hydraulic excess. It cannot name the cause, and four causes share the signature — the playbook discriminates:

  1. Coil fouling — the finding this card is named for. Air-side (fin blockage, a filter bank someone stopped changing) or water-side (scale, biofilm, corrosion product). Both reduce transfer and both are answered by more flow.
  2. Valve authority degradation — an oversized, worn, or failing valve and actuator delivering less flow per point of command than it used to. The command creeps for a reason that has nothing to do with the coil.
  3. Air-bound coil — air trapped at a high point reduces the wetted surface and the flow through it. The cheapest fix on this list: bleed it.
  4. Low chilled-water differential pressure at the coil — a plant-side shortfall, a throttled branch, or a DP reset tuned past what the far coils need. Every coil on the affected branch creeps together, which is the tell.
  5. A baseline that no longer describes the operating condition — a changed supply-air setpoint strategy, a new load, or a fit that has simply aged out. The fix is a refit, not a work order.

Energy Impact

EFFICIENCY_LOSS, MEDIUM confidence, QUALITATIVE_ONLY. The AHU’s own thermal energy does not move — that is the mechanism, not a caveat — so there is nothing to book on the air side. The cost is on the water side: the same duty carried by more flow returns colder water, which is the low delta-T arithmetic CHW-0004 prices in pump energy and chiller staging, one coil’s contribution at a time. Confidence is MEDIUM because the mechanism is well published while the shipped thresholds are placeholders and the baseline is a host obligation the graph cannot audit. Cooling-dominant.

Emissions Impact

Scope 2, QUALITATIVE_EMISSIONS, MEDIUM confidence. All of it is purchased electricity — pumping and the chiller hours the depressed delta-T buys — so the avoided-emissions basis is the marginal operating emissions rate (MOER), and the timing is unhelpful: coils creep hardest on the hottest afternoons, when the marginal generator is dirtiest.

Deviations

  • The observable is hydraulic, and that is the whole design. Patil et al. (2022) show that under temperature control a fouling exchanger holds its heat rate while the loop compensates, so thermal tests cannot see it. AHU-0033, AHU-0032 and the SAT-to-MAT family all stay quiet through this fault by construction; only the valve moves.
  • clg_vlv_baseline is consumed as an ordinary Real, with the fit host-side. Same contract and same precedent as RTU-0007’s cond_split_baseline: the regression lives in the host, the comparison in the graph. The known-clean fit window is the price, it is unverifiable from inside the rule, and baseline_fitted_on_a_fouled_coil_stays_silent pins the failure so nobody discovers it in the field.
  • creep_threshold 10.0% and in_control_band 1.0 °C ship as commissioning placeholders, not literature values. No source publishes a portable valve-creep band — the right number depends on the fit’s residual scatter and the loop’s hunting amplitude, which are properties of the installation. HP-0001 ships its baseline coefficients the same way and for the same reason.
  • The in-control band is tighter than AHU-0033’s 1.7 K alarm line on purpose. The two rules then partition the tracking error: inside 1.0 K this card evaluates, beyond 1.7 K AHU-0033 alarms, and the strip between is deliberately NO_EVAL here — a loop that is neither clearly compensating nor clearly failing supports neither finding.
  • The fan gate is host-side, against AHU-0036’s in-graph choice. AHU-0036 wired fan status into its graph because dampers park open and the signature is present all night, so the timer would charge through it. Here the valve is driven shut when the fan stops: the excess goes negative and the fault term is false on arithmetic alone (fan_off_signature_is_absent). A host-side gate suppresses output for a condition the graph already reads healthy, which is the library’s default and costs a block.
  • yInControlOk is an evaluability flag, not a sub-condition flag. False means the comparison is meaningless, so the host must read it before yFault — the CHW-0004 / HP-0001 / RTU-0007 shape. It is a comparison against a parameter rather than an echo of an input, which is what SCHEMA.md asks of a boundary output.
  • Valve position stands in for flow, and the rule cannot separate the causes. A fouled coil, a valve losing authority, an air-bound coil and a starved branch all move the command up at constant duty; distinguishing them needs a wrench, a flow meter, or the neighbours’ behaviour. Naming the fault after the most common cause and listing the other three in Possible Diagnoses is the honest form — the same trade CHW-0004 makes at plant scale.
  • playbooks: [low-delta-t] is the least-bad fit, and the library has a gap. That playbook’s Step 2.4 (CHW DP reset), Step 3.1 (coil fouling), Step 3.2 (valve sizing) and Step 3.3 (air in the piping) are exactly the four causes above, in order — but its Step 1 verifies plant delta-T and its Step 4 confirms at the plant, where this finding is one coil. A coil-maintenance playbook that starts at the AHU is a queued gap for the orchestrator; until it exists, this binding gets the discrimination steps right and the framing wrong.
  • clusters: []. CLU-06 is chilled-water plant inefficiency triggered by CHW-0001, and fixing plant efficiency does not clear a fouled AHU coil — the causality runs the other way, coil by coil. This card is a contributor to that syndrome, not a member of it, and related: [CHW-0004] carries the link.
  • alarm_delay 3600 s and the restart semantics. Fouling is a season-long process, so detection latency is free and false positives are not. The cost of an hour is that both conjuncts must hold continuously through it: a loop that steps out of band every twenty minutes never accumulates the window (loop_excursion_restarts_persistence), which is the correct behaviour for a rule whose premise is a settled loop.
  • method: statistical describes the baseline’s provenance, not the runtime. The graph performs two subtractions, an absolute value, two comparisons, an AND and a delay. The fit behind clg_vlv_baseline is the statistical part; RTU-0007 and HP-0001 carry the same note.
  • Severity 3, category: EFFICIENCY_LOSS, estimation_method: QUALITATIVE_ONLY and the name are library-authored, mirrored from RTU-0007 — the nearest shipped relative, a fouling rule against a host-fitted baseline. g36: null: G36 has no coil-degradation check.
  • persist.delayOnInit = true (CDL default false), the library’s standing choice: a coil already creeping at controller start waits out the full hour rather than alarming on the first tick.
  • Every scenario in vectors.json is library-authored — there is no reference card to transcribe. Both threshold edges, both band edges, both gates on and off, the persistence edge and the two restart paths are pinned against the engine rev in verified.

Notes

Read yInControlOk before yFault. A coil that has lost control and a coil that is fine both hold yFault low, and only the flag separates them; the first case belongs to AHU-0033 and is the more urgent of the two.

Run this beside CHW-0004. The plant rule sees the sum of every creeping coil in the building and cannot say which; this rule names them one at a time. A building where CHW-0004 alarms and no AHU-0038 instance does is telling you the coils are fine and the bypass, the staging or the plant sensors are not.

Test Vectors

13 scenarios, clock step 60 s over 7200 s.

ScenarioDescription
clean_coil_holding_setpointNO_FAULT: the loop sits at 45% against a fitted 42% and holds supply air 0.2 K off a 13.0 °C setpoint. Three points of hydraulic excess is fit residual, not fouling, and yInControlOk is true the whole run so the silence means healthy rather than unevaluated.
fouled_coil_alarms_at_the_persistence_edgeFAULT, and the whole point of the card: supply air is still 0.2 K off setpoint — thermally the unit looks perfect — while the valve holds 65% where the fit expects 42%. The alarm lands at exactly 3600 s, which also pins delayOnInit: the condition is present from the first tick and still serves the full hour.
excess_exactly_at_the_thresholdBoundary, bit-exact: 52.0 − 42.0 is exactly 10.0 and the parameter is exactly 10.0, so the verdict is decided by the strictness of Reals.GreaterThreshold and not by rounding. A valve sitting exactly on the creep line reads healthy.
excess_just_above_the_thresholdBoundary from above: one hundredth of a percent more valve opening puts the excess at 10.01 points and the rule alarms on the normal schedule.
in_control_band_exactly_at_the_edge_is_no_evalNO_EVAL boundary: 14.0 − 13.0 is exactly 1.0 and Reals.LessThreshold is strict, so a loop sitting exactly on the band edge is NOT in control. The hydraulic excess is 23 points and the rule still says nothing — yInControlOk false is the only thing that distinguishes this from a clean coil.
in_control_band_just_inside_alarmsThe other side of the same boundary: 13.99 against 13.0 is 0.99 K of tracking error, inside the band, and the same 23 points of excess now count. Together with the previous scenario this pins both sides of the evaluability gate.
loop_lost_control_is_no_evalNO_EVAL: the valve is pinned at 100% and supply air runs 4 K warm. This is a coil that has stopped meeting its setpoint, which is AHU-0033’s finding and a louder one; the excess-over-baseline arithmetic means nothing once the loop is saturated, so this card stands down.
overcooling_side_of_the_band_is_no_evalNO_EVAL on the cold side, and the vector that pins the Abs: sat − sat_sp is −1.5 K, which is less than the 1.0 K band as a signed number and outside it as a magnitude. Without absErr the rule would read an overcooling loop as in control and count its open valve as fouling.
fan_off_signature_is_absentWhy the fan gate is a host precondition rather than a conjunct in the graph: with the fan stopped the cooling valve is driven shut, so the excess is −45 points and the fault term is false on arithmetic alone. Nothing charges the persistence timer overnight, which is the failure mode that put the fan status inside AHU-0036’s graph.
loop_excursion_restarts_persistenceA single tick outside the control band at 1800 s — a load step the loop has to chase — drops the evaluability gate and restarts the hour. The alarm moves from 3600 s to 5460 s, one delay after the loop settles back inside the band.
excess_dip_restarts_persistenceThe same restart driven from the other conjunct: the valve backs off to 50% for one tick, putting the excess at 8 points, then returns to 65%. The creep gate cycles off and on and the alarm lands at 5460 s.
coil_cleaned_mid_run_clears_the_alarmRecovery: the alarm asserts at 3600 s, then the coil is washed at 4800 s and the same duty is met at 44% — two points of excess. TrueDelay drops on the falling edge with no release delay, so the fault clears on the tick the valve backs off.
baseline_fitted_on_a_fouled_coil_stays_silentThe point dictionary’s known-clean caveat made executable. The valve is at 65% and the coil is fouled, but the host fitted the baseline from that same period and publishes 64%, so the excess is one point and the rule is quiet forever. Nothing in the graph can see this; the fit window is a commissioning obligation.
vectors.json
{
  "schema": "cxf-library/vectors/v1",
  "clock": {
    "step_s": 60,
    "horizon_s": 7200
  },
  "scenarios": [
    {
      "name": "clean_coil_holding_setpoint",
      "description": "NO_FAULT: the loop sits at 45% against a fitted 42% and holds supply air 0.2 K off a 13.0 \u00b0C setpoint. Three points of hydraulic excess is fit residual, not fouling, and yInControlOk is true the whole run so the silence means healthy rather than unevaluated.",
      "inputs": {
        "clg_vlv_cmd": 45.0,
        "clg_vlv_baseline": 42.0,
        "sat": 13.2,
        "sat_sp": 13.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        },
        {
          "output": "yInControlOk",
          "from_s": 0,
          "to_s": 7200,
          "equals": true
        }
      ]
    },
    {
      "name": "fouled_coil_alarms_at_the_persistence_edge",
      "description": "FAULT, and the whole point of the card: supply air is still 0.2 K off setpoint \u2014 thermally the unit looks perfect \u2014 while the valve holds 65% where the fit expects 42%. The alarm lands at exactly 3600 s, which also pins delayOnInit: the condition is present from the first tick and still serves the full hour.",
      "inputs": {
        "clg_vlv_cmd": 65.0,
        "clg_vlv_baseline": 42.0,
        "sat": 13.2,
        "sat_sp": 13.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3540,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 3600,
          "to_s": 7200,
          "equals": true
        },
        {
          "output": "yInControlOk",
          "from_s": 0,
          "to_s": 7200,
          "equals": true
        }
      ]
    },
    {
      "name": "excess_exactly_at_the_threshold",
      "description": "Boundary, bit-exact: 52.0 \u2212 42.0 is exactly 10.0 and the parameter is exactly 10.0, so the verdict is decided by the strictness of Reals.GreaterThreshold and not by rounding. A valve sitting exactly on the creep line reads healthy.",
      "inputs": {
        "clg_vlv_cmd": 52.0,
        "clg_vlv_baseline": 42.0,
        "sat": 13.2,
        "sat_sp": 13.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        },
        {
          "output": "yInControlOk",
          "from_s": 0,
          "to_s": 7200,
          "equals": true
        }
      ]
    },
    {
      "name": "excess_just_above_the_threshold",
      "description": "Boundary from above: one hundredth of a percent more valve opening puts the excess at 10.01 points and the rule alarms on the normal schedule.",
      "inputs": {
        "clg_vlv_cmd": 52.01,
        "clg_vlv_baseline": 42.0,
        "sat": 13.2,
        "sat_sp": 13.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3540,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 3600,
          "to_s": 7200,
          "equals": true
        },
        {
          "output": "yInControlOk",
          "from_s": 0,
          "to_s": 7200,
          "equals": true
        }
      ]
    },
    {
      "name": "in_control_band_exactly_at_the_edge_is_no_eval",
      "description": "NO_EVAL boundary: 14.0 \u2212 13.0 is exactly 1.0 and Reals.LessThreshold is strict, so a loop sitting exactly on the band edge is NOT in control. The hydraulic excess is 23 points and the rule still says nothing \u2014 yInControlOk false is the only thing that distinguishes this from a clean coil.",
      "inputs": {
        "clg_vlv_cmd": 65.0,
        "clg_vlv_baseline": 42.0,
        "sat": 14.0,
        "sat_sp": 13.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        },
        {
          "output": "yInControlOk",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        }
      ]
    },
    {
      "name": "in_control_band_just_inside_alarms",
      "description": "The other side of the same boundary: 13.99 against 13.0 is 0.99 K of tracking error, inside the band, and the same 23 points of excess now count. Together with the previous scenario this pins both sides of the evaluability gate.",
      "inputs": {
        "clg_vlv_cmd": 65.0,
        "clg_vlv_baseline": 42.0,
        "sat": 13.99,
        "sat_sp": 13.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3540,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 3600,
          "to_s": 7200,
          "equals": true
        },
        {
          "output": "yInControlOk",
          "from_s": 0,
          "to_s": 7200,
          "equals": true
        }
      ]
    },
    {
      "name": "loop_lost_control_is_no_eval",
      "description": "NO_EVAL: the valve is pinned at 100% and supply air runs 4 K warm. This is a coil that has stopped meeting its setpoint, which is AHU-0033's finding and a louder one; the excess-over-baseline arithmetic means nothing once the loop is saturated, so this card stands down.",
      "inputs": {
        "clg_vlv_cmd": 100.0,
        "clg_vlv_baseline": 42.0,
        "sat": 17.0,
        "sat_sp": 13.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        },
        {
          "output": "yInControlOk",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        }
      ]
    },
    {
      "name": "overcooling_side_of_the_band_is_no_eval",
      "description": "NO_EVAL on the cold side, and the vector that pins the Abs: sat \u2212 sat_sp is \u22121.5 K, which is less than the 1.0 K band as a signed number and outside it as a magnitude. Without absErr the rule would read an overcooling loop as in control and count its open valve as fouling.",
      "inputs": {
        "clg_vlv_cmd": 65.0,
        "clg_vlv_baseline": 42.0,
        "sat": 11.5,
        "sat_sp": 13.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        },
        {
          "output": "yInControlOk",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        }
      ]
    },
    {
      "name": "fan_off_signature_is_absent",
      "description": "Why the fan gate is a host precondition rather than a conjunct in the graph: with the fan stopped the cooling valve is driven shut, so the excess is \u221245 points and the fault term is false on arithmetic alone. Nothing charges the persistence timer overnight, which is the failure mode that put the fan status inside AHU-0036's graph.",
      "inputs": {
        "clg_vlv_cmd": 0.0,
        "clg_vlv_baseline": 45.0,
        "sat": 24.0,
        "sat_sp": 13.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        },
        {
          "output": "yInControlOk",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        }
      ]
    },
    {
      "name": "loop_excursion_restarts_persistence",
      "description": "A single tick outside the control band at 1800 s \u2014 a load step the loop has to chase \u2014 drops the evaluability gate and restarts the hour. The alarm moves from 3600 s to 5460 s, one delay after the loop settles back inside the band.",
      "inputs": {
        "clg_vlv_cmd": 65.0,
        "clg_vlv_baseline": 42.0,
        "sat": [
          {
            "t": 0,
            "value": 13.2
          },
          {
            "t": 1800,
            "value": 15.0
          },
          {
            "t": 1860,
            "value": 13.2
          }
        ],
        "sat_sp": 13.0
      },
      "expect": [
        {
          "output": "yInControlOk",
          "from_s": 0,
          "to_s": 1740,
          "equals": true
        },
        {
          "output": "yInControlOk",
          "from_s": 1800,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yInControlOk",
          "from_s": 1860,
          "to_s": 7200,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 5460,
          "to_s": 7200,
          "equals": true
        }
      ]
    },
    {
      "name": "excess_dip_restarts_persistence",
      "description": "The same restart driven from the other conjunct: the valve backs off to 50% for one tick, putting the excess at 8 points, then returns to 65%. The creep gate cycles off and on and the alarm lands at 5460 s.",
      "inputs": {
        "clg_vlv_cmd": [
          {
            "t": 0,
            "value": 65.0
          },
          {
            "t": 1800,
            "value": 50.0
          },
          {
            "t": 1860,
            "value": 65.0
          }
        ],
        "clg_vlv_baseline": 42.0,
        "sat": 13.2,
        "sat_sp": 13.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 5460,
          "to_s": 7200,
          "equals": true
        },
        {
          "output": "yInControlOk",
          "from_s": 0,
          "to_s": 7200,
          "equals": true
        }
      ]
    },
    {
      "name": "coil_cleaned_mid_run_clears_the_alarm",
      "description": "Recovery: the alarm asserts at 3600 s, then the coil is washed at 4800 s and the same duty is met at 44% \u2014 two points of excess. TrueDelay drops on the falling edge with no release delay, so the fault clears on the tick the valve backs off.",
      "inputs": {
        "clg_vlv_cmd": [
          {
            "t": 0,
            "value": 65.0
          },
          {
            "t": 4800,
            "value": 44.0
          }
        ],
        "clg_vlv_baseline": 42.0,
        "sat": 13.2,
        "sat_sp": 13.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3540,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 3600,
          "to_s": 4740,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 4800,
          "to_s": 7200,
          "equals": false
        }
      ]
    },
    {
      "name": "baseline_fitted_on_a_fouled_coil_stays_silent",
      "description": "The point dictionary's known-clean caveat made executable. The valve is at 65% and the coil is fouled, but the host fitted the baseline from that same period and publishes 64%, so the excess is one point and the rule is quiet forever. Nothing in the graph can see this; the fit window is a commissioning obligation.",
      "inputs": {
        "clg_vlv_cmd": 65.0,
        "clg_vlv_baseline": 64.0,
        "sat": 13.2,
        "sat_sp": 13.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        },
        {
          "output": "yInControlOk",
          "from_s": 0,
          "to_s": 7200,
          "equals": true
        }
      ]
    }
  ]
}

AHU-0039 — Supply fan proof-of-operation failure

Statusverified — engine e2ff2f8, cxf:fnv1a128:96279d88150a6da7569a7b4e335f35f4, 2026-08-18
Severity2
Methodrule
Phase2
CategoryPROTECTIVE
ConfidenceHIGH
EstimationPROXY_ESTIMATION
G36
Clusters
Suppresses
Suppressed by
RelatedSYS-0008, AHU-0026, VFD-0001
Playbooksproof-of-operation, vfd-pump-faults
SourceLibrary-authored: HVAC FDD Reference v1.0 §9 carries no proof-of-operation card for the AHU family (its ch.9 range is transcribed in AHU-0001–0031), so the name, severity, category and both proof times are argued on this card; Buildings.Controls.OBC.CDL.Logical.Proof (Hu & Wetter, March 2023), read at engine pin e2ff2f8 from third_party/modelica-buildings-cdl/Buildings/Controls/OBC/CDL/Logical/Proof.mo — the standardized command-vs-status idiom and the vocabulary this card’s two directions inherit (yLocFal / yLocTru). The block is exported at the pin and is deliberately NOT used; see Deviations; ASHRAE Guideline 36-2021 §5.1.6 — the ‘proven’ definition (the equipment’s DI status point matches the state set by its DO command point) — and §5.16.13.2, this exact fault as the VAV AHU supply-fan alarm: status different from command for 15 s, commanded-on/status-off Level 2, commanded-off/status-on Level 4 (identical wording for the other air-handling classes at §5.17.3.2/§5.18.12.2/§5.19.2.2/§5.22.5.2). Clause numbers verified against the standard text after authoring; both proof times are transcribed from that 15 s window (see Deviations); points/ahu.points.json sf_cmd and sf_status — the command/status pair the dictionary already names for this rule, and the run-enable-not-speed-reference binding note that makes sf_cmd meaningful on a VFD-driven fan; Library precedent: SYS-0008 (two direction flags, each sustained on its own branch, joined by an Or), PMP-0001 (whose Deviations record the command/status mismatch as ‘nobody’s fault rule today’ — this card closes that hole for the AHU supply fan), VFD-0001 (command-versus-feedback on the drive’s analog pair)
Operating statesall — the pair is judged in every hour and every mode. A fan that will not start at 06:00 and a fan running at 03:00 are both findings, and there is no operating state in which command and status may disagree past their proof time.

Preconditions (host-enforced): Bind sf_cmd to the run enable the BAS writes to the starter or drive, not to the speed reference (sf_speed carries that) — a percentage written into a boolean is either always-on or always-off. Bind sf_status to an INDEPENDENT proof of operation; a starter auxiliary contact or a software echo of the command makes the two inputs one input, and the start direction can then never fire while the card looks healthy. The status device TYPE decides what a passing proof means, and the rule cannot tell which device it is reading. A CURRENT SWITCH proves the motor is drawing current: a broken belt or a sheared coupling leaves the motor spinning at no load, so it is caught only where the switch’s trip point was field-set above that motor’s measured no-load current — a fixed-setpoint switch, or an adjustable one left at the factory minimum, reports ‘running’ with no air moving and this rule stays silent. A DIFFERENTIAL PRESSURE SWITCH across the fan proves air is moving and therefore catches the broken belt directly, but it can be held true by wind or stack pressure through an open OA damper on a stopped fan, which reads here as an unexpected run. On a VFD reporting its own run feedback, ‘running’ may mean ‘drive enabled’, which some configurations assert at zero speed. start_proof_time must exceed the drive’s accel ramp plus the status device’s own response; stop_proof_time must exceed coast-down plus whatever backdraft the status device can still see. One instance per fan — a dual-fan or fan-array AHU needs one per bound pair. Delivery quality is the host’s job and there is no in-rule evaluability output: the rule is evaluable whenever both booleans arrive fresh, and a stale status held at its last value either fakes a proof failure or hides one.

Points: sf_cmd, sf_status

Outputs:

  • yFault — True while either proof direction has matured — the roll-up alarm, and the signal a host should treat as NO_EVAL pressure on every fan-gated rule
  • yFailToStart — Sub-condition flag — the fan has been commanded on with no run status for start_proof_time. Diagnostic direction only; false never means NO_EVAL
  • yUnexpectedRun — Sub-condition flag — the fan has proven running against an off command for stop_proof_time. Same kind as yFailToStart; the two are mutually exclusive by construction

Parameters:

NameDefaultUnitCXF pathDescription
start_proof_time15.0sstartHeld.delayTimeContinuous commanded-on-without-status required before the start direction alarms. TRANSCRIBED from G36 §5.16.13.2’s alarm window (15 s), which assumes a clean DI on a fast starter. RETUNE ON REAL DEVICES: a belt-drive fan on a 30–60 s accel ramp, or a status poll slower than the window, manufactures a self-clearing transient alarm on every start (slow_status_device_alarms_at_the_shipped_default pins it) — raise toward 120 s for belt-drive-on-VFD, 60 s for direct drive across the line.
stop_proof_time15.0sstopHeld.delayTimeContinuous proven-running-without-command required before the stop direction alarms. G36’s fan clause publishes ONE 15 s window for both directions (unlike its pump clauses, which give the commanded-off direction its own 60 s — the shape PMP-0003 transcribes), so both directions ship the fan clause’s number. The stop clock is coast-down: a large plenum wheel free-wheels past 15 s and a DP switch keeps seeing that air — raise this one on big plenum fans or behind a slow-closing OA damper.

Description

Almost every AHU rule in this library opens with “while the supply fan is running.” This card checks whether that sentence is true. It compares the run command the BAS writes against the status the field reports and alarms in either direction: commanded on with nothing proving (broken belt, tripped overload, faulted drive, open disconnect), or proving running with no command behind it (a switch left in HAND, a welded contactor, a miswired output). The first means an air handler that is not handling air while its trend log looks scheduled; the second means fan energy and conditioned air nobody asked for. The finding is also structural — while these two points disagree, no rule gated on “fan on” is standing on solid ground.

Detection Logic

yFailToStart   = ( sf_cmd AND NOT sf_status ) sustained for start_proof_time
yUnexpectedRun = ( sf_status AND NOT sf_cmd ) sustained for stop_proof_time

yFault         = yFailToStart OR yUnexpectedRun

Block graph (rule.cxf.jsonld):

AHU-0039 block graph

Seven blocks, two symmetric branches and a join. Each direction carries its own published proof time so a healthy start and a healthy coast-down tune apart; the Or is a roll-up holding no state. Every input is a boolean, so the library’s strict-comparison caveat does not arise.

The proof times demand CONTINUOUS mismatch: a command withdrawn mid-window restarts startHeld from zero rather than banking the elapsed seconds. delayOnInit = true on both delays (CDL default false) makes a controller restart into an already-broken fan serve the full window instead of alarming on its first tick — the case that matters most here, since restarts and fan starts arrive together at 06:00.

Both sub-condition flags are diagnostic. False on either never means NO_EVAL, and the rule needs no evaluability output: it is evaluable whenever both points are delivered, which is the host’s delivery-quality job. The one blind spot is a status chattering faster than the proof window — no mismatch matures and both flags stay false, which is deliberately the opposite of what CDL.Logical.Proof does with that input (status_chatter_never_matures).

Possible Diagnoses

yFailToStart — commanded on, nothing proving:

  1. Broken belt or sheared coupling — the motor turns and no air moves. Whether this card sees it at all depends on the status device: a DP switch catches it, a current switch only if its trip point was set above the motor’s no-load current (see preconditions)
  2. Tripped motor overload or a dropped-out starter — the most common single cause, and free to confirm at the starter door
  3. VFD in LOCAL/HAND at the keypad, or faulted — overcurrent, phase loss, DC bus, or a drive coasting on a fault-state default. The BAS output is correct and nobody downstream is listening
  4. Disconnect open, blown fuse, or phase loss — lockout-tagout left open after a service call is the classic version
  5. A hard-wired safety in the start circuit — smoke detector, freezestat, high-static cutout. The fan will not start and the sequence is working as designed; check the safeties before the fan
  6. The status device itself — failed current switch, DP switch with blown-off or plugged tubing, or a setpoint above the real signal. This card cannot tell that from a genuinely stopped fan
  7. Wiring — the BAS output landed on the wrong terminal, or the status input did

yUnexpectedRun — running with no command:

  1. HOA switch in HAND at the starter or drive keypad, left after a service call. The most common cause, and a $0 fix once found
  2. Welded starter contactor or stuck control relay — the fan cannot be stopped from the BAS at all, a safety finding as much as an energy one
  3. Drive running on a local reference or a fault-state default — SYS-0008’s diagnosis 5, reached here through the supply fan
  4. A second controller or a local timeclock writing the same output, or a BAS output miswired to a normally-closed contact
  5. The status device stuck true — a DP switch reading wind or stack draft through an open OA damper, or a current switch clamped on the wrong conductor

Energy Impact

PROTECTIVE, HIGH confidence, PROXY_ESTIMATION. The two directions do not share an energy story. yUnexpectedRun wastes the whole fan — waste_kw = sf_rated_kw × (sf_speed/100)³, SYS-0008’s formula on host-supplied nameplate and speed, plus the conditioning penalty on air nobody scheduled, which after hours is usually the larger term. yFailToStart wastes nothing; it costs comfort, and it costs the coverage of every fan-gated rule that quietly stops meaning anything. PROXY rather than DIRECT because the rule reads two booleans and borrows both terms of its formula from the host.

Emissions Impact

Scope 1 or 2, PROXY_EMISSIONS. The fan is electric everywhere, so its share is Scope 2 on a marginal operating emissions rate basis; where the air an unbidden fan pushes is conditioned by a fuel-fired plant, the larger term is Scope 1 and belongs to the heating rules that see it. No range is published — the quantity is set entirely by fan size and by how long the condition stood before somebody read the alarm.

Deviations

  • CDL.Logical.Proof is exported at the pin and was rejected on measured behavior, not on availability. Loaded through the harness at e2ff2f8 it alarms on the tick the command changes: with the status stably false, a command flip to true at t = 300 s asserted yLocFal at t = 300 s, and raising feedbackDelay from 120 s to 600 s did not move that edge. Its own documentation says why — verification begins when the feedbackDelay + debounce timer or the debounce timer lapses, “whichever is first” — so a fan with a settled status gets no spin-up grace and every morning start alarms.
  • Three further semantic gaps, any one disqualifying. Proof publishes a single window (feedbackDelay + debounce) for both directions where this family needs two independently tunable times, because spin-up and coast-down are different physics. It sets BOTH outputs true on an unstable measurement (its documented step 1) — the probe latched yLocFal at t = 30 s and yLocTru at t = 210 s on a status chattering every 30 s — a third state this card’s mutual-exclusivity claim does not admit. And it latches rather than sustains: its outputs held until a rising edge of stable equality cleared them, 60 s (one debounce) after agreement returned.
  • So the graph is composed from four verified export classesLogical.Not ×2, Logical.And ×2, Logical.TrueDelay ×2, Logical.Or — in SYS-0008’s per-branch-sustain shape. The cost is that this library now owns proof semantics it would otherwise inherit from CDL; the benefit is two published times, flags that fall when the condition does, and flags that mean what the card says.
  • Mutual exclusivity is structural, not asserted. startMismatch needs sf_cmd and stopMismatch needs NOT sf_cmd, so they cannot be true on one tick, and a TrueDelay outputs false whenever its input is false, so neither flag outlives its branch. direction_flip_never_asserts_both pins the handover, including the real 120 s gap in yFault between the two.
  • Both proof times ship at G36’s 15 s, transcribed, with the argued device-physics numbers demoted to retune guidance. The card originally argued 120 s from drive ramps and coast-down; the standard’s own number for exactly this alarm exists (§5.16.13.2) and published beats argued — PMP-0003 set the family pattern by transcribing its pump clauses’ 15 s / 60 s pair. The cost is pinned, not hidden: slow_status_device_alarms_at_the_shipped_default shows the transient a 45 s ramp manufactures at the default. They remain two parameters so a site can move either clock alone.
  • delayOnInit = true on both delays (CDL default false) is the library’s standing choice and does real work here specifically: controller restarts cluster at the same hour as fan starts, and the default would turn every restart-into-a-starting-fan into an alarm.
  • No evaluability output; both extra outputs are direction flags. SCHEMA.md’s two kinds are easy to confuse and these are SYS-0008’s kind. The rule is evaluable whenever both booleans are delivered, so the only NO_EVAL condition is a delivery failure — which the graph cannot see and the host already owns. Hosts treating every non-yFault boolean as a gate will read this card backwards.
  • No adjudicates and no suppresses, although this rule plainly casts doubt on sf_status. adjudicates judges a point’s DATA validity, and here the point is usually telling the truth: under yFailToStart the fan really is stopped, and under yUnexpectedRun sf_status is the honest half while the command is the lie — marking it invalid_while_active would tell AHU-0026 and SYS-0008 to discard a correct reading at the moment it matters. Suppression is wrong for the same reason: those rules are usually right and this one is naming their cause. Two of the twelve diagnoses do accuse the status device, and the rule cannot pick them out of the other ten — which is an argument for reading the list, not for adjudicating the point. So the consequence ships as prose: an active yFault means the fan-on precondition is contested and hosts should apply NO_EVAL pressure to fan-gated rules.
  • Severity 2 / PROTECTIVE, mirrored from PMP-0001 and VFD-0001, the shipped command-versus-status relatives, rather than from AHU-0018’s CRITICAL_WASTE. This card’s product is proof integrity; where the unexpected run is also after-hours waste, AHU-0018 fires on the same event with its own numbers.
  • HIGH confidence claims the verdict, not the diagnosis; PROXY_ESTIMATION splits the two directions. No model, no threshold, no derived quantity — “these two booleans have disagreed for two minutes” is exactly what the graph computes and reports, so the breadth lives in the twelve-item diagnosis list rather than in the verdict (PMP-0001 chose MEDIUM for a rule whose flow conjunct carries real measurement risk). On the energy side DIRECT_MEASUREMENT would oversell yFailToStart, which has no term at all, and QUALITATIVE_ONLY would undersell yUnexpectedRun.
  • g36: null with verified clause citations in source. §5.1.6 defines ‘proven’ and §5.16.13.2 is this fault as G36’s own alarm (15 s, Level 2 / Level 4 by direction) — verified against the standard text. The g36 field carries transcribed-AFDD lineage, and G36’s AFDD routine has no command-vs-status fault, so the citations live in source (HW-0009’s family stance). The shipped windows ARE the alarm’s 15 s; outlasting real device latency and drive ramps is the params’ retune guidance, not the default.
  • playbooks: [proof-of-operation, vfd-pump-faults]. The family playbook (authored with this batch) owns the HOA/overload/belt/contactor walk and the status-device-type question; vfd-pump-faults stays bound for its drive fault-code and command-tracking steps, which cover the VFD diagnoses.
  • clusters: []. clusters/clusters.json defines no proof-of-operation cluster, and CLU-09 (Sensor Integrity Failure) is the wrong home for the reasons the adjudicates bullet gives. The cluster set is orchestrator-maintained and this card does not edit it.
  • No published test vectors exist. Every scenario in vectors.json is authored from the equation above and replayed against the pinned engine rev, including both proof-time edges at tick resolution.

Notes

Read this card before trusting any fan-gated verdict on the same air handler. AHU-0026 asks whether the OA damper is open while the fan runs unoccupied and SYS-0008 asks whether exhaust and supply move together; both take sf_status at face value, and while yFault is active that face value is what is in dispute. A proof failure poisons the precondition of the whole fan-gated family, which is why it ships as a first-class rule rather than a data-quality note.

The gap worth naming: there is no proof-of-operation playbook. Someone standing at the AHU needs the HOA-switch-and-overload walk, the belt check, and the current-switch-versus-DP-switch question this card’s preconditions turns on.

Test Vectors

10 scenarios, clock step 5 s over 1800 s.

ScenarioDescription
agreement_in_both_states_is_silentCommand and status agree through a full on-period and the stop at t = 900 lands on both points in the same tick. Neither direction ever sees a mismatch.
fail_to_start_asserts_at_start_proof_timeCommanded on at t = 300 and the status never comes. The start branch matures exactly at start_proof_time: false through 310, true from 315 — G36 §5.16.13.2’s 15 s window, transcribed.
unexpected_run_asserts_at_stop_proof_timeThe mirror: status rises at t = 300 with the command held off — HAND at the starter or a welded contactor. The stop branch matures at the same 15 s.
healthy_start_proves_inside_the_windowStatus follows the command 10 s later — a clean DI on a fast starter. The mismatch dies before the 15 s window matures and nothing asserts.
healthy_stop_coasts_down_inside_the_windowCommand drops at t = 300 and the status follows 10 s later as the wheel spins down past the device’s threshold. Silent.
command_flip_inside_the_window_restarts_the_timerThe command pulses off for one tick mid-proof and returns. TrueDelay starts over on the false gap: the alarm that would have landed at 315 lands at 335 instead.
direction_flip_never_asserts_bothA standing fail-to-start becomes an unexpected run at t = 600 when command and status swap in one tick. The flags hand over with a real 15 s gap in yFault between them; both are never true together — the negated command term makes it structural.
restart_into_an_unexpected_run_serves_the_full_windowThe rule loads with status already on and command off — a controller restart into a fan someone left in HAND. delayOnInit = true serves the full window from init: false through 10, true from 15.
status_chatter_never_maturesA current switch chattering on 10 s half-periods against a held-on command. No mismatch survives 15 s continuously, so nothing asserts — deliberately the opposite of Logical.Proof’s both-outputs-latch behavior on this trace.
slow_status_device_alarms_at_the_shipped_defaultA belt-drive fan on a 45 s accel ramp against the transcribed 15 s window: the start alarm fires at 315 and self-clears at 345 when the status finally latches. The shipped default manufactures this transient on every start of a slow device — the params say raise start_proof_time past the ramp; this vector pins what happens if nobody does.
vectors.json
{
  "schema": "cxf-library/vectors/v1",
  "clock": {
    "step_s": 5,
    "horizon_s": 1800
  },
  "scenarios": [
    {
      "name": "agreement_in_both_states_is_silent",
      "description": "Command and status agree through a full on-period and the stop at t = 900 lands on both points in the same tick. Neither direction ever sees a mismatch.",
      "inputs": {
        "sf_cmd": [
          {
            "t": 0,
            "value": true
          },
          {
            "t": 900,
            "value": false
          }
        ],
        "sf_status": [
          {
            "t": 0,
            "value": true
          },
          {
            "t": 900,
            "value": false
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        }
      ]
    },
    {
      "name": "fail_to_start_asserts_at_start_proof_time",
      "description": "Commanded on at t = 300 and the status never comes. The start branch matures exactly at start_proof_time: false through 310, true from 315 \u2014 G36 \u00a75.16.13.2's 15 s window, transcribed.",
      "inputs": {
        "sf_cmd": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 300,
            "value": true
          }
        ],
        "sf_status": false
      },
      "expect": [
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 310,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 315,
          "to_s": 1800,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 315,
          "to_s": 1800,
          "equals": true
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        }
      ]
    },
    {
      "name": "unexpected_run_asserts_at_stop_proof_time",
      "description": "The mirror: status rises at t = 300 with the command held off \u2014 HAND at the starter or a welded contactor. The stop branch matures at the same 15 s.",
      "inputs": {
        "sf_cmd": false,
        "sf_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 300,
            "value": true
          }
        ]
      },
      "expect": [
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 310,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 315,
          "to_s": 1800,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 315,
          "to_s": 1800,
          "equals": true
        },
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        }
      ]
    },
    {
      "name": "healthy_start_proves_inside_the_window",
      "description": "Status follows the command 10 s later \u2014 a clean DI on a fast starter. The mismatch dies before the 15 s window matures and nothing asserts.",
      "inputs": {
        "sf_cmd": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 300,
            "value": true
          }
        ],
        "sf_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 310,
            "value": true
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        }
      ]
    },
    {
      "name": "healthy_stop_coasts_down_inside_the_window",
      "description": "Command drops at t = 300 and the status follows 10 s later as the wheel spins down past the device's threshold. Silent.",
      "inputs": {
        "sf_cmd": [
          {
            "t": 0,
            "value": true
          },
          {
            "t": 300,
            "value": false
          }
        ],
        "sf_status": [
          {
            "t": 0,
            "value": true
          },
          {
            "t": 310,
            "value": false
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        }
      ]
    },
    {
      "name": "command_flip_inside_the_window_restarts_the_timer",
      "description": "The command pulses off for one tick mid-proof and returns. TrueDelay starts over on the false gap: the alarm that would have landed at 315 lands at 335 instead.",
      "inputs": {
        "sf_cmd": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 300,
            "value": true
          },
          {
            "t": 310,
            "value": false
          },
          {
            "t": 320,
            "value": true
          }
        ],
        "sf_status": false
      },
      "expect": [
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 330,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 335,
          "to_s": 1800,
          "equals": true
        }
      ]
    },
    {
      "name": "direction_flip_never_asserts_both",
      "description": "A standing fail-to-start becomes an unexpected run at t = 600 when command and status swap in one tick. The flags hand over with a real 15 s gap in yFault between them; both are never true together \u2014 the negated command term makes it structural.",
      "inputs": {
        "sf_cmd": [
          {
            "t": 0,
            "value": true
          },
          {
            "t": 600,
            "value": false
          }
        ],
        "sf_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 600,
            "value": true
          }
        ]
      },
      "expect": [
        {
          "output": "yFailToStart",
          "from_s": 15,
          "to_s": 595,
          "equals": true
        },
        {
          "output": "yFailToStart",
          "from_s": 600,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 595,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 615,
          "to_s": 1800,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 15,
          "to_s": 595,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 600,
          "to_s": 610,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 615,
          "to_s": 1800,
          "equals": true
        }
      ]
    },
    {
      "name": "restart_into_an_unexpected_run_serves_the_full_window",
      "description": "The rule loads with status already on and command off \u2014 a controller restart into a fan someone left in HAND. delayOnInit = true serves the full window from init: false through 10, true from 15.",
      "inputs": {
        "sf_cmd": false,
        "sf_status": true
      },
      "expect": [
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 10,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 15,
          "to_s": 1800,
          "equals": true
        }
      ]
    },
    {
      "name": "status_chatter_never_matures",
      "description": "A current switch chattering on 10 s half-periods against a held-on command. No mismatch survives 15 s continuously, so nothing asserts \u2014 deliberately the opposite of Logical.Proof's both-outputs-latch behavior on this trace.",
      "inputs": {
        "sf_cmd": true,
        "sf_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 10,
            "value": true
          },
          {
            "t": 20,
            "value": false
          },
          {
            "t": 30,
            "value": true
          },
          {
            "t": 40,
            "value": false
          },
          {
            "t": 50,
            "value": true
          },
          {
            "t": 60,
            "value": false
          },
          {
            "t": 70,
            "value": true
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        }
      ]
    },
    {
      "name": "slow_status_device_alarms_at_the_shipped_default",
      "description": "A belt-drive fan on a 45 s accel ramp against the transcribed 15 s window: the start alarm fires at 315 and self-clears at 345 when the status finally latches. The shipped default manufactures this transient on every start of a slow device \u2014 the params say raise start_proof_time past the ramp; this vector pins what happens if nobody does.",
      "inputs": {
        "sf_cmd": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 300,
            "value": true
          }
        ],
        "sf_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 345,
            "value": true
          }
        ]
      },
      "expect": [
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 310,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 315,
          "to_s": 340,
          "equals": true
        },
        {
          "output": "yFailToStart",
          "from_s": 345,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 315,
          "to_s": 340,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 345,
          "to_s": 1800,
          "equals": false
        }
      ]
    }
  ]
}

CHW Fault Rules

Chilled-water plant fault detection rules (CHW-*). CHW-0001..0006 originate in HVAC FDD Reference v1.0 ch.13 or close its cited approach-rule gaps. CHW-0007..0009 are library-authored control/protection extensions grounded in public plant guidance and verified graph precedents. The family now separates efficiency and reset performance from per-machine tracking, proof, and cycling.

Point dictionary: points/chw.points.json. Supplementary deep-read sources (paraphrase-and-cite only, per the licensing rule): G36 cooling-plant AFDD document, ASHRAE RP-1043 chiller FDD review, and the chiller-plant entries in the project’s internal research triage (licensed sources; not distributed).

Index

IDNameSevMethodStatus
CHW-0001Chiller efficiency (kW/ton) degradation3statisticalverified
CHW-0002CHWST reset not functioning3statisticalverified
CHW-0003CHW loop DP reset not functioning3statisticalverified
CHW-0004Chilled water low delta-T syndrome3ruleverified
CHW-0005Chiller condenser approach high3ruleverified
CHW-0006Chiller evaporator approach high3ruleverified
CHW-0007Chilled-water supply temperature tracking failure3ruleverified
CHW-0008Chiller proof-of-operation failure2ruleverified
CHW-0009Chiller short-cycling2ruleverified

Severity and method per the reference’s ch.13 cards. CHW-0005/CHW-0006 are the approach pair resolving the reference’s dangling CHW-FC-008/009 playbook citations (mentioned there, never defined): condenser side and evaporator side of the same verify step, tube-side discriminators for CHW-0001.

Relationships

  • CHW-0002/CHW-0003 are the plant-side siblings of AHU-0023/AHU-0024 (SAT/DSP reset) — same detector shape (setpoint range flat over a window while the load moves), same RetuningOpps lineage, and the same CLU-02-style “reset never programmed” root cause one system further upstream.
  • CHW-0004 (low delta-T) is the plant-level symptom of coil-side defects the library already detects locally: fouled/leaking coil valves (FCU-0004, AHU-0014), three-way bypass, dirty filters — tied together by cluster CLU-06 (trigger CHW-0001).
  • CHW-0001’s baseline follows the HP-0001 host-fitted pattern — the reference specifies a Ridge regression over load/CWST/CHWST; the graph carries the fitted coefficients as set_param placeholders.
  • CHW-0007 complements CHW-0001/0005/0006: tracking direction is useful evidence during an approach or efficiency finding, so none suppresses it.
  • CHW-0008 stays relationship-only. Its fail-to-start direction makes tracking non-evaluable, but its unexpected-run direction can still be loaded and meaningfully fail tracking. Current metadata cannot encode only one lane.
  • CHW-0009 and CHW-0007 are related because low-load overshoot can produce cycling, but neither implies the other and no graph state is shared.

Per-machine binding boundaries

PointCHW-0007CHW-0008CHW-0009Plant/loop use
chiller_cmdFinal command for this machinePlant enable or cooling demand is invalid
chiller_statusThis machineThis machine, independent proofThis machineA fleet OR hides lag-machine starts
chiller_loadThis machineA documented plant maximum remains valid only for plant-level gates such as CHW-0004
chwst / chwst_spSame controllable leaving-water targetCommon header is conditional, not automatically per-machine

On parallel plants, prefer each chiller’s evaporator outlet plus the final active target delivered to its controller. A mixed-header measurement/setpoint may be used for CHW-0007 only when the deployment proves the staged machine(s) actually control that same point. An OR of statuses or maximum of loads is not a weaker per-machine proxy; it changes the question.

Ontology and cluster decision

The PR 04 point additions pin ASHRAE 223 to the inspected public-review artifact 1.0.0-ppr.2.1 (SHA-256 1f156f9938c0be430d2216e01e31bb183c438ba318d8d4a23d2f074ebcd6f573), replacing the former unverified v1.0.0-2026 label. The migration also replaces legacy Water/Refrigerant and free-text aspect shorthands with artifact-defined local names such as Water-ChilledWater, Fluid-Water, Constituent-Refrigerant, and Aspect-Setpoint. Brick 1.4.4 provides exact generic Start_Stop_Command and Run_Status classes on a Chiller.

CLU-06 is not broadened. Tracking, proof, and cycling improve diagnosis but do not share one trigger/fix with the existing efficiency-reset-approach cluster.

CHW-0001 — Chiller efficiency (kW/ton) degradation

Statusverified — engine e2ff2f8, cxf:fnv1a128:86abcc0abe758ffcc575e7dff93afc03, 2026-08-17
Severity3
Methodstatistical
Phase2
CategoryEFFICIENCY_LOSS
ConfidenceHIGH
EstimationBASELINE_COMPARISON
G36
ClustersCLU-06
Suppresses
Suppressed by
RelatedCHW-0002, CHW-0004, CHW-0005, CHW-0007, CHW-0009, HP-0001
Playbookschiller-efficiency
SourceHVAC FDD Reference v1.0 §13 (ch. ‘Chilled Water Plants’, pdf pp. 118-119), CHW-0001; PNNL retuning; PNNL EEM-26; Chen et al. 2024
Operating stateschiller running and loaded above min_chiller_load, past the start transient — one rule instance per chiller, each carrying that machine’s fitted coefficients

Preconditions (host-enforced): The host owns the baseline. It runs the learning_period_days (30 d) Ridge regression of kW/ton against load, CWST and CHWST for THIS machine, confirms the fit is good enough to hold the plant to, and writes the four coefficients in with set_param; until it has, the rule is comparing against the shipped placeholders and means nothing (see Deviations). chiller_tons MUST be bound in refrigeration tons — the point dictionary flags this as its main hazard, because kW/ton and the fitted coefficients go wrong together and silently if the host feeds kW thermal instead. On most plants chiller_tons is a host-computed virtual point (flow × delta-T × cp), so its provenance is part of the baseline precondition rather than separate from it: a tons figure derived from a drifting flow meter moves the quotient and the fit together. The chiller must also have settled after a start or a capacity step before the quotient means anything; a machine still pulling down reads degraded on physics. The regressors must lie inside the range the fit was taken over — the graph extrapolates the plane forever and knows nothing about where the fit stops being physical. Evaluability is signalled in-rule by two outputs: yLoadOk (the reference’s min_chiller_load gate) and yTonsOk (the divide guard). When either is false the verdict is NO_EVAL, not healthy.

Points: chiller_kw, chiller_tons, chiller_load, cwst, chwst

Outputs:

  • yFault — True while the measured kW/ton has stayed above degradation_ratio_threshold × the fitted baseline for the current load and water temperatures, continuously for at least alarm_delay
  • yLoadOk — Evaluability signal — true when chiller_load is above min_chiller_load, the load below which kW/ton is not comparable to the baseline. False means NO_EVAL and the host must ignore yFault
  • yTonsOk — Evaluability signal — true when chiller_tons is above min_chiller_tons, the floor below which the kW/ton quotient is meaningless or undefined. False means NO_EVAL and the host must ignore yFault

Parameters:

NameDefaultUnitCXF pathDescription
kw_per_ton_load_coeff0.0025kW/ton per %loadTerm.kLoad coefficient of the host-fitted kW/ton baseline. PER-MACHINE SITE CONFIGURATION — the reference supplies a Ridge regression, not a number, and the shipped value is a placeholder describing a generic water-cooled centrifugal machine. Meaningful only together with the other three coefficients.
kw_per_ton_cwst_coeff0.01kW/ton per °CcwstTerm.kCondenser water supply temperature coefficient of the same fit — the condenser-lift term, positive because warmer condenser water costs more kW per ton. PER-MACHINE SITE CONFIGURATION on the same terms.
kw_per_ton_chwst_coeff-0.015kW/ton per °CchwstTerm.kChilled water supply temperature coefficient of the same fit. Inherently negative for a normal machine — raising the evaporator temperature reduces lift and improves kW/ton — which is why this parameter is signed (see Deviations). PER-MACHINE SITE CONFIGURATION.
kw_per_ton_intercept0.25kW/tonexpected.pIntercept of the same fit: the expected kW/ton at zero load, 0 °C condenser water and 0 °C chilled water, which is a fitting artefact rather than an operating point. PER-MACHINE SITE CONFIGURATION; the four coefficients are only meaningful as a set.
degradation_ratio_threshold1.11allowed.kMultiple of the baseline kW/ton the machine must stay below. 1.1 is the reference’s 10% degradation_threshold written as a multiplier: fault when measured > 1.1 × expected. It is a multiplier, not a percentage — writing 10 here silences the rule (see Deviations).
min_chiller_load30.0%loadOk.tChiller load below which kW/ton is not evaluated. The reference’s min_chiller_load: a lightly loaded chiller is inefficient on physics, and the baseline was not fitted down there.
min_chiller_tons10.0tonstonsOk.tCooling output below which the kW/ton quotient is not evaluated. Guards the division — at zero tons the quotient is infinite or NaN. PER-MACHINE SITE CONFIGURATION: set it from the machine’s real minimum output, not from zero (see Deviations). The shipped 10 tons suits a mid-size machine and is arbitrary on a 3,000-ton one.
alarm_delay3600.0spersist.delayTimeContinuous degradation required before the alarm asserts (60 min). The reference’s AlarmDelay, renamed to the library’s convention

Description

Kilowatts per ton is the chiller trade’s efficiency number, and on its own it diagnoses nothing: the machine that turns in 0.45 kW/ton on a mild morning with cold condenser water turns in 0.75 on a design afternoon, and both can be perfectly healthy. A fixed threshold alarms all summer or never alarms at all. What is stable is the relationship — for a given machine kW/ton is close to a plane in three variables (load, condenser water temperature, chilled water temperature). The host fits that plane (the reference specifies a Ridge regression retrained periodically over 30 days) and writes the four coefficients in as parameters; the graph asks whether today’s reading is more than 10% above what the plane predicts. The fault it finds best is the one nobody notices — condenser tube fouling, which develops over months and never trips the chiller’s own panel. The reference’s yardstick: 0.1 kW/ton above baseline is roughly 15% excess chiller energy.

Detection Logic

measured_kw_per_ton = chiller_kw / chiller_tons
expected_kw_per_ton = kw_per_ton_load_coeff  × chiller_load
                    + kw_per_ton_cwst_coeff  × cwst
                    + kw_per_ton_chwst_coeff × chwst
                    + kw_per_ton_intercept
allowed_kw_per_ton  = degradation_ratio_threshold × expected_kw_per_ton

yLoadOk = chiller_load > min_chiller_load       (false ⇒ host reports NO_EVAL)
yTonsOk = chiller_tons > min_chiller_tons       (false ⇒ host reports NO_EVAL)
yFault  = measured_kw_per_ton > allowed_kw_per_ton AND yLoadOk AND yTonsOk,
          sustained continuously for alarm_delay

Block graph (rule.cxf.jsonld):

CHW-0001 block graph

loadTerm, cwstTerm, chwstTerm, the two Reals.Add blocks and expected are the fitted plane — the whole statistical content of the rule at runtime. allowed scales it by the tolerance, so the comparison is against a second plane parallel to the first rather than against a number: at the shipped placeholders a machine at 50% load with 30 °C condenser water making 6 °C chilled water is expected at 0.585 kW/ton and allowed 0.6435, and identical power and tonnage give opposite verdicts once the regressors move.

kwPerTon is the only division and its denominator goes to zero whenever the machine unloads or the tons calculation fails; with tons at zero the quotient is infinite and the comparison is true, so without tonsOk a dead flow meter would produce a permanent alarm. tonsOk and loadOk each drive a boundary output and a gate, so a machine below either floor holds yFault down and the host reads the silence as “not evaluated”. The load floor is about comparability rather than arithmetic: at 20% load a chiller is inefficient because it is a chiller at 20% load.

The comparison at the allowance is strict, so a machine exactly on the allowed plane reads healthy. persist requires 60 continuous minutes — enough to ride out a capacity step, a condenser water reset or a stage change — and carries delayOnInit = true.

Possible Diagnoses

Transcribed from the reference’s CHW-0001 card:

  1. Condenser tube fouling — scale, biofilm or silt from an open tower loop. The most common cause and the one this rule is really for; the condenser approach widens long before anything else complains
  2. Evaporator tube fouling — rarer, the loop being closed, and usually a water treatment or an opened-for-work story
  3. Low refrigerant charge — degrades kW/ton across the whole operating range without producing a single reading that looks wrong on its own
  4. Compressor degradation: worn bearings, damaged impellers, failing unloaders — elevated draw for the same delivered capacity
  5. Non-condensable gases raising condensing pressure; on a low-pressure machine the purge unit’s runtime usually tells the story first

The discriminator between 1 and 2 is the approach temperature on each side, which this rule does not read — a plant that trends both approaches alongside this alarm separates the two causes for free. The chapter’s introduction promises approach-temperature analysis, but none of its specified rules performs it and neither does this library.

Energy Impact

EFFICIENCY_LOSS, HIGH confidence, BASELINE_COMPARISON. The estimator is the ratio the rule already computes: waste_kw = chiller_kw × (1 − expected_kw_per_ton / measured_kw_per_ton) — a machine drawing 358 kW at 0.70 kW/ton against a 0.585 baseline spends about 59 kW on the degradation. The reference’s range is 5–15% of chiller energy, and its 0.1 kW/ton ≈ 15% yardstick is the one to quote to an operator because it is in the units the plant’s trend screen already shows. HIGH confidence carries the caveat every self-learned baseline has: the fit is this machine’s own recent history, so a chiller with fouled tubes at commissioning learns a fouled baseline and reads healthy forever. Cooling-dominant, and largest on the hottest days.

Emissions Impact

Scope 2, PROXY_EMISSIONS, HIGH confidence; the reference’s typical range is 1,000–10,000 kg CO₂e/yr on a marginal operating emissions rate basis. All of it is purchased electricity at the compressor. The range is wide because it spans a small machine with a slight charge loss and a large one with fouled tubes, and because the marginal rate in the hours a chiller runs hardest is well above the annual average — a cooling peak is when the dirtiest generator on the system is dispatched.

Deviations

  • All four baseline coefficients ship as documented PLACEHOLDERS. The reference specifies a model, not numbers (baseline_model.predict([chiller_load, cwst, chwst]), sklearn Ridge, learning_period_days = 30), and this library’s split puts the fitting in the host and the fitted plane in the graph as set_param targets. The shipped set describes a generic water-cooled centrifugal machine and exists so the document is runnable as delivered. They are not site values, and a wrong set fails silently in both directions — fit the plane 15% high and nothing ever alarms, fit it low and every hour does. Precedent: HP-0001’s COP line, VAV-0001’s ventilation_requirement.
  • The reference publishes no fit-quality bar for this fault, and this card does not invent one. HP-0001’s chapter specifies R² > 0.6; chapter 13 specifies only “retrained periodically” over 30 days, so the precondition asks the host to confirm the fit is good enough to hold the plant to. A site adopting 0.6 by analogy is making a defensible choice, not following the reference.
  • kw_per_ton_chwst_coeff is negative — the documented exception to the library’s no-negative-parameters convention. A regression coefficient is inherently signed (raising the evaporator temperature reduces lift and improves kW/ton), and a host re-fitting on odd data could get any sign on any of the three; forcing signs into the topology would mean a different graph per machine. Same reasoning as HP-0001’s cop_baseline_slope.
  • The degradation test is a multiplier, not the reference’s fraction. The reference writes (measured − expected)/expected > degradation_threshold at 10%; this rule computes measured > 1.1 × expected, the same predicate for any positive expected, and it avoids a second division by a fitted plane that can cross zero. The units consequence bites: degradation_ratio_threshold is a multiplier (1.1), not a percentage (10), and writing 10 silences the rule because no machine draws ten times its baseline. HP-0001 carries the mirror image, where the same mistake alarms permanently.
  • min_chiller_tons and yTonsOk are adopted, not transcribed. The reference’s only evaluability gate is min_chiller_load, but the graph divides by a live signal, and per SCHEMA.md a test computable from the rule’s own inputs belongs in the graph as a boundary output. Same stance as HP-0001’s yPowerOk.
  • The tonnage floor guards zero, not a wrong reading. A tons signal that has collapsed to a small non-zero value — a flow meter reading 5% of actual, a delta-T using a failed sensor — produces a believable quotient and a false alarm on a healthy machine. Set min_chiller_tons from the machine’s real minimum output rather than from zero, and read yFault beside the plant’s own tonnage trend.
  • The tons unit hazard is the biggest single way to deploy this rule wrong. The kW/ton convention uses refrigeration tons while most BAS trend kW thermal or derive tons from flow × delta-T; feeding kW thermal scales the quotient by 3.517, and because the host fits the coefficients against the same wrong signal the baseline scales with it — the rule keeps working while every number in it is meaningless to a human reading the alarm. Nothing in the graph can detect this; it is a binding-time check.
  • Strict > at the allowance and at both floors. CDL Reals has no GreaterEqual, and the reference’s degradation test is strict too. The load floor is a different case — min_chiller_load appears only in the reference’s tunables table, never in its equation — so the operator is this card’s choice and strict is the conservative one: exactly at a floor is NO_EVAL, and a machine exactly on the allowed plane reads healthy.
  • learning_period_days (30 d) stays a host precondition, and re-fitting is the dangerous part. The fit happens offline, outside anything the graph can see. Whatever schedules it must refuse to re-fit while this fault is active: re-fitting a degraded machine bakes the degradation in as the new normal, and it matters more here than on HP-0001 because the reference asks for periodic retraining rather than a one-time fit.
  • The fitted plane is extrapolated without limit. Nothing in the graph knows the range the regression was fitted over. Far enough outside it — and with the negative CHWST coefficient — expected can be driven non-positive, at which point every measured quotient exceeds allowed and the rule alarms permanently rather than going quiet (the opposite failure from HP-0001’s, because the comparison runs the other way). The block set has no domain guard, so it is a precondition; a host can clamp the regressors with Reals.Limiter upstream.
  • One instance per chiller. The reference’s points are per-machine and so is the fit; a plant with three chillers runs three instances with three coefficient sets. The library has no plant-level aggregate rule, and averaging machines would hide exactly the one that is degraded.
  • method: statistical describes the baseline’s provenance, not the runtime. The graph performs one division, four multiplies, three adds, three comparisons and a delay; the classification is honest because the coefficients come from a regression. HP-0001 and RTU-0002 carry the same note.
  • AlarmDelay is renamed alarm_delay, matching every other card and unchanged at 60 min. persist.delayOnInit = true (CDL default false), the library’s standing choice: a machine already above its plane at controller restart waits out the full hour.
  • The reference publishes no test vectors for this fault, so every scenario in vectors.json is authored from the equation and replayed against the pinned engine rev.
  • The chapter’s Notes line for this fault is truncated in the source extract. It reads “Chen et al. (2024) showed data-driven chiller FDD degrades under” and stops. The missing clause is presumably about generalisation to unseen conditions, but this card does not transcribe what it cannot read: source cites the paper and the extrapolation deviation states the blind spot in terms this rule supports.
  • clusters: [CLU-06] is the existing cluster set’s membership, not this card’s authorshipclusters/clusters.json already names this fault as the trigger of “Chilled Water Plant Inefficiency” with CHW-0002, 052 and 053 as members, and its playbook slug resolves to the reference’s transcribed playbooks/chiller-efficiency.md.
  • playbooks cites chiller-efficiency, the reference’s own playbook for this fault; its Applies-To row names CHW-0001 and CLU-06 directly.
  • Operating states and preconditions are declared in frontmatter for host enforcement rather than encoded in the block graph, per the library’s design stance. Severity 3, method: statistical, confidence: HIGH and the fault name are the reference’s chapter 13 card.

Notes

Read yLoadOk and yTonsOk before yFault. A cleaned condenser, a chiller that unloaded and a dead tonnage signal all drop yFault on the same tick and mean completely different things, so a host that treats the falling edge as a repair will close this fault every time the plant stages down.

Pull the condenser approach temperature first: it is free, it is already on the chiller’s panel, and a widening approach with a rising kW/ton is tube fouling with enough confidence to schedule a cleaning. A normal approach on both sides puts the loss inside the machine — charge, compressor or non-condensables — and purge runtime is the cheapest test for the last of those.

Check CHW-0004 when both fire: low delta-T forces early staging, so a plant running two machines where one would do has a kW/ton problem whose cause is in neither machine. And raising the chilled water setpoint moves this rule’s expected line down, so a site switching CHW-0002’s reset on mid-baseline should re-fit rather than let the old plane judge the new regime.

Test Vectors

21 scenarios, clock step 60 s over 7200 s.

ScenarioDescription
healthy_chiller_at_baselineThe shipped-placeholder baseline evaluated at its own conditions: 50% load, 30 °C condenser water, 6 °C chilled water gives an expected 0.585 kW/ton and an allowance of 0.6435. The machine draws 299.52 kW for 512 tons — exactly 0.585 kW/ton — and is silent. All numbers are placeholder-relative; a host that has fitted its own coefficients gets a different line.
degraded_chiller_alarms_at_60_min358.4 kW for 512 tons is 0.70 kW/ton against a 0.6435 allowance — roughly the 0.1 kW/ton the reference calls 15% excess. delayOnInit holds the condition from the first tick, so the alarm lands at exactly alarm_delay.
measured_exactly_at_the_allowanceBoundary, bit-exact: 329.472 / 512 is the same double the graph computes for 1.1 × expected, because the divisor is a power of two and the numerator is that allowance scaled by it. Reals.Greater is strict, so a chiller sitting exactly on the allowance reads healthy.
measured_just_above_the_allowanceBoundary from above: 329.984 kW is 0.001 kW/ton past the allowance and alarms on the normal schedule.
measured_just_below_the_allowanceBoundary from below: 328.96 kW is 0.001 kW/ton short of the allowance and never alarms.
same_kw_per_ton_is_a_fault_on_cool_condenser_water337.92 kW / 512 tons = 0.66 kW/ton at 30 °C condenser water, where the allowance is 0.6435. Fault. The companion scenario feeds the identical power and tonnage at 35 °C and reads healthy — the verdict is decided by the regressors, not by the quotient.
same_kw_per_ton_is_healthy_on_warm_condenser_waterThe same 0.66 kW/ton at 35 °C condenser water, where the placeholder line expects 0.635 and allows 0.6985. Silent. Five degrees of condenser lift is worth 0.05 kW/ton on this line, which is half the reference’s own 0.1 kW/ton degradation yardstick.
raised_chwst_tightens_the_allowanceThe third regressor, and the one whose placeholder coefficient is negative: at a 10 °C chilled water supply temperature the machine has less lift to do, so the line expects 0.525 and allows 0.5775. 307.2 kW / 512 tons = 0.60 kW/ton is a fault here.
same_kw_per_ton_is_healthy_at_a_colder_chwstThe companion: the identical 0.60 kW/ton at a 6 °C supply temperature, where the allowance is 0.6435. Silent. A plant that has raised its CHWST for the reset savings this chapter’s CHW-0002 is about is held to a tighter efficiency line by this rule, which is the physics and not an interaction between the two rules.
low_load_is_not_evaluatedThe reference’s min_chiller_load gate: 20% load with a grossly degraded 0.70 kW/ton. yLoadOk is false, so the verdict is NO_EVAL rather than healthy — a lightly loaded chiller is inefficient on physics, and the reference declines to call that a fault.
load_exactly_at_the_evaluability_floorBoundary: load is exactly min_chiller_load (30%). Reals.GreaterThreshold is strict, so exactly-at-the-floor is NO_EVAL.
load_just_below_the_evaluability_floor29.9% load: still NO_EVAL, with the same degraded quotient.
load_just_above_the_evaluability_floor30.1% load: evaluable. The load regressor moves the expected line with it (0.5353 expected, 0.5888 allowed at these conditions), and 0.70 kW/ton clears that too, so the alarm lands at exactly alarm_delay.
zero_tons_holds_the_quotient_downThe divide guard doing its job: a tonnage signal that has gone to zero while the meter still reads 299.52 kW. The quotient is +inf and the comparison against the allowance is true, so without yTonsOk gating the AND chain this would be a permanent alarm on a dead sensor. yTonsOk is false and yFault stays down.
tons_exactly_at_the_divide_floorBoundary on the guard: tonnage exactly at min_chiller_tons (10 tons). Strict GreaterThreshold again, so exactly-at-the-floor is NO_EVAL.
tons_just_above_the_divide_floor_still_alarmsThe guard’s documented limit: 10.1 tons against 20 kW is 1.98 kW/ton, three times any plausible allowance, and the rule alarms. The floor only guards the neighbourhood of zero — a tonnage signal that has collapsed to a small non-zero value produces a believable-looking division and a false alarm. Set min_chiller_tons from the machine’s real minimum output, not from zero.
degradation_starts_mid_runA healthy machine loses efficiency at t = 1800 s — a condenser tube bundle fouling far faster than it would in life, standing in for the slow version. The alarm lands at exactly 5400 s: mid-run rising edges carry the same T + delayTime arithmetic as the init case.
degradation_clears_after_serviceRecovery: the alarm asserts at 3600 s and the tubes are cleaned at t = 5400 s. TrueDelay passes the falling edge with no delay, so yFault drops on that tick and the accumulated time is discarded.
tons_signal_drops_after_alarmThe evaluability release, and the reason to read both outputs: an alarming chiller loses its tonnage signal at t = 5400 s. yFault falls on that tick exactly as it does in degradation_clears_after_service, and only yTonsOk distinguishes a cleaned condenser from a dead flow meter.
load_drops_after_alarmThe other evaluability release: an alarming chiller unloads to 20% at t = 5400 s with its power and tonnage unchanged. yFault and yLoadOk fall on the same tick, and the falling edge of yFault alone is indistinguishable from a repair.
intermittent_degradation_never_alarmsPower alternating between 0.70 and 0.585 kW/ton every 1200 s — a machine hunting between compressor stages, or a tonnage calculation that jumps with flow noise. No single episode reaches alarm_delay, so nothing fires. Persistence is not averaging, and a chiller spending half its hours above the line is a real finding this rule cannot make.
vectors.json
{
  "schema": "cxf-library/vectors/v1",
  "clock": {
    "step_s": 60,
    "horizon_s": 7200
  },
  "scenarios": [
    {
      "name": "healthy_chiller_at_baseline",
      "description": "The shipped-placeholder baseline evaluated at its own conditions: 50% load, 30 \u00b0C condenser water, 6 \u00b0C chilled water gives an expected 0.585 kW/ton and an allowance of 0.6435. The machine draws 299.52 kW for 512 tons \u2014 exactly 0.585 kW/ton \u2014 and is silent. All numbers are placeholder-relative; a host that has fitted its own coefficients gets a different line.",
      "inputs": {
        "chiller_kw": 299.52,
        "chiller_tons": 512.0,
        "chiller_load": 50.0,
        "cwst": 30.0,
        "chwst": 6.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        },
        {
          "output": "yLoadOk",
          "from_s": 0,
          "to_s": 7200,
          "equals": true
        },
        {
          "output": "yTonsOk",
          "from_s": 0,
          "to_s": 7200,
          "equals": true
        }
      ]
    },
    {
      "name": "degraded_chiller_alarms_at_60_min",
      "description": "358.4 kW for 512 tons is 0.70 kW/ton against a 0.6435 allowance \u2014 roughly the 0.1 kW/ton the reference calls 15% excess. delayOnInit holds the condition from the first tick, so the alarm lands at exactly alarm_delay.",
      "inputs": {
        "chiller_kw": 358.4,
        "chiller_tons": 512.0,
        "chiller_load": 50.0,
        "cwst": 30.0,
        "chwst": 6.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3540,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 3600,
          "to_s": 7200,
          "equals": true
        },
        {
          "output": "yLoadOk",
          "from_s": 0,
          "to_s": 7200,
          "equals": true
        },
        {
          "output": "yTonsOk",
          "from_s": 0,
          "to_s": 7200,
          "equals": true
        }
      ]
    },
    {
      "name": "measured_exactly_at_the_allowance",
      "description": "Boundary, bit-exact: 329.472 / 512 is the same double the graph computes for 1.1 \u00d7 expected, because the divisor is a power of two and the numerator is that allowance scaled by it. Reals.Greater is strict, so a chiller sitting exactly on the allowance reads healthy.",
      "inputs": {
        "chiller_kw": 329.472,
        "chiller_tons": 512.0,
        "chiller_load": 50.0,
        "cwst": 30.0,
        "chwst": 6.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        },
        {
          "output": "yLoadOk",
          "from_s": 0,
          "to_s": 7200,
          "equals": true
        },
        {
          "output": "yTonsOk",
          "from_s": 0,
          "to_s": 7200,
          "equals": true
        }
      ]
    },
    {
      "name": "measured_just_above_the_allowance",
      "description": "Boundary from above: 329.984 kW is 0.001 kW/ton past the allowance and alarms on the normal schedule.",
      "inputs": {
        "chiller_kw": 329.984,
        "chiller_tons": 512.0,
        "chiller_load": 50.0,
        "cwst": 30.0,
        "chwst": 6.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3540,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 3600,
          "to_s": 7200,
          "equals": true
        },
        {
          "output": "yLoadOk",
          "from_s": 0,
          "to_s": 7200,
          "equals": true
        },
        {
          "output": "yTonsOk",
          "from_s": 0,
          "to_s": 7200,
          "equals": true
        }
      ]
    },
    {
      "name": "measured_just_below_the_allowance",
      "description": "Boundary from below: 328.96 kW is 0.001 kW/ton short of the allowance and never alarms.",
      "inputs": {
        "chiller_kw": 328.96,
        "chiller_tons": 512.0,
        "chiller_load": 50.0,
        "cwst": 30.0,
        "chwst": 6.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        },
        {
          "output": "yLoadOk",
          "from_s": 0,
          "to_s": 7200,
          "equals": true
        },
        {
          "output": "yTonsOk",
          "from_s": 0,
          "to_s": 7200,
          "equals": true
        }
      ]
    },
    {
      "name": "same_kw_per_ton_is_a_fault_on_cool_condenser_water",
      "description": "337.92 kW / 512 tons = 0.66 kW/ton at 30 \u00b0C condenser water, where the allowance is 0.6435. Fault. The companion scenario feeds the identical power and tonnage at 35 \u00b0C and reads healthy \u2014 the verdict is decided by the regressors, not by the quotient.",
      "inputs": {
        "chiller_kw": 337.92,
        "chiller_tons": 512.0,
        "chiller_load": 50.0,
        "cwst": 30.0,
        "chwst": 6.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3540,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 3600,
          "to_s": 7200,
          "equals": true
        },
        {
          "output": "yLoadOk",
          "from_s": 0,
          "to_s": 7200,
          "equals": true
        },
        {
          "output": "yTonsOk",
          "from_s": 0,
          "to_s": 7200,
          "equals": true
        }
      ]
    },
    {
      "name": "same_kw_per_ton_is_healthy_on_warm_condenser_water",
      "description": "The same 0.66 kW/ton at 35 \u00b0C condenser water, where the placeholder line expects 0.635 and allows 0.6985. Silent. Five degrees of condenser lift is worth 0.05 kW/ton on this line, which is half the reference's own 0.1 kW/ton degradation yardstick.",
      "inputs": {
        "chiller_kw": 337.92,
        "chiller_tons": 512.0,
        "chiller_load": 50.0,
        "cwst": 35.0,
        "chwst": 6.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        },
        {
          "output": "yLoadOk",
          "from_s": 0,
          "to_s": 7200,
          "equals": true
        },
        {
          "output": "yTonsOk",
          "from_s": 0,
          "to_s": 7200,
          "equals": true
        }
      ]
    },
    {
      "name": "raised_chwst_tightens_the_allowance",
      "description": "The third regressor, and the one whose placeholder coefficient is negative: at a 10 \u00b0C chilled water supply temperature the machine has less lift to do, so the line expects 0.525 and allows 0.5775. 307.2 kW / 512 tons = 0.60 kW/ton is a fault here.",
      "inputs": {
        "chiller_kw": 307.2,
        "chiller_tons": 512.0,
        "chiller_load": 50.0,
        "cwst": 30.0,
        "chwst": 10.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3540,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 3600,
          "to_s": 7200,
          "equals": true
        },
        {
          "output": "yLoadOk",
          "from_s": 0,
          "to_s": 7200,
          "equals": true
        },
        {
          "output": "yTonsOk",
          "from_s": 0,
          "to_s": 7200,
          "equals": true
        }
      ]
    },
    {
      "name": "same_kw_per_ton_is_healthy_at_a_colder_chwst",
      "description": "The companion: the identical 0.60 kW/ton at a 6 \u00b0C supply temperature, where the allowance is 0.6435. Silent. A plant that has raised its CHWST for the reset savings this chapter's CHW-0002 is about is held to a tighter efficiency line by this rule, which is the physics and not an interaction between the two rules.",
      "inputs": {
        "chiller_kw": 307.2,
        "chiller_tons": 512.0,
        "chiller_load": 50.0,
        "cwst": 30.0,
        "chwst": 6.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        },
        {
          "output": "yLoadOk",
          "from_s": 0,
          "to_s": 7200,
          "equals": true
        },
        {
          "output": "yTonsOk",
          "from_s": 0,
          "to_s": 7200,
          "equals": true
        }
      ]
    },
    {
      "name": "low_load_is_not_evaluated",
      "description": "The reference's min_chiller_load gate: 20% load with a grossly degraded 0.70 kW/ton. yLoadOk is false, so the verdict is NO_EVAL rather than healthy \u2014 a lightly loaded chiller is inefficient on physics, and the reference declines to call that a fault.",
      "inputs": {
        "chiller_kw": 358.4,
        "chiller_tons": 512.0,
        "chiller_load": 20.0,
        "cwst": 30.0,
        "chwst": 6.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        },
        {
          "output": "yLoadOk",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        },
        {
          "output": "yTonsOk",
          "from_s": 0,
          "to_s": 7200,
          "equals": true
        }
      ]
    },
    {
      "name": "load_exactly_at_the_evaluability_floor",
      "description": "Boundary: load is exactly min_chiller_load (30%). Reals.GreaterThreshold is strict, so exactly-at-the-floor is NO_EVAL.",
      "inputs": {
        "chiller_kw": 358.4,
        "chiller_tons": 512.0,
        "chiller_load": 30.0,
        "cwst": 30.0,
        "chwst": 6.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        },
        {
          "output": "yLoadOk",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        },
        {
          "output": "yTonsOk",
          "from_s": 0,
          "to_s": 7200,
          "equals": true
        }
      ]
    },
    {
      "name": "load_just_below_the_evaluability_floor",
      "description": "29.9% load: still NO_EVAL, with the same degraded quotient.",
      "inputs": {
        "chiller_kw": 358.4,
        "chiller_tons": 512.0,
        "chiller_load": 29.9,
        "cwst": 30.0,
        "chwst": 6.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        },
        {
          "output": "yLoadOk",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        },
        {
          "output": "yTonsOk",
          "from_s": 0,
          "to_s": 7200,
          "equals": true
        }
      ]
    },
    {
      "name": "load_just_above_the_evaluability_floor",
      "description": "30.1% load: evaluable. The load regressor moves the expected line with it (0.5353 expected, 0.5888 allowed at these conditions), and 0.70 kW/ton clears that too, so the alarm lands at exactly alarm_delay.",
      "inputs": {
        "chiller_kw": 358.4,
        "chiller_tons": 512.0,
        "chiller_load": 30.1,
        "cwst": 30.0,
        "chwst": 6.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3540,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 3600,
          "to_s": 7200,
          "equals": true
        },
        {
          "output": "yLoadOk",
          "from_s": 0,
          "to_s": 7200,
          "equals": true
        },
        {
          "output": "yTonsOk",
          "from_s": 0,
          "to_s": 7200,
          "equals": true
        }
      ]
    },
    {
      "name": "zero_tons_holds_the_quotient_down",
      "description": "The divide guard doing its job: a tonnage signal that has gone to zero while the meter still reads 299.52 kW. The quotient is +inf and the comparison against the allowance is true, so without yTonsOk gating the AND chain this would be a permanent alarm on a dead sensor. yTonsOk is false and yFault stays down.",
      "inputs": {
        "chiller_kw": 299.52,
        "chiller_tons": 0.0,
        "chiller_load": 50.0,
        "cwst": 30.0,
        "chwst": 6.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        },
        {
          "output": "yLoadOk",
          "from_s": 0,
          "to_s": 7200,
          "equals": true
        },
        {
          "output": "yTonsOk",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        }
      ]
    },
    {
      "name": "tons_exactly_at_the_divide_floor",
      "description": "Boundary on the guard: tonnage exactly at min_chiller_tons (10 tons). Strict GreaterThreshold again, so exactly-at-the-floor is NO_EVAL.",
      "inputs": {
        "chiller_kw": 299.52,
        "chiller_tons": 10.0,
        "chiller_load": 50.0,
        "cwst": 30.0,
        "chwst": 6.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        },
        {
          "output": "yLoadOk",
          "from_s": 0,
          "to_s": 7200,
          "equals": true
        },
        {
          "output": "yTonsOk",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        }
      ]
    },
    {
      "name": "tons_just_above_the_divide_floor_still_alarms",
      "description": "The guard's documented limit: 10.1 tons against 20 kW is 1.98 kW/ton, three times any plausible allowance, and the rule alarms. The floor only guards the neighbourhood of zero \u2014 a tonnage signal that has collapsed to a small non-zero value produces a believable-looking division and a false alarm. Set min_chiller_tons from the machine's real minimum output, not from zero.",
      "inputs": {
        "chiller_kw": 20.0,
        "chiller_tons": 10.1,
        "chiller_load": 50.0,
        "cwst": 30.0,
        "chwst": 6.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3540,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 3600,
          "to_s": 7200,
          "equals": true
        },
        {
          "output": "yLoadOk",
          "from_s": 0,
          "to_s": 7200,
          "equals": true
        },
        {
          "output": "yTonsOk",
          "from_s": 0,
          "to_s": 7200,
          "equals": true
        }
      ]
    },
    {
      "name": "degradation_starts_mid_run",
      "description": "A healthy machine loses efficiency at t = 1800 s \u2014 a condenser tube bundle fouling far faster than it would in life, standing in for the slow version. The alarm lands at exactly 5400 s: mid-run rising edges carry the same T + delayTime arithmetic as the init case.",
      "inputs": {
        "chiller_kw": [
          {
            "t": 0,
            "value": 299.52
          },
          {
            "t": 1800,
            "value": 358.4
          }
        ],
        "chiller_tons": 512.0,
        "chiller_load": 50.0,
        "cwst": 30.0,
        "chwst": 6.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5340,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 5400,
          "to_s": 7200,
          "equals": true
        },
        {
          "output": "yLoadOk",
          "from_s": 0,
          "to_s": 7200,
          "equals": true
        },
        {
          "output": "yTonsOk",
          "from_s": 0,
          "to_s": 7200,
          "equals": true
        }
      ]
    },
    {
      "name": "degradation_clears_after_service",
      "description": "Recovery: the alarm asserts at 3600 s and the tubes are cleaned at t = 5400 s. TrueDelay passes the falling edge with no delay, so yFault drops on that tick and the accumulated time is discarded.",
      "inputs": {
        "chiller_kw": [
          {
            "t": 0,
            "value": 358.4
          },
          {
            "t": 5400,
            "value": 299.52
          }
        ],
        "chiller_tons": 512.0,
        "chiller_load": 50.0,
        "cwst": 30.0,
        "chwst": 6.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3540,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 3600,
          "to_s": 5340,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 5400,
          "to_s": 7200,
          "equals": false
        },
        {
          "output": "yLoadOk",
          "from_s": 0,
          "to_s": 7200,
          "equals": true
        },
        {
          "output": "yTonsOk",
          "from_s": 0,
          "to_s": 7200,
          "equals": true
        }
      ]
    },
    {
      "name": "tons_signal_drops_after_alarm",
      "description": "The evaluability release, and the reason to read both outputs: an alarming chiller loses its tonnage signal at t = 5400 s. yFault falls on that tick exactly as it does in degradation_clears_after_service, and only yTonsOk distinguishes a cleaned condenser from a dead flow meter.",
      "inputs": {
        "chiller_kw": 358.4,
        "chiller_tons": [
          {
            "t": 0,
            "value": 512.0
          },
          {
            "t": 5400,
            "value": 0.0
          }
        ],
        "chiller_load": 50.0,
        "cwst": 30.0,
        "chwst": 6.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3540,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 3600,
          "to_s": 5340,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 5400,
          "to_s": 7200,
          "equals": false
        },
        {
          "output": "yLoadOk",
          "from_s": 0,
          "to_s": 7200,
          "equals": true
        },
        {
          "output": "yTonsOk",
          "from_s": 0,
          "to_s": 5340,
          "equals": true
        },
        {
          "output": "yTonsOk",
          "from_s": 5400,
          "to_s": 7200,
          "equals": false
        }
      ]
    },
    {
      "name": "load_drops_after_alarm",
      "description": "The other evaluability release: an alarming chiller unloads to 20% at t = 5400 s with its power and tonnage unchanged. yFault and yLoadOk fall on the same tick, and the falling edge of yFault alone is indistinguishable from a repair.",
      "inputs": {
        "chiller_kw": 358.4,
        "chiller_tons": 512.0,
        "chiller_load": [
          {
            "t": 0,
            "value": 50.0
          },
          {
            "t": 5400,
            "value": 20.0
          }
        ],
        "cwst": 30.0,
        "chwst": 6.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3540,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 3600,
          "to_s": 5340,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 5400,
          "to_s": 7200,
          "equals": false
        },
        {
          "output": "yLoadOk",
          "from_s": 0,
          "to_s": 5340,
          "equals": true
        },
        {
          "output": "yLoadOk",
          "from_s": 5400,
          "to_s": 7200,
          "equals": false
        },
        {
          "output": "yTonsOk",
          "from_s": 0,
          "to_s": 7200,
          "equals": true
        }
      ]
    },
    {
      "name": "intermittent_degradation_never_alarms",
      "description": "Power alternating between 0.70 and 0.585 kW/ton every 1200 s \u2014 a machine hunting between compressor stages, or a tonnage calculation that jumps with flow noise. No single episode reaches alarm_delay, so nothing fires. Persistence is not averaging, and a chiller spending half its hours above the line is a real finding this rule cannot make.",
      "inputs": {
        "chiller_kw": [
          {
            "t": 0,
            "value": 358.4
          },
          {
            "t": 1200,
            "value": 299.52
          },
          {
            "t": 2400,
            "value": 358.4
          },
          {
            "t": 3600,
            "value": 299.52
          },
          {
            "t": 4800,
            "value": 358.4
          },
          {
            "t": 6000,
            "value": 299.52
          }
        ],
        "chiller_tons": 512.0,
        "chiller_load": 50.0,
        "cwst": 30.0,
        "chwst": 6.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        },
        {
          "output": "yLoadOk",
          "from_s": 0,
          "to_s": 7200,
          "equals": true
        },
        {
          "output": "yTonsOk",
          "from_s": 0,
          "to_s": 7200,
          "equals": true
        }
      ]
    }
  ]
}

CHW-0002 — CHWST reset not functioning

Statusverified — engine e2ff2f8, cxf:fnv1a128:352546be4d185d46c2132a8845134625, 2026-08-17
Severity3
Methodstatistical
Phase2
CategoryEXCESS_CONSUMPTION
ConfidenceHIGH
EstimationPROXY_ESTIMATION
G36
Clusters
Suppresses
Suppressed by
RelatedCHW-0003, CHW-0007, AHU-0023, AHU-0024
Playbooksmissing-reset
SourceHVAC FDD Reference v1.0 ch.13, CHW-0002 (pdf pp. 119-121); PNNL RetuningOpps C01; PNNL-25985 EEM-11
Operating statesChiller plant enabled and producing chilled water for the bulk of the evaluation window

Preconditions (host-enforced): chwst_sp and chiller_load must belong to the same plant: bind the setpoint the chillers actually track (the plant controller’s active CHWST setpoint, not a schedule table entry) and a load signal from the same loop. On a multi-chiller plant, bind the lead chiller or a plant-level load — a lagging machine’s own load percentage is pinned by staging, not by the building, and reads flat while the plant swings. The plant must be running for most of the window: a chiller off for days holds both signals still, and only yLoadVaried stands between that and a false alarm. Evaluability is signalled in-rule by yLoadVaried: when it is false the verdict is NO_EVAL, not healthy. The host must also confirm the setpoint point is not simply unwritten — a BAS that trends the reset output only while the reset is enabled will show a flat last-known value that no plant is following.

Points: chwst_sp, chiller_load

Outputs:

  • yFault — True while the CHWST setpoint has stayed flat over the evaluation window despite sufficient chiller-load variation, for at least alarm_delay
  • yLoadVaried — Evaluability signal — true when chiller load has varied enough within the evaluation window for a flat setpoint to mean anything; false means NO_EVAL and the host must ignore yFault

Parameters:

NameDefaultUnitCXF pathDescription
evaluation_window604800.0sspRef.samplePeriod, loadRef.samplePeriod, spFlatHeld.delayTime, loadFlatHeld.delayTimeWindow over which setpoint flatness and load variation are assessed (7 days); drives both baseline sample periods and both dwell timers
sp_flat_tolerance0.75°CspFlat.tMax deviation of chwst_sp from its sampled baseline to count as flat (half the reference’s 1.5 °C min_expected_sp_range)
load_variation_tolerance10.0%loadFlat.tMax deviation of chiller_load from its sampled baseline to still count as flat (half the adopted 20 % min_load_range; the reference names min_load_range in the logic but ships no default — see Deviations)
alarm_delay86400.0spersist.delayTimeFault persistence before alarm (24 h)

Description

The chilled water supply temperature setpoint sits at one value week after week while the plant’s load moves underneath it. A working CHWST reset raises the setpoint as cooling demand falls, and a chiller making 8 °C water instead of 6 °C does the same job for 4–6% less power, because the compressor lifts against a smaller temperature difference. PNNL’s 151-building study found this in more than 30% of buildings — the plant-side half of the story AHU-0023 and AHU-0024 tell about air handlers, and the fix is desk work.

What the rule cannot do is distinguish a plant that will not reset from one that has nothing to reset against. A hospital chiller pinned at full load all week has a defensible reason to hold its setpoint, so the load signal must move before a flat setpoint is evidence — that is yLoadVaried, and why the host reads it first.

Detection Logic

baseline(x)  = x sampled and held every evaluation_window (7 days)
sp_flat      = |chwst_sp − baseline(chwst_sp)| < sp_flat_tolerance,
               continuously for evaluation_window
load_flat    = |chiller_load − baseline(chiller_load)| < load_variation_tolerance,
               continuously for evaluation_window

yLoadVaried  = NOT load_flat      (false ⇒ host reports NO_EVAL)
yFault       = sp_flat AND yLoadVaried, sustained for alarm_delay

Block graph (rule.cxf.jsonld):

CHW-0002 block graph

This is AHU-0023’s detector with the plant’s points bound to it. Two symmetric chains compare each signal against a weekly Discrete.Sampler baseline, which emits the live input on its first tick, so there is no startup artifact. spFlatHeld asserts only after the setpoint has stayed within tolerance continuously for a full window, and any reset activity of 0.75 °C or more restarts it. loadFlatHeld does the same for load, and its negation is yLoadVaried — “not varied” means a full window of continuous flatness, so the signal is optimistically true during the first window after startup, which is harmless because yFault needs that same window.

Both dwell timers fire on the same tick when the plant is flat in both signals, so the fault conjunction is false by construction on that tick and there is no boundary race. persist (24 h) filters the remainder and every TrueDelay carries delayOnInit = true. Worst-case time to alarm from cold start is evaluation_window + alarm_delay — 8 days. Comparisons are strict, so a signal sitting exactly on a tolerance falls on the not-flat side.

Possible Diagnoses

  1. CHWST reset never programmed
  2. CHWST reset disabled by operator
  3. Reset overridden to fixed value
  4. Valve request signals not reaching the plant controller

Energy Impact

EXCESS_CONSUMPTION, HIGH confidence, PROXY_ESTIMATION (EEM-11, PNNL-25985). Raising CHWST by 1 °C typically improves chiller efficiency by 2–3%; the whole-site figure is 0.5–2%, cooling-dominant. Prevalence is above 30% of buildings (PNNL 151-building study). The savings are pure sequence work, but they are capped by the coils — raise CHWST past what they can still dehumidify with and the plant trades chiller kW for humidity complaints, which is why the reference’s estimate is parameterised on a potential_chwst_increase the host supplies rather than on the fault alone.

Emissions Impact

Scope 2, PROXY_EMISSIONS, HIGH confidence; typical 500–5,000 kg CO₂e/yr for a suboptimal reset strategy. Avoided-emissions basis: MOER (marginal).

Deviations

  • Windowed range → deviation from a weekly sampled baseline. The reference computes max(CHWST_SP) − min(CHWST_SP) over the window; CDL’s elementary library has no windowed min/max, so both chains compare against a Discrete.Sampler hold with tolerances at half the reference ranges. Detection is equivalent for signals that move and return, and slightly conservative for monotonic drift inside one window: a setpoint ramping by 1.4 °C across the week clears the dwell where a range test would still call it flat. AHU-0023 is the precedent and the mechanism is copied unchanged.
  • Reals.MovingAverage rejected, and the tick band that follows. The engine implements it with a fixed 64-checkpoint ring, so a seven-day window would need dt ≥ 9,600 s before it stops silently dropping its oldest samples. No BAS ticks that slowly; AHU-0023 found this and this card inherits it. The sampler-and-dwell chain has no lower bound on tick period, and its upper bound is the one to watch — a reset excursion shorter than one tick is invisible to the flatness test, so trend at 5–15 min.
  • min_load_range is adopted, not transcribed. The chapter names it in the equation but its tunables line lists only evaluation_window, min_expected_sp_range and AlarmDelay, so the parameter has no published default. The shipped 20% (as load_variation_tolerance = 10 %, half-range) follows AHU-0023’s min_oat_range_for_eval in shape and intent: a low bar a real plant clears easily, not a discriminating threshold. A chiller that never swings 20 points across seven days is base-loaded — NO_EVAL is the right answer — or its load point is dead.
  • Strict comparisons on both tolerances. Reals.LessThreshold is u < t, so a setpoint deviating exactly 0.75 °C clears the flatness dwell and a load deviating exactly 10% counts as varied. Equality is measure-zero in continuous data but perfectly reachable in a BAS that scales setpoints to fixed increments, so both boundaries are pinned from both sides.
  • NO_EVAL is surfaced as yLoadVaried. Boolean block logic has no tri-state, so evaluability is a second boundary output the host consults before interpreting yFault — false means NO_EVAL, never healthy. Same inverted-flat semantics as AHU-0023’s yOatVaried.
  • AlarmDelay = 24 h implemented as TrueDelay on the fault conjunction; the evaluation window itself is enforced by the two flatness dwells. delayOnInit = true on every TrueDelay (startup conservatism per AHU-0016), so a rule loaded into an already-faulted plant still waits the full window plus delay.
  • Transcription gaps in the source. The chapter gives CHW-0002 no description paragraph, no operating-states line and no test vectors, so operating_states, preconditions and every scenario in vectors.json are this card’s judgement. The chapter’s heading is “Chilled water supply temperature reset not functioning”; name carries the shorter index spelling from faults/chw/README.md, which owns names.
  • Blind spots. The rule reads the setpoint, never the water: a plant whose setpoint moves while the chillers ignore it is CHW-0001’s problem. Diagnoses 1–3 are one signature. A reset driven by something other than plant load — an OAT-scheduled CHWST reset in stable weather, or a demand-limited plant — is flat for legitimate reasons and reads as a fault whenever the load happens to move. And a plant flat in both signals because it is off is indistinguishable from a healthy idle plant; the host’s operating-state gate, not the graph, keeps that quiet.

Notes

Fix path is the missing-reset playbook: verify by plotting CHWST setpoint against plant load over the window, then program the reset in the plant controller. The playbook’s worked examples are the AHU-side pair (SAT and DSP); the plant-side procedure is the same shape one system upstream.

clusters is deliberately empty. CLU-02 (“Missing Reset Strategy”) is currently an AHU-scoped cluster triggered by AHU-0023, and membership is clusters/clusters.json’s to declare — faults/chw/README.md calls this pair the CLU-02-style root cause one system further upstream. A plant failing both CHW-0002 and CHW-0003 has one root cause, no reset strategy ever commissioned, and should be dispatched as one visit.

Test Vectors

9 scenarios, clock step 300 s over 777600 s.

ScenarioDescription
reset_workingCHWST_SP swings 2.5 degC with load; flatness dwell resets every half day, no fault
fixed_setpointCHWST_SP pinned at 6.5 degC while load swings 30-80%; fault asserts at exactly evaluation_window (7 d) + alarm_delay (24 h) = 691200 s, pinned from both sides
flat_load_no_evalNO_EVAL: setpoint flat but load never moves; yLoadVaried drops at exactly evaluation_window and yFault never fires
sp_at_flat_toleranceSetpoint deviates from its baseline by exactly sp_flat_tolerance (0.75 degC): strict < makes that not flat, so no fault
sp_within_flat_toleranceSetpoint jitter of 0.625 degC stays inside sp_flat_tolerance, so it still counts as flat; fault at 691200 s
load_at_variation_toleranceLoad deviates from its baseline by exactly load_variation_tolerance (10%): strict < makes that varied, so the rule stays evaluable and the flat setpoint alarms
load_within_variation_toleranceLoad moves only 9.5% about its baseline, inside load_variation_tolerance: NO_EVAL at exactly evaluation_window, and the simultaneous setpoint-flat edge cannot race it into a fault
sp_goes_flat_mid_runReset stops working 12 h into the run: dwell starts at 43200 s, so the alarm lands at exactly 43200 + 604800 + 86400 = 734400 s
reset_resumesSetpoint flat for 4 days, then reset activity resumes before the window completes; dwell resets and never alarms
vectors.json
{
  "schema": "cxf-library/vectors/v1",
  "clock": {
    "step_s": 300,
    "horizon_s": 777600
  },
  "scenarios": [
    {
      "name": "reset_working",
      "description": "CHWST_SP swings 2.5 degC with load; flatness dwell resets every half day, no fault",
      "inputs": {
        "chwst_sp": [
          {
            "t": 0,
            "value": 6.5
          },
          {
            "t": 43200,
            "value": 9.0
          },
          {
            "t": 86400,
            "value": 6.5
          },
          {
            "t": 129600,
            "value": 9.0
          },
          {
            "t": 172800,
            "value": 6.5
          },
          {
            "t": 216000,
            "value": 9.0
          },
          {
            "t": 259200,
            "value": 6.5
          },
          {
            "t": 302400,
            "value": 9.0
          },
          {
            "t": 345600,
            "value": 6.5
          },
          {
            "t": 388800,
            "value": 9.0
          },
          {
            "t": 432000,
            "value": 6.5
          },
          {
            "t": 475200,
            "value": 9.0
          },
          {
            "t": 518400,
            "value": 6.5
          },
          {
            "t": 561600,
            "value": 9.0
          },
          {
            "t": 604800,
            "value": 6.5
          },
          {
            "t": 648000,
            "value": 9.0
          },
          {
            "t": 691200,
            "value": 6.5
          },
          {
            "t": 734400,
            "value": 9.0
          },
          {
            "t": 777600,
            "value": 6.5
          }
        ],
        "chiller_load": [
          {
            "t": 0,
            "value": 30.0
          },
          {
            "t": 43200,
            "value": 80.0
          },
          {
            "t": 86400,
            "value": 30.0
          },
          {
            "t": 129600,
            "value": 80.0
          },
          {
            "t": 172800,
            "value": 30.0
          },
          {
            "t": 216000,
            "value": 80.0
          },
          {
            "t": 259200,
            "value": 30.0
          },
          {
            "t": 302400,
            "value": 80.0
          },
          {
            "t": 345600,
            "value": 30.0
          },
          {
            "t": 388800,
            "value": 80.0
          },
          {
            "t": 432000,
            "value": 30.0
          },
          {
            "t": 475200,
            "value": 80.0
          },
          {
            "t": 518400,
            "value": 30.0
          },
          {
            "t": 561600,
            "value": 80.0
          },
          {
            "t": 604800,
            "value": 30.0
          },
          {
            "t": 648000,
            "value": 80.0
          },
          {
            "t": 691200,
            "value": 30.0
          },
          {
            "t": 734400,
            "value": 80.0
          },
          {
            "t": 777600,
            "value": 30.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 777600,
          "equals": false
        },
        {
          "output": "yLoadVaried",
          "from_s": 0,
          "to_s": 777600,
          "equals": true
        }
      ]
    },
    {
      "name": "fixed_setpoint",
      "description": "CHWST_SP pinned at 6.5 degC while load swings 30-80%; fault asserts at exactly evaluation_window (7 d) + alarm_delay (24 h) = 691200 s, pinned from both sides",
      "inputs": {
        "chwst_sp": 6.5,
        "chiller_load": [
          {
            "t": 0,
            "value": 30.0
          },
          {
            "t": 43200,
            "value": 80.0
          },
          {
            "t": 86400,
            "value": 30.0
          },
          {
            "t": 129600,
            "value": 80.0
          },
          {
            "t": 172800,
            "value": 30.0
          },
          {
            "t": 216000,
            "value": 80.0
          },
          {
            "t": 259200,
            "value": 30.0
          },
          {
            "t": 302400,
            "value": 80.0
          },
          {
            "t": 345600,
            "value": 30.0
          },
          {
            "t": 388800,
            "value": 80.0
          },
          {
            "t": 432000,
            "value": 30.0
          },
          {
            "t": 475200,
            "value": 80.0
          },
          {
            "t": 518400,
            "value": 30.0
          },
          {
            "t": 561600,
            "value": 80.0
          },
          {
            "t": 604800,
            "value": 30.0
          },
          {
            "t": 648000,
            "value": 80.0
          },
          {
            "t": 691200,
            "value": 30.0
          },
          {
            "t": 734400,
            "value": 80.0
          },
          {
            "t": 777600,
            "value": 30.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 690900,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 691200,
          "to_s": 777600,
          "equals": true
        },
        {
          "output": "yLoadVaried",
          "from_s": 0,
          "to_s": 777600,
          "equals": true
        }
      ]
    },
    {
      "name": "flat_load_no_eval",
      "description": "NO_EVAL: setpoint flat but load never moves; yLoadVaried drops at exactly evaluation_window and yFault never fires",
      "inputs": {
        "chwst_sp": 6.5,
        "chiller_load": 55.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 777600,
          "equals": false
        },
        {
          "output": "yLoadVaried",
          "from_s": 0,
          "to_s": 604500,
          "equals": true
        },
        {
          "output": "yLoadVaried",
          "from_s": 604800,
          "to_s": 777600,
          "equals": false
        }
      ]
    },
    {
      "name": "sp_at_flat_tolerance",
      "description": "Setpoint deviates from its baseline by exactly sp_flat_tolerance (0.75 degC): strict < makes that not flat, so no fault",
      "inputs": {
        "chwst_sp": [
          {
            "t": 0,
            "value": 6.5
          },
          {
            "t": 43200,
            "value": 7.25
          },
          {
            "t": 86400,
            "value": 6.5
          },
          {
            "t": 129600,
            "value": 7.25
          },
          {
            "t": 172800,
            "value": 6.5
          },
          {
            "t": 216000,
            "value": 7.25
          },
          {
            "t": 259200,
            "value": 6.5
          },
          {
            "t": 302400,
            "value": 7.25
          },
          {
            "t": 345600,
            "value": 6.5
          },
          {
            "t": 388800,
            "value": 7.25
          },
          {
            "t": 432000,
            "value": 6.5
          },
          {
            "t": 475200,
            "value": 7.25
          },
          {
            "t": 518400,
            "value": 6.5
          },
          {
            "t": 561600,
            "value": 7.25
          },
          {
            "t": 604800,
            "value": 6.5
          },
          {
            "t": 648000,
            "value": 7.25
          },
          {
            "t": 691200,
            "value": 6.5
          },
          {
            "t": 734400,
            "value": 7.25
          },
          {
            "t": 777600,
            "value": 6.5
          }
        ],
        "chiller_load": [
          {
            "t": 0,
            "value": 30.0
          },
          {
            "t": 43200,
            "value": 80.0
          },
          {
            "t": 86400,
            "value": 30.0
          },
          {
            "t": 129600,
            "value": 80.0
          },
          {
            "t": 172800,
            "value": 30.0
          },
          {
            "t": 216000,
            "value": 80.0
          },
          {
            "t": 259200,
            "value": 30.0
          },
          {
            "t": 302400,
            "value": 80.0
          },
          {
            "t": 345600,
            "value": 30.0
          },
          {
            "t": 388800,
            "value": 80.0
          },
          {
            "t": 432000,
            "value": 30.0
          },
          {
            "t": 475200,
            "value": 80.0
          },
          {
            "t": 518400,
            "value": 30.0
          },
          {
            "t": 561600,
            "value": 80.0
          },
          {
            "t": 604800,
            "value": 30.0
          },
          {
            "t": 648000,
            "value": 80.0
          },
          {
            "t": 691200,
            "value": 30.0
          },
          {
            "t": 734400,
            "value": 80.0
          },
          {
            "t": 777600,
            "value": 30.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 777600,
          "equals": false
        },
        {
          "output": "yLoadVaried",
          "from_s": 0,
          "to_s": 777600,
          "equals": true
        }
      ]
    },
    {
      "name": "sp_within_flat_tolerance",
      "description": "Setpoint jitter of 0.625 degC stays inside sp_flat_tolerance, so it still counts as flat; fault at 691200 s",
      "inputs": {
        "chwst_sp": [
          {
            "t": 0,
            "value": 6.5
          },
          {
            "t": 43200,
            "value": 7.125
          },
          {
            "t": 86400,
            "value": 6.5
          },
          {
            "t": 129600,
            "value": 7.125
          },
          {
            "t": 172800,
            "value": 6.5
          },
          {
            "t": 216000,
            "value": 7.125
          },
          {
            "t": 259200,
            "value": 6.5
          },
          {
            "t": 302400,
            "value": 7.125
          },
          {
            "t": 345600,
            "value": 6.5
          },
          {
            "t": 388800,
            "value": 7.125
          },
          {
            "t": 432000,
            "value": 6.5
          },
          {
            "t": 475200,
            "value": 7.125
          },
          {
            "t": 518400,
            "value": 6.5
          },
          {
            "t": 561600,
            "value": 7.125
          },
          {
            "t": 604800,
            "value": 6.5
          },
          {
            "t": 648000,
            "value": 7.125
          },
          {
            "t": 691200,
            "value": 6.5
          },
          {
            "t": 734400,
            "value": 7.125
          },
          {
            "t": 777600,
            "value": 6.5
          }
        ],
        "chiller_load": [
          {
            "t": 0,
            "value": 30.0
          },
          {
            "t": 43200,
            "value": 80.0
          },
          {
            "t": 86400,
            "value": 30.0
          },
          {
            "t": 129600,
            "value": 80.0
          },
          {
            "t": 172800,
            "value": 30.0
          },
          {
            "t": 216000,
            "value": 80.0
          },
          {
            "t": 259200,
            "value": 30.0
          },
          {
            "t": 302400,
            "value": 80.0
          },
          {
            "t": 345600,
            "value": 30.0
          },
          {
            "t": 388800,
            "value": 80.0
          },
          {
            "t": 432000,
            "value": 30.0
          },
          {
            "t": 475200,
            "value": 80.0
          },
          {
            "t": 518400,
            "value": 30.0
          },
          {
            "t": 561600,
            "value": 80.0
          },
          {
            "t": 604800,
            "value": 30.0
          },
          {
            "t": 648000,
            "value": 80.0
          },
          {
            "t": 691200,
            "value": 30.0
          },
          {
            "t": 734400,
            "value": 80.0
          },
          {
            "t": 777600,
            "value": 30.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 690900,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 691200,
          "to_s": 777600,
          "equals": true
        }
      ]
    },
    {
      "name": "load_at_variation_tolerance",
      "description": "Load deviates from its baseline by exactly load_variation_tolerance (10%): strict < makes that varied, so the rule stays evaluable and the flat setpoint alarms",
      "inputs": {
        "chwst_sp": 6.5,
        "chiller_load": [
          {
            "t": 0,
            "value": 50.0
          },
          {
            "t": 43200,
            "value": 60.0
          },
          {
            "t": 86400,
            "value": 50.0
          },
          {
            "t": 129600,
            "value": 60.0
          },
          {
            "t": 172800,
            "value": 50.0
          },
          {
            "t": 216000,
            "value": 60.0
          },
          {
            "t": 259200,
            "value": 50.0
          },
          {
            "t": 302400,
            "value": 60.0
          },
          {
            "t": 345600,
            "value": 50.0
          },
          {
            "t": 388800,
            "value": 60.0
          },
          {
            "t": 432000,
            "value": 50.0
          },
          {
            "t": 475200,
            "value": 60.0
          },
          {
            "t": 518400,
            "value": 50.0
          },
          {
            "t": 561600,
            "value": 60.0
          },
          {
            "t": 604800,
            "value": 50.0
          },
          {
            "t": 648000,
            "value": 60.0
          },
          {
            "t": 691200,
            "value": 50.0
          },
          {
            "t": 734400,
            "value": 60.0
          },
          {
            "t": 777600,
            "value": 50.0
          }
        ]
      },
      "expect": [
        {
          "output": "yLoadVaried",
          "from_s": 0,
          "to_s": 777600,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 690900,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 691200,
          "to_s": 777600,
          "equals": true
        }
      ]
    },
    {
      "name": "load_within_variation_tolerance",
      "description": "Load moves only 9.5% about its baseline, inside load_variation_tolerance: NO_EVAL at exactly evaluation_window, and the simultaneous setpoint-flat edge cannot race it into a fault",
      "inputs": {
        "chwst_sp": 6.5,
        "chiller_load": [
          {
            "t": 0,
            "value": 50.0
          },
          {
            "t": 43200,
            "value": 59.5
          },
          {
            "t": 86400,
            "value": 50.0
          },
          {
            "t": 129600,
            "value": 59.5
          },
          {
            "t": 172800,
            "value": 50.0
          },
          {
            "t": 216000,
            "value": 59.5
          },
          {
            "t": 259200,
            "value": 50.0
          },
          {
            "t": 302400,
            "value": 59.5
          },
          {
            "t": 345600,
            "value": 50.0
          },
          {
            "t": 388800,
            "value": 59.5
          },
          {
            "t": 432000,
            "value": 50.0
          },
          {
            "t": 475200,
            "value": 59.5
          },
          {
            "t": 518400,
            "value": 50.0
          },
          {
            "t": 561600,
            "value": 59.5
          },
          {
            "t": 604800,
            "value": 50.0
          },
          {
            "t": 648000,
            "value": 59.5
          },
          {
            "t": 691200,
            "value": 50.0
          },
          {
            "t": 734400,
            "value": 59.5
          },
          {
            "t": 777600,
            "value": 50.0
          }
        ]
      },
      "expect": [
        {
          "output": "yLoadVaried",
          "from_s": 0,
          "to_s": 604500,
          "equals": true
        },
        {
          "output": "yLoadVaried",
          "from_s": 604800,
          "to_s": 777600,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 777600,
          "equals": false
        }
      ]
    },
    {
      "name": "sp_goes_flat_mid_run",
      "description": "Reset stops working 12 h into the run: dwell starts at 43200 s, so the alarm lands at exactly 43200 + 604800 + 86400 = 734400 s",
      "inputs": {
        "chwst_sp": [
          {
            "t": 0,
            "value": 6.5
          },
          {
            "t": 21600,
            "value": 9.0
          },
          {
            "t": 43200,
            "value": 6.5
          }
        ],
        "chiller_load": [
          {
            "t": 0,
            "value": 30.0
          },
          {
            "t": 43200,
            "value": 80.0
          },
          {
            "t": 86400,
            "value": 30.0
          },
          {
            "t": 129600,
            "value": 80.0
          },
          {
            "t": 172800,
            "value": 30.0
          },
          {
            "t": 216000,
            "value": 80.0
          },
          {
            "t": 259200,
            "value": 30.0
          },
          {
            "t": 302400,
            "value": 80.0
          },
          {
            "t": 345600,
            "value": 30.0
          },
          {
            "t": 388800,
            "value": 80.0
          },
          {
            "t": 432000,
            "value": 30.0
          },
          {
            "t": 475200,
            "value": 80.0
          },
          {
            "t": 518400,
            "value": 30.0
          },
          {
            "t": 561600,
            "value": 80.0
          },
          {
            "t": 604800,
            "value": 30.0
          },
          {
            "t": 648000,
            "value": 80.0
          },
          {
            "t": 691200,
            "value": 30.0
          },
          {
            "t": 734400,
            "value": 80.0
          },
          {
            "t": 777600,
            "value": 30.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 734100,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 734400,
          "to_s": 777600,
          "equals": true
        },
        {
          "output": "yLoadVaried",
          "from_s": 0,
          "to_s": 777600,
          "equals": true
        }
      ]
    },
    {
      "name": "reset_resumes",
      "description": "Setpoint flat for 4 days, then reset activity resumes before the window completes; dwell resets and never alarms",
      "inputs": {
        "chwst_sp": [
          {
            "t": 0,
            "value": 6.5
          },
          {
            "t": 345600,
            "value": 9.0
          },
          {
            "t": 388800,
            "value": 6.5
          },
          {
            "t": 432000,
            "value": 9.0
          },
          {
            "t": 475200,
            "value": 6.5
          },
          {
            "t": 518400,
            "value": 9.0
          },
          {
            "t": 561600,
            "value": 6.5
          },
          {
            "t": 604800,
            "value": 9.0
          },
          {
            "t": 648000,
            "value": 6.5
          },
          {
            "t": 691200,
            "value": 9.0
          },
          {
            "t": 734400,
            "value": 6.5
          },
          {
            "t": 777600,
            "value": 9.0
          }
        ],
        "chiller_load": [
          {
            "t": 0,
            "value": 30.0
          },
          {
            "t": 43200,
            "value": 80.0
          },
          {
            "t": 86400,
            "value": 30.0
          },
          {
            "t": 129600,
            "value": 80.0
          },
          {
            "t": 172800,
            "value": 30.0
          },
          {
            "t": 216000,
            "value": 80.0
          },
          {
            "t": 259200,
            "value": 30.0
          },
          {
            "t": 302400,
            "value": 80.0
          },
          {
            "t": 345600,
            "value": 30.0
          },
          {
            "t": 388800,
            "value": 80.0
          },
          {
            "t": 432000,
            "value": 30.0
          },
          {
            "t": 475200,
            "value": 80.0
          },
          {
            "t": 518400,
            "value": 30.0
          },
          {
            "t": 561600,
            "value": 80.0
          },
          {
            "t": 604800,
            "value": 30.0
          },
          {
            "t": 648000,
            "value": 80.0
          },
          {
            "t": 691200,
            "value": 30.0
          },
          {
            "t": 734400,
            "value": 80.0
          },
          {
            "t": 777600,
            "value": 30.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 777600,
          "equals": false
        }
      ]
    }
  ]
}

CHW-0003 — CHW loop DP reset not functioning

Statusverified — engine e2ff2f8, cxf:fnv1a128:89ddf1254d82b6820f9b536b32c1c5d9, 2026-08-17
Severity3
Methodstatistical
Phase2
CategoryEXCESS_CONSUMPTION
ConfidenceHIGH
EstimationPROXY_ESTIMATION
G36
Clusters
Suppresses
Suppressed by
RelatedCHW-0002, CHW-0004, AHU-0024
Playbooksmissing-reset
SourceHVAC FDD Reference v1.0 ch.13, CHW-0003 (pdf pp. 121-122); PNNL RetuningOpps C03; PNNL-25985 EEM-10
Operating statesCHW distribution running (variable-speed secondary or primary pumps enabled) with coils served

Preconditions (host-enforced): dp_sp must be the setpoint the pumps actually control to, on the same loop as the coils feeding chw_valve_max. chw_valve_max is host-derived — the maximum across the CHW coil valves the loop serves — and the aggregate must span every coil on the loop: a maximum taken over a subset can sit at 55% while an unmonitored coil is wide open and starving, which is exactly the case the fault claims to have excluded. When the aggregate is stale, partial, or missing the verdict is NO_EVAL, not healthy; there is no in-rule evaluability output to catch it, because a stale feed is indistinguishable from a genuine low reading at the boundary. Prefer valve position feedback over valve command where both exist — a command reads low on a valve that is stuck open. The rule assumes modulating two-way valves; a loop on two-position valves or with three-way bypasses has no meaningful maximum position and must not be bound at all.

Points: dp_sp, chw_valve_max

Outputs:

  • yFault — True while the CHW loop differential-pressure setpoint has stayed flat over the evaluation window with every served coil valve below high_valve_threshold, for at least alarm_delay

Parameters:

NameDefaultUnitCXF pathDescription
evaluation_window259200.0sspRef.samplePeriod, spFlatHeld.delayTime, vlvLowHeld.delayTimeWindow over which setpoint flatness and low valve demand are assessed (3 days)
sp_flat_tolerance7.5kPaspFlat.tMax deviation of dp_sp from its sampled baseline to count as flat (half the reference’s 15 kPa min_expected_sp_range)
high_valve_threshold90.0%vlvLow.tPosition below which the most-open coil valve still has authority, so the loop is not at maximum demand and the setpoint could have come down. Adopted — the reference names high_valve_threshold in the logic but ships no default (see Deviations)
alarm_delay86400.0spersist.delayTimeFault persistence before alarm (24 h)

Description

The chilled water loop differential-pressure setpoint never moves while every coil valve on the loop is throttling. The pumps hold a design-day pressure against a building that is not asking for one, and the valves burn the difference across their seats. Pump power goes with the cube of pressure, so this is the cheapest large number in the plant: a 20% setpoint reduction is about half the pump energy, and the reset that achieves it is a sequence, not a purchase. The valve conjunct is what makes the finding safe to act on — a flat setpoint alone is also what a working reset looks like when a starving coil has pinned it at its upper limit. Found in more than 30% of buildings (PNNL 151-building study), usually alongside its supply-temperature twin CHW-0002 and for the same reason.

Detection Logic

baseline(dp_sp) = dp_sp sampled and held every evaluation_window (3 days)
sp_flat         = |dp_sp − baseline(dp_sp)| < sp_flat_tolerance,
                  continuously for evaluation_window
low_demand      = chw_valve_max < high_valve_threshold,
                  continuously for evaluation_window

yFault = sp_flat AND low_demand, sustained for alarm_delay

Block graph (rule.cxf.jsonld):

CHW-0003 block graph

The setpoint chain is AHU-0024’s sampled-baseline flatness detector with the plant’s points bound to it: a Discrete.Sampler refreshed once per window supplies the reference value, and spFlatHeld asserts only after the setpoint has stayed within sp_flat_tolerance of it continuously for a full window.

The demand condition needs no baseline. The reference’s max(chw_valve_positions) < high_valve_threshold over the window is exactly equivalent to “the most-open valve stays below the threshold continuously” — one LessThreshold plus a dwell, an exact transformation rather than an approximation. Both comparisons are strict, so a valve at exactly 90% is not low demand and a setpoint deviating exactly 7.5 kPa is not flat; both boundaries fall on the no-fault side. Every TrueDelay carries delayOnInit = true, and worst-case time to alarm from cold start is evaluation_window + alarm_delay — 4 days.

Possible Diagnoses

  1. DP reset never programmed
  2. DP reset disabled or overridden
  3. Valve position feedback not connected
  4. DP sensor at wrong location

Energy Impact

EXCESS_CONSUMPTION, HIGH confidence, PROXY_ESTIMATION (EEM-10, PNNL-25985). Savings 0.5–2% of site energy, climate-neutral, through the cubic pump law: pump_waste_kw = chw_pump_kw × [1 − (1 − DP_reduction/100)³]. Prevalence above 30% of buildings. Diagnosis 4 changes the economics — moving a DP sensor to the hydraulically most remote coil is a pipe-fitting job rather than a desk job, and the reference lists it because a sensor at the pump discharge makes a correct reset impossible rather than merely absent.

Emissions Impact

Scope 2, PROXY_EMISSIONS, HIGH confidence; typical 300–3,000 kg CO₂e/yr of excess pump energy. Avoided-emissions basis: MOER (marginal).

Deviations

  • chw_valve_positions (vector) → host-derived chw_valve_max (scalar). Library v1 has no vector boundary points, so the host aggregates and feeds one scalar (derived: true in the point dictionary). AHU-0024’s zone_dmpr_pos_max is the precedent, and the cost is in preconditions: the graph cannot tell a maximum over ten coils from a maximum over three.
  • Windowed range → deviation from a sampled baseline on the setpoint chain, with sp_flat_tolerance = min_expected_sp_range/2, exactly as AHU-0023/AHU-0024; CDL has no windowed min/max block. Detection is equivalent for a setpoint that moves and returns and slightly conservative for monotonic drift inside one window. The valve chain is not an approximation.
  • Reals.MovingAverage rejected, and the tick band that follows. Its fixed 64-checkpoint ring needs dt ≥ evaluation_window/63 — 4,114 s at three days — before the window stops silently dropping its oldest samples, and no BAS ticks that slowly. The sampler-and-dwell replacement has no lower bound on tick period; its upper bound is what you need to see, since an excursion shorter than one tick is invisible to the dwells. Trend at 5–15 min.
  • high_valve_threshold is adopted, not transcribed. The chapter names it in the equation but its tunables line lists only evaluation_window, min_expected_sp_range and AlarmDelay. The shipped 90% is deliberately permissive on the fault side — the conjunct’s only job is to exclude a loop genuinely pinned at maximum demand, and a modulating two-way valve at 90% is within a hair of having no authority left. It is looser than AHU-0024’s chapter-supplied 70% damper analog, so a site wanting that margin should set 70–80. Note the direction: raising this number makes the rule fire more often.
  • No evaluability output. The valve test is a conjunct of the reference’s fault condition, not an evaluability gate (contrast CHW-0002’s yLoadVaried, which mirrors the reference’s own NO_EVAL semantics). An output carrying chw_valve_max < high_valve_threshold would only echo one boundary input through a threshold, so the rule ships yFault alone and the staleness question stays in preconditions.
  • AlarmDelay = 24 h implemented as TrueDelay on the fault conjunction; the evaluation window itself is enforced by the two dwells. delayOnInit = true on every TrueDelay (startup conservatism per AHU-0016).
  • Transcription gaps in the source. The chapter gives CHW-0003 no description paragraph, no operating-states line and no test vectors, so all vectors here are constructed. Its Required Points line reads “DP_SP, chw_valve_positions”; canonical names come from points/chw.points.json. The chapter’s heading is “CHW loop differential pressure reset not functioning”; name carries the shorter index spelling from faults/chw/README.md, which owns names.
  • Blind spots. The rule sees the setpoint, not the pressure: a loop whose setpoint resets correctly while the pumps fail to track it is a different fault. Diagnosis 4 is invisible here — a badly placed sensor produces a plausible flat setpoint this rule reports as a missing reset, which is why the playbook’s first step is to check where the sensor is. Diagnosis 3 is worse than invisible: it corrupts the input the rule leans on, and a defaulted-to-zero feedback reads as a permanent low maximum. And a loop whose pumps are off for the window holds both signals flat, which only the host’s operating-state gate can suppress.

Notes

Fix path is the missing-reset playbook. Its worked examples are the AHU-side pair; the plant-side procedure is the same shape one system upstream — plot dp_sp against the most-open coil valve over the window, confirm the DP sensor is at the hydraulically most remote coil, then program the reset.

clusters is deliberately empty: CLU-02 (“Missing Reset Strategy”) is currently AHU-scoped and triggered by AHU-0023, and membership is clusters/clusters.json’s to declare. A plant failing both CHW-0002 and CHW-0003 has one root cause and should be dispatched as one visit. Expect CHW-0004 (low delta-T) nearby for the opposite reason: low delta-T drives flow up and can hold coil valves open, which is the condition that legitimately suppresses this fault.

Test Vectors

8 scenarios, clock step 300 s over 432000 s.

ScenarioDescription
reset_workingDP_SP swings 100 kPa with load; flatness dwell resets every 6 h, no fault
fixed_sp_low_valveDP_SP pinned while the most-open coil valve sits at 55%; fault at exactly evaluation_window (3 d) + alarm_delay (24 h) = 345600 s, pinned from both sides
valve_at_thresholdMost-open valve exactly at high_valve_threshold (90%) is not low demand (strict <), so a flat setpoint is not a fault
valve_just_below_thresholdMost-open valve at 89.9% is below the threshold; the same flat setpoint alarms at 345600 s
sp_at_flat_toleranceSetpoint deviates from its baseline by exactly sp_flat_tolerance (7.5 kPa): strict < makes that not flat, so no fault
sp_within_flat_toleranceSetpoint jitter of 7 kPa stays inside sp_flat_tolerance, so it still counts as flat; fault at 345600 s
valve_drops_mid_runCoil demand falls away 12 h into the run: the valve dwell starts at 43200 s, so the alarm lands at exactly 43200 + 259200 + 86400 = 388800 s
valve_opens_resetsLow valve demand for 2.3 days, then a coil starves before the window completes; dwell resets, never alarms
vectors.json
{
  "schema": "cxf-library/vectors/v1",
  "clock": {
    "step_s": 300,
    "horizon_s": 432000
  },
  "scenarios": [
    {
      "name": "reset_working",
      "description": "DP_SP swings 100 kPa with load; flatness dwell resets every 6 h, no fault",
      "inputs": {
        "dp_sp": [
          {
            "t": 0,
            "value": 200.0
          },
          {
            "t": 21600,
            "value": 300.0
          },
          {
            "t": 43200,
            "value": 200.0
          },
          {
            "t": 64800,
            "value": 300.0
          },
          {
            "t": 86400,
            "value": 200.0
          },
          {
            "t": 108000,
            "value": 300.0
          },
          {
            "t": 129600,
            "value": 200.0
          },
          {
            "t": 151200,
            "value": 300.0
          },
          {
            "t": 172800,
            "value": 200.0
          },
          {
            "t": 194400,
            "value": 300.0
          },
          {
            "t": 216000,
            "value": 200.0
          },
          {
            "t": 237600,
            "value": 300.0
          },
          {
            "t": 259200,
            "value": 200.0
          },
          {
            "t": 280800,
            "value": 300.0
          },
          {
            "t": 302400,
            "value": 200.0
          },
          {
            "t": 324000,
            "value": 300.0
          },
          {
            "t": 345600,
            "value": 200.0
          },
          {
            "t": 367200,
            "value": 300.0
          },
          {
            "t": 388800,
            "value": 200.0
          },
          {
            "t": 410400,
            "value": 300.0
          },
          {
            "t": 432000,
            "value": 200.0
          }
        ],
        "chw_valve_max": 60.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 432000,
          "equals": false
        }
      ]
    },
    {
      "name": "fixed_sp_low_valve",
      "description": "DP_SP pinned while the most-open coil valve sits at 55%; fault at exactly evaluation_window (3 d) + alarm_delay (24 h) = 345600 s, pinned from both sides",
      "inputs": {
        "dp_sp": 250.0,
        "chw_valve_max": 55.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 345300,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 345600,
          "to_s": 432000,
          "equals": true
        }
      ]
    },
    {
      "name": "valve_at_threshold",
      "description": "Most-open valve exactly at high_valve_threshold (90%) is not low demand (strict <), so a flat setpoint is not a fault",
      "inputs": {
        "dp_sp": 250.0,
        "chw_valve_max": 90.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 432000,
          "equals": false
        }
      ]
    },
    {
      "name": "valve_just_below_threshold",
      "description": "Most-open valve at 89.9% is below the threshold; the same flat setpoint alarms at 345600 s",
      "inputs": {
        "dp_sp": 250.0,
        "chw_valve_max": 89.9
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 345300,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 345600,
          "to_s": 432000,
          "equals": true
        }
      ]
    },
    {
      "name": "sp_at_flat_tolerance",
      "description": "Setpoint deviates from its baseline by exactly sp_flat_tolerance (7.5 kPa): strict < makes that not flat, so no fault",
      "inputs": {
        "dp_sp": [
          {
            "t": 0,
            "value": 250.0
          },
          {
            "t": 21600,
            "value": 257.5
          },
          {
            "t": 43200,
            "value": 250.0
          },
          {
            "t": 64800,
            "value": 257.5
          },
          {
            "t": 86400,
            "value": 250.0
          },
          {
            "t": 108000,
            "value": 257.5
          },
          {
            "t": 129600,
            "value": 250.0
          },
          {
            "t": 151200,
            "value": 257.5
          },
          {
            "t": 172800,
            "value": 250.0
          },
          {
            "t": 194400,
            "value": 257.5
          },
          {
            "t": 216000,
            "value": 250.0
          },
          {
            "t": 237600,
            "value": 257.5
          },
          {
            "t": 259200,
            "value": 250.0
          },
          {
            "t": 280800,
            "value": 257.5
          },
          {
            "t": 302400,
            "value": 250.0
          },
          {
            "t": 324000,
            "value": 257.5
          },
          {
            "t": 345600,
            "value": 250.0
          },
          {
            "t": 367200,
            "value": 257.5
          },
          {
            "t": 388800,
            "value": 250.0
          },
          {
            "t": 410400,
            "value": 257.5
          },
          {
            "t": 432000,
            "value": 250.0
          }
        ],
        "chw_valve_max": 55.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 432000,
          "equals": false
        }
      ]
    },
    {
      "name": "sp_within_flat_tolerance",
      "description": "Setpoint jitter of 7 kPa stays inside sp_flat_tolerance, so it still counts as flat; fault at 345600 s",
      "inputs": {
        "dp_sp": [
          {
            "t": 0,
            "value": 250.0
          },
          {
            "t": 21600,
            "value": 257.0
          },
          {
            "t": 43200,
            "value": 250.0
          },
          {
            "t": 64800,
            "value": 257.0
          },
          {
            "t": 86400,
            "value": 250.0
          },
          {
            "t": 108000,
            "value": 257.0
          },
          {
            "t": 129600,
            "value": 250.0
          },
          {
            "t": 151200,
            "value": 257.0
          },
          {
            "t": 172800,
            "value": 250.0
          },
          {
            "t": 194400,
            "value": 257.0
          },
          {
            "t": 216000,
            "value": 250.0
          },
          {
            "t": 237600,
            "value": 257.0
          },
          {
            "t": 259200,
            "value": 250.0
          },
          {
            "t": 280800,
            "value": 257.0
          },
          {
            "t": 302400,
            "value": 250.0
          },
          {
            "t": 324000,
            "value": 257.0
          },
          {
            "t": 345600,
            "value": 250.0
          },
          {
            "t": 367200,
            "value": 257.0
          },
          {
            "t": 388800,
            "value": 250.0
          },
          {
            "t": 410400,
            "value": 257.0
          },
          {
            "t": 432000,
            "value": 250.0
          }
        ],
        "chw_valve_max": 55.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 345300,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 345600,
          "to_s": 432000,
          "equals": true
        }
      ]
    },
    {
      "name": "valve_drops_mid_run",
      "description": "Coil demand falls away 12 h into the run: the valve dwell starts at 43200 s, so the alarm lands at exactly 43200 + 259200 + 86400 = 388800 s",
      "inputs": {
        "dp_sp": 250.0,
        "chw_valve_max": [
          {
            "t": 0,
            "value": 95.0
          },
          {
            "t": 43200,
            "value": 55.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 388500,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 388800,
          "to_s": 432000,
          "equals": true
        }
      ]
    },
    {
      "name": "valve_opens_resets",
      "description": "Low valve demand for 2.3 days, then a coil starves before the window completes; dwell resets, never alarms",
      "inputs": {
        "dp_sp": 250.0,
        "chw_valve_max": [
          {
            "t": 0,
            "value": 55.0
          },
          {
            "t": 200000,
            "value": 95.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 432000,
          "equals": false
        }
      ]
    }
  ]
}

CHW-0004 — Chilled water low delta-T syndrome

Statusverified — engine e2ff2f8, cxf:fnv1a128:f12c5169f0aa1755a37402fe2dbfe04a, 2026-08-17
Severity3
Methodrule
Phase2
CategoryEXCESS_CONSUMPTION
ConfidenceMEDIUM
EstimationPROXY_ESTIMATION
G36
ClustersCLU-06
Suppresses
Suppressed by
RelatedCHW-0001, CHW-0003, AHU-0014, FCU-0004
Playbookslow-delta-t
SourceHVAC FDD Reference v1.0 §13 (ch. ‘Chilled Water Plants’, pdf pp. 122-123), CHW-0004; PNNL-27338 §3; PNNL-25985
Operating stateschilled water plant producing, with the chiller loaded above min_load_for_eval — the rule’s own yLoadOk is that state

Preconditions (host-enforced): chwst, chwrt and chiller_load must describe the same hydraulic loop at the same moment. On a primary/secondary plant that is the precondition most often violated: the chiller’s own entering/leaving temperatures see primary flow and read a healthy delta-T while the secondary loop that actually serves the coils is short-circuiting through the decoupler, which is the fault. Bind the temperatures where the coils are — the secondary supply and return headers on a decoupled plant, the chiller connections on a variable-primary one — and bind chiller_load from the same plant. Both temperatures must be in °C (the rule converts nothing) and design_delta_t must be this loop’s design value, not the shipped 5.6 °C, before any verdict means anything. The two sensors must also be in the right places: nothing in the rule can tell a swapped supply/return pair from a genuine low delta-T (see Deviations), and a supply sensor reading high biases delta-T low in exactly the direction that alarms. Sensor calibration is worth confirming before a first deployment, because a 0.5 K offset on a 2.8 K trip line is 18% of the decision. Evaluability is signalled in-rule by yLoadOk: when it is false the verdict is NO_EVAL, not a healthy plant.

Points: chwst, chwrt, chiller_load

Outputs:

  • yFault — True while the chilled water delta-T has stayed below design_delta_t × low_dt_fraction with the chiller loaded above min_load_for_eval, continuously for at least alarm_delay
  • yLoadOk — Evaluability signal — true when chiller_load is above min_load_for_eval, the load below which a small delta-T says nothing about the plant. False means NO_EVAL and the host must ignore yFault

Parameters:

NameDefaultUnitCXF pathDescription
design_delta_t5.6°CdesignDt.kDesign chilled water delta-T (the reference’s 5.6 °C = 10 °F). PER-LOOP SITE CONFIGURATION — read it off the plant’s design documents; a 6.7 K (12 °F) plant and a 4.4 K (8 °F) plant are both common and neither is served by the shipped value.
low_dt_fraction0.51lowDtLimit.kFraction of design delta-T below which the plant is faulted (the reference’s 50%). Kept as its own parameter rather than folded into the trip line so that a site can retune the tolerance and the design value independently — see Deviations.
min_load_for_eval40.0%loadOk.tChiller load below which delta-T is not evaluated. The reference’s min_load_for_eval: a lightly loaded plant has a small delta-T because there is little load, not because anything is wrong.
alarm_delay3600.0spersist.delayTimeContinuous low delta-T at load required before the alarm asserts (60 min). The reference’s AlarmDelay, renamed to the library’s convention

Description

A chilled water plant is sized on a temperature difference, not on a flow. Design the coils for 5.6 K between supply and return and the pumps move enough water to carry the peak load; let that difference fall to 2 K and the same load needs nearly three times the flow, so the pumps run faster, the second pump starts, and eventually a second chiller comes on to make water the first could have made if the water had come back warm enough to use. That is low delta-T syndrome, and its signature is that nothing looks broken — every zone is comfortable and the only symptom is a plant working much harder than the building it serves. It is measured at the plant because that is where the individual causes add up: a bypassing three-way valve, a fouled coil, a filter nobody changed, each too small to see from the AHU that owns it.

Detection Logic

delta_t   = chwrt − chwst
low_limit = design_delta_t × low_dt_fraction        (5.6 × 0.5 = 2.8 K)

yLoadOk = chiller_load > min_load_for_eval          (false ⇒ host reports NO_EVAL)
yFault  = delta_t < low_limit AND yLoadOk,
          sustained continuously for alarm_delay

Block graph (rule.cxf.jsonld):

CHW-0004 block graph

designDt and lowDtLimit assemble the trip line inside the graph rather than shipping a pre-multiplied 2.8, so both of the reference’s numbers survive as independent set_param targets; they are retuned for different reasons.

lowDt is strict, so a plant sitting exactly on the trip line reads healthy, and the boundary is bit-exact rather than approximate: 5.6 halved is the double nearest 2.8, which a realistic temperature pair can reach exactly, so the comparison is decided by the strictness and not by rounding. loadOk is the reference’s min_load_for_eval and the whole NO_EVAL story — at 20% load a 1 K delta-T is what a healthy plant produces, and exposing the conjunct as yLoadOk lets the host tell that from a plant that is loaded and fine. persist requires 60 continuous minutes and carries delayOnInit = true; low delta-T is a plant condition, and anything shorter is a valve stroking or a coil catching up.

Possible Diagnoses

Transcribed from the reference’s CHW-0004 card:

  1. Three-way valve bypass allowing CHW to short-circuit — the classic cause, and worst as the building unloads, because that is when the bypass is widest
  2. AHU/FCU coil fouling, so the water leaves the coil colder than it should. AHU-0014 and FCU-0004 see this from the air side, one unit at a time
  3. Low airflow across cooling coils — dirty filters, a slow fan, a closed damper
  4. CHW valve leaking or stuck partially open — the same arithmetic as a bypass valve with a different part number
  5. Oversized CHW system relative to actual load — the case with no repair, where the delta-T is telling the truth

Causes 1 through 4 are local defects this plant-level rule aggregates: a building with forty coils can reach the trip line with four misbehaving and thirty-six fine, which is what makes the finding hard to chase and worth having.

Energy Impact

EXCESS_CONSUMPTION, MEDIUM confidence, PROXY_ESTIMATION. The reference’s estimator is excess_pump_kw ≈ chw_pump_kw × (design_dt − actual_dt) / design_dt — a plant at 2.8 K on a 5.6 K design spends about half its pump energy on water that comes back too cold to be worth moving — with a published range of 5–15% of pump energy. That understates the cost: the expensive consequence is staging, a second chiller serving a load the first could have carried, and the compressor energy dwarfs the pumps. The reference names “staging inefficiency” without putting a number on it and neither does this card, because the number depends on the plant’s staging logic. Confidence is MEDIUM because the finding is plant-level and the repair is not. Cooling-dominant.

Emissions Impact

Scope 2, PROXY_EMISSIONS, MEDIUM confidence; the reference’s typical range is 200–2,000 kg CO₂e/yr for pump and staging inefficiency together, on a marginal operating emissions rate basis. All of it is purchased electricity, so the scope does not vary by site the way a heating fault’s does, and the timing works against the building: low delta-T bites hardest on the hottest afternoons, which are also the hours when the marginal generator is dirtiest.

Deviations

  • The trip line is assembled in the graph rather than folded into a threshold. A single Reals.LessThreshold with t = 2.8 would compute the same verdict with one block instead of three and would lose both of the reference’s tunables — a site with a 6.7 K design could no longer change it without recomputing the product, and the 50% fraction would stop being visible. Precedent: VFD-0002’s assembled speed floor, which adds two constants where this rule multiplies, because the reference’s composition is a product.
  • Strict < at the trip line. The reference writes < too, and CDL Reals has no LessEqual in any case, so a plant at exactly 2.8 K reads healthy. The disagreement is measure-zero, and the on-the-line vector uses a 5 °C supply temperature rather than the published vectors’ 6 °C for an arithmetic reason: 8.8 − 6.0 lands one ulp above 2.8 and would have pinned the wrong side.
  • Strict > at the load floor, same treatment. The reference writes chiller_load > min_load_for_eval, so a chiller at exactly 40% is NO_EVAL.
  • yLoadOk is the library’s shape for the reference’s NO_EVAL row. The reference writes the load test as a conjunct of the fault condition and publishes a NO_EVAL vector for it; the graph computes that conjunct and additionally exposes it as a boundary output, which adds no logic and changes no verdict. It is a comparison of an input against a parameter rather than an echo of an input, which is what SCHEMA.md asks. Same stance as HP-0001’s yPowerOk.
  • Nothing guards against an inverted delta-T. A swapped supply/return pair, or sensors on the wrong side of a decoupler, produces a negative delta-T that is below any positive trip line and alarms permanently. The block set could express a guard, but suppressing negative delta-T would also suppress the genuine short-circuit case on a plant whose sensors are fine, so the honest answer is a documented blind spot. Commissioning check: swap the leads and watch the sign, once, before trusting the rule.
  • The rule is blind to which coil is responsible, and to how many. That is the reference’s design rather than a simplification — the individual coil faults are usually too small to detect one at a time, which is why the syndrome is measured in the return header. AHU-0014 and FCU-0004 are the coil-side rules worth running alongside it; neither is wired to this one.
  • Persistence stands in for averaging. The rule consumes instantaneous points and the reference specifies no averaging, so a delta-T alternating above and below the line every 20 minutes never accumulates the hour and never alarms, though a plant spending half its day low is a genuine finding. A steady syndrome — a bypassing valve or a fouled coil — reads the same either way.
  • AlarmDelay is renamed alarm_delay. The reference’s tunables table spells the fault-persistence parameter in G36’s PascalCase while spelling its three neighbours in snake_case; the library uses alarm_delay throughout and the value is unchanged at 60 min.
  • persist.delayOnInit = true (CDL default false), the library’s standing choice: a plant already below the line at controller restart waits out the full hour rather than alarming on the first tick.
  • chiller_load is per-chiller while the delta-T is per-loop. On a multi-chiller plant the load signal belongs to one machine and the header temperatures to the loop, so a plant running two chillers at 45% each is evaluated on one of them. The reference names the same single point and does not address the case; bind the lead chiller or a host-computed plant load.
  • Three published test vectors, the rest authored. The reference publishes normal delta-T, low delta-T and low load (NO_EVAL); all three are transcribed into vectors.json and pass. The remainder — the trip line and load floor boundaries, the mid-run collapse and recovery edges, the evaluability release, the intermittent case and the swapped-sensor blind spot — are library-authored.
  • The chapter’s Notes line for this fault is truncated in the source extract. It reads “One of the most common and costly CHW plant issues. Forces” and stops. Nothing here depends on the missing clause: the “forces extra pumping” reading in energy_impact.savings_range comes from the chapter’s own Savings Range row, which is complete.
  • clusters: [CLU-06] is the existing cluster set’s membership, not this card’s authorship. clusters/clusters.json already lists this fault as a member of “Chilled Water Plant Inefficiency” with CHW-0001 as the trigger, and that cluster’s playbook slug resolves to playbooks/chiller-efficiency.md.
  • playbooks cites low-delta-t, the reference’s own playbook, whose Applies-To row names this card directly.
  • Operating states and preconditions are declared in frontmatter for host enforcement rather than encoded in the block graph, per the library’s design stance. Severity 3, method: rule and the fault name are the reference’s chapter 13 card.

Notes

Source-pointer precision: the reference’s ch.13 card cites “PNNL-27338 §3”, but in PNNL-27338’s own numbering the low delta-T algorithm sits in the hot-water distribution chapter (§4.6), and that document names chilled-water diagnostics as future work. The citation is an analog/pattern source — the HW delta-T algorithm mirrored to CHW — not a CHW-specific specification.

Read yLoadOk before yFault. A plant that is off, or coasting through a mild morning at 25% load, holds yLoadOk false for hours, and every yFault = false underneath it means “not evaluated” rather than “delta-T is fine”.

Trend delta-T against plant load for a week before sending anyone: a delta-T that degrades as the building unloads points at bypass and leaking control valves, while one that is flat and low across the range points at fouling or at a plant oversized for the building. Then check the largest coils, because the syndrome is a sum. CHW-0001 shares this rule’s cluster and the two reinforce each other — extra pumping and an early-staged chiller both push kW/ton up — so treat the delta-T as the trigger and the efficiency alarm as its consequence.

Test Vectors

14 scenarios, clock step 60 s over 7200 s.

ScenarioDescription
normal_delta_tThe reference’s published vector 1: CHWST 6 °C, CHWRT 12 °C, load 60% — expected NO_FAULT. A 6 K delta-T is above design, let alone above the 2.8 K trip line, so the rule is evaluated and silent.
low_delta_tThe reference’s published vector 2: CHWST 6 °C, CHWRT 8 °C, load 60% — expected FAULT. Delta-T is 2 K against a 2.8 K trip line; the alarm lands at exactly alarm_delay because delayOnInit holds the condition from the first tick.
low_loadThe reference’s published vector 3: CHWST 6 °C, CHWRT 7 °C, load 20% — expected NO_EVAL. The 1 K delta-T is well below the trip line and means nothing at 20% load, which is what yLoadOk = false tells the host.
delta_t_exactly_at_the_thresholdBoundary, bit-exact: 7.8 − 5.0 is the double nearest 2.8, and 5.6 × 0.5 is the same double (halving is exact), so delta-T sits exactly on the trip line. Reals.Less is strict, so exactly-at-the-line is not a fault.
delta_t_just_below_the_thresholdBoundary from below: 7.79 − 5.0 = 2.79 K clears the strict comparison by 10 mK and alarms on the normal schedule.
delta_t_just_above_the_thresholdBoundary from above: 7.81 − 5.0 = 2.81 K is above the trip line and never alarms, however marginal the plant’s performance is.
load_exactly_at_the_evaluability_floorBoundary on the other conjunct: load is exactly min_load_for_eval (40%) with a collapsed 2 K delta-T. Reals.GreaterThreshold is strict, so exactly-at-the-floor is NO_EVAL and the rule stays silent.
load_just_below_the_evaluability_floor39.9% load with the same collapsed delta-T: still NO_EVAL.
load_just_above_the_evaluability_floor40.1% load with the same collapsed delta-T: evaluable, and the alarm lands at exactly alarm_delay. One tenth of a percent of chiller load is the whole difference between this scenario and the previous one.
delta_t_collapses_mid_runA plant running at a healthy 6 K delta-T loses it at t = 1800 s — a three-way valve opening its bypass, or a coil starving. The alarm lands at exactly 5400 s: the mid-run rising edge carries the same T + delayTime arithmetic as the init case.
delta_t_recovers_after_alarmRecovery: the alarm asserts at 3600 s and the return temperature comes back at t = 5400 s. TrueDelay passes the falling edge with no delay, so yFault drops on that tick.
load_drops_after_alarmThe evaluability release: an alarming plant unloads to 20% at t = 5400 s with the low delta-T unchanged. yFault and yLoadOk fall on the same tick, and only the pair tells the host that the chiller unloaded rather than that the delta-T recovered.
intermittent_low_delta_never_alarmsDelta-T alternating between 2 K and 6 K every 1200 s — a hunting control valve, or a plant cycling between two coils. No single episode reaches alarm_delay, so nothing fires. Persistence is not averaging: a loop spending half its day at 2 K delta-T is a real finding this rule cannot make.
supply_and_return_sensors_swappedBlind spot, pinned: the two temperature sensors are wired backwards (CHWST 12 °C, CHWRT 6 °C), so delta-T reads −6 K. Nothing in the graph knows a negative delta-T is impossible, and the rule reports the strongest possible low-delta-T fault on a plant that may be running perfectly.
vectors.json
{
  "schema": "cxf-library/vectors/v1",
  "clock": {
    "step_s": 60,
    "horizon_s": 7200
  },
  "scenarios": [
    {
      "name": "normal_delta_t",
      "description": "The reference's published vector 1: CHWST 6 \u00b0C, CHWRT 12 \u00b0C, load 60% \u2014 expected NO_FAULT. A 6 K delta-T is above design, let alone above the 2.8 K trip line, so the rule is evaluated and silent.",
      "inputs": {
        "chwst": 6.0,
        "chwrt": 12.0,
        "chiller_load": 60.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        },
        {
          "output": "yLoadOk",
          "from_s": 0,
          "to_s": 7200,
          "equals": true
        }
      ]
    },
    {
      "name": "low_delta_t",
      "description": "The reference's published vector 2: CHWST 6 \u00b0C, CHWRT 8 \u00b0C, load 60% \u2014 expected FAULT. Delta-T is 2 K against a 2.8 K trip line; the alarm lands at exactly alarm_delay because delayOnInit holds the condition from the first tick.",
      "inputs": {
        "chwst": 6.0,
        "chwrt": 8.0,
        "chiller_load": 60.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3540,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 3600,
          "to_s": 7200,
          "equals": true
        },
        {
          "output": "yLoadOk",
          "from_s": 0,
          "to_s": 7200,
          "equals": true
        }
      ]
    },
    {
      "name": "low_load",
      "description": "The reference's published vector 3: CHWST 6 \u00b0C, CHWRT 7 \u00b0C, load 20% \u2014 expected NO_EVAL. The 1 K delta-T is well below the trip line and means nothing at 20% load, which is what yLoadOk = false tells the host.",
      "inputs": {
        "chwst": 6.0,
        "chwrt": 7.0,
        "chiller_load": 20.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        },
        {
          "output": "yLoadOk",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        }
      ]
    },
    {
      "name": "delta_t_exactly_at_the_threshold",
      "description": "Boundary, bit-exact: 7.8 \u2212 5.0 is the double nearest 2.8, and 5.6 \u00d7 0.5 is the same double (halving is exact), so delta-T sits exactly on the trip line. Reals.Less is strict, so exactly-at-the-line is not a fault.",
      "inputs": {
        "chwst": 5.0,
        "chwrt": 7.8,
        "chiller_load": 60.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        },
        {
          "output": "yLoadOk",
          "from_s": 0,
          "to_s": 7200,
          "equals": true
        }
      ]
    },
    {
      "name": "delta_t_just_below_the_threshold",
      "description": "Boundary from below: 7.79 \u2212 5.0 = 2.79 K clears the strict comparison by 10 mK and alarms on the normal schedule.",
      "inputs": {
        "chwst": 5.0,
        "chwrt": 7.79,
        "chiller_load": 60.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3540,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 3600,
          "to_s": 7200,
          "equals": true
        },
        {
          "output": "yLoadOk",
          "from_s": 0,
          "to_s": 7200,
          "equals": true
        }
      ]
    },
    {
      "name": "delta_t_just_above_the_threshold",
      "description": "Boundary from above: 7.81 \u2212 5.0 = 2.81 K is above the trip line and never alarms, however marginal the plant's performance is.",
      "inputs": {
        "chwst": 5.0,
        "chwrt": 7.81,
        "chiller_load": 60.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        },
        {
          "output": "yLoadOk",
          "from_s": 0,
          "to_s": 7200,
          "equals": true
        }
      ]
    },
    {
      "name": "load_exactly_at_the_evaluability_floor",
      "description": "Boundary on the other conjunct: load is exactly min_load_for_eval (40%) with a collapsed 2 K delta-T. Reals.GreaterThreshold is strict, so exactly-at-the-floor is NO_EVAL and the rule stays silent.",
      "inputs": {
        "chwst": 6.0,
        "chwrt": 8.0,
        "chiller_load": 40.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        },
        {
          "output": "yLoadOk",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        }
      ]
    },
    {
      "name": "load_just_below_the_evaluability_floor",
      "description": "39.9% load with the same collapsed delta-T: still NO_EVAL.",
      "inputs": {
        "chwst": 6.0,
        "chwrt": 8.0,
        "chiller_load": 39.9
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        },
        {
          "output": "yLoadOk",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        }
      ]
    },
    {
      "name": "load_just_above_the_evaluability_floor",
      "description": "40.1% load with the same collapsed delta-T: evaluable, and the alarm lands at exactly alarm_delay. One tenth of a percent of chiller load is the whole difference between this scenario and the previous one.",
      "inputs": {
        "chwst": 6.0,
        "chwrt": 8.0,
        "chiller_load": 40.1
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3540,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 3600,
          "to_s": 7200,
          "equals": true
        },
        {
          "output": "yLoadOk",
          "from_s": 0,
          "to_s": 7200,
          "equals": true
        }
      ]
    },
    {
      "name": "delta_t_collapses_mid_run",
      "description": "A plant running at a healthy 6 K delta-T loses it at t = 1800 s \u2014 a three-way valve opening its bypass, or a coil starving. The alarm lands at exactly 5400 s: the mid-run rising edge carries the same T + delayTime arithmetic as the init case.",
      "inputs": {
        "chwst": 6.0,
        "chwrt": [
          {
            "t": 0,
            "value": 12.0
          },
          {
            "t": 1800,
            "value": 8.0
          }
        ],
        "chiller_load": 60.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5340,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 5400,
          "to_s": 7200,
          "equals": true
        },
        {
          "output": "yLoadOk",
          "from_s": 0,
          "to_s": 7200,
          "equals": true
        }
      ]
    },
    {
      "name": "delta_t_recovers_after_alarm",
      "description": "Recovery: the alarm asserts at 3600 s and the return temperature comes back at t = 5400 s. TrueDelay passes the falling edge with no delay, so yFault drops on that tick.",
      "inputs": {
        "chwst": 6.0,
        "chwrt": [
          {
            "t": 0,
            "value": 8.0
          },
          {
            "t": 5400,
            "value": 12.0
          }
        ],
        "chiller_load": 60.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3540,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 3600,
          "to_s": 5340,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 5400,
          "to_s": 7200,
          "equals": false
        },
        {
          "output": "yLoadOk",
          "from_s": 0,
          "to_s": 7200,
          "equals": true
        }
      ]
    },
    {
      "name": "load_drops_after_alarm",
      "description": "The evaluability release: an alarming plant unloads to 20% at t = 5400 s with the low delta-T unchanged. yFault and yLoadOk fall on the same tick, and only the pair tells the host that the chiller unloaded rather than that the delta-T recovered.",
      "inputs": {
        "chwst": 6.0,
        "chwrt": 8.0,
        "chiller_load": [
          {
            "t": 0,
            "value": 60.0
          },
          {
            "t": 5400,
            "value": 20.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3540,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 3600,
          "to_s": 5340,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 5400,
          "to_s": 7200,
          "equals": false
        },
        {
          "output": "yLoadOk",
          "from_s": 0,
          "to_s": 5340,
          "equals": true
        },
        {
          "output": "yLoadOk",
          "from_s": 5400,
          "to_s": 7200,
          "equals": false
        }
      ]
    },
    {
      "name": "intermittent_low_delta_never_alarms",
      "description": "Delta-T alternating between 2 K and 6 K every 1200 s \u2014 a hunting control valve, or a plant cycling between two coils. No single episode reaches alarm_delay, so nothing fires. Persistence is not averaging: a loop spending half its day at 2 K delta-T is a real finding this rule cannot make.",
      "inputs": {
        "chwst": 6.0,
        "chwrt": [
          {
            "t": 0,
            "value": 8.0
          },
          {
            "t": 1200,
            "value": 12.0
          },
          {
            "t": 2400,
            "value": 8.0
          },
          {
            "t": 3600,
            "value": 12.0
          },
          {
            "t": 4800,
            "value": 8.0
          },
          {
            "t": 6000,
            "value": 12.0
          }
        ],
        "chiller_load": 60.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        },
        {
          "output": "yLoadOk",
          "from_s": 0,
          "to_s": 7200,
          "equals": true
        }
      ]
    },
    {
      "name": "supply_and_return_sensors_swapped",
      "description": "Blind spot, pinned: the two temperature sensors are wired backwards (CHWST 12 \u00b0C, CHWRT 6 \u00b0C), so delta-T reads \u22126 K. Nothing in the graph knows a negative delta-T is impossible, and the rule reports the strongest possible low-delta-T fault on a plant that may be running perfectly.",
      "inputs": {
        "chwst": 12.0,
        "chwrt": 6.0,
        "chiller_load": 60.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3540,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 3600,
          "to_s": 7200,
          "equals": true
        },
        {
          "output": "yLoadOk",
          "from_s": 0,
          "to_s": 7200,
          "equals": true
        }
      ]
    }
  ]
}

CHW-0005 — Chiller condenser approach high

Statusverified — engine e2ff2f8, cxf:fnv1a128:85a781d555bacb1fd1cd2bc048597f1e, 2026-08-18
Severity3
Methodrule
Phase2
CategoryEFFICIENCY_LOSS
ConfidenceMEDIUM
EstimationPROXY_ESTIMATION
G36
ClustersCLU-06, CLU-10
Suppresses
Suppressed by
RelatedCHW-0001, CHW-0004, CHW-0007, TOWER-0001, TOWER-0005, HP-0004
Playbookscooling-tower-performance, chiller-efficiency
SourceBEE/Devki Energy Consultancy, Best Practice Manual: HVAC Chillers (2006), §3.8 — condenser approach design bands by heat-exchanger type (plate 1-5 °C, shell-and-tube 5-10 °C), the anchor for the shipped clean_approach; §3.10 — tube fouling and non-condensable gas as the two mechanisms, with a 4x-design fouling factor associated with roughly 20% energy loss; §2.5.2 — the 2-4% per °C of lift thumb-rule; DOE/PNNL, O&M Best Practices Guide Release 3.0 (2010) §9.4, and PNNL-13890 (2002) §7.4 — fouling described as requiring an enlarged temperature difference between water and refrigerant, the mechanism this rule detects, plus the compressor-type-split lift sensitivity (1.7%/°F centrifugal, 1.2%/°F reciprocating) used in the energy model; HVAC FDD Reference v1.0, Remediation Playbooks (pp. 161-163), carried in playbooks/chiller-efficiency.md: an approach more than 2x design indicates fouling on that side — the only fault-side rule of thumb behind approach_fault_multiple; tools/simharness/README.md, ‘Tower groundwork (condenser-loop stats)’ — this library’s 4-climate simulation study, source of the batch’s 2x-design band shape; its healthy envelope measures the tower’s water-to-wet-bulb approach, not this card’s refrigerant-to-water approach (see Deviations); ASHRAE RP-1043 — PENDING primary source for a fault-grade condenser approach threshold, named in faults/chw/README.md; Sibling precedent: CHW-0004 (graph shape, two-parameter trip line, yLoadOk evaluability output), HP-0001 (commissioning-placeholder contract); Library extension: the HVAC FDD Reference v1.0 ch.13 specifies CHW-0001 through 053 only
Operating statesChiller running and loaded above min_load_for_eval, settled after a start or a capacity step — one instance per machine, each carrying that machine’s commissioned clean approach. The rule’s yLoadOk covers the load half of that state; running and settled are the host’s to enforce.

Preconditions (host-enforced): cond_sat_temp and cwst must describe the same machine at the same moment, in °C, and cwst must be the WARM water leaving the condenser on its way to the tower. The point dictionary flags this as the entry’s main hazard: trade usage often calls the cold tower-leaving water ‘CW supply’, and binding that instead adds the condenser range — commonly 3-5 K — to every reading, which is most of a shipped 10 K band and alarms permanently on a clean machine (pinned by entering_condenser_water_bound_by_mistake). cond_sat_temp is host-derived: either the saturated condensing temperature the chiller controller already publishes, which is the cheaper and safer source, or a P-T lookup the host runs against the machine’s own refrigerant. A lookup against the wrong refrigerant biases the approach by whole kelvin in either direction, and the graph cannot see the derivation. clean_approach must be this machine’s commissioned clean-condenser approach measured at a load comparable to the evaluation window, not the shipped 5.0 K, and re-measured after any tube cleaning; until it is, the rule ships a deliberately silent placeholder rather than a threshold (see Deviations). Sensor calibration decides marginal findings — a 0.5 K offset on the shipped 10 K line is 5% of the decision, and much more of it once a site commissions a tighter band. Condenser water flow must be at or near design: an approach widened by low flow is a real finding but a different repair, and nothing in the rule separates the two. Evaluability is signalled in-rule by yLoadOk; when it is false the verdict is NO_EVAL, not a clean condenser.

Points: cond_sat_temp, cwst, chiller_load

Outputs:

  • yFault — True while the condenser approach has stayed above clean_approach × approach_fault_multiple with the chiller loaded above min_load_for_eval, continuously for at least alarm_delay
  • yLoadOk — Evaluability signal — true when chiller_load is above min_load_for_eval, the load below which approach says nothing about the condenser because the heat flux driving it is small. False means NO_EVAL and the host must ignore yFault

Parameters:

NameDefaultUnitCXF pathDescription
clean_approach5.0°CcleanApproach.kThe machine’s commissioned clean-condenser approach — saturated condensing temperature minus leaving condenser water temperature with clean tubes at a load comparable to the evaluation window. PER-MACHINE SITE CONFIGURATION, and the parameter this card exists to be retuned on: BEE 2006 §3.8’s shell-and-tube design band is 5-10 °C while a plate condenser sits at 1-5 °C and the reference’s own remediation playbook quotes 1-2 °F for a chiller condenser, so the shipped 5.0 spans an order of magnitude of real machines (see Deviations). Measure it, do not adopt it.
approach_fault_multiple2.01approachLimit.kMultiple of the clean approach at which the condenser is called fouled. 2.0 is the reference’s remediation-playbook rule of thumb — an approach more than 2x design indicates fouling on that side — and is the same band shape batch 18’s tower cards adopt. Kept separate from clean_approach so a site can retune the tolerance and the commissioned baseline independently.
min_load_for_eval40.0%loadOk.tChiller load below which approach is not evaluated. Approach scales with the heat flux across the tubes, so a lightly loaded machine makes a small approach whether its condenser is clean or fouled. ADOPTED from CHW-0004’s floor for the same chiller_load point; the sources supply no load gate of their own.
alarm_delay3600.0spersist.delayTimeContinuous high approach at load required before the alarm asserts (60 min). ADOPTED from CHW-0004; fouling is a condition of the tubes, not an event, and an hour rides out a capacity step or a condenser water reset.

Description

A condenser rejects heat through a temperature difference. Saturated condensing temperature sits above the water leaving the condenser by an approach a clean machine holds nearly constant at a given load, and everything that gets between refrigerant and water widens it: scale and biofilm on the tubes, non-condensables collected in the shell, oil carryover, condenser water flow below design. None of it looks like a breakdown — the chiller still makes its chilled water setpoint, it just lifts further to do it, and the compressor pays for the extra lift at roughly 2-4% of its power per degree. This rule watches the approach while the machine is loaded and alarms when it stays above that machine’s commissioned band.

Detection Logic

approach   = cond_sat_temp − cwst
band_limit = clean_approach × approach_fault_multiple   (5.0 × 2.0 = 10.0 K)

yLoadOk = chiller_load > min_load_for_eval        (false ⇒ host reports NO_EVAL)
yFault  = approach > band_limit AND yLoadOk,
          sustained continuously for alarm_delay

Block graph (rule.cxf.jsonld):

CHW-0005 block graph

cleanApproach and approachLimit assemble the band inside the graph rather than shipping a pre-multiplied 10.0: the commissioned approach is measured on the machine and the multiple is a tolerance, and they are retuned separately.

approachHigh is strict, and the boundary is bit-exact — 5.0 doubled is exactly 10.0, which a realistic temperature pair reaches exactly — so a machine sitting on the band reads healthy by the strictness rather than by rounding. loadOk is the whole NO_EVAL story: approach scales with heat flux, so at 20% load a fouled condenser and a clean one both look fine, and yLoadOk is what lets a host tell that silence from a verdict. persist requires 60 continuous minutes and carries delayOnInit = true.

Being differential is what makes this rule worth having next to the tower cards: warm condenser water raises condensing temperature and leaving water together, so a tower that cannot make its setpoint is TOWER-0001’s finding, not this one.

Possible Diagnoses

  1. Condenser tube fouling — scale, biofilm, or silt on the water side, the mechanism both cited sources name first, and the one a tube cleaning fixes
  2. Non-condensable gas in the shell, from a leaking seal or an evacuation that was cut short; it raises condensing pressure and separately degrades the heat-transfer coefficient, and on this signal alone it is indistinguishable from fouling (BEE 2006 §3.10). A purge unit that runs constantly is the tell
  3. Condenser water flow below design — a throttled or failing pump, a fouled strainer, a mis-positioned balancing or isolation valve. Same symptom, a different repair, and CHW-0004’s low delta-T sibling on the condenser side
  4. Oil carried over into the condenser, coating tubes; no point observes it
  5. Water treatment lapsed — the cause behind cause 1, and the one that decides how soon the tubes foul again after cleaning
  6. Neither: a wrong-refrigerant P-T lookup, a mis-bound cwst, or a drifting sensor. Rule this out first, because it costs nothing and it is common

Energy Impact

EFFICIENCY_LOSS, MEDIUM confidence, PROXY_ESTIMATION. The estimator is waste_kw ≈ chiller_kw × lift_sensitivity × (approach − clean_approach): the share of compressor power spent lifting across a resistance that should not be there. A machine 5 K past its commissioned approach spends roughly 11-16% of its compressor power on it, which brackets BEE 2006 §3.10’s report of about 20% at four times the design fouling factor. MEDIUM because the sensitivity ratio is well corroborated while the excess it multiplies is only as good as the commissioned baseline. Cooling-dominant, worst on design days.

Emissions Impact

Scope 2, PROXY_EMISSIONS, MEDIUM confidence; the same order as CHW-0001’s 1,000-10,000 kg CO₂e/yr, since both findings are compressor electricity on one machine, and toward the lower end of it because this rule accuses one heat exchanger rather than the whole machine. Marginal operating emissions rate basis. The timing works against the building: condenser fouling costs most on hot afternoons, which are also the hours the dirtiest generator is dispatched.

Deviations

  • The band is a commissioning placeholder, not a threshold. Three deep-read sources — BEE 2006, PNNL-13890, and the DOE/PNNL O&M Best Practices Guide 3.0 — corroborate the mechanism and supply no fault-grade approach magnitude for a chiller condenser; ASHRAE RP-1043 is the pending primary source for that number. Until a site commissions clean_approach, this rule runs against a placeholder in the same sense HP-0001’s shipped regression coefficients do.
  • The shipped 5.0 K spans an order of magnitude of real machines. BEE §3.8 gives shell-and-tube condensers a 5-10 °C approach and plate condensers 1-5 °C, while the reference’s remediation playbook quotes 1-2 °F design for a chiller condenser; neither defines the difference precisely enough to reconcile them, and the playbook writes it in the order that cannot be right physically. The shipped pair reproduces BEE’s shell-and-tube band (5 K clean, 10 K trip) and is therefore biased toward silence: on a machine whose true clean approach is 1 K, an uncommissioned instance never alarms. That is the intended direction.
  • approach_fault_multiple = 2.0 is the one fault-side number any source supplies. The reference’s remediation playbook states that an approach more than 2x design indicates fouling on that side; the deep-read literature offers nothing to compare it against. It is also the band shape batch 18’s tower cards adopt, so the condenser side and the tower side read the same way — a design consistency choice, not a second derivation.
  • The simulation study grounds the band’s shape, not its magnitude. The 4-climate healthy envelope committed in tools/simharness/README.md measures a tower’s leaving-water-to-wet-bulb approach (healthy p50 1.6-11.5 °C, fan-speed dependent). This card’s approach is refrigerant-to-water inside the chiller barrel — a different quantity that the study does not bound — so the honest claim is the 2x band form the study argued for, and nothing numeric.
  • The energy model applies an evaporator-side sensitivity to the condenser side. The DOE/PNNL guide’s 1.7%/°F centrifugal and 1.2%/°F reciprocating split is stated for chilled-water supply temperature; both sources argue lift symmetrically and BEE §2.5.2 states the symmetric form directly, so the split crosses over as a lift sensitivity — an approximation, which is why runtime_estimation writes it as one.
  • The same document family’s condenser-side numbers disagree by about 2x. Its chiller chapter puts condenser water lowered 2-3 °F at 2-3% efficiency; its cooling-tower chapter puts 2.5-3.5% per °F of condenser temperature. Neither cites a derivation. This card uses the chiller-chapter figure, which is consistent with BEE’s independent 2-4%/°C band, and treats the tower-chapter number as a loose ceiling rather than a second measurement.
  • min_load_for_eval is entirely adopted; no source gates this test. 40% is CHW-0004’s floor on the same chiller_load point, so the two chiller rules read the load axis identically. The gate matters more here than there: approach shrinks with heat flux, so a low-load machine hides a fouled condenser rather than faking one, and the failure it prevents is a host reading silence as health.
  • A fixed band across the whole load range is a simplification. Approach rises with load on a clean machine too, so one line evaluated anywhere above 40% is loose at 90% load and tight at 45%. A load-normalized band is the RP-1043-shaped successor; nothing in the sources says how approach should scale, so it is not expressible as a placeholder.
  • Strict > at the band and at the load floor. CDL Reals has no GreaterEqual; a machine exactly on the band reads healthy, one at exactly 40% load reads NO_EVAL. Both disagreements are measure-zero and err toward silence.
  • yLoadOk is an evaluability output, not an echo of an input — a boundary input compared against a parameter, which is what SCHEMA.md asks. Same stance as CHW-0004’s yLoadOk and HP-0001’s yPowerOk.
  • Nothing guards the mis-binding in either direction. Bound to the entering (cold) condenser water, the rule adds the condenser range to every reading and alarms forever; fed a saturation temperature from the wrong refrigerant table, it goes quiet on a fouled machine. Both are pinned as vectors and left to preconditions, because the graph cannot see a derivation it does not perform. Commissioning check: read the approach once on a machine known clean and confirm it lands where the manufacturer’s data says it should.
  • Persistence stands in for averaging. The rule consumes instantaneous points, so an approach oscillating either side of the band never accumulates the hour. Fouling is steady and reads the same either way; a hunting condenser water valve does not, and is a finding this rule cannot make.
  • persist.delayOnInit = true (CDL default false), the library’s standing choice: a machine already above its band at controller restart waits out the full hour rather than alarming on the first tick.
  • clusters: [CLU-06, CLU-10] with CLU-06 the obvious candidate. clusters/clusters.json groups the chilled water plant syndrome behind CHW-0001 as trigger, and a fouled condenser drives kW/ton up exactly as that cluster describes. Membership is the cluster owner’s edit.
  • playbooks cites two and only one names this card back. cooling-tower-performance’s Applies-To row already carries CHW-0005 and its Step 1 describes this test correctly; chiller-efficiency’s row does not yet, and that edit belongs to the index owner — the sequencing CHW-0004 and HW-0004 both recorded.
  • No published test vectors exist. No source specifies cases for this test, so every scenario in vectors.json is authored from the equation and replayed against the pinned engine rev.
  • Operating states and preconditions are declared in frontmatter for host enforcement, not encoded in the block graph. Severity 3 and method: rule are this card’s own, matching the CHW chapter’s efficiency-loss cards.

Notes

Read yLoadOk before yFault, and read this rule next to CHW-0001. A fouled condenser raises kW/ton, so the efficiency alarm usually fires first and says only that the machine costs more than it should; the approach says which heat exchanger to open. If both are quiet and kW/ton is still high, the evaporator side is the remaining candidate and no card covers it — the evap_sat_temp route HP-0004 takes on packaged equipment. Trend approach against load for a week before scheduling a cleaning: an approach that widens with load points at fouling or at flow, while one flat and wide across the range points at non-condensables or at a mis-set clean_approach.

Test Vectors

15 scenarios, clock step 60 s over 7200 s.

ScenarioDescription
clean_condenser_at_loadNO_FAULT: 36 °C saturated condensing against 30 °C leaving condenser water is a 6 K approach, inside the shipped 10 K band, at 60% load. The approach conjunct blocks alone — the machine is evaluable and silent.
fouled_condenser_at_loadFAULT: 42 °C condensing against 30 °C leaving water is a 12 K approach at 60% load. The alarm lands at exactly alarm_delay because delayOnInit holds the condition from the first tick.
high_approach_at_low_loadNO_EVAL: the same 12 K approach at 20% load. The load conjunct blocks alone, and yLoadOk = false is what tells the host that the silence is not a healthy verdict — at part load a clean machine and a fouled one both make small approaches.
approach_exactly_at_the_bandBoundary, bit-exact: 40.0 − 30.0 is exactly 10.0 and 5.0 × 2.0 is the same double, so the approach sits exactly on the band. Reals.Greater is strict, so exactly-at-the-band reads healthy.
approach_just_above_the_bandBoundary from above: 40.01 − 30.0 clears the strict comparison by 10 mK and alarms on the normal schedule.
approach_just_below_the_bandBoundary from below: 39.99 − 30.0 is 10 mK inside the band and never alarms, however marginal the machine’s condenser is.
load_exactly_at_the_evaluability_floorBoundary on the other conjunct: load is exactly min_load_for_eval (40%) with a 12 K approach. Reals.GreaterThreshold is strict, so exactly-at-the-floor is NO_EVAL.
load_just_below_the_evaluability_floor39.9% load with the same widened approach: still NO_EVAL.
load_just_above_the_evaluability_floor40.1% load with the same widened approach: evaluable, and the alarm lands at exactly alarm_delay. One tenth of a percent of chiller load is the whole difference between this scenario and the previous one.
approach_widens_mid_runA machine holding a 6 K approach loses it at t = 1800 s — condenser water flow falling away, or non-condensables released into the shell. The alarm lands at exactly 5400 s: the mid-run rising edge carries the same T + delayTime arithmetic as the init case.
approach_recovers_after_alarmRecovery: the alarm asserts at 3600 s and the condenser is cleaned — or the purge unit runs — at t = 5400 s. TrueDelay passes the falling edge with no delay, so yFault drops on that tick.
load_drops_after_alarmThe evaluability release: an alarming machine unloads to 20% at t = 5400 s with the widened approach unchanged. yFault and yLoadOk fall on the same tick, and only the pair tells the host that the chiller unloaded rather than that the condenser recovered.
intermittent_high_approach_never_alarmsApproach alternating between 12 K and 6 K every 1200 s — a condenser water valve hunting, or a machine cycling between capacity steps. No single episode reaches alarm_delay, so nothing fires. Persistence is not averaging: a machine spending half its day at 12 K is a real finding this rule cannot make.
entering_condenser_water_bound_by_mistakeBlind spot, pinned: cwst is bound to the cold tower-leaving water entering the condenser instead of the warm water leaving it. The measured difference then carries the condenser range as well as the approach — 38.0 − 26.5 = 11.5 K on a machine whose real approach is 8 K — and the rule alarms permanently on a healthy condenser. This is the loop-side error the point dictionary’s cwst note warns about.
saturation_lookup_biased_low_hides_the_faultThe opposite blind spot, pinned: a P-T lookup run against the wrong refrigerant biases cond_sat_temp several kelvin low, so a machine at a true 13 K approach reports 9 K and reads healthy for as long as the mis-binding lasts. Nothing in the graph can see the derivation, so the failure is silent in the false-negative direction.
vectors.json
{
  "schema": "cxf-library/vectors/v1",
  "clock": {
    "step_s": 60,
    "horizon_s": 7200
  },
  "scenarios": [
    {
      "name": "clean_condenser_at_load",
      "description": "NO_FAULT: 36 \u00b0C saturated condensing against 30 \u00b0C leaving condenser water is a 6 K approach, inside the shipped 10 K band, at 60% load. The approach conjunct blocks alone \u2014 the machine is evaluable and silent.",
      "inputs": {
        "cond_sat_temp": 36.0,
        "cwst": 30.0,
        "chiller_load": 60.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        },
        {
          "output": "yLoadOk",
          "from_s": 0,
          "to_s": 7200,
          "equals": true
        }
      ]
    },
    {
      "name": "fouled_condenser_at_load",
      "description": "FAULT: 42 \u00b0C condensing against 30 \u00b0C leaving water is a 12 K approach at 60% load. The alarm lands at exactly alarm_delay because delayOnInit holds the condition from the first tick.",
      "inputs": {
        "cond_sat_temp": 42.0,
        "cwst": 30.0,
        "chiller_load": 60.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3540,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 3600,
          "to_s": 7200,
          "equals": true
        },
        {
          "output": "yLoadOk",
          "from_s": 0,
          "to_s": 7200,
          "equals": true
        }
      ]
    },
    {
      "name": "high_approach_at_low_load",
      "description": "NO_EVAL: the same 12 K approach at 20% load. The load conjunct blocks alone, and yLoadOk = false is what tells the host that the silence is not a healthy verdict \u2014 at part load a clean machine and a fouled one both make small approaches.",
      "inputs": {
        "cond_sat_temp": 42.0,
        "cwst": 30.0,
        "chiller_load": 20.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        },
        {
          "output": "yLoadOk",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        }
      ]
    },
    {
      "name": "approach_exactly_at_the_band",
      "description": "Boundary, bit-exact: 40.0 \u2212 30.0 is exactly 10.0 and 5.0 \u00d7 2.0 is the same double, so the approach sits exactly on the band. Reals.Greater is strict, so exactly-at-the-band reads healthy.",
      "inputs": {
        "cond_sat_temp": 40.0,
        "cwst": 30.0,
        "chiller_load": 60.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        },
        {
          "output": "yLoadOk",
          "from_s": 0,
          "to_s": 7200,
          "equals": true
        }
      ]
    },
    {
      "name": "approach_just_above_the_band",
      "description": "Boundary from above: 40.01 \u2212 30.0 clears the strict comparison by 10 mK and alarms on the normal schedule.",
      "inputs": {
        "cond_sat_temp": 40.01,
        "cwst": 30.0,
        "chiller_load": 60.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3540,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 3600,
          "to_s": 7200,
          "equals": true
        },
        {
          "output": "yLoadOk",
          "from_s": 0,
          "to_s": 7200,
          "equals": true
        }
      ]
    },
    {
      "name": "approach_just_below_the_band",
      "description": "Boundary from below: 39.99 \u2212 30.0 is 10 mK inside the band and never alarms, however marginal the machine's condenser is.",
      "inputs": {
        "cond_sat_temp": 39.99,
        "cwst": 30.0,
        "chiller_load": 60.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        },
        {
          "output": "yLoadOk",
          "from_s": 0,
          "to_s": 7200,
          "equals": true
        }
      ]
    },
    {
      "name": "load_exactly_at_the_evaluability_floor",
      "description": "Boundary on the other conjunct: load is exactly min_load_for_eval (40%) with a 12 K approach. Reals.GreaterThreshold is strict, so exactly-at-the-floor is NO_EVAL.",
      "inputs": {
        "cond_sat_temp": 42.0,
        "cwst": 30.0,
        "chiller_load": 40.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        },
        {
          "output": "yLoadOk",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        }
      ]
    },
    {
      "name": "load_just_below_the_evaluability_floor",
      "description": "39.9% load with the same widened approach: still NO_EVAL.",
      "inputs": {
        "cond_sat_temp": 42.0,
        "cwst": 30.0,
        "chiller_load": 39.9
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        },
        {
          "output": "yLoadOk",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        }
      ]
    },
    {
      "name": "load_just_above_the_evaluability_floor",
      "description": "40.1% load with the same widened approach: evaluable, and the alarm lands at exactly alarm_delay. One tenth of a percent of chiller load is the whole difference between this scenario and the previous one.",
      "inputs": {
        "cond_sat_temp": 42.0,
        "cwst": 30.0,
        "chiller_load": 40.1
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3540,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 3600,
          "to_s": 7200,
          "equals": true
        },
        {
          "output": "yLoadOk",
          "from_s": 0,
          "to_s": 7200,
          "equals": true
        }
      ]
    },
    {
      "name": "approach_widens_mid_run",
      "description": "A machine holding a 6 K approach loses it at t = 1800 s \u2014 condenser water flow falling away, or non-condensables released into the shell. The alarm lands at exactly 5400 s: the mid-run rising edge carries the same T + delayTime arithmetic as the init case.",
      "inputs": {
        "cond_sat_temp": [
          {
            "t": 0,
            "value": 36.0
          },
          {
            "t": 1800,
            "value": 42.0
          }
        ],
        "cwst": 30.0,
        "chiller_load": 60.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5340,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 5400,
          "to_s": 7200,
          "equals": true
        },
        {
          "output": "yLoadOk",
          "from_s": 0,
          "to_s": 7200,
          "equals": true
        }
      ]
    },
    {
      "name": "approach_recovers_after_alarm",
      "description": "Recovery: the alarm asserts at 3600 s and the condenser is cleaned \u2014 or the purge unit runs \u2014 at t = 5400 s. TrueDelay passes the falling edge with no delay, so yFault drops on that tick.",
      "inputs": {
        "cond_sat_temp": [
          {
            "t": 0,
            "value": 42.0
          },
          {
            "t": 5400,
            "value": 36.0
          }
        ],
        "cwst": 30.0,
        "chiller_load": 60.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3540,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 3600,
          "to_s": 5340,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 5400,
          "to_s": 7200,
          "equals": false
        },
        {
          "output": "yLoadOk",
          "from_s": 0,
          "to_s": 7200,
          "equals": true
        }
      ]
    },
    {
      "name": "load_drops_after_alarm",
      "description": "The evaluability release: an alarming machine unloads to 20% at t = 5400 s with the widened approach unchanged. yFault and yLoadOk fall on the same tick, and only the pair tells the host that the chiller unloaded rather than that the condenser recovered.",
      "inputs": {
        "cond_sat_temp": 42.0,
        "cwst": 30.0,
        "chiller_load": [
          {
            "t": 0,
            "value": 60.0
          },
          {
            "t": 5400,
            "value": 20.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3540,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 3600,
          "to_s": 5340,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 5400,
          "to_s": 7200,
          "equals": false
        },
        {
          "output": "yLoadOk",
          "from_s": 0,
          "to_s": 5340,
          "equals": true
        },
        {
          "output": "yLoadOk",
          "from_s": 5400,
          "to_s": 7200,
          "equals": false
        }
      ]
    },
    {
      "name": "intermittent_high_approach_never_alarms",
      "description": "Approach alternating between 12 K and 6 K every 1200 s \u2014 a condenser water valve hunting, or a machine cycling between capacity steps. No single episode reaches alarm_delay, so nothing fires. Persistence is not averaging: a machine spending half its day at 12 K is a real finding this rule cannot make.",
      "inputs": {
        "cond_sat_temp": [
          {
            "t": 0,
            "value": 42.0
          },
          {
            "t": 1200,
            "value": 36.0
          },
          {
            "t": 2400,
            "value": 42.0
          },
          {
            "t": 3600,
            "value": 36.0
          },
          {
            "t": 4800,
            "value": 42.0
          },
          {
            "t": 6000,
            "value": 36.0
          }
        ],
        "cwst": 30.0,
        "chiller_load": 60.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        },
        {
          "output": "yLoadOk",
          "from_s": 0,
          "to_s": 7200,
          "equals": true
        }
      ]
    },
    {
      "name": "entering_condenser_water_bound_by_mistake",
      "description": "Blind spot, pinned: cwst is bound to the cold tower-leaving water entering the condenser instead of the warm water leaving it. The measured difference then carries the condenser range as well as the approach \u2014 38.0 \u2212 26.5 = 11.5 K on a machine whose real approach is 8 K \u2014 and the rule alarms permanently on a healthy condenser. This is the loop-side error the point dictionary's cwst note warns about.",
      "inputs": {
        "cond_sat_temp": 38.0,
        "cwst": 26.5,
        "chiller_load": 60.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3540,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 3600,
          "to_s": 7200,
          "equals": true
        },
        {
          "output": "yLoadOk",
          "from_s": 0,
          "to_s": 7200,
          "equals": true
        }
      ]
    },
    {
      "name": "saturation_lookup_biased_low_hides_the_fault",
      "description": "The opposite blind spot, pinned: a P-T lookup run against the wrong refrigerant biases cond_sat_temp several kelvin low, so a machine at a true 13 K approach reports 9 K and reads healthy for as long as the mis-binding lasts. Nothing in the graph can see the derivation, so the failure is silent in the false-negative direction.",
      "inputs": {
        "cond_sat_temp": 39.0,
        "cwst": 30.0,
        "chiller_load": 60.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        },
        {
          "output": "yLoadOk",
          "from_s": 0,
          "to_s": 7200,
          "equals": true
        }
      ]
    }
  ]
}

CHW-0006 — Chiller evaporator approach high

Statusverified — engine e2ff2f8, cxf:fnv1a128:a48359dffe02c0f63f220d11df953eb4, 2026-08-18
Severity3
Methodrule
Phase2
CategoryEFFICIENCY_LOSS
ConfidenceMEDIUM
EstimationPROXY_ESTIMATION
G36
ClustersCLU-06
Suppresses
Suppressed by
RelatedCHW-0001, CHW-0005, CHW-0007
Playbookschiller-efficiency
SourceHVAC FDD Reference v1.0, Remediation Playbooks (pp. 161-163), carried in playbooks/chiller-efficiency.md: Step 1.3 compares BOTH approaches to design and states that an approach more than 2x design indicates fouling on that side — the rule of thumb behind approach_fault_multiple; Step 3.2 names evaporator tube fouling and chilled-water flow as the evaporator-side repairs; its ‘design typically 1-2 °F’ is the only evaporator-specific approach magnitude any source in this card’s grounding states; BEE/Devki Energy Consultancy, Best Practice Manual: HVAC Chillers (2006), §2.5.2 and §3.2 (Table 3.2) — each 1 °C gained on the EVAPORATING side buys roughly 2-4% lower specific power, stated in this card’s own direction; §3.10 — fouling and oil carryover as heat-transfer mechanisms, with a 4x-design fouling factor associated with roughly 20% energy loss. §3.8 gives approach bands for condensers ONLY and states no evaporator equivalent (see Deviations); DOE/PNNL, O&M Best Practices Guide Release 3.0 (2010) §9.4, and PNNL-13890 (2002) §7.4 — fouling described as requiring an enlarged temperature difference between water and refrigerant, the mechanism this rule detects, plus the compressor-type-split sensitivity (1.7%/°F centrifugal, 1.2%/°F reciprocating) stated for chilled-water supply temperature, which is this card’s own side of the machine; ASHRAE RP-1043 — PENDING primary source for a fault-grade evaporator approach threshold, named in faults/chw/README.md; Sibling precedent: CHW-0005 (graph shape, in-graph band assembly, yLoadOk evaluability output, commissioning-placeholder contract), CHW-0004 (load floor and alarm delay on the same chiller_load point); Library extension: the HVAC FDD Reference v1.0 ch.13 specifies CHW-FC-050 through 053 only (this library’s CHW-0001 through CHW-0004), and its remediation playbook cites CHW-FC-008/009 without ever defining them (see Deviations)
Operating statesChiller running and loaded above min_load_for_eval, settled after a start or a capacity step — one instance per machine, each carrying that machine’s commissioned clean approach. The rule’s yLoadOk covers the load half of that state; running and settled are the host’s to enforce.

Preconditions (host-enforced): chwst and evap_sat_temp must describe the same machine at the same moment, in °C, and chwst must be the COLD water LEAVING the evaporator on its way to the loop. Binding the return (chiller-entering) water instead adds the evaporator range — commonly 5-6 K — to every reading, which is most of a shipped 10 K band and alarms permanently on a clean barrel (pinned by chilled_water_return_bound_by_mistake); it is the evaporator-side twin of the mis-binding CHW-0005’s cwst note warns about. evap_sat_temp is host-derived: either the saturated suction temperature the chiller controller already publishes, which is the cheaper and safer source, or a P-T lookup the host runs against the machine’s own refrigerant. A lookup against the wrong refrigerant biases the approach by whole kelvin in either direction, and the graph cannot see the derivation. clean_approach must be this machine’s commissioned clean-evaporator approach measured at a load comparable to the evaluation window, not the shipped 5.0 K, and re-measured after any tube cleaning; until it is, the rule ships a deliberately silent placeholder rather than a threshold (see Deviations). The shipped band is looser here than on the condenser side: a real evaporator approach is a few kelvin, so an uncommissioned instance is silent by construction. Chilled water flow must be at or near design: an approach widened by low flow is a real finding but a different repair, and nothing in the rule separates the two. On a glycol or process machine the band still applies, but its commissioned baseline is that machine’s, not a water chiller’s. Evaluability is signalled in-rule by yLoadOk; when it is false the verdict is NO_EVAL, not a clean evaporator.

Points: chwst, evap_sat_temp, chiller_load

Outputs:

  • yFault — True while the evaporator approach has stayed above clean_approach × approach_fault_multiple with the chiller loaded above min_load_for_eval, continuously for at least alarm_delay
  • yLoadOk — Evaluability signal — true when chiller_load is above min_load_for_eval, the load below which approach says nothing about the evaporator because the heat flux driving it is small. False means NO_EVAL and the host must ignore yFault

Parameters:

NameDefaultUnitCXF pathDescription
clean_approach5.0°CcleanApproach.kThe machine’s commissioned clean-evaporator approach — leaving chilled water temperature minus saturated evaporating temperature with clean tubes, full charge, and design flow at a load comparable to the evaluation window. PER-MACHINE SITE CONFIGURATION, and the parameter this card exists to be retuned on: no source in this card’s grounding states an evaporator design band, so the shipped 5.0 is CHW-0005’s condenser number carried across for symmetry (see Deviations). A flooded barrel typically runs a few kelvin. Measure it, do not adopt it.
approach_fault_multiple2.01approachLimit.kMultiple of the clean approach at which the evaporator is called fouled. 2.0 is the reference’s remediation-playbook rule of thumb — an approach more than 2x design indicates fouling on that side — applied to the evaporator half of the same sentence CHW-0005 applies to the condenser half. Kept separate from clean_approach so a site can retune the tolerance and the commissioned baseline independently.
min_load_for_eval40.0%loadOk.tChiller load below which approach is not evaluated. Approach scales with the heat flux across the tubes, so a lightly loaded machine makes a small approach whether its evaporator is clean or fouled. ADOPTED from CHW-0004 and CHW-0005’s floor on the same chiller_load point; the sources supply no load gate of their own.
alarm_delay3600.0spersist.delayTimeContinuous high approach at load required before the alarm asserts (60 min). ADOPTED from CHW-0005; fouling and a lost charge are conditions of the machine, not events, and an hour rides out a capacity step or a chilled-water reset.

Description

An evaporator absorbs heat through a temperature difference. Refrigerant boils below the water leaving the barrel by an approach a clean machine holds nearly constant at a given load, and everything that gets between water and refrigerant widens it: scale and biofilm on the tubes, oil that has drained out of the compressor and coats them, a charge too low to wet the bundle, chilled water flow below design. The machine still makes its chilled water setpoint — it just boils colder to do it, and the compressor pays for the extra lift at roughly 2-4% of its power per degree. This rule watches the approach while the machine is loaded and alarms when it stays above that machine’s commissioned band. It is the evaporator half of the pair CHW-0005 opens on the condenser side.

Detection Logic

approach   = chwst − evap_sat_temp
band_limit = clean_approach × approach_fault_multiple   (5.0 × 2.0 = 10.0 K)

yLoadOk = chiller_load > min_load_for_eval        (false ⇒ host reports NO_EVAL)
yFault  = approach > band_limit AND yLoadOk,
          sustained continuously for alarm_delay

Block graph (rule.cxf.jsonld):

CHW-0006 block graph

The subtraction is water-minus-refrigerant, so a working chiller makes a positive approach and a widening one moves toward the trip. The test is single-sided: an approach smaller than commissioned is better heat transfer, not a fault.

cleanApproach and approachLimit assemble the band inside the graph rather than shipping a pre-multiplied 10.0 — the commissioned approach is measured on the machine, the multiple is a tolerance, and they are retuned separately.

approachHigh is strict and the boundary is bit-exact (5.0 doubled is exactly 10.0), so a machine sitting on the band reads healthy. loadOk is the whole NO_EVAL story: approach scales with heat flux, so at 20% load a fouled evaporator and a clean one both look fine, and yLoadOk is what lets a host tell that silence from a verdict. persist requires 60 continuous minutes and carries delayOnInit = true.

Possible Diagnoses

  1. Evaporator tube fouling — scale, biofilm, or silt on the water side; the playbook’s evaporator-side branch, and the one a tube cleaning fixes
  2. Low refrigerant charge — undercharge starves the barrel, leaving tube surface dry and pulling suction pressure down, which reads here as a widened approach. The playbook’s own “most common root cause” behind an expensive chiller, and BEE 2006’s case study 1 is the same fault at plant scale
  3. Chilled water flow below design — a throttled or failing pump, a fouled strainer, a mis-positioned balancing valve. Same symptom, a different repair, and the sibling reading of CHW-0004’s low delta-T on the same loop
  4. Excess oil in the evaporator, drained out of the compressor and filming the tubes; no point observes it (BEE 2006 §3.10)
  5. Water treatment lapsed — the cause behind cause 1, and the one that decides how soon the tubes foul again after cleaning
  6. Neither: a wrong-refrigerant P-T lookup, chwst bound to the return water, or a drifting sensor. Rule this out first, because it costs nothing and it is common

Energy Impact

EFFICIENCY_LOSS, MEDIUM confidence, PROXY_ESTIMATION. The estimator is waste_kw ≈ chiller_kw × lift_sensitivity × (approach − clean_approach): the share of compressor power spent boiling colder than the machine should have to. A machine 5 K past its commissioned approach spends roughly 11-16% of its compressor power on it, bracketing BEE 2006 §3.10’s report of about 20% at four times the design fouling factor. Both source families state the sensitivity for this side of the machine, so the ratio is firmer here than on the condenser side; MEDIUM stands because the excess it multiplies is only as good as the commissioned baseline. Cooling-dominant, worst on design days.

Emissions Impact

Scope 2, PROXY_EMISSIONS, MEDIUM confidence; the same order as CHW-0001’s 1,000-10,000 kg CO₂e/yr, since both findings are compressor electricity on one machine, and toward the lower end of it because this rule accuses one heat exchanger rather than the whole machine. Marginal operating emissions rate basis. The timing works against the building: the approach costs most at high load, which is when the dirtiest generator on the grid is dispatched.

Deviations

  • This card resolves half of a dangling reference. The HVAC FDD Reference v1.0’s chiller-efficiency playbook (pp. 161-163) has the technician compare both approaches to design and cites rule IDs CHW-FC-008/009 for them, but its ch.13 rule set defines CHW-FC-050 through 053 only — those two IDs are named and never specified anywhere in the reference. This library resolves the pair as CHW-0005 (condenser) plus this card (evaporator), in its own {EQUIP}-{NNNN} namespace rather than adopting the dangling legacy codes.
  • The band is a commissioning placeholder, not a threshold — and more so than on the condenser side. BEE 2006 §3.8 gives approach bands for condensers by heat-exchanger type (plate 1-5 °C, shell-and-tube 5-10 °C) and states no evaporator equivalent anywhere; symmetry is engineering judgment, not something that text supports. ASHRAE RP-1043 is the pending primary source. Until a site commissions clean_approach, this rule runs against a placeholder in the same sense HP-0001’s shipped regression coefficients do.
  • The shipped 5.0 K is CHW-0005’s condenser number carried across, and it is deliberately silent. The one evaporator-specific magnitude in the grounding is the reference playbook’s “design typically 1-2 °F”, which would put the trip near 2 K and alarm on healthy barrels that run 2-3 K. Rather than ship a line that fires on clean machines, this card ships the sibling’s baseline: a 10 K evaporator approach is past where a water chiller’s own freeze protection would have tripped it, so an uncommissioned instance says nothing at all.
  • approach_fault_multiple = 2.0 is the one fault-side number any source supplies, and it is literally the same sentence CHW-0005 cites — the playbook states it for whichever side the approach was measured on. Shipping the same value on both cards is what makes the pair readable as a pair, not a second derivation.
  • The playbook’s sign order is corrected. It writes the evaporator approach as “refrigerant evaporating temp − chilled water leaving temp”, which is negative on every working chiller; this card computes chwst − evap_sat_temp. Same correction CHW-0005 made to the condenser half of the same paragraph, which is written backwards in the same way.
  • Single-sided, and the low side is not a fault. A smaller-than-commissioned approach is better heat transfer — a cleaned barrel, a machine below the load its baseline was taken at, a conservative clean_approach. Pinned by low_approach_is_never_a_fault. The corollary is a real blind spot: an approach reported negative is thermodynamically impossible and can only be a bad derivation, and this rule reads it as healthy (negative_approach_reads_healthy). A host wanting that check reads the approach itself, not yFault.
  • The energy model uses its sensitivity in the native direction. BEE §2.5.2 states the 2-4%/°C rule for the evaporating side and the DOE/PNNL split (1.7%/°F centrifugal, 1.2%/°F reciprocating) is stated for chilled-water supply temperature, so this card borrows nothing across the machine the way CHW-0005 had to — a firmer ratio at the same MEDIUM confidence.
  • min_load_for_eval is entirely adopted; no source gates this test. 40% is CHW-0004’s and CHW-0005’s floor on the same chiller_load point, so the three chiller rules read the load axis identically. Approach shrinks with heat flux, so a low-load machine hides a fouled evaporator rather than faking one, and the failure the gate prevents is a host reading silence as health.
  • A fixed band across the whole load range is a simplification. Evaporator approach rises with load on a clean machine too, so one line evaluated anywhere above 40% is loose at 90% load and tight at 45%. A load-normalized band is the RP-1043-shaped successor; nothing in the sources says how approach should scale, so it is not expressible as a placeholder.
  • Strict > at the band and at the load floor. CDL Reals has no GreaterEqual; a machine exactly on the band reads healthy, one at exactly 40% load reads NO_EVAL. Both disagreements are measure-zero and err toward silence.
  • yLoadOk is an evaluability output, not an echo of an input — a boundary input compared against a parameter, which is what SCHEMA.md asks. Same stance as CHW-0004’s and CHW-0005’s yLoadOk.
  • Nothing guards the mis-binding in either direction. Bound to the chilled water return, the rule adds the evaporator range to every reading and alarms forever; fed a saturation temperature from the wrong refrigerant table, it goes quiet on a starved barrel. Both are pinned as vectors and left to preconditions, because the graph cannot see a derivation it does not perform. Commissioning check: read the approach once on a machine known clean and confirm it lands where the manufacturer’s data says it should.
  • related: [CHW-0001, CHW-0005]. CHW-0001 is the symptom this rule explains (kW/ton up, one heat exchanger named) and CHW-0005 is the other half of the pair; no suppression either way, because both approach rules can be true at once and each names a different repair.
  • Persistence stands in for averaging. The rule consumes instantaneous points, so an approach oscillating either side of the band never accumulates the hour. Fouling and a lost charge are steady and read the same either way; a hunting expansion device does not, and is a finding this rule cannot make.
  • persist.delayOnInit = true (CDL default false), the library’s standing choice: a machine already above its band at controller restart waits out the full hour rather than alarming on the first tick.
  • clusters: [CLU-06], and CLU-10 is deliberately not mirrored from CHW-0005. CLU-06 (chilled water plant inefficiency, trigger CHW-0001) fits: a fouled evaporator drives kW/ton up exactly as that cluster describes. CLU-10 is named Condenser-Side Degradation and its trigger is TOWER-0001 — an evaporator finding shares neither its causes nor its investigation path, so claiming membership would be a false lead. Membership is the cluster owner’s edit either way.
  • playbooks: [chiller-efficiency] and its Applies-To row names this card only as one of “the reference’s CHW-FC-008/009, not yet authored”. Step 3.2 is already the correct remediation branch for this finding; updating the row to the authored IDs belongs to the index owner — the sequencing CHW-0004, CHW-0005, and HW-0004 each recorded.
  • No published test vectors exist. No source specifies cases for this test, so every scenario in vectors.json is authored from the equation and replayed against the pinned engine rev.
  • Operating states and preconditions are declared in frontmatter for host enforcement, not encoded in the block graph. Severity 3, method: rule, and MEDIUM confidence match CHW-0005 and the CHW chapter’s other efficiency-loss cards.

Notes

Read yLoadOk before yFault, and read this card next to CHW-0005 and CHW-0001. When kW/ton is high, the two approach rules are the split — condenser, evaporator, or neither — and “neither” is the playbook’s cue to check the charge and the compressor. Even then the split is not clean: an undercharged machine widens the evaporator approach too. To commission clean_approach, trend approach against load for a week on a machine known clean, take the baseline at the load the alarm will be evaluated at, and re-measure after every tube cleaning.

Test Vectors

17 scenarios, clock step 60 s over 7200 s.

ScenarioDescription
clean_evaporator_at_loadNO_FAULT: 7 °C leaving chilled water against 5 °C saturated suction is a 2 K approach — a healthy flooded barrel — at 60% load. The approach conjunct blocks alone, and the machine is evaluable and silent.
fouled_evaporator_at_loadFAULT: the same 7 °C leaving water against −5 °C saturated suction is a 12 K approach at 60% load — the machine is pulling its suction 12 K below the water it makes. The alarm lands at exactly alarm_delay because delayOnInit holds the condition from the first tick.
high_approach_at_low_loadNO_EVAL: the same 12 K approach at 20% load. The load conjunct blocks alone, and yLoadOk = false is what tells the host that the silence is not a healthy verdict — at part load a clean barrel and a fouled one both make small approaches.
approach_exactly_at_the_bandBoundary, bit-exact: 7.0 − (−3.0) is exactly 10.0 and 5.0 × 2.0 is the same double, so the approach sits exactly on the band. Reals.Greater is strict, so exactly-at-the-band reads healthy.
approach_just_above_the_bandBoundary from above: −3.01 °C saturated suction clears the strict comparison by 10 mK and alarms on the normal schedule.
approach_just_below_the_bandBoundary from below: −2.99 °C saturated suction is 10 mK inside the band and never alarms, however marginal the machine’s evaporator is.
load_exactly_at_the_evaluability_floorBoundary on the other conjunct: load is exactly min_load_for_eval (40%) with a 12 K approach. Reals.GreaterThreshold is strict, so exactly-at-the-floor is NO_EVAL.
load_just_below_the_evaluability_floor39.9% load with the same widened approach: still NO_EVAL.
load_just_above_the_evaluability_floor40.1% load with the same widened approach: evaluable, and the alarm lands at exactly alarm_delay. One tenth of a percent of chiller load is the whole difference between this scenario and the previous one.
low_approach_is_never_a_faultSingle-sidedness pinned: a 0.5 K approach — better heat transfer than the commissioned barrel, or a lightly fouled machine after a tube cleaning — is silent at full evaluability. The rule has no low-side comparison, by design.
negative_approach_reads_healthyBlind spot, pinned: saturated suction reported ABOVE the leaving chilled water is thermodynamically impossible in steady state and can only be a bad P-T lookup or a mis-calibrated sensor. The rule is single-sided, so it reads healthy and says nothing. A host wanting that check reads the approach itself.
approach_widens_mid_runA machine holding a 2 K approach loses it at t = 1800 s — chilled water flow falling away, or refrigerant charge migrating out of the barrel. The alarm lands at exactly 5400 s: the mid-run rising edge carries the same T + delayTime arithmetic as the init case.
approach_recovers_after_alarmRecovery: the alarm asserts at 3600 s and the evaporator is cleaned — or the charge is restored, or the pump comes back — at t = 5400 s. TrueDelay passes the falling edge with no delay, so yFault drops on that tick.
load_drops_after_alarmThe evaluability release: an alarming machine unloads to 20% at t = 5400 s with the widened approach unchanged. yFault and yLoadOk fall on the same tick, and only the pair tells the host that the chiller unloaded rather than that the evaporator recovered.
intermittent_high_approach_never_alarmsApproach alternating between 12 K and 2 K every 1200 s — a hunting expansion device, or a machine cycling between capacity steps. No single episode reaches alarm_delay, so nothing fires. Persistence is not averaging: a machine spending half its day at 12 K is a real finding this rule cannot make.
chilled_water_return_bound_by_mistakeBlind spot, pinned: chwst is bound to the return (chiller-entering, warm) water instead of the leaving water. The measured difference then carries the evaporator range as well as the approach — 12.0 − 1.0 = 11.0 K on a machine whose real approach is 6 K — and the rule alarms permanently on an evaporator inside its band. The mirror of CHW-0005’s condenser-side mis-binding, and the same repair: check which side of the barrel the point is bound to.
saturation_lookup_biased_high_hides_the_faultThe opposite blind spot, pinned: a P-T lookup run against the wrong refrigerant biases evap_sat_temp several kelvin high, so a machine at a true 12 K approach reports 7 K and reads healthy for as long as the mis-binding lasts. Nothing in the graph can see the derivation, so the failure is silent in the false-negative direction.
vectors.json
{
  "schema": "cxf-library/vectors/v1",
  "clock": {
    "step_s": 60,
    "horizon_s": 7200
  },
  "scenarios": [
    {
      "name": "clean_evaporator_at_load",
      "description": "NO_FAULT: 7 \u00b0C leaving chilled water against 5 \u00b0C saturated suction is a 2 K approach \u2014 a healthy flooded barrel \u2014 at 60% load. The approach conjunct blocks alone, and the machine is evaluable and silent.",
      "inputs": {
        "chwst": 7.0,
        "evap_sat_temp": 5.0,
        "chiller_load": 60.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        },
        {
          "output": "yLoadOk",
          "from_s": 0,
          "to_s": 7200,
          "equals": true
        }
      ]
    },
    {
      "name": "fouled_evaporator_at_load",
      "description": "FAULT: the same 7 \u00b0C leaving water against \u22125 \u00b0C saturated suction is a 12 K approach at 60% load \u2014 the machine is pulling its suction 12 K below the water it makes. The alarm lands at exactly alarm_delay because delayOnInit holds the condition from the first tick.",
      "inputs": {
        "chwst": 7.0,
        "evap_sat_temp": -5.0,
        "chiller_load": 60.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3540,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 3600,
          "to_s": 7200,
          "equals": true
        },
        {
          "output": "yLoadOk",
          "from_s": 0,
          "to_s": 7200,
          "equals": true
        }
      ]
    },
    {
      "name": "high_approach_at_low_load",
      "description": "NO_EVAL: the same 12 K approach at 20% load. The load conjunct blocks alone, and yLoadOk = false is what tells the host that the silence is not a healthy verdict \u2014 at part load a clean barrel and a fouled one both make small approaches.",
      "inputs": {
        "chwst": 7.0,
        "evap_sat_temp": -5.0,
        "chiller_load": 20.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        },
        {
          "output": "yLoadOk",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        }
      ]
    },
    {
      "name": "approach_exactly_at_the_band",
      "description": "Boundary, bit-exact: 7.0 \u2212 (\u22123.0) is exactly 10.0 and 5.0 \u00d7 2.0 is the same double, so the approach sits exactly on the band. Reals.Greater is strict, so exactly-at-the-band reads healthy.",
      "inputs": {
        "chwst": 7.0,
        "evap_sat_temp": -3.0,
        "chiller_load": 60.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        },
        {
          "output": "yLoadOk",
          "from_s": 0,
          "to_s": 7200,
          "equals": true
        }
      ]
    },
    {
      "name": "approach_just_above_the_band",
      "description": "Boundary from above: \u22123.01 \u00b0C saturated suction clears the strict comparison by 10 mK and alarms on the normal schedule.",
      "inputs": {
        "chwst": 7.0,
        "evap_sat_temp": -3.01,
        "chiller_load": 60.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3540,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 3600,
          "to_s": 7200,
          "equals": true
        },
        {
          "output": "yLoadOk",
          "from_s": 0,
          "to_s": 7200,
          "equals": true
        }
      ]
    },
    {
      "name": "approach_just_below_the_band",
      "description": "Boundary from below: \u22122.99 \u00b0C saturated suction is 10 mK inside the band and never alarms, however marginal the machine's evaporator is.",
      "inputs": {
        "chwst": 7.0,
        "evap_sat_temp": -2.99,
        "chiller_load": 60.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        },
        {
          "output": "yLoadOk",
          "from_s": 0,
          "to_s": 7200,
          "equals": true
        }
      ]
    },
    {
      "name": "load_exactly_at_the_evaluability_floor",
      "description": "Boundary on the other conjunct: load is exactly min_load_for_eval (40%) with a 12 K approach. Reals.GreaterThreshold is strict, so exactly-at-the-floor is NO_EVAL.",
      "inputs": {
        "chwst": 7.0,
        "evap_sat_temp": -5.0,
        "chiller_load": 40.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        },
        {
          "output": "yLoadOk",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        }
      ]
    },
    {
      "name": "load_just_below_the_evaluability_floor",
      "description": "39.9% load with the same widened approach: still NO_EVAL.",
      "inputs": {
        "chwst": 7.0,
        "evap_sat_temp": -5.0,
        "chiller_load": 39.9
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        },
        {
          "output": "yLoadOk",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        }
      ]
    },
    {
      "name": "load_just_above_the_evaluability_floor",
      "description": "40.1% load with the same widened approach: evaluable, and the alarm lands at exactly alarm_delay. One tenth of a percent of chiller load is the whole difference between this scenario and the previous one.",
      "inputs": {
        "chwst": 7.0,
        "evap_sat_temp": -5.0,
        "chiller_load": 40.1
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3540,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 3600,
          "to_s": 7200,
          "equals": true
        },
        {
          "output": "yLoadOk",
          "from_s": 0,
          "to_s": 7200,
          "equals": true
        }
      ]
    },
    {
      "name": "low_approach_is_never_a_fault",
      "description": "Single-sidedness pinned: a 0.5 K approach \u2014 better heat transfer than the commissioned barrel, or a lightly fouled machine after a tube cleaning \u2014 is silent at full evaluability. The rule has no low-side comparison, by design.",
      "inputs": {
        "chwst": 7.0,
        "evap_sat_temp": 6.5,
        "chiller_load": 60.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        },
        {
          "output": "yLoadOk",
          "from_s": 0,
          "to_s": 7200,
          "equals": true
        }
      ]
    },
    {
      "name": "negative_approach_reads_healthy",
      "description": "Blind spot, pinned: saturated suction reported ABOVE the leaving chilled water is thermodynamically impossible in steady state and can only be a bad P-T lookup or a mis-calibrated sensor. The rule is single-sided, so it reads healthy and says nothing. A host wanting that check reads the approach itself.",
      "inputs": {
        "chwst": 7.0,
        "evap_sat_temp": 8.0,
        "chiller_load": 60.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        },
        {
          "output": "yLoadOk",
          "from_s": 0,
          "to_s": 7200,
          "equals": true
        }
      ]
    },
    {
      "name": "approach_widens_mid_run",
      "description": "A machine holding a 2 K approach loses it at t = 1800 s \u2014 chilled water flow falling away, or refrigerant charge migrating out of the barrel. The alarm lands at exactly 5400 s: the mid-run rising edge carries the same T + delayTime arithmetic as the init case.",
      "inputs": {
        "chwst": 7.0,
        "evap_sat_temp": [
          {
            "t": 0,
            "value": 5.0
          },
          {
            "t": 1800,
            "value": -5.0
          }
        ],
        "chiller_load": 60.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5340,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 5400,
          "to_s": 7200,
          "equals": true
        },
        {
          "output": "yLoadOk",
          "from_s": 0,
          "to_s": 7200,
          "equals": true
        }
      ]
    },
    {
      "name": "approach_recovers_after_alarm",
      "description": "Recovery: the alarm asserts at 3600 s and the evaporator is cleaned \u2014 or the charge is restored, or the pump comes back \u2014 at t = 5400 s. TrueDelay passes the falling edge with no delay, so yFault drops on that tick.",
      "inputs": {
        "chwst": 7.0,
        "evap_sat_temp": [
          {
            "t": 0,
            "value": -5.0
          },
          {
            "t": 5400,
            "value": 5.0
          }
        ],
        "chiller_load": 60.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3540,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 3600,
          "to_s": 5340,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 5400,
          "to_s": 7200,
          "equals": false
        },
        {
          "output": "yLoadOk",
          "from_s": 0,
          "to_s": 7200,
          "equals": true
        }
      ]
    },
    {
      "name": "load_drops_after_alarm",
      "description": "The evaluability release: an alarming machine unloads to 20% at t = 5400 s with the widened approach unchanged. yFault and yLoadOk fall on the same tick, and only the pair tells the host that the chiller unloaded rather than that the evaporator recovered.",
      "inputs": {
        "chwst": 7.0,
        "evap_sat_temp": -5.0,
        "chiller_load": [
          {
            "t": 0,
            "value": 60.0
          },
          {
            "t": 5400,
            "value": 20.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3540,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 3600,
          "to_s": 5340,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 5400,
          "to_s": 7200,
          "equals": false
        },
        {
          "output": "yLoadOk",
          "from_s": 0,
          "to_s": 5340,
          "equals": true
        },
        {
          "output": "yLoadOk",
          "from_s": 5400,
          "to_s": 7200,
          "equals": false
        }
      ]
    },
    {
      "name": "intermittent_high_approach_never_alarms",
      "description": "Approach alternating between 12 K and 2 K every 1200 s \u2014 a hunting expansion device, or a machine cycling between capacity steps. No single episode reaches alarm_delay, so nothing fires. Persistence is not averaging: a machine spending half its day at 12 K is a real finding this rule cannot make.",
      "inputs": {
        "chwst": 7.0,
        "evap_sat_temp": [
          {
            "t": 0,
            "value": -5.0
          },
          {
            "t": 1200,
            "value": 5.0
          },
          {
            "t": 2400,
            "value": -5.0
          },
          {
            "t": 3600,
            "value": 5.0
          },
          {
            "t": 4800,
            "value": -5.0
          },
          {
            "t": 6000,
            "value": 5.0
          }
        ],
        "chiller_load": 60.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        },
        {
          "output": "yLoadOk",
          "from_s": 0,
          "to_s": 7200,
          "equals": true
        }
      ]
    },
    {
      "name": "chilled_water_return_bound_by_mistake",
      "description": "Blind spot, pinned: chwst is bound to the return (chiller-entering, warm) water instead of the leaving water. The measured difference then carries the evaporator range as well as the approach \u2014 12.0 \u2212 1.0 = 11.0 K on a machine whose real approach is 6 K \u2014 and the rule alarms permanently on an evaporator inside its band. The mirror of CHW-0005's condenser-side mis-binding, and the same repair: check which side of the barrel the point is bound to.",
      "inputs": {
        "chwst": 12.0,
        "evap_sat_temp": 1.0,
        "chiller_load": 60.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3540,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 3600,
          "to_s": 7200,
          "equals": true
        },
        {
          "output": "yLoadOk",
          "from_s": 0,
          "to_s": 7200,
          "equals": true
        }
      ]
    },
    {
      "name": "saturation_lookup_biased_high_hides_the_fault",
      "description": "The opposite blind spot, pinned: a P-T lookup run against the wrong refrigerant biases evap_sat_temp several kelvin high, so a machine at a true 12 K approach reports 7 K and reads healthy for as long as the mis-binding lasts. Nothing in the graph can see the derivation, so the failure is silent in the false-negative direction.",
      "inputs": {
        "chwst": 7.0,
        "evap_sat_temp": 0.0,
        "chiller_load": 60.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        },
        {
          "output": "yLoadOk",
          "from_s": 0,
          "to_s": 7200,
          "equals": true
        }
      ]
    }
  ]
}

CHW-0007 — Chilled-water supply temperature tracking failure

Statusverified — engine e2ff2f8, cxf:fnv1a128:c2159704c8d4e994bfed070006ff5d4e, 2026-08-20
Severity3
Methodrule
Phase2
CategoryCOMFORT_ENERGY
ConfidenceMEDIUM
EstimationQUALITATIVE_ONLY
G36
Clusters
Suppresses
Suppressed by
RelatedCHW-0001, CHW-0002, CHW-0005, CHW-0006, CHW-0008, CHW-0009
Playbookschiller-efficiency
SourcePNNL-29078, Building Re-Tuning Training Guide: Central Utility Plant Heating and Cooling Control Guide, PDF pp.91 and 96 (§10) — the plant maintains a chilled-water supply target and should trend supply temperature during operation; EPA Facilities Manual, Volume 2, ch.9 Table 9-2 — chiller BAS monitoring includes leaving-water setpoint, start/stop, failure, and chilled-water temperature above setpoint; Library-authored executable adaptation of AHU-0033’s strict mirrored tracking-error topology; no source publishes the shipped 1 K / 20% / 900 s combination as portable
Operating statesnormal automatic chiller operation after startup, with the individual machine proven running and loaded above minimum_load

Preconditions (host-enforced): chwst and chwst_sp must describe the same controllable leaving-water target. Prefer this chiller’s evaporator outlet temperature and the final active setpoint used by its controller. A common-header measurement/setpoint is acceptable only when the deployment proves the staged machine(s) truly control that same mixed-header target; it is not interchangeable with an individual barrel outlet by assumption. chiller_status and chiller_load must belong to the same machine, not a fleet OR/max. Flow and minimum-flow permissives must be established. Exclude startup pull-down, setpoint/reset ramps, staging transfers, pump/valve transients, ice-making, and manufacturer current/lift/surge/freeze/demand limits. Temperature points must be healthy, fresh, aligned, and in degC. yLoadOk is numerical evaluability only: false means NO_EVAL; true does not prove any of the host obligations. A stopped chiller is also NO_EVAL even though yLoadOk can remain true.

Points: chwst, chwst_sp, chiller_status, chiller_load

Outputs:

  • yFault — True while a running, meaningfully loaded chiller has remained outside either side of the active CHWST band for sustained_duration
  • yTooWarm — Diagnostic direction flag — true when the evaluable leaving-water temperature is more than tracking_error above setpoint
  • yTooCold — Diagnostic direction flag — true when the evaluable leaving-water temperature is more than tracking_error below setpoint
  • yLoadOk — Evaluability flag — true only when chiller_load is strictly above minimum_load; false means NO_EVAL

Parameters:

NameDefaultUnitCXF pathDescription
tracking_error1.0Kwarm.t, cold.tSymmetric leaving-water tracking allowance. ADOPTED_TUNABLE: commission above combined sensor uncertainty and the settled control deadband; equality is clear.
minimum_load20.0%loadOk.tPer-machine load floor. ADOPTED_TUNABLE: excludes low-load cycling and unloading behavior; equality is not evaluable.
sustained_duration900.0spersist.delayTimeContinuous out-of-band duration. LIBRARY_PRECEDENT informed by hydronic/SAT tracking timescales; verify against this plant’s pull-down, reset, and staging dynamics.

Description

This rule reports a chiller or genuinely representative plant leaving-water temperature that cannot hold its active target under meaningful load. A warm direction can mean insufficient capacity, lost flow, fouling, refrigerant or sensor trouble, or a current/lift limit. A cold direction can mean aggressive staging, a misapplied setpoint, or a control loop driving below target. The direction narrows investigation; it does not identify the failed component.

Detection Logic

error          = chwst - chwst_sp
load_ok        = chiller_load > minimum_load
running_loaded = chiller_status AND load_ok
warm           = error > tracking_error
cold           = -error > tracking_error

yLoadOk  = load_ok
yTooWarm = running_loaded AND warm
yTooCold = running_loaded AND cold
yFault   = running_loaded AND (warm OR cold), sustained for sustained_duration

CHW-0007 block graph

Both directional comparisons are strict. The diagnostic flags are gated by machine status and load but are not persistence outputs. The single TrueDelay(delayOnInit=true) follows their OR, so a sampled direct handoff from too warm to too cold preserves timing: the machine never re-entered its band. Any in-band, stopped, or low-load tick resets the timer, and recovery clears immediately.

Possible Diagnoses

  1. Insufficient evaporator flow, closed isolation valve, or failed pump proof
  2. Fouled evaporator tubes or low refrigerant charge
  3. Compressor current, demand, surge, lift, or freeze-limit operation
  4. Incorrect staging or a machine too small for the present load
  5. Leaving-water sensor bias, stale value, or machine/header misbinding
  6. Active setpoint not delivered to the chiller controller
  7. Over-responsive local control, bad tuning, or unexcluded setpoint reset
  8. Intentional ice-making or other non-comfort operating mode not host-gated

Energy Impact

COMFORT_ENERGY and QUALITATIVE_ONLY. Overcooling normally increases compressor lift; water that is too warm can force more air/water flow and can miss space or humidity targets. This graph has no causal baseline or flow/power measurement, so it does not turn temperature error into claimed savings.

Emissions Impact

Scope 2, qualitative. Any avoided electrical energy is established only after the host quantifies the chiller, pump, and air-side response.

Deviations

  • All three defaults require commissioning. PNNL supports the control and low-load context, not a universal 1 K error, 20% load floor, or 900 s delay.
  • Confidence is MEDIUM rather than the brief’s proposed HIGH. The signature is direct, but operating limits, setpoint transitions, and machine/header topology can produce the same evidence until binding and host gates are commissioned.
  • Common-header binding is conditional. A mixed-header temperature can be a real plant target, but cannot validate an individual barrel by name alone.
  • Direction handoff preserves persistence. One timer after the OR measures continuous out-of-band operation; an actual in-band sample resets it.
  • No automatic suppression is added. CHW-0008 cannot suppress only its fail-to-start direction in current rule-wide metadata, and tracking during an unexpected run remains useful evidence. Hosts order diagnosis by direction.
  • CLU-06 is unchanged. This control-capability signature is related to efficiency and approach findings, but the existing cluster is not broadened into a mixed staging/proof cluster in this slice.

Test Vectors

20 scenarios, clock step 60 s over 3600 s.

ScenarioDescription
running_loaded_on_setpointA loaded running chiller exactly on its active leaving-water target is evaluable and clear.
too_warm_at_initializationInsufficient cooling is exposed immediately but delayOnInit serves the complete 900-second persistence window.
too_cold_at_initializationOvercooling follows the mirrored diagnostic lane and the same persistence window.
warm_exact_boundary_clearExactly +1.0 K is clear under the strict comparator.
cold_exact_boundary_clearExactly -1.0 K is clear under the mirrored strict comparator.
warm_just_beyond_boundaryOne test increment beyond the warm allowance asserts direction and then the fault.
cold_just_beyond_boundaryOne test increment beyond the cold allowance follows the independent cold lane.
load_below_floor_not_evaluableA large temperature error below the load floor produces neither direction nor fault.
load_exact_floor_not_evaluableExactly minimum_load is not evaluable because the load comparator is strict.
load_just_above_floor_evaluableOne increment above the floor opens the load gate while an on-target machine remains clear.
chiller_off_large_errorA stopped chiller has no tracking verdict even if a warm header and stale setpoint create a large raw error.
startup_transient_shorter_than_delayA loaded startup pull-down reaches the band before 900 seconds and never alarms.
violation_ends_one_step_before_persistenceA warm violation clearing one evaluator step before 900 seconds never matures.
mature_fault_clears_on_recoveryA matured tracking alarm clears immediately after leaving-water temperature recovers into band.
setpoint_step_then_recoveryA setpoint reset step creates a short raw error that recovers before persistence; hosts should also exclude unsettled ramps.
intermittent_error_resets_timerA compliant interval resets persistence; the second warm episode must serve a fresh 900 seconds.
load_gate_drop_resets_timerDropping below the evaluability floor clears direction and resets persistence even while temperature remains warm.
status_gate_drop_resets_timerA stopped interval resets timing; restart into the same error requires the full duration again.
direct_direction_handoff_keeps_timerA sampled warm-to-cold handoff changes diagnostic direction but the out-of-band OR never falls, so persistence continues.
threshold_chatter_never_persistsAlternating inside and outside the warm allowance exposes chatter but never accumulates 900 continuous seconds.
vectors.json
{
  "schema": "cxf-library/vectors/v1",
  "clock": {
    "step_s": 60,
    "horizon_s": 3600
  },
  "scenarios": [
    {
      "name": "running_loaded_on_setpoint",
      "description": "A loaded running chiller exactly on its active leaving-water target is evaluable and clear.",
      "inputs": {
        "chwst": 6.0,
        "chwst_sp": 6.0,
        "chiller_status": true,
        "chiller_load": 60.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        },
        {
          "output": "yTooWarm",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        },
        {
          "output": "yTooCold",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        },
        {
          "output": "yLoadOk",
          "from_s": 0,
          "to_s": 3600,
          "equals": true
        }
      ]
    },
    {
      "name": "too_warm_at_initialization",
      "description": "Insufficient cooling is exposed immediately but delayOnInit serves the complete 900-second persistence window.",
      "inputs": {
        "chwst": 7.1,
        "chwst_sp": 6.0,
        "chiller_status": true,
        "chiller_load": 60.0
      },
      "expect": [
        {
          "output": "yLoadOk",
          "from_s": 0,
          "to_s": 3600,
          "equals": true
        },
        {
          "output": "yTooWarm",
          "from_s": 0,
          "to_s": 3600,
          "equals": true
        },
        {
          "output": "yTooCold",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 840,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 960,
          "to_s": 3600,
          "equals": true
        }
      ]
    },
    {
      "name": "too_cold_at_initialization",
      "description": "Overcooling follows the mirrored diagnostic lane and the same persistence window.",
      "inputs": {
        "chwst": 4.9,
        "chwst_sp": 6.0,
        "chiller_status": true,
        "chiller_load": 60.0
      },
      "expect": [
        {
          "output": "yLoadOk",
          "from_s": 0,
          "to_s": 3600,
          "equals": true
        },
        {
          "output": "yTooWarm",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        },
        {
          "output": "yTooCold",
          "from_s": 0,
          "to_s": 3600,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 840,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 960,
          "to_s": 3600,
          "equals": true
        }
      ]
    },
    {
      "name": "warm_exact_boundary_clear",
      "description": "Exactly +1.0 K is clear under the strict comparator.",
      "inputs": {
        "chwst": 7.0,
        "chwst_sp": 6.0,
        "chiller_status": true,
        "chiller_load": 60.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        },
        {
          "output": "yTooWarm",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        },
        {
          "output": "yTooCold",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "cold_exact_boundary_clear",
      "description": "Exactly -1.0 K is clear under the mirrored strict comparator.",
      "inputs": {
        "chwst": 5.0,
        "chwst_sp": 6.0,
        "chiller_status": true,
        "chiller_load": 60.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        },
        {
          "output": "yTooWarm",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        },
        {
          "output": "yTooCold",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "warm_just_beyond_boundary",
      "description": "One test increment beyond the warm allowance asserts direction and then the fault.",
      "inputs": {
        "chwst": 7.01,
        "chwst_sp": 6.0,
        "chiller_status": true,
        "chiller_load": 60.0
      },
      "expect": [
        {
          "output": "yTooWarm",
          "from_s": 0,
          "to_s": 3600,
          "equals": true
        },
        {
          "output": "yTooCold",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 840,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 960,
          "to_s": 3600,
          "equals": true
        }
      ]
    },
    {
      "name": "cold_just_beyond_boundary",
      "description": "One test increment beyond the cold allowance follows the independent cold lane.",
      "inputs": {
        "chwst": 4.99,
        "chwst_sp": 6.0,
        "chiller_status": true,
        "chiller_load": 60.0
      },
      "expect": [
        {
          "output": "yTooWarm",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        },
        {
          "output": "yTooCold",
          "from_s": 0,
          "to_s": 3600,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 840,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 960,
          "to_s": 3600,
          "equals": true
        }
      ]
    },
    {
      "name": "load_below_floor_not_evaluable",
      "description": "A large temperature error below the load floor produces neither direction nor fault.",
      "inputs": {
        "chwst": 9.0,
        "chwst_sp": 6.0,
        "chiller_status": true,
        "chiller_load": 19.9
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        },
        {
          "output": "yTooWarm",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        },
        {
          "output": "yTooCold",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        },
        {
          "output": "yLoadOk",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "load_exact_floor_not_evaluable",
      "description": "Exactly minimum_load is not evaluable because the load comparator is strict.",
      "inputs": {
        "chwst": 9.0,
        "chwst_sp": 6.0,
        "chiller_status": true,
        "chiller_load": 20.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        },
        {
          "output": "yTooWarm",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        },
        {
          "output": "yTooCold",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        },
        {
          "output": "yLoadOk",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "load_just_above_floor_evaluable",
      "description": "One increment above the floor opens the load gate while an on-target machine remains clear.",
      "inputs": {
        "chwst": 6.0,
        "chwst_sp": 6.0,
        "chiller_status": true,
        "chiller_load": 20.1
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        },
        {
          "output": "yTooWarm",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        },
        {
          "output": "yTooCold",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        },
        {
          "output": "yLoadOk",
          "from_s": 0,
          "to_s": 3600,
          "equals": true
        }
      ]
    },
    {
      "name": "chiller_off_large_error",
      "description": "A stopped chiller has no tracking verdict even if a warm header and stale setpoint create a large raw error.",
      "inputs": {
        "chwst": 12.0,
        "chwst_sp": 6.0,
        "chiller_status": false,
        "chiller_load": 60.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        },
        {
          "output": "yTooWarm",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        },
        {
          "output": "yTooCold",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        },
        {
          "output": "yLoadOk",
          "from_s": 0,
          "to_s": 3600,
          "equals": true
        }
      ]
    },
    {
      "name": "startup_transient_shorter_than_delay",
      "description": "A loaded startup pull-down reaches the band before 900 seconds and never alarms.",
      "inputs": {
        "chwst": [
          {
            "t": 0,
            "value": 9.0
          },
          {
            "t": 600,
            "value": 6.0
          }
        ],
        "chwst_sp": 6.0,
        "chiller_status": true,
        "chiller_load": 60.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "violation_ends_one_step_before_persistence",
      "description": "A warm violation clearing one evaluator step before 900 seconds never matures.",
      "inputs": {
        "chwst": [
          {
            "t": 0,
            "value": 8.0
          },
          {
            "t": 840,
            "value": 6.0
          }
        ],
        "chwst_sp": 6.0,
        "chiller_status": true,
        "chiller_load": 60.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        },
        {
          "output": "yTooWarm",
          "from_s": 0,
          "to_s": 780,
          "equals": true
        },
        {
          "output": "yTooWarm",
          "from_s": 900,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "mature_fault_clears_on_recovery",
      "description": "A matured tracking alarm clears immediately after leaving-water temperature recovers into band.",
      "inputs": {
        "chwst": [
          {
            "t": 0,
            "value": 8.0
          },
          {
            "t": 1200,
            "value": 6.0
          }
        ],
        "chwst_sp": 6.0,
        "chiller_status": true,
        "chiller_load": 60.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 840,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 960,
          "to_s": 1140,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 1260,
          "to_s": 3600,
          "equals": false
        },
        {
          "output": "yTooWarm",
          "from_s": 0,
          "to_s": 1140,
          "equals": true
        },
        {
          "output": "yTooWarm",
          "from_s": 1260,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "setpoint_step_then_recovery",
      "description": "A setpoint reset step creates a short raw error that recovers before persistence; hosts should also exclude unsettled ramps.",
      "inputs": {
        "chwst": 6.0,
        "chwst_sp": [
          {
            "t": 0,
            "value": 6.0
          },
          {
            "t": 600,
            "value": 4.5
          },
          {
            "t": 1200,
            "value": 6.0
          }
        ],
        "chiller_status": true,
        "chiller_load": 60.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "intermittent_error_resets_timer",
      "description": "A compliant interval resets persistence; the second warm episode must serve a fresh 900 seconds.",
      "inputs": {
        "chwst": [
          {
            "t": 0,
            "value": 8.0
          },
          {
            "t": 600,
            "value": 6.0
          },
          {
            "t": 900,
            "value": 8.0
          }
        ],
        "chwst_sp": 6.0,
        "chiller_status": true,
        "chiller_load": 60.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1740,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 1860,
          "to_s": 3600,
          "equals": true
        }
      ]
    },
    {
      "name": "load_gate_drop_resets_timer",
      "description": "Dropping below the evaluability floor clears direction and resets persistence even while temperature remains warm.",
      "inputs": {
        "chwst": 8.0,
        "chwst_sp": 6.0,
        "chiller_status": true,
        "chiller_load": [
          {
            "t": 0,
            "value": 60.0
          },
          {
            "t": 600,
            "value": 10.0
          },
          {
            "t": 900,
            "value": 60.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1740,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 1860,
          "to_s": 3600,
          "equals": true
        }
      ]
    },
    {
      "name": "status_gate_drop_resets_timer",
      "description": "A stopped interval resets timing; restart into the same error requires the full duration again.",
      "inputs": {
        "chwst": 8.0,
        "chwst_sp": 6.0,
        "chiller_status": [
          {
            "t": 0,
            "value": true
          },
          {
            "t": 600,
            "value": false
          },
          {
            "t": 900,
            "value": true
          }
        ],
        "chiller_load": 60.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1740,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 1860,
          "to_s": 3600,
          "equals": true
        }
      ]
    },
    {
      "name": "direct_direction_handoff_keeps_timer",
      "description": "A sampled warm-to-cold handoff changes diagnostic direction but the out-of-band OR never falls, so persistence continues.",
      "inputs": {
        "chwst": [
          {
            "t": 0,
            "value": 8.0
          },
          {
            "t": 600,
            "value": 4.0
          }
        ],
        "chwst_sp": 6.0,
        "chiller_status": true,
        "chiller_load": 60.0
      },
      "expect": [
        {
          "output": "yTooWarm",
          "from_s": 0,
          "to_s": 540,
          "equals": true
        },
        {
          "output": "yTooCold",
          "from_s": 660,
          "to_s": 3600,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 840,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 960,
          "to_s": 3600,
          "equals": true
        }
      ]
    },
    {
      "name": "threshold_chatter_never_persists",
      "description": "Alternating inside and outside the warm allowance exposes chatter but never accumulates 900 continuous seconds.",
      "inputs": {
        "chwst": [
          {
            "t": 0,
            "value": 6.9
          },
          {
            "t": 120,
            "value": 7.1
          },
          {
            "t": 240,
            "value": 6.9
          },
          {
            "t": 360,
            "value": 7.1
          },
          {
            "t": 480,
            "value": 6.9
          },
          {
            "t": 600,
            "value": 7.1
          },
          {
            "t": 720,
            "value": 6.9
          }
        ],
        "chwst_sp": 6.0,
        "chiller_status": true,
        "chiller_load": 60.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    }
  ]
}

CHW-0008 — Chiller proof-of-operation failure

Statusverified — engine e2ff2f8, cxf:fnv1a128:aae864b287b6e0c975e77111d088cfd6, 2026-08-20
Severity2
Methodrule
Phase2
CategoryPROTECTIVE
ConfidenceHIGH
EstimationPROXY_ESTIMATION
G36
Clusters
Suppresses
Suppressed by
RelatedCHW-0007, CHW-0009
Playbookschiller-efficiency
SourceEPA Facilities Manual, Volume 2, ch.9 Table 9-2 — chiller BAS monitoring includes per-equipment start/stop and failure; Library proof-of-operation precedents PMP-0003 and HW-0009 — independent command/status mismatch lanes with separate initialization-safe timers; Library-authored chiller timing adaptation; no cited source publishes 300 s start proof and 120 s stop proof as portable limits
Operating statesall states in which the final individual-machine stage command and independent chiller run proof are authoritative

Preconditions (host-enforced): Bind chiller_cmd to the final per-machine request issued downstream of plant enable, lead/lag selection, normal anti-recycle logic, and applicable BAS interlocks; a plant enable or cooling demand cannot say which machine was requested. Bind chiller_status to independent proof that this chiller is actually producing cooling or operating a compressor, not availability, alarm-free, enable-ready, an echoed command, or a fleet OR. For modular/multi-circuit equipment, define the rule instance boundary consistently. Both points must be fresh and time-aligned, and each proof time must exceed worst-case delivery latency plus the normal sequence for that direction. Exclude approved anti-recycle lockout, pump/valve pre-run and post-run, oil-system preparation, shutdown unloading/coast-down, demand response, emergency or manufacturer safety actions, manual/local operation, maintenance, and functional tests. When ownership, authority, or mode is unknown the host reports NO_EVAL.

Points: chiller_cmd, chiller_status

Outputs:

  • yFault — True while either independent command/status mismatch has matured through its direction-specific proof timer
  • yFailToStart — True after a final per-chiller start command remains unproved for start_proof_time
  • yUnexpectedRun — True after proven operation continues without a final per-chiller command for stop_proof_time

Parameters:

NameDefaultUnitCXF pathDescription
start_proof_time300.0sstartProof.delayTimeAllowed start/permissive sequence before failure-to-start. ADOPTED_TUNABLE: set longer than normal oil, valve, pump/flow, starter, and delivery latency but shorter than operator response.
stop_proof_time120.0sstopProof.delayTimeAllowed unload/coast-down after final command removal. ADOPTED_TUNABLE and independent of the start window.

Description

This rule asks whether an individual chiller did what its final stage command requested. Commanded but unproved operation can indicate a failed permissive, locked-out machine, starter/drive fault, missing flow, or bad status. Proven operation after command removal can indicate manual/local control, welded hardware, a second controller, or a command bound upstream of the real owner.

Detection Logic

fail_to_start  = chiller_cmd AND NOT chiller_status
unexpected_run = NOT chiller_cmd AND chiller_status

yFailToStart   = fail_to_start sustained for start_proof_time
yUnexpectedRun = unexpected_run sustained for stop_proof_time
yFault         = yFailToStart OR yUnexpectedRun

CHW-0008 block graph

The two conditions are structurally mutually exclusive and each has its own TrueDelay(delayOnInit=true). A mismatch that reverses direction therefore clears the old flag and serves the new direction’s complete timer; time never accumulates across lanes.

Possible Diagnoses

yFailToStart:

  1. Chilled/condenser-water flow, valve, or pump permissive not established
  2. Active anti-recycle, oil-system, freeze, lift, current, or safety lockout
  3. Starter, VFD, compressor, control transformer, or disconnect failure
  4. Final stage command landed on the wrong machine
  5. Run-proof sensor, integration, or point freshness failure

yUnexpectedRun:

  1. Local/manual mode, service override, or second plant controller
  2. Welded contactor or command output stuck active
  3. Command bound upstream of chiller-internal logic
  4. Normal unload/coast-down longer than the configured stop window

Energy Impact

PROTECTIVE with a direction-dependent proxy. Failure to start threatens cooling, humidity control, and low-flow/freeze protection but is not excess chiller kW. Unexpected operation can be sized host-side from the same machine’s measured power and mismatch duration; the graph itself reads no power.

Emissions Impact

Scope 2, proxy-only for unexpected run. Do not assign avoided electricity to the fail-to-start direction.

Deviations

  • The timers are adopted, not source-transcribed. Chiller permissive and coast-down sequences vary materially; 300/120 s are commissioning starts.
  • No rule-wide suppression targets CHW-0007. Fail-to-start makes tracking non-evaluable, but unexpected run can still be loaded and meaningfully fail tracking. Current metadata cannot express one directional suppression safely.
  • The final command is downstream of ordinary anti-recycle behavior. If a BAS request is bound upstream, correct machine protection looks like failure.
  • delayOnInit=true on both lanes prevents immediate alarms when commands are re-driven and statuses repopulate after a runtime restart.
  • One card severity/category cannot describe both directions perfectly. Severity 2 PROTECTIVE follows failure-to-start; hosts may route unexpected run as a lower urgency energy/override finding using the direction output.
  • No EnergyPlus validation is claimed. Part-load ratio or power can proxy status, but the current model exposes no independent final per-chiller BAS stage command; fabricating it from status would make proof tautological.
  • CLU-06 is unchanged. A command/proof disagreement is diagnosis ordering, not a member of the existing efficiency-triggered cluster.

Test Vectors

13 scenarios, clock step 60 s over 1800 s.

ScenarioDescription
agreement_stoppedCommand and proof both false are healthy agreement.
agreement_runningCommand and independent run proof both true are healthy agreement.
fail_to_start_at_initializationA standing start mismatch serves the full 300-second initialization-safe delay.
unexpected_run_at_initializationA standing stop mismatch serves the independent 120-second delay.
normal_delayed_start_and_stopOil-pump/permissive startup and normal unload/coast-down both complete inside their own windows.
start_recovers_on_timer_boundaryRun proof arriving exactly at the 300-second boundary clears the candidate before evaluation and never alarms.
stop_recovers_on_timer_boundaryStatus clearing exactly at the 120-second boundary prevents an unexpected-run alarm.
run_proof_lost_midcycleA normally running machine that loses proof begins the start-direction timer at the loss event.
command_chatter_resets_start_timerA start request that drops and returns restarts the continuous 300-second proof window.
pending_direction_reversal_uses_new_timerA start mismatch reverses to an unexpected-run mismatch before maturity; the old lane resets and the new lane serves 120 seconds.
mature_direction_reversal_reserves_new_windowA mature unexpected-run alarm clears when the mismatch flips; fail-to-start then waits its separate 300 seconds.
status_chatter_never_maturesA proof signal alternating every minute never leaves either mismatch continuous for its timer.
mismatch_recovery_clears_alarmA matured fail-to-start output clears immediately when independent status proves run.
vectors.json
{
  "schema": "cxf-library/vectors/v1",
  "clock": {
    "step_s": 60,
    "horizon_s": 1800
  },
  "scenarios": [
    {
      "name": "agreement_stopped",
      "description": "Command and proof both false are healthy agreement.",
      "inputs": {
        "chiller_cmd": false,
        "chiller_status": false
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        }
      ]
    },
    {
      "name": "agreement_running",
      "description": "Command and independent run proof both true are healthy agreement.",
      "inputs": {
        "chiller_cmd": true,
        "chiller_status": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        }
      ]
    },
    {
      "name": "fail_to_start_at_initialization",
      "description": "A standing start mismatch serves the full 300-second initialization-safe delay.",
      "inputs": {
        "chiller_cmd": true,
        "chiller_status": false
      },
      "expect": [
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 240,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 360,
          "to_s": 1800,
          "equals": true
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 240,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 360,
          "to_s": 1800,
          "equals": true
        }
      ]
    },
    {
      "name": "unexpected_run_at_initialization",
      "description": "A standing stop mismatch serves the independent 120-second delay.",
      "inputs": {
        "chiller_cmd": false,
        "chiller_status": true
      },
      "expect": [
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 60,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 180,
          "to_s": 1800,
          "equals": true
        },
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 60,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 180,
          "to_s": 1800,
          "equals": true
        }
      ]
    },
    {
      "name": "normal_delayed_start_and_stop",
      "description": "Oil-pump/permissive startup and normal unload/coast-down both complete inside their own windows.",
      "inputs": {
        "chiller_cmd": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 300,
            "value": true
          },
          {
            "t": 900,
            "value": false
          }
        ],
        "chiller_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 540,
            "value": true
          },
          {
            "t": 960,
            "value": false
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        }
      ]
    },
    {
      "name": "start_recovers_on_timer_boundary",
      "description": "Run proof arriving exactly at the 300-second boundary clears the candidate before evaluation and never alarms.",
      "inputs": {
        "chiller_cmd": true,
        "chiller_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 300,
            "value": true
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        }
      ]
    },
    {
      "name": "stop_recovers_on_timer_boundary",
      "description": "Status clearing exactly at the 120-second boundary prevents an unexpected-run alarm.",
      "inputs": {
        "chiller_cmd": false,
        "chiller_status": [
          {
            "t": 0,
            "value": true
          },
          {
            "t": 120,
            "value": false
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        }
      ]
    },
    {
      "name": "run_proof_lost_midcycle",
      "description": "A normally running machine that loses proof begins the start-direction timer at the loss event.",
      "inputs": {
        "chiller_cmd": true,
        "chiller_status": [
          {
            "t": 0,
            "value": true
          },
          {
            "t": 600,
            "value": false
          }
        ]
      },
      "expect": [
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 840,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 960,
          "to_s": 1800,
          "equals": true
        }
      ]
    },
    {
      "name": "command_chatter_resets_start_timer",
      "description": "A start request that drops and returns restarts the continuous 300-second proof window.",
      "inputs": {
        "chiller_cmd": [
          {
            "t": 0,
            "value": true
          },
          {
            "t": 180,
            "value": false
          },
          {
            "t": 300,
            "value": true
          }
        ],
        "chiller_status": false
      },
      "expect": [
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 540,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 660,
          "to_s": 1800,
          "equals": true
        }
      ]
    },
    {
      "name": "pending_direction_reversal_uses_new_timer",
      "description": "A start mismatch reverses to an unexpected-run mismatch before maturity; the old lane resets and the new lane serves 120 seconds.",
      "inputs": {
        "chiller_cmd": [
          {
            "t": 0,
            "value": true
          },
          {
            "t": 180,
            "value": false
          }
        ],
        "chiller_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 180,
            "value": true
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 240,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 360,
          "to_s": 1800,
          "equals": true
        },
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        }
      ]
    },
    {
      "name": "mature_direction_reversal_reserves_new_window",
      "description": "A mature unexpected-run alarm clears when the mismatch flips; fail-to-start then waits its separate 300 seconds.",
      "inputs": {
        "chiller_cmd": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 600,
            "value": true
          }
        ],
        "chiller_status": [
          {
            "t": 0,
            "value": true
          },
          {
            "t": 600,
            "value": false
          }
        ]
      },
      "expect": [
        {
          "output": "yUnexpectedRun",
          "from_s": 180,
          "to_s": 540,
          "equals": true
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 660,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 180,
          "to_s": 540,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 660,
          "to_s": 840,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 960,
          "to_s": 1800,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 960,
          "to_s": 1800,
          "equals": true
        }
      ]
    },
    {
      "name": "status_chatter_never_matures",
      "description": "A proof signal alternating every minute never leaves either mismatch continuous for its timer.",
      "inputs": {
        "chiller_cmd": true,
        "chiller_status": [
          {
            "t": 0,
            "value": true
          },
          {
            "t": 60,
            "value": false
          },
          {
            "t": 120,
            "value": true
          },
          {
            "t": 180,
            "value": false
          },
          {
            "t": 240,
            "value": true
          },
          {
            "t": 300,
            "value": false
          },
          {
            "t": 360,
            "value": true
          },
          {
            "t": 420,
            "value": false
          },
          {
            "t": 480,
            "value": true
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        }
      ]
    },
    {
      "name": "mismatch_recovery_clears_alarm",
      "description": "A matured fail-to-start output clears immediately when independent status proves run.",
      "inputs": {
        "chiller_cmd": true,
        "chiller_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 900,
            "value": true
          }
        ]
      },
      "expect": [
        {
          "output": "yFailToStart",
          "from_s": 360,
          "to_s": 840,
          "equals": true
        },
        {
          "output": "yFailToStart",
          "from_s": 960,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 360,
          "to_s": 840,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 960,
          "to_s": 1800,
          "equals": false
        }
      ]
    }
  ]
}

CHW-0009 — Chiller short-cycling

Statusverified — engine e2ff2f8, cxf:fnv1a128:1d73386b26deb3b315e35f31e43ffbff, 2026-08-20
Severity2
Methodrule
Phase2
CategoryPROTECTIVE
ConfidenceMEDIUM
EstimationQUALITATIVE_ONLY
G36
Clusters
Suppresses
Suppressed by
RelatedCHW-0001, CHW-0007, CHW-0008
Playbookschiller-efficiency
SourcePNNL-29078, Building Re-Tuning Training Guide: Central Utility Plant Heating and Cooling Control Guide, PDF pp.90-91 (§10.1) — low-load inability to turn down can overshoot the internal setpoint and short-cycle a chiller; Library graph precedents PMP-0004, TOWER-0003, RTU-0001, and HW-0001 — rising-edge pulse integrated into a rolling event count; Library-authored threshold adaptation; the chiller manufacturer and plant sequence, not this source, define acceptable starts and minimum off time
Operating statesall plant states in which this individual chiller may legitimately start; seasonal shutdown with no starts is a valid clear result

Preconditions (host-enforced): Bind chiller_status to independent per-machine proof of capacity-producing operation, not availability, command, a fleet OR, or an enumeration that changes while continuously running. Status edges must represent real machine starts rather than communication loss or integration replay. The host reports NO_EVAL for the first evaluation_window after engine load/state reset because MovingAverage extrapolates an event pace while history fills. Acquire fast enough to observe the shortest real OFF/ON dwell, then evaluate on a fixed tick satisfying evaluation_window/63 <= tick < evaluation_window/(2*max_starts); defaults permit about 57.2 to under 600 s, with 60 s recommended. count_scale must equal evaluation_window/tick. Exclude approved exercise, tests, seasonal switchover, and maintenance. Commission max_starts and minimum on/off behavior against the chiller manufacturer’s limits and the actual staging sequence.

Points: chiller_status

Outputs:

  • yFault — True while the observed per-machine rising-edge count is strictly above max_starts in the trailing evaluation_window

Parameters:

NameDefaultUnitCXF pathDescription
evaluation_window3600.0srate.deltaTrailing start-count window. LIBRARY_PRECEDENT: one hour is an executable observation window, not a universal manufacturer requirement.
max_starts3.01/windowcntHigh.tAllowed starts per window. ADOPTED_TUNABLE commissioning placeholder; strict comparison leaves exactly three clear and faults at four.
count_scale60.01count.kDerived coupling evaluation_window/evaluator_tick. The default 3600/60 is valid only at a 60 s fixed tick and must change with window or cadence.

Description

This rule counts starts of one chiller over a trailing window. PNNL identifies low-load inability to turn down as a common mechanism: the machine quickly overshoots its internal target and shuts off, then restarts when load returns. Unstable staging, narrow deadbands, missing storage, safety trips, or unreliable proof can create the same observable signature.

Detection Logic

start = rising edge of chiller_status
count = MovingAverage(start, evaluation_window) × count_scale

yFault = count > max_starts

CHW-0009 block graph

Logical.Edge counts OFF-to-ON proof transitions. The initialization pulse has zero area, but the partially filled moving-average window extrapolates a pace; the first complete window is therefore host-NO_EVAL. The fourth observed start inside the default trailing hour raises the immediate raw verdict, with no additional delay.

Possible Diagnoses

  1. Chiller oversized for shoulder-season or process load
  2. Staging/deadband or minimum on/off timers configured too narrowly
  3. Insufficient loop volume or thermal storage
  4. CHWST reset/control causing low-load setpoint overshoot
  5. Compressor, starter/drive, oil, flow, freeze, or safety trip and auto-reset
  6. Chattering or stale run proof and communication replay
  7. Multiple chiller statuses incorrectly ORed into one point
  8. Approved exercise/test sequence not host-gated

Energy Impact

PROTECTIVE and QUALITATIVE_ONLY. Short cycling can waste transient energy and accelerate component wear, but a Boolean start counter cannot quantify either. Use machine power, OEM start limits, and plant staging history host-side.

Emissions Impact

Scope 2, qualitative. Avoided transient electricity and premature component replacement are not inferable from status edges alone.

Deviations

  • The three-start limit is adopted, not source-transcribed. The OEM minimum on/off and starts-per-hour limits are authoritative for each machine.
  • count_scale is added to the brief’s parameter table. MovingAverage returns pulse rate; multiplying by evaluation_window/tick recovers count.
  • The first full window is NO_EVAL. Partial-window event pace and reload history loss are exposed in vectors rather than hidden.
  • The legal evaluator band is [evaluation_window/63, evaluation_window/(2×max_starts)). The lower edge protects the 64-sample ring; the upper edge preserves observability of the first integer count above a strict threshold. Real OFF/ON dwell may require faster acquisition still.
  • Confidence is MEDIUM rather than the brief’s proposed HIGH. Edge counting is direct, but the shipped count and status quality require commissioning.
  • A fleet OR is invalid. Two chillers can cycle independently while the OR remains continuously true, making every lag-machine start invisible.
  • CLU-06 is unchanged. Cycling relates to efficiency but does not share the cluster’s current efficiency trigger/fix semantics, so the cluster is not broadened in this slice.

Test Vectors

12 scenarios, clock step 60 s over 14400 s.

ScenarioDescription
no_startsA stopped machine produces no rising edges.
first_sample_true_is_inertStatus true at engine initialization creates zero edge area and no counted start.
one_normal_startOne start after a completed warm-up window stays below the three-start allowance.
exactly_three_starts_clearThe strict comparison leaves exactly the commissioned allowance clear.
half_open_window_boundary_clearThe fourth start arrives exactly one window after the first; that oldest pulse has left and only three remain.
four_starts_faultOne observed start over the allowance raises the raw rolling-count verdict.
oldest_start_ages_outAfter four starts raise the verdict, it clears when the oldest contributing pulse leaves the trailing hour.
status_chatter_counts_rising_edgesEight status transitions are four starts, not eight; proof chatter is indistinguishable from real cycling to this graph.
warmup_single_start_asserts_rawPartial-window extrapolation makes one early start look faster than three per hour; the host must report NO_EVAL for the first full window.
warmup_four_starts_assert_rawA burst during history warm-up raises the raw graph but remains host-NO_EVAL until one full window is available.
reload_running_loses_prior_historyA fresh engine loaded while a chiller is already running cannot reconstruct starts that happened before reload.
subtick_cycle_aliases_clearA complete off-on-off cycle between evaluator ticks is invisible, pinning the sampling obligation.
vectors.json
{
  "schema": "cxf-library/vectors/v1",
  "clock": {
    "step_s": 60,
    "horizon_s": 14400
  },
  "scenarios": [
    {
      "name": "no_starts",
      "description": "A stopped machine produces no rising edges.",
      "inputs": {
        "chiller_status": false
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 14400,
          "equals": false
        }
      ]
    },
    {
      "name": "first_sample_true_is_inert",
      "description": "Status true at engine initialization creates zero edge area and no counted start.",
      "inputs": {
        "chiller_status": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 14400,
          "equals": false
        }
      ]
    },
    {
      "name": "one_normal_start",
      "description": "One start after a completed warm-up window stays below the three-start allowance.",
      "inputs": {
        "chiller_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 4200,
            "value": true
          },
          {
            "t": 6000,
            "value": false
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 14400,
          "equals": false
        }
      ]
    },
    {
      "name": "exactly_three_starts_clear",
      "description": "The strict comparison leaves exactly the commissioned allowance clear.",
      "inputs": {
        "chiller_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 4200,
            "value": true
          },
          {
            "t": 4320,
            "value": false
          },
          {
            "t": 4800,
            "value": true
          },
          {
            "t": 4920,
            "value": false
          },
          {
            "t": 5400,
            "value": true
          },
          {
            "t": 5520,
            "value": false
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 14400,
          "equals": false
        }
      ]
    },
    {
      "name": "half_open_window_boundary_clear",
      "description": "The fourth start arrives exactly one window after the first; that oldest pulse has left and only three remain.",
      "inputs": {
        "chiller_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 3600,
            "value": true
          },
          {
            "t": 3720,
            "value": false
          },
          {
            "t": 4800,
            "value": true
          },
          {
            "t": 4920,
            "value": false
          },
          {
            "t": 6000,
            "value": true
          },
          {
            "t": 6120,
            "value": false
          },
          {
            "t": 7200,
            "value": true
          },
          {
            "t": 7320,
            "value": false
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 14400,
          "equals": false
        }
      ]
    },
    {
      "name": "four_starts_fault",
      "description": "One observed start over the allowance raises the raw rolling-count verdict.",
      "inputs": {
        "chiller_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 4200,
            "value": true
          },
          {
            "t": 4320,
            "value": false
          },
          {
            "t": 4680,
            "value": true
          },
          {
            "t": 4800,
            "value": false
          },
          {
            "t": 5160,
            "value": true
          },
          {
            "t": 5280,
            "value": false
          },
          {
            "t": 5640,
            "value": true
          },
          {
            "t": 5760,
            "value": false
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5580,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 5760,
          "to_s": 7740,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 7980,
          "to_s": 14400,
          "equals": false
        }
      ]
    },
    {
      "name": "oldest_start_ages_out",
      "description": "After four starts raise the verdict, it clears when the oldest contributing pulse leaves the trailing hour.",
      "inputs": {
        "chiller_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 3600,
            "value": true
          },
          {
            "t": 3720,
            "value": false
          },
          {
            "t": 4200,
            "value": true
          },
          {
            "t": 4320,
            "value": false
          },
          {
            "t": 4800,
            "value": true
          },
          {
            "t": 4920,
            "value": false
          },
          {
            "t": 5400,
            "value": true
          },
          {
            "t": 5520,
            "value": false
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 5520,
          "to_s": 7140,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 7380,
          "to_s": 14400,
          "equals": false
        }
      ]
    },
    {
      "name": "status_chatter_counts_rising_edges",
      "description": "Eight status transitions are four starts, not eight; proof chatter is indistinguishable from real cycling to this graph.",
      "inputs": {
        "chiller_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 4200,
            "value": true
          },
          {
            "t": 4260,
            "value": false
          },
          {
            "t": 4440,
            "value": true
          },
          {
            "t": 4500,
            "value": false
          },
          {
            "t": 4680,
            "value": true
          },
          {
            "t": 4740,
            "value": false
          },
          {
            "t": 4920,
            "value": true
          },
          {
            "t": 4980,
            "value": false
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 5040,
          "to_s": 7620,
          "equals": true
        }
      ]
    },
    {
      "name": "warmup_single_start_asserts_raw",
      "description": "Partial-window extrapolation makes one early start look faster than three per hour; the host must report NO_EVAL for the first full window.",
      "inputs": {
        "chiller_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 300,
            "value": true
          },
          {
            "t": 420,
            "value": false
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 420,
          "to_s": 1080,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 1260,
          "to_s": 14400,
          "equals": false
        }
      ]
    },
    {
      "name": "warmup_four_starts_assert_raw",
      "description": "A burst during history warm-up raises the raw graph but remains host-NO_EVAL until one full window is available.",
      "inputs": {
        "chiller_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 300,
            "value": true
          },
          {
            "t": 360,
            "value": false
          },
          {
            "t": 600,
            "value": true
          },
          {
            "t": 660,
            "value": false
          },
          {
            "t": 900,
            "value": true
          },
          {
            "t": 960,
            "value": false
          },
          {
            "t": 1200,
            "value": true
          },
          {
            "t": 1260,
            "value": false
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 1320,
          "to_s": 3780,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 3960,
          "to_s": 14400,
          "equals": false
        }
      ]
    },
    {
      "name": "reload_running_loses_prior_history",
      "description": "A fresh engine loaded while a chiller is already running cannot reconstruct starts that happened before reload.",
      "inputs": {
        "chiller_status": [
          {
            "t": 0,
            "value": true
          },
          {
            "t": 600,
            "value": false
          },
          {
            "t": 4200,
            "value": true
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 14400,
          "equals": false
        }
      ]
    },
    {
      "name": "subtick_cycle_aliases_clear",
      "description": "A complete off-on-off cycle between evaluator ticks is invisible, pinning the sampling obligation.",
      "inputs": {
        "chiller_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 10,
            "value": true
          },
          {
            "t": 20,
            "value": false
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 14400,
          "equals": false
        }
      ]
    }
  ]
}

ERV Fault Rules

Energy recovery ventilator fault detection rules (ERV-*). Source grounding: HVAC FDD Reference v1.0 ch.15 (Energy Recovery — adapted authority; see each card’s Deviations section) plus clearly labeled library-authored operating, proof, and airflow-balance extensions.

Point dictionary: points/erv.points.json.

Index

IDNameSevMethodStatus
ERV-0001Sensible effectiveness degradation3statisticalverified
ERV-0002Frost protection not engaging2ruleverified
ERV-0003Frost protection active above release conditions3ruleverified
ERV-0004Recovery device proof-of-operation failure2ruleverified
ERV-0005Supply/exhaust airflow imbalance3ruleverified

ERV-0001/0002 adapt the reference chapter. ERV-0003..0005 are library-authored: the cards classify every threshold and name their reusable graph precedents.

Applicability and point availability

  • Passive plate cores: ERV-0001, ERV-0002/0003 where a frost-state point exists, and ERV-0005 where both device-local flow measurements exist. ERV-0004 is not applicable because the core has no commanded active device.
  • Rotary wheels: all five rules may apply. ERV-0004 needs a final wheel-run command and independent rotation/speed proof; command echo is not proof.
  • Runaround loops: all five rules may apply where a frost sequence/state exists and temperatures/flows map to both air streams. ERV-0004 binds the loop pump’s final command and independent pump work/rotation proof.
  • Point scarcity: enabled/frost flags and three temperatures do not imply command/status proof or two comparable airflow measurements. Omit a rule rather than substitute a high-level enable or an unrelated AHU flow meter.

Relationships

  • All rules share the erv-effectiveness investigation order. ERV-0004 also uses proof-of-operation for command/status diagnosis.
  • ERV-0003 relates to both existing frost/effectiveness rules but suppresses neither: excessive frost protection can be the real cause of lost recovery.
  • ERV-0005’s direction flags identify the air path to inspect; yFlowOk=false means NO_EVAL, not balanced operation.

ERV-0001 — Sensible effectiveness degradation

Statusverified — engine e2ff2f8, cxf:fnv1a128:968e1ca7cc234dd1107a381f01adb7b2, 2026-08-17
Severity3
Methodstatistical
Phase2
CategoryEFFICIENCY_LOSS
ConfidenceMEDIUM
EstimationBASELINE_COMPARISON
G36
Clusters
Suppresses
Suppressed by
RelatedERV-0002
Playbookserv-effectiveness
SourceHVAC FDD Reference v1.0 §15, ERV-0001; Nehasil et al. 2021; Mattera et al. 2020; PNNL EEM-37 (optimized heat recovery wheel)
Operating statesERV enabled with both supply and exhaust fans running

Preconditions (host-enforced): Both fans must be running. The graph consumes erv_enabled and holds the alarm down while it is false, but an enable command is not proof that air is moving: a unit enabled with a failed exhaust fan reads as low effectiveness, correctly in arithmetic and wrongly in diagnosis, and the ERV dictionary carries no fan-status point to separate the two. The host must also not evaluate during frost protection — a unit in preheat, wheel-speed reduction, or bypass is recovering less on purpose, and erv_frost_prot (ERV-0002’s point) is the flag to gate on. All three temperature sensors must be trustworthy and correctly positioned: entering upstream of the recovery device, leaving downstream of it and upstream of any coil, exhaust on the building side. A leaving-air sensor mounted after the preheat coil measures the coil, not the wheel. Temperature-difference evaluability is signalled in-rule by yTempDeltaOk; when it is false the verdict is NO_EVAL, not healthy.

Points: erv_oa_entering_temp, erv_oa_leaving_temp, erv_exhaust_temp, erv_enabled

Outputs:

  • yFault — True while the measured sensible effectiveness has stayed more than effectiveness_threshold below baseline_effectiveness for at least alarm_delay, with the ERV enabled and the temperature difference large enough to evaluate
  • yTempDeltaOk — Evaluability signal — true when |erv_exhaust_temp − erv_oa_entering_temp| exceeds min_delta_for_eval; false means NO_EVAL and the host must ignore yFault

Parameters:

NameDefaultUnitCXF pathDescription
baseline_effectiveness0.751base.kDesign sensible effectiveness of the recovery device (0–1). Per-unit configuration — the shipped 0.75 is the reference’s population default, not this unit’s rating (see Deviations)
effectiveness_threshold0.151shortHigh.tShortfall below baseline that counts as degradation (0.15 = 15 effectiveness points)
min_delta_for_eval5.0°CdeltaOk.tMinimum
alarm_delay1800.0spersist.delayTimeContinuous degradation required before the alarm asserts (30 min)

Description

An energy recovery device is a heat exchanger between two air streams, and its whole value is the fraction of the available temperature difference it manages to move. That fraction is measurable from three temperatures: how far the incoming outdoor air was dragged toward the exhaust temperature, over how far it could have been dragged. A wheel that has stopped turning, a plate core packed with dust, a bypass damper stuck open, a run-around loop that lost its pump — all read the same way, and none of them shows anywhere else. Ventilation is still delivered and the extra load lands on the downstream coils looking like ordinary weather. Nehasil et al. (2021) report a 90% detection rate for this diagnostic; the reference cites Mattera et al. (2020) alongside it.

Detection Logic

effectiveness = (erv_oa_leaving_temp − erv_oa_entering_temp)
                / (erv_exhaust_temp − erv_oa_entering_temp)
shortfall     = baseline_effectiveness − effectiveness

yTempDeltaOk  = |erv_exhaust_temp − erv_oa_entering_temp| > min_delta_for_eval
                                                    (false ⇒ host reports NO_EVAL)
yFault        = shortfall > effectiveness_threshold AND yTempDeltaOk AND erv_enabled,
                sustained for alarm_delay

Block graph (rule.cxf.jsonld):

ERV-0001 block graph

rise is the temperature the device actually delivered, avail the temperature it had to work with, and eff divides them. base carries the design effectiveness as a constant so shortfall can subtract the measured value from it and shortHigh can test the remainder against one positive threshold — the AHU-0021 arrangement. The ratio needs no seasonal branch: in summer both numerator and denominator go negative and the quotient reads as it does in winter.

avail fans out into absDelta and deltaOk, which does two jobs — the reference’s min_delta_for_eval precondition, exposed as the boundary output yTempDeltaOk because it is computable from this rule’s own inputs, and the guard on the division. CDL Divide follows IEEE-754, so an exhaust temperature equal to the entering temperature yields ±∞ or NaN rather than an error, and a near-zero denominator turns a tenth of a degree of sensor noise into an effectiveness of any magnitude. Because deltaOk also drives gate, arithmetic garbage can make this rule unevaluable; it cannot make it fire.

armed adds the enable state, so a unit that is switched off is not accused of recovering nothing. Both comparisons are strict — the temperature-difference boundary is exact, the effectiveness boundary is not representable at all (see Deviations) — and persist requires 30 continuous minutes, long enough to ride out a brief frost-control excursion.

Possible Diagnoses

  1. Energy recovery wheel fouled or contaminated — dust bridging the media, the most common cause and the cheapest to correct ($200–$1,000 for cleaning)
  2. Energy recovery wheel motor stopped — a failed drive motor or broken belt leaves the wheel stationary and produces the most extreme readings this rule sees
  3. Bypass damper stuck open, routing air around the core entirely
  4. Plate heat exchanger fouled — same failure, no moving parts to check
  5. Run-around coil pump failure or glycol degradation, on the loop-type installations where the two air streams never meet

Energy Impact

EFFICIENCY_LOSS, MEDIUM confidence, BASELINE_COMPARISON. The reference gives 10–30% of recovery energy lost and the estimator lost_kw = (baseline_eff − actual_eff) × airflow × cp × |exhaust − entering|, whose first factor is the shortfall this rule already computes; airflow and cp are not rule inputs, so the conversion to kilowatts is the host’s. PNNL EEM-37 (optimized heat recovery wheel) is the related measure. A wheel at half its rated effectiveness is not saving half as much energy — it is handing the coils half of the ventilation load it was bought to eliminate. Both climates, and worth most where the outdoor-to-exhaust difference is largest, which is when the rule is most evaluable.

Emissions Impact

Scope 1 + 2, PROXY_EMISSIONS, MEDIUM confidence; typically 400–3,000 kg CO₂e/yr for lost recovery. The split follows the season and the plant: the unrecovered winter load usually burns scope 1 fuel at a heating coil, the summer load draws scope 2 electricity at a chiller, and an all-electric building puts both in scope 2. Avoided-emissions basis: marginal operating emissions rate (MOER).

Deviations

  • min_delta_for_eval is 5.0 °C, and the reference disagrees with itself about that number. The chapter’s tunables table gives 5 °C; the erv-effectiveness playbook gives the same gate as |OAT − RAT| > 10 °F (5.56 °C), a Fahrenheit rule of thumb the reference never reconciles with its metric restatement. This card adopts the tunables value, the library’s standing precedent when a chapter card and its playbook disagree numerically (RTU-0002 does the same). The cost is a narrow 5.0–5.56 °C band where this rule evaluates and the playbook would not; hosts preferring the playbook set deltaOk.t = 5.56. The playbook’s |OAT − RAT| is this rule’s |exhaust − entering| — exhaust air entering the ERV is return air measured at the device.
  • The evaluability gate is also the divide guard, deliberately. SCHEMA.md requires exposing an in-rule evaluability test as a boolean output, which is yTempDeltaOk; wiring the same signal into gate is the second, independent reason it exists. NaN compares false everywhere and can never raise the alarm, but +∞ can and does, so a host that read yTempDeltaOk as advisory and ignored the gate would be relying on arithmetic with no defined answer.
  • The effectiveness boundary is not representable, and the nominal case lands on the fault side. With the shipped 0.75 and 0.15 as IEEE-754 doubles, no measured effectiveness makes the shortfall exactly equal the threshold: near 0.6 the subtraction 0.75 − eff is exact (Sterbenz), so equality would require 0.75 − 0.15 to be a double, and it is not. An effectiveness of exactly 60.0% computes 0.15000000000000002, one ulp above the threshold, so the strict > fires where paper arithmetic says it should not. Both sides of the machine crossing are pinned. Invisible in practice — sensors resolve 0.1 °C at best — but “exactly 15 points below baseline is safe” is wrong by one ulp, toward alarming.
  • erv_enabled is in the block graph, not only the frontmatter. Operating states are normally host-side here, but the enable half is a dictionary point ERV-0002 already consumes and the failure mode is nightly: a disabled unit recovers nothing by construction, so an ungated rule alarms every unoccupied period on every healthy ERV. Precedent: RTU-0006 consumes sf_status and occ_schedule. The fans-running half stays a host precondition — the dictionary has no ERV fan-status point.
  • baseline_effectiveness ships the reference’s default, which is a population value. 75% is reasonable for a well-specified wheel and it is what the reference publishes, so it carries more authority than a placeholder — but it is not this unit’s rating. Devices in service run 50–80%, and the interaction with the threshold is unforgiving at the low end: a unit rated 60% operating exactly at its rating alarms. Set base.k to the certified sensible effectiveness at design airflow, or to the commissioning measurement.
  • The comparison is written baseline − measured > threshold, not measured < (baseline − threshold). Algebraically identical to the reference’s form; implemented this way so both tunables stay independent single-value parameters and the threshold stays positive, clear of the library’s prohibition on negative parameters.
  • method: statistical describes where the baseline comes from, not what the graph does. Two subtractions, a division, a comparison — nothing statistical happens on a tick. The classification is the reference’s and it is fair: the baseline is a design or commissioning figure, and the detection literature behind the card (Nehasil et al. 2021, Mattera et al. 2020) is statistical. Same stance as RTU-0002.
  • Latent recovery is out of scope. The reference specifies sensible effectiveness and this rule computes only that, so an enthalpy wheel whose desiccant coating has failed while sensible transfer is intact passes. Humidity points are not in the ERV dictionary; the gap is recorded, not papered over.
  • Frost protection is not excluded in-graph. A unit in its frost sequence is recovering less on purpose. The 30-minute alarm_delay rides out short excursions, but a long cold snap with frost control active for hours will alarm; hosts with sustained frost operation should gate on erv_frost_prot, which exists in the dictionary for ERV-0002. Not added as a fourth input because the reference does not list it for this fault.
  • Strict > on both comparisons, as CDL requires — there is no GreaterEqual in Reals. The temperature-difference boundary is exactly representable and pinned from both sides: 5.0 °C is not evaluable, 5.1 °C is.
  • persist.delayOnInit = true (CDL default is false), the library’s standing choice: a wheel already degraded when the controller starts waits out the full 30 minutes rather than alarming on the first tick.
  • All three of the reference’s published test vectors are reproduced and their computed effectiveness matches its stated values to the digit — the cheapest available confirmation that the ratio is wired the right way up. The remaining scenarios in vectors.json are authored.

Notes

The erv-effectiveness playbook starts with operating/frost state, proves any active recovery device, and compares the two air streams before recalculating effectiveness or cleaning the core. That order avoids condemning heat-transfer media for a stopped wheel, intentional frost sequence, bypass condition, or airflow problem. Its resolution target is effectiveness back within 15 percentage points of the commissioned value, the same absolute 0.15 shortfall this rule alarms at. A device restored just inside the alarm point has therefore met the target by exactly the margin that clears the alarm and is worth re-measuring next season.

Test Vectors

15 scenarios, clock step 60 s over 3600 s.

ScenarioDescription
reference_good_effectivenessReference vector: entering −5 °C, leaving 15 °C, exhaust 22 °C — 74.1% sensible effectiveness against a 75% baseline. The shortfall is under one point, nowhere near the 15-point threshold.
reference_degraded_effectivenessReference vector: the same air with the leaving temperature at 0 °C — 18.5% effectiveness, a 56-point shortfall. The wheel is barely recovering anything and the alarm lands one alarm_delay in.
reference_insufficient_deltaReference vector (NO_EVAL): entering 20 °C, leaving 21 °C, exhaust 22 °C. Only 2 °C is available to recover, so the ratio reads 50% off a denominator dominated by sensor error. yTempDeltaOk is false and the host must report NO_EVAL rather than reading yFault as healthy.
delta_exactly_at_eval_thresholdBoundary: exhaust − entering is exactly min_delta_for_eval (25.0 − 20.0 = 5.0 °C) with a 35-point shortfall behind it. The comparison is strict, so the rule reports itself unevaluable rather than alarming.
delta_just_above_eval_thresholdBoundary from the other side: exhaust 25.1 °C makes the available difference 5.1 °C, the rule becomes evaluable, and the same 36-point shortfall now alarms.
shortfall_just_under_thresholdThreshold boundary, readable side: entering 0 °C, leaving 12.1 °C, exhaust 20 °C — 60.5% effectiveness, a 14.5-point shortfall, below the 15-point threshold.
shortfall_just_over_thresholdThe other readable side: leaving 11.9 °C gives 59.5% effectiveness, a 15.5-point shortfall, and the alarm lands after alarm_delay.
shortfall_at_nominal_boundary_alarmsThe nominal boundary: leaving 12.0 °C gives effectiveness 0.6 exactly, so in decimal arithmetic the shortfall is exactly 0.15 and the strict > would read healthy. In IEEE-754 doubles 0.75 − 0.6 evaluates to 0.15000000000000002, one ulp above the threshold, so the rule alarms. See Deviations — the exact boundary is unrepresentable and this is which side the machine lands on.
shortfall_one_ulp_under_thresholdThe machine boundary from the other side: leaving 12.000000000000002 °C is one ulp above 12, effectiveness comes out 0.6000000000000001, and the shortfall is 0.1499999999999999 — just under the threshold, so the rule is silent. This scenario and the one above are the two adjacent doubles that straddle the comparison.
zero_delta_divide_guardDivide guard: exhaust equals entering (both 20 °C) so the denominator is exactly zero, and with leaving below entering the quotient is −∞ and the shortfall +∞ — an ungated rule would alarm on garbage arithmetic. yTempDeltaOk is false and holds yFault down.
summer_reverse_delta_degradedSign case: in summer the exhaust is the cold stream (entering 32 °C, exhaust 24 °C, leaving 30 °C). Both differences flip sign and the quotient still reads an effectiveness — 25%, a 50-point shortfall — so the rule alarms without a seasonal branch.
erv_disabled_is_silentThe recovery device is off with the reference degraded temperatures on its sensors. A stationary wheel recovers nothing by design, so the shortfall means nothing and erv_enabled holds the alarm down. yTempDeltaOk stays true throughout — it reports the temperature difference only, never the enable state.
enable_starts_the_persistence_clockThe unit is enabled at t=600 s with the degraded temperatures already present. The persistence timer starts at the enable, not at t=0, so the alarm lands one alarm_delay after the enable rather than at 1800 s.
transient_degradation_clears_before_delayTransient: the leaving temperature collapses from 15 °C to 0 °C at t=600 s and recovers at t=1800 s — 1200 s of degradation, short of the 1800 s alarm_delay. A wheel briefly slowed by a frost-control cycle looks exactly like this, and it raises nothing.
alarm_clears_after_cleaningRecovery: the degraded alarm asserts, the core is cleaned at t=2400 s and effectiveness returns to 74.1%. TrueDelay passes falling edges immediately, so yFault clears on that tick.
vectors.json
{
  "schema": "cxf-library/vectors/v1",
  "clock": {
    "step_s": 60,
    "horizon_s": 3600
  },
  "scenarios": [
    {
      "name": "reference_good_effectiveness",
      "description": "Reference vector: entering \u22125 \u00b0C, leaving 15 \u00b0C, exhaust 22 \u00b0C \u2014 74.1% sensible effectiveness against a 75% baseline. The shortfall is under one point, nowhere near the 15-point threshold.",
      "inputs": {
        "erv_oa_entering_temp": -5.0,
        "erv_oa_leaving_temp": 15.0,
        "erv_exhaust_temp": 22.0,
        "erv_enabled": true
      },
      "expect": [
        {
          "output": "yTempDeltaOk",
          "from_s": 0,
          "to_s": 3600,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "reference_degraded_effectiveness",
      "description": "Reference vector: the same air with the leaving temperature at 0 \u00b0C \u2014 18.5% effectiveness, a 56-point shortfall. The wheel is barely recovering anything and the alarm lands one alarm_delay in.",
      "inputs": {
        "erv_oa_entering_temp": -5.0,
        "erv_oa_leaving_temp": 0.0,
        "erv_exhaust_temp": 22.0,
        "erv_enabled": true
      },
      "expect": [
        {
          "output": "yTempDeltaOk",
          "from_s": 0,
          "to_s": 3600,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1740,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 1860,
          "to_s": 3600,
          "equals": true
        }
      ]
    },
    {
      "name": "reference_insufficient_delta",
      "description": "Reference vector (NO_EVAL): entering 20 \u00b0C, leaving 21 \u00b0C, exhaust 22 \u00b0C. Only 2 \u00b0C is available to recover, so the ratio reads 50% off a denominator dominated by sensor error. yTempDeltaOk is false and the host must report NO_EVAL rather than reading yFault as healthy.",
      "inputs": {
        "erv_oa_entering_temp": 20.0,
        "erv_oa_leaving_temp": 21.0,
        "erv_exhaust_temp": 22.0,
        "erv_enabled": true
      },
      "expect": [
        {
          "output": "yTempDeltaOk",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "delta_exactly_at_eval_threshold",
      "description": "Boundary: exhaust \u2212 entering is exactly min_delta_for_eval (25.0 \u2212 20.0 = 5.0 \u00b0C) with a 35-point shortfall behind it. The comparison is strict, so the rule reports itself unevaluable rather than alarming.",
      "inputs": {
        "erv_oa_entering_temp": 20.0,
        "erv_oa_leaving_temp": 22.0,
        "erv_exhaust_temp": 25.0,
        "erv_enabled": true
      },
      "expect": [
        {
          "output": "yTempDeltaOk",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "delta_just_above_eval_threshold",
      "description": "Boundary from the other side: exhaust 25.1 \u00b0C makes the available difference 5.1 \u00b0C, the rule becomes evaluable, and the same 36-point shortfall now alarms.",
      "inputs": {
        "erv_oa_entering_temp": 20.0,
        "erv_oa_leaving_temp": 22.0,
        "erv_exhaust_temp": 25.1,
        "erv_enabled": true
      },
      "expect": [
        {
          "output": "yTempDeltaOk",
          "from_s": 0,
          "to_s": 3600,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1740,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 1860,
          "to_s": 3600,
          "equals": true
        }
      ]
    },
    {
      "name": "shortfall_just_under_threshold",
      "description": "Threshold boundary, readable side: entering 0 \u00b0C, leaving 12.1 \u00b0C, exhaust 20 \u00b0C \u2014 60.5% effectiveness, a 14.5-point shortfall, below the 15-point threshold.",
      "inputs": {
        "erv_oa_entering_temp": 0.0,
        "erv_oa_leaving_temp": 12.1,
        "erv_exhaust_temp": 20.0,
        "erv_enabled": true
      },
      "expect": [
        {
          "output": "yTempDeltaOk",
          "from_s": 0,
          "to_s": 3600,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "shortfall_just_over_threshold",
      "description": "The other readable side: leaving 11.9 \u00b0C gives 59.5% effectiveness, a 15.5-point shortfall, and the alarm lands after alarm_delay.",
      "inputs": {
        "erv_oa_entering_temp": 0.0,
        "erv_oa_leaving_temp": 11.9,
        "erv_exhaust_temp": 20.0,
        "erv_enabled": true
      },
      "expect": [
        {
          "output": "yTempDeltaOk",
          "from_s": 0,
          "to_s": 3600,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1740,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 1860,
          "to_s": 3600,
          "equals": true
        }
      ]
    },
    {
      "name": "shortfall_at_nominal_boundary_alarms",
      "description": "The nominal boundary: leaving 12.0 \u00b0C gives effectiveness 0.6 exactly, so in decimal arithmetic the shortfall is exactly 0.15 and the strict `>` would read healthy. In IEEE-754 doubles 0.75 \u2212 0.6 evaluates to 0.15000000000000002, one ulp above the threshold, so the rule alarms. See Deviations \u2014 the exact boundary is unrepresentable and this is which side the machine lands on.",
      "inputs": {
        "erv_oa_entering_temp": 0.0,
        "erv_oa_leaving_temp": 12.0,
        "erv_exhaust_temp": 20.0,
        "erv_enabled": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1740,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 1860,
          "to_s": 3600,
          "equals": true
        }
      ]
    },
    {
      "name": "shortfall_one_ulp_under_threshold",
      "description": "The machine boundary from the other side: leaving 12.000000000000002 \u00b0C is one ulp above 12, effectiveness comes out 0.6000000000000001, and the shortfall is 0.1499999999999999 \u2014 just under the threshold, so the rule is silent. This scenario and the one above are the two adjacent doubles that straddle the comparison.",
      "inputs": {
        "erv_oa_entering_temp": 0.0,
        "erv_oa_leaving_temp": 12.000000000000002,
        "erv_exhaust_temp": 20.0,
        "erv_enabled": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "zero_delta_divide_guard",
      "description": "Divide guard: exhaust equals entering (both 20 \u00b0C) so the denominator is exactly zero, and with leaving below entering the quotient is \u2212\u221e and the shortfall +\u221e \u2014 an ungated rule would alarm on garbage arithmetic. yTempDeltaOk is false and holds yFault down.",
      "inputs": {
        "erv_oa_entering_temp": 20.0,
        "erv_oa_leaving_temp": 19.0,
        "erv_exhaust_temp": 20.0,
        "erv_enabled": true
      },
      "expect": [
        {
          "output": "yTempDeltaOk",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "summer_reverse_delta_degraded",
      "description": "Sign case: in summer the exhaust is the cold stream (entering 32 \u00b0C, exhaust 24 \u00b0C, leaving 30 \u00b0C). Both differences flip sign and the quotient still reads an effectiveness \u2014 25%, a 50-point shortfall \u2014 so the rule alarms without a seasonal branch.",
      "inputs": {
        "erv_oa_entering_temp": 32.0,
        "erv_oa_leaving_temp": 30.0,
        "erv_exhaust_temp": 24.0,
        "erv_enabled": true
      },
      "expect": [
        {
          "output": "yTempDeltaOk",
          "from_s": 0,
          "to_s": 3600,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1740,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 1860,
          "to_s": 3600,
          "equals": true
        }
      ]
    },
    {
      "name": "erv_disabled_is_silent",
      "description": "The recovery device is off with the reference degraded temperatures on its sensors. A stationary wheel recovers nothing by design, so the shortfall means nothing and erv_enabled holds the alarm down. yTempDeltaOk stays true throughout \u2014 it reports the temperature difference only, never the enable state.",
      "inputs": {
        "erv_oa_entering_temp": -5.0,
        "erv_oa_leaving_temp": 0.0,
        "erv_exhaust_temp": 22.0,
        "erv_enabled": false
      },
      "expect": [
        {
          "output": "yTempDeltaOk",
          "from_s": 0,
          "to_s": 3600,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "enable_starts_the_persistence_clock",
      "description": "The unit is enabled at t=600 s with the degraded temperatures already present. The persistence timer starts at the enable, not at t=0, so the alarm lands one alarm_delay after the enable rather than at 1800 s.",
      "inputs": {
        "erv_oa_entering_temp": -5.0,
        "erv_oa_leaving_temp": 0.0,
        "erv_exhaust_temp": 22.0,
        "erv_enabled": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 600,
            "value": true
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2340,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2460,
          "to_s": 3600,
          "equals": true
        }
      ]
    },
    {
      "name": "transient_degradation_clears_before_delay",
      "description": "Transient: the leaving temperature collapses from 15 \u00b0C to 0 \u00b0C at t=600 s and recovers at t=1800 s \u2014 1200 s of degradation, short of the 1800 s alarm_delay. A wheel briefly slowed by a frost-control cycle looks exactly like this, and it raises nothing.",
      "inputs": {
        "erv_oa_entering_temp": -5.0,
        "erv_oa_leaving_temp": [
          {
            "t": 0,
            "value": 15.0
          },
          {
            "t": 600,
            "value": 0.0
          },
          {
            "t": 1800,
            "value": 15.0
          }
        ],
        "erv_exhaust_temp": 22.0,
        "erv_enabled": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "alarm_clears_after_cleaning",
      "description": "Recovery: the degraded alarm asserts, the core is cleaned at t=2400 s and effectiveness returns to 74.1%. TrueDelay passes falling edges immediately, so yFault clears on that tick.",
      "inputs": {
        "erv_oa_entering_temp": -5.0,
        "erv_oa_leaving_temp": [
          {
            "t": 0,
            "value": 0.0
          },
          {
            "t": 2400,
            "value": 15.0
          }
        ],
        "erv_exhaust_temp": 22.0,
        "erv_enabled": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1740,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 1860,
          "to_s": 2340,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 2460,
          "to_s": 3600,
          "equals": false
        }
      ]
    }
  ]
}

ERV-0002 — Frost protection not engaging

Statusverified — engine e2ff2f8, cxf:fnv1a128:c975d4eb5acff4ebf97b661be18c5552, 2026-08-17
Severity2
Methodrule
Phase2
CategoryPROTECTIVE
ConfidenceMEDIUM
EstimationQUALITATIVE_ONLY
G36
Clusters
Suppresses
Suppressed by
RelatedERV-0001
Playbookserv-effectiveness
SourceHVAC FDD Reference v1.0 §15, ERV-0002; Engineering best practice
Operating statesERV enabled, heating season — the rule cannot fire above frost_threshold

Preconditions (host-enforced): oat must be a live, sane outdoor-air reading. This rule trusts it completely, which makes diagnosis 2 (a sensor reading warmer than actual) invisible from inside the rule: a sun-struck or failed-high sensor produces silence, not an alarm. erv_frost_prot must be bound to the frost sequence’s active state — the preheat stage, wheel speed reduction, or bypass modulation the unit actually uses — and not to an enable or permissive flag, which reads true all winter and silences the rule permanently. Both supply and exhaust fans must be running: the enable half of the operating state is in the graph as erv_enabled, the fan half is not, and a unit enabled with stopped fans is not moving the air that would frost the core. Units with no frost-protection sequence at all must be excluded host-side — they hold this fault true for the entire heating season, which is a design finding rather than an operating one.

Points: oat, erv_frost_prot, erv_enabled

Outputs:

  • yFault — True while the ERV is enabled and outdoor air is below frost_threshold with the frost sequence reporting inactive, continuously for at least alarm_delay

Parameters:

NameDefaultUnitCXF pathDescription
frost_threshold-10.0°CcoldOat.tOutdoor-air temperature below which the frost-protection sequence is expected to be engaged. Signed by construction — this is a point on the Celsius scale, not a magnitude
alarm_delay300.0spersist.delayTimeContinuous unprotected operation required before the alarm asserts (5 min)

Description

Below roughly -10 °C the moisture in the exhaust airstream starts freezing onto the recovery core as it gives up its heat, so every ERV that runs in a cold climate carries a frost-protection sequence — preheat the incoming air, slow the wheel, or bypass part of the outdoor air around the core. This rule watches for that sequence failing to appear when the weather calls for it. It is a watchdog on a control sequence, not on a physical measurement: nothing here observes ice, only that the conditions for ice are present and the thing meant to prevent it reports itself inactive. The failure is silent — frost accumulates over hours and the first symptom is usually a collapsed plate core or a seized wheel found in spring — which is what severity 2 and PROTECTIVE reflect: the cost is a core.

Detection Logic

cold    = oat < frost_threshold                (-10.0 °C)
noFrost = NOT erv_frost_prot

yFault  = (cold AND noFrost AND erv_enabled) sustained continuously for alarm_delay

Block graph (rule.cxf.jsonld):

ERV-0002 block graph

coldOat carries a negative parameter, which the library normally avoids — see Deviations for why a sub-zero temperature threshold is the exception. noFrost inverts the frost status so the conjunction reads as a single sentence: cold outside, protection off, unit running. unprotected combines the first two and armed adds the enable, which is the reference’s third conjunct rather than a host-side gate — so with the ERV disabled yFault reads false, and that false means not applicable, not protected.

The comparison is strict and so is the reference’s, so nothing is lost at the boundary: outdoor air at precisely -10.0 °C is not a fault and -10.1 °C is. persist requires 5 continuous minutes, and the delay does real work despite the short window — frost sequences stage in and out around their own setpoint, and an outdoor reading crossing -10 °C on a windy afternoon can toggle the cold term several times before the sequence latches. Any engagement of the sequence, however brief, drops the timer and discards the accumulated time.

Possible Diagnoses

  1. Frost protection control sequence disabled — switched off during troubleshooting, or never enabled at commissioning
  2. OAT sensor error, reading warmer than actual — the sequence is working and has simply not been told it is cold; the rule cannot distinguish that from a dead sequence, since its own cold term reads the same sensor
  3. Frost protection damper or valve actuator failure — the sequence commands, nothing moves, and the status point may or may not admit it
  4. Preheat coil not functioning: no hot water, a closed isolation valve, or a failed electric element behind a status point that still reports “on”

Energy Impact

PROTECTIVE, MEDIUM confidence, QUALITATIVE_ONLY. There is no energy model here and the reference does not attempt one — it points to the Energy Impact Reference §4.4 and stops. The value is avoided equipment damage: frozen plate cores crack, iced wheels stall their drive motors, and both end in replacement rather than repair (the erv-effectiveness playbook prices a core at $2,000-$5,000). Heating-dominant by construction — the rule is unreachable above -10 °C. The second-order energy cost is unmodelled but ordered: a partially iced core is a degraded core, so a unit that runs unprotected through a cold snap shows up in ERV-0001’s effectiveness test afterwards, as a consequence rather than a coincidence.

Emissions Impact

Scope 2, DIRECT_EMISSIONS, HIGH confidence (the reference rates emissions confidence above energy confidence here, and the frontmatter’s single confidence field carries the MEDIUM from the energy profile). The reference’s typical range is “protective; pump energy minor; equipment damage primary” with no avoided-emissions basis — the consequence is the embodied carbon of a replacement core plus the conditioning energy spent while the recovery device is out of service, neither of which this rule can meter.

Deviations

  • The threshold parameter is negative, deliberately. The library normally expresses negatives as a Sources.Constant plus a Subtract so every literal reads as a magnitude, but a sub-zero temperature threshold is the documented exception: -10 °C is a point on a scale with a fixed zero, not a negated quantity, and 0 − 10 would invent arithmetic the physics does not have. coldOat.t = -10.0 is what an operator types into the BAS, and a host retuning for a milder climate sets -7.0, not 7.0.
  • erv_enabled is a conjunct in the graph, not a host-side operating-state gate. The library keeps operating-state gating in frontmatter, but the reference writes the enable into the equation itself, so the graph implements what the reference states. ERV-0001 makes the same choice with the same point, so the pair behaves consistently on a unit that is off.
  • No evaluability output. SCHEMA.md asks for one when the reference’s semantics include an in-rule evaluability condition; the only gating term here is erv_enabled, which the host binds as a boundary input and can read directly. Contrast ERV-0001’s yTempDeltaOk, which is computed from three temperatures and cannot be seen from outside the rule.
  • The frost test reads oat, the site sensor, not erv_oa_entering_temp, following the reference’s required-points list and the point dictionary, whose oat entry names this frost test as a consumer. On most units the two are the same sensor; where they are not, a well-sited inlet sensor is arguably the better measure of what the core sees, but a rooftop oat in direct sun reads warm, which is diagnosis 2 and silences the rule.
  • Fan status stays a host precondition. The reference’s ERV operating state is “ERV enabled, both supply and exhaust fans running”. The enable is in the graph because the equation names it; fan status is not in this rule’s required point list and is not invented here.
  • A unit with no frost sequence looks identical to a broken one. The rule reads a status point and cannot ask whether the sequence exists, so deploying it on a unit that never had frost protection produces a season-long standing alarm. That is a real finding, but it belongs in a design review, so the exclusion is host-side.
  • AlarmDelay = 5 min becomes persist.delayTime = 300 s with delayOnInit = true (CDL default is false), the library’s standing choice: a unit already running unprotected at controller start waits out the full five minutes rather than alarming on the first tick.
  • Frontmatter clusters is empty: the reference defines no cluster containing an ERV rule, and this card does not edit the cluster set. The relationship to ERV-0001 is carried by related and the shared playbook.
  • Frontmatter g36 is null. This is a research-backed 050-range rule sourced to engineering best practice; G36 has no ERV frost sequence to cite.
  • The reference publishes no test vectors for this card; every scenario in vectors.json is library-authored.

Notes

The erv-effectiveness playbook now starts with a dedicated mode/frost-state check: verify the OAT against a second local sensor, confirm the point means active protection rather than a permissive, and exercise one safe transition through the installed sequence. Continue by checking the damper, valve, wheel, or preheat source that implements protection. A unit that ran unprotected through a cold snap should have its core inspected for damage before it is trusted again.

Test Vectors

9 scenarios, clock step 60 s over 1800 s.

ScenarioDescription
cold_with_frost_protection_offThe motivating case: -15 C outdoor air, the ERV running, and the frost sequence reporting inactive. All three conjuncts hold from t=0, so delayOnInit makes the alarm land one alarm_delay (300 s) in.
cold_with_frost_protection_engagedSame -15 C outdoor air with the frost sequence engaged. This is the unit working as designed and the rule stays silent no matter how cold it gets.
mild_outdoor_air_no_frost_protection-2 C with frost protection off. Frost protection is not supposed to be running here — the threshold is what separates a missing sequence from a sequence that has no reason to act.
erv_disabled_in_deep_cold-20 C, frost protection off, but the ERV is not enabled: no air is moving through the core, so there is nothing to frost. The enable conjunct holds yFault down — and false here means ‘not applicable’, not ‘protected’.
oat_exactly_at_frost_thresholdBoundary: outdoor air sitting exactly on frost_threshold (-10.0 C) with frost protection off. CDL Reals has no LessEqual, so the comparison is strict < and exactly -10.0 clears.
oat_just_below_frost_thresholdBoundary from the other side: -10.1 C, one tenth of a degree colder than the threshold. The cold term now passes and the alarm lands after alarm_delay.
brief_cold_excursionA three-minute dip to -12 C with frost protection off, ending 120 s short of the 300 s alarm_delay. The timer resets when the air warms and no alarm is ever raised.
frost_protection_engages_after_alarmRecovery: the alarm asserts at 300 s, then the frost sequence starts at t=900 s. yFault drops on that same tick — TrueDelay only delays the rising edge — even though the outdoor air is still at -15 C.
erv_shuts_down_after_alarmThe other release path: the alarm asserts at 300 s and the ERV is disabled at t=900 s. yFault drops with the enable, which is the reference’s own semantics — the fault describes an enabled ERV running unprotected.
vectors.json
{
  "schema": "cxf-library/vectors/v1",
  "clock": {
    "step_s": 60,
    "horizon_s": 1800
  },
  "scenarios": [
    {
      "name": "cold_with_frost_protection_off",
      "description": "The motivating case: -15 C outdoor air, the ERV running, and the frost sequence reporting inactive. All three conjuncts hold from t=0, so delayOnInit makes the alarm land one alarm_delay (300 s) in.",
      "inputs": {
        "oat": -15.0,
        "erv_frost_prot": false,
        "erv_enabled": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 240,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 360,
          "to_s": 1800,
          "equals": true
        }
      ]
    },
    {
      "name": "cold_with_frost_protection_engaged",
      "description": "Same -15 C outdoor air with the frost sequence engaged. This is the unit working as designed and the rule stays silent no matter how cold it gets.",
      "inputs": {
        "oat": -15.0,
        "erv_frost_prot": true,
        "erv_enabled": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        }
      ]
    },
    {
      "name": "mild_outdoor_air_no_frost_protection",
      "description": "-2 C with frost protection off. Frost protection is not supposed to be running here \u2014 the threshold is what separates a missing sequence from a sequence that has no reason to act.",
      "inputs": {
        "oat": -2.0,
        "erv_frost_prot": false,
        "erv_enabled": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        }
      ]
    },
    {
      "name": "erv_disabled_in_deep_cold",
      "description": "-20 C, frost protection off, but the ERV is not enabled: no air is moving through the core, so there is nothing to frost. The enable conjunct holds yFault down \u2014 and false here means 'not applicable', not 'protected'.",
      "inputs": {
        "oat": -20.0,
        "erv_frost_prot": false,
        "erv_enabled": false
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        }
      ]
    },
    {
      "name": "oat_exactly_at_frost_threshold",
      "description": "Boundary: outdoor air sitting exactly on frost_threshold (-10.0 C) with frost protection off. CDL Reals has no LessEqual, so the comparison is strict `<` and exactly -10.0 clears.",
      "inputs": {
        "oat": -10.0,
        "erv_frost_prot": false,
        "erv_enabled": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        }
      ]
    },
    {
      "name": "oat_just_below_frost_threshold",
      "description": "Boundary from the other side: -10.1 C, one tenth of a degree colder than the threshold. The cold term now passes and the alarm lands after alarm_delay.",
      "inputs": {
        "oat": -10.1,
        "erv_frost_prot": false,
        "erv_enabled": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 240,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 360,
          "to_s": 1800,
          "equals": true
        }
      ]
    },
    {
      "name": "brief_cold_excursion",
      "description": "A three-minute dip to -12 C with frost protection off, ending 120 s short of the 300 s alarm_delay. The timer resets when the air warms and no alarm is ever raised.",
      "inputs": {
        "oat": [
          {
            "t": 0,
            "value": -12.0
          },
          {
            "t": 180,
            "value": -5.0
          }
        ],
        "erv_frost_prot": false,
        "erv_enabled": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        }
      ]
    },
    {
      "name": "frost_protection_engages_after_alarm",
      "description": "Recovery: the alarm asserts at 300 s, then the frost sequence starts at t=900 s. yFault drops on that same tick \u2014 TrueDelay only delays the rising edge \u2014 even though the outdoor air is still at -15 C.",
      "inputs": {
        "oat": -15.0,
        "erv_frost_prot": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 900,
            "value": true
          }
        ],
        "erv_enabled": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 240,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 360,
          "to_s": 840,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 960,
          "to_s": 1800,
          "equals": false
        }
      ]
    },
    {
      "name": "erv_shuts_down_after_alarm",
      "description": "The other release path: the alarm asserts at 300 s and the ERV is disabled at t=900 s. yFault drops with the enable, which is the reference's own semantics \u2014 the fault describes an enabled ERV running unprotected.",
      "inputs": {
        "oat": -15.0,
        "erv_frost_prot": false,
        "erv_enabled": [
          {
            "t": 0,
            "value": true
          },
          {
            "t": 900,
            "value": false
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 240,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 360,
          "to_s": 840,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 960,
          "to_s": 1800,
          "equals": false
        }
      ]
    }
  ]
}

ERV-0003 — Frost protection active above release conditions

Statusverified — engine e2ff2f8, cxf:fnv1a128:2d84409ba6461e88a64910d5f847870c, 2026-08-20
Severity3
Methodrule
Phase2
CategoryEXCESS_CONSUMPTION
ConfidenceMEDIUM
EstimationQUALITATIVE_ONLY
G36
Clusters
Suppresses
Suppressed by
RelatedERV-0001, ERV-0002
Playbookserv-effectiveness
SourceLibrary-authored complement to HVAC FDD Reference v1.0 §15 ERV-0002: that rule detects frost protection missing in cold weather; this rule detects the same reported sequence state persisting after warm-weather release should have occurred; Library precedent: ERV-0002 (oat / erv_frost_prot / erv_enabled point contract and strict frost-state watchdog), ERV-0001 (recovery lost intentionally during frost protection), and the repository’s 900 s transient-rejection convention; points/erv.points.json oat, erv_frost_prot, and erv_enabled — the binding distinction between an active sequence state and a permissive/advisory flag; PNNL-19004 p.55 and DOE/NREL Ventilation Integrated Comfort System report pp.28-29 — public examples whose frost controls use exhaust-leaving/core-entering conditions rather than one portable OAT release threshold, supporting site configuration rather than transcription; Greenheck ERV controller IOM 484118, p.4 — manufacturer example combining a device-specific OAT permissive with wheel differential pressure; evidence that technology/controller logic varies, not support for the shipped +5 °C
Operating statesERV enabled and expected to recover energy, with both air streams moving and the installed frost sequence capable of releasing under warm conditions

Preconditions (host-enforced): erv_frost_prot must be the actual active state of the installed frost sequence, not a frost-enable permissive, alarm, or low-temperature advisory. release_oat must be commissioned from that unit’s own release logic and must remain above ERV-0002’s engagement threshold; the shipped +5 °C is only an executable starting point. OAT must be valid and representative of the ERV intake. The host must verify both air streams are moving and exclude manual frost tests, commissioning, smoke/purge modes, maintenance overrides, and technology-specific recovery modes that legitimately retain frost protection above the configured line. A unit with no frost sequence or no observable active state is not deployable for this rule.

Points: oat, erv_frost_prot, erv_enabled

Outputs:

  • yFault — True while the enabled ERV has reported frost protection active above release_oat continuously for sustained_duration

Parameters:

NameDefaultUnitCXF pathDescription
release_oat5.0°CaboveRelease.tADOPTED_TUNABLE: outdoor temperature above which continued frost protection is suspicious. Configure from the installed sequence’s actual release point; +5 °C is not a universal frost boundary and deliberately leaves a neutral band above ERV-0002’s -10 °C engagement default.
sustained_duration900.0spersist.delayTimeADOPTED_TUNABLE: continuous warm-weather frost state required before alarm (15 min). Long enough to reject ordinary release and sensor-filter lag; raise it where the manufacturer’s sequence completes a longer defrost or recovery transition.

Description

Frost protection is supposed to trade recovery efficiency for equipment safety only while icing is credible. A preheat stage, bypass, wheel slowdown, or airflow-unbalance strategy that stays active in mild weather continues paying that trade after its benefit has disappeared. This rule watches the sequence’s reported active state against a commissioned outdoor-air release boundary. It does not judge how the unit protects itself or redesign its frost sequence.

Detection Logic

above_release = oat > release_oat
candidate     = erv_enabled AND erv_frost_prot AND above_release

yFault = candidate sustained continuously for sustained_duration

Block graph (rule.cxf.jsonld):

ERV-0003 block graph

Both the temperature comparison and timer are strict in their own ways: OAT at exactly release_oat is clear, and the alarm appears only after the full continuous duration. delayOnInit = true serves that duration after a restart. Any release of frost mode, OAT return to the boundary, or ERV disable clears the alarm and discards elapsed time immediately.

Possible Diagnoses

  1. Frost-mode software latch, timer, or state machine failed to release
  2. OAT sensor biased high (false alarm), biased low (missed finding), stale, or installed where it does not represent the ERV intake
  3. Preheat valve/relay, bypass damper, or wheel-speed command left overridden
  4. BAS point bound to a frost permissive rather than the sequence’s active state
  5. release_oat configured below the installed sequence’s true release point

Energy Impact

EXCESS_CONSUMPTION, MEDIUM confidence, QUALITATIVE_ONLY. The cost depends on the frost technology: preheat can consume fuel or electricity, bypass/wheel slowdown hands ventilation load back to downstream coils, and airflow imbalance adds fan and envelope load. The rule measures duration but no power or recovered heat.

Emissions Impact

Scope 1 + 2, QUALITATIVE_EMISSIONS. Electric fan/preheat and cooling effects are scope 2; fuel-fired preheat or downstream heat is scope 1. Quantification needs the host’s power, airflow, and temperature measurements rather than this state flag alone.

Deviations

  • Library-authored complement, not a transcribed reference card. The HVAC FDD Reference publishes ERV-0002’s missing-protection direction; this card mirrors its point contract for the opposite operational failure.
  • release_oat = 5 °C is ADOPTED_TUNABLE. It is intentionally distinct from ERV-0002’s -10 °C engagement default, leaving a 15 K neutral band in which neither rule asserts. The installed sequence remains authoritative.
  • Public examples do not establish a generic OAT release line. PNNL-19004 controls an exhaust-leaving temperature and DOE’s VICS prototype tempers core-entering air; Greenheck combines its own permissive with wheel pressure.
  • The roadmap classified 900 s as precedent; this card classifies it as ADOPTED_TUNABLE. The library uses 15-minute rejection windows, but no cited source establishes that duration for every frost technology.
  • The optional release_margin is omitted. With no configured release input, a zero-default margin duplicates release_oat without adding behavior.
  • erv_enabled remains in-graph. ERV-0001/0002 already use this boundary point to avoid nightly raw alarms; fan proof, tests, and overrides remain host preconditions.
  • No suppression or cluster. Excessive frost protection can genuinely cause low effectiveness, so ERV-0001 remains useful; shared remediation is carried by the playbook rather than a new taxonomy entry.
  • No empirical validation claim. Required synthetic vectors ran; the current EnergyPlus harness has no defensible frost-state mapping for this PR.

Notes

Confirm the point meaning before tuning the threshold. A flag that means “frost protection available” rather than “frost protection active” will hold this rule on all year and no temperature adjustment will fix the binding.

Test Vectors

11 scenarios, clock step 30 s over 2400 s.

ScenarioDescription
disabled_unit_holds_the_alarm_downThe raw graph receives a warm OAT and an active frost flag, but erv_enabled is false. This pins the in-graph enable gate; maintenance and test exclusions remain host-side.
frost_protection_below_release_is_healthyProtection is active at 0 C, below the adopted +5 C release line, so the sequence may legitimately remain in frost mode.
oat_exactly_at_release_is_healthyGreaterThreshold is strict: OAT exactly at release_oat does not start persistence.
warm_frost_mode_asserts_after_the_delayOAT is just above the release line from initialization. The assertions leave one 30 s step unbound at the 900 s timer edge, as required by the vector contract.
warm_excursion_just_short_of_delay_stays_clearThe warm interval lasts 870 s on the 30 s grid, one sample short of persistence, then OAT returns to the safe side.
warm_excursion_asserts_after_crossingThe candidate begins at t=300. The true assertion starts one 30 s step after the 900 s timer edge.
frost_flag_clear_restarts_persistenceThe frost flag clears for 300 s during a warm spell, resetting the clock; the true assertion begins one step after the second interval’s timer edge.
repeated_short_warm_excursions_do_not_accumulateThree 300 s warm excursions are separated by boundary crossings. TrueDelay discards rather than accumulates their time.
sequence_release_clears_an_active_alarmA standing warm-weather alarm clears on the same tick the frost sequence releases.
oat_returns_to_release_one_step_before_maturityOAT clears at t=870, one 30 s step before the 900 s timer edge, so the alarm never matures.
frost_releases_two_steps_after_maturityThe violation remains active two steps beyond the 900 s edge. Assertions skip the boundary tick, prove the alarm one step later, and prove immediate recovery at t=960.
vectors.json
{
  "schema": "cxf-library/vectors/v1",
  "clock": {
    "step_s": 30,
    "horizon_s": 2400
  },
  "scenarios": [
    {
      "name": "disabled_unit_holds_the_alarm_down",
      "description": "The raw graph receives a warm OAT and an active frost flag, but erv_enabled is false. This pins the in-graph enable gate; maintenance and test exclusions remain host-side.",
      "inputs": {
        "oat": 10.0,
        "erv_frost_prot": true,
        "erv_enabled": false
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        }
      ]
    },
    {
      "name": "frost_protection_below_release_is_healthy",
      "description": "Protection is active at 0 C, below the adopted +5 C release line, so the sequence may legitimately remain in frost mode.",
      "inputs": {
        "oat": 0.0,
        "erv_frost_prot": true,
        "erv_enabled": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        }
      ]
    },
    {
      "name": "oat_exactly_at_release_is_healthy",
      "description": "GreaterThreshold is strict: OAT exactly at release_oat does not start persistence.",
      "inputs": {
        "oat": 5.0,
        "erv_frost_prot": true,
        "erv_enabled": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        }
      ]
    },
    {
      "name": "warm_frost_mode_asserts_after_the_delay",
      "description": "OAT is just above the release line from initialization. The assertions leave one 30 s step unbound at the 900 s timer edge, as required by the vector contract.",
      "inputs": {
        "oat": 5.1,
        "erv_frost_prot": true,
        "erv_enabled": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 870,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 930,
          "to_s": 2400,
          "equals": true
        }
      ]
    },
    {
      "name": "warm_excursion_just_short_of_delay_stays_clear",
      "description": "The warm interval lasts 870 s on the 30 s grid, one sample short of persistence, then OAT returns to the safe side.",
      "inputs": {
        "oat": [
          {
            "t": 0,
            "value": 0.0
          },
          {
            "t": 300,
            "value": 5.1
          },
          {
            "t": 1170,
            "value": 5.0
          }
        ],
        "erv_frost_prot": true,
        "erv_enabled": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        }
      ]
    },
    {
      "name": "warm_excursion_asserts_after_crossing",
      "description": "The candidate begins at t=300. The true assertion starts one 30 s step after the 900 s timer edge.",
      "inputs": {
        "oat": [
          {
            "t": 0,
            "value": 0.0
          },
          {
            "t": 300,
            "value": 5.1
          }
        ],
        "erv_frost_prot": true,
        "erv_enabled": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1170,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 1230,
          "to_s": 2400,
          "equals": true
        }
      ]
    },
    {
      "name": "frost_flag_clear_restarts_persistence",
      "description": "The frost flag clears for 300 s during a warm spell, resetting the clock; the true assertion begins one step after the second interval's timer edge.",
      "inputs": {
        "oat": 10.0,
        "erv_frost_prot": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 300,
            "value": true
          },
          {
            "t": 900,
            "value": false
          },
          {
            "t": 1200,
            "value": true
          }
        ],
        "erv_enabled": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2070,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2130,
          "to_s": 2400,
          "equals": true
        }
      ]
    },
    {
      "name": "repeated_short_warm_excursions_do_not_accumulate",
      "description": "Three 300 s warm excursions are separated by boundary crossings. TrueDelay discards rather than accumulates their time.",
      "inputs": {
        "oat": [
          {
            "t": 0,
            "value": 0.0
          },
          {
            "t": 300,
            "value": 10.0
          },
          {
            "t": 600,
            "value": 0.0
          },
          {
            "t": 900,
            "value": 10.0
          },
          {
            "t": 1200,
            "value": 0.0
          },
          {
            "t": 1500,
            "value": 10.0
          },
          {
            "t": 1800,
            "value": 0.0
          }
        ],
        "erv_frost_prot": true,
        "erv_enabled": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        }
      ]
    },
    {
      "name": "sequence_release_clears_an_active_alarm",
      "description": "A standing warm-weather alarm clears on the same tick the frost sequence releases.",
      "inputs": {
        "oat": 10.0,
        "erv_frost_prot": [
          {
            "t": 0,
            "value": true
          },
          {
            "t": 1200,
            "value": false
          }
        ],
        "erv_enabled": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 870,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 930,
          "to_s": 1170,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 1200,
          "to_s": 2400,
          "equals": false
        }
      ]
    },
    {
      "name": "oat_returns_to_release_one_step_before_maturity",
      "description": "OAT clears at t=870, one 30 s step before the 900 s timer edge, so the alarm never matures.",
      "inputs": {
        "oat": [
          {
            "t": 0,
            "value": 5.1
          },
          {
            "t": 870,
            "value": 5.0
          }
        ],
        "erv_frost_prot": true,
        "erv_enabled": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        }
      ]
    },
    {
      "name": "frost_releases_two_steps_after_maturity",
      "description": "The violation remains active two steps beyond the 900 s edge. Assertions skip the boundary tick, prove the alarm one step later, and prove immediate recovery at t=960.",
      "inputs": {
        "oat": 5.1,
        "erv_frost_prot": [
          {
            "t": 0,
            "value": true
          },
          {
            "t": 960,
            "value": false
          }
        ],
        "erv_enabled": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 870,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 930,
          "to_s": 930,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 960,
          "to_s": 2400,
          "equals": false
        }
      ]
    }
  ]
}

ERV-0004 — Recovery device proof-of-operation failure

Statusverified — engine e2ff2f8, cxf:fnv1a128:0c006939620ca8d334e3d521c711d2cc, 2026-08-20
Severity2
Methodrule
Phase2
CategoryPROTECTIVE
ConfidenceHIGH
EstimationPROXY_ESTIMATION
G36
Clusters
Suppresses
Suppressed by
RelatedERV-0001, ERV-0003, ERV-0005
Playbooksproof-of-operation, erv-effectiveness
SourceLibrary-authored active-recovery application of the command/status proof family established by AHU-0039, PMP-0003, and HW-0009; Buildings.Controls.OBC.CDL.Logical.Proof (Hu & Wetter, March 2023), available at engine pin e2ff2f8; the library uses the proven two-direction vocabulary but composes independent timers because the packaged block cannot express this card’s required behavior; points/erv.points.json erv_recovery_cmd and erv_recovery_status — final-command and independent-physical-proof contracts for wheels, runaround pumps, and other active recovery devices; Greenheck ERV controller IOM 484118, pp.5 and 11 — the controller I/O identifies a wheel-rotation alarm and the menu documents automatic wheel jog; supports available independent proof and the final-command/jog binding caveat, not the 120 s defaults
Operating statesall operating states of an active recovery device; both mismatch directions remain meaningful whenever the final command and independent proof are available

Preconditions (host-enforced): Applicable only to an active recovery component with a final run command and independent physical proof: a rotary wheel drive, runaround-loop pump, or equivalent. Passive fixed-plate cores are excluded. Both points must belong to the same component, be fresh, and arrive faster than the configured proof windows. Bind erv_recovery_cmd after frost, bypass, smoke, safety, automatic wheel-jog/exercise, and local sequence logic; an upstream ERV enable is not the same command. If jog is not represented in the final command, exclude its intervals host-side. erv_recovery_status must prove rotation/work (wheel speed/rotation switch, pump current/flow, or equivalent), not echo the command relay. Configure start_proof_time above the slowest legitimate acceleration and delivery delay and stop_proof_time above wheel coast-down. Exclude maintenance and local/hand testing at the host.

Points: erv_recovery_cmd, erv_recovery_status

Outputs:

  • yFault — True while either independently timed command/status mismatch direction is active
  • yFailToStart — Diagnostic flag — command true and independent status false continuously for start_proof_time
  • yUnexpectedRun — Diagnostic flag — status true and command false continuously for stop_proof_time

Parameters:

NameDefaultUnitCXF pathDescription
start_proof_time120.0sstartHeld.delayTimeADOPTED_TUNABLE: maximum allowed time from final run command to independent proof (2 min). Set above wheel/pump acceleration plus worst-case point-delivery latency; this is not a manufacturer-universal limit.
stop_proof_time120.0sstopHeld.delayTimeADOPTED_TUNABLE: maximum allowed time for proof to remain after command-off (2 min). Set above legitimate wheel coast-down or runaround-pump proof decay and point-delivery latency.

Description

An active recovery device has to do more than receive an enable. This rule compares the final command reaching the wheel drive or runaround pump with an independent indication that the component actually operates. It detects both a device that fails to start and one that continues running after its command is removed. Passive plate cores have no command/status pair and are outside scope.

Detection Logic

yFailToStart   = (erv_recovery_cmd AND NOT erv_recovery_status)
                 sustained for start_proof_time
yUnexpectedRun = (erv_recovery_status AND NOT erv_recovery_cmd)
                 sustained for stop_proof_time

yFault = yFailToStart OR yUnexpectedRun

Block graph (rule.cxf.jsonld):

ERV-0004 block graph

Each direction owns its delay, and both delays use delayOnInit = true. A direction flip clears the old flag immediately and starts the other timer from zero; the two diagnostic outputs cannot overlap because their command terms are opposites. Agreement clears without an off-delay.

Possible Diagnoses

yFailToStart:

  1. Broken wheel belt/coupling, stalled motor, tripped overload, or failed drive
  2. Runaround pump locked out, isolated, air-bound, or mechanically failed
  3. Status switch/threshold failed even though the device operates
  4. Final command point not reaching the starter or drive

yUnexpectedRun:

  1. HOA/local switch left in HAND or drive left in local mode
  2. Software override, welded contactor, or a second controller still commanding
  3. Status point stuck true or sourced from the wrong component

Energy Impact

PROTECTIVE, HIGH confidence, PROXY_ESTIMATION. A fail-to-start can remove most of the intended heat recovery while downstream coils silently make up the load; an unexpected run adds motor/pump power and can oppose bypass or frost intent. Duration is known, but power and lost recovered heat are host-side quantities.

Emissions Impact

Scope 1 + 2, PROXY_EMISSIONS. Device power is usually scope 2. Lost recovery shifts load to electric cooling and either electric or fuel heating, so the emissions scope follows the downstream plant and operating season.

Deviations

  • Library-authored for active recovery only. The HVAC FDD Reference has no ERV proof card; this is the established AHU/pump/boiler topology applied only where a real command and independent proof exist.
  • Both proof times are 120 s ADOPTED_TUNABLE defaults. No cited source gives one portable wheel/runaround value; separate parameters preserve legitimate acceleration and coast-down differences.
  • Automatic jog belongs in the final command. Greenheck documents wheel jog as normal control behavior; binding an upstream enable or excluding jog from the command would turn intentional operation into yUnexpectedRun.
  • Composed logic replaces CDL.Logical.Proof. At engine pin e2ff2f8, that block has one timing contract and unsuitable initialization/chatter behavior; Not + And + two TrueDelays preserves mutual exclusivity and full startup delays exactly like AHU-0039.
  • No evaluability output. Evaluability is freshness, independence, and applicability of the two bindings, all host knowledge that cannot be derived from their boolean values.
  • No suppression. A proof failure can cause ERV-0001’s real effectiveness loss, and a wheel can fail while airflow stays balanced; operators benefit from both findings.
  • No ERV cluster. The shared order belongs in erv-effectiveness and proof-of-operation; a three-rule delivery batch is not a causal taxonomy.
  • No empirical validation claim. Command and independent wheel/pump proof are not available in the current simulation mapping; synthetic vectors cover all truth-table and timing behavior.

Notes

Command echo is the dangerous binding: it agrees perfectly with the command even when a belt is broken or a pump is seized. If the two points share the same controller object or relay source, omit the rule until independent proof exists.

Test Vectors

12 scenarios, clock step 30 s over 1800 s.

ScenarioDescription
agreement_in_both_states_is_silentCommand and independent status agree through one on/off cycle, covering both healthy truth-table rows.
fail_to_start_asserts_after_start_proof_timeThe command rises at t=300 and proof never follows. Assertions leave the exact 120 s timer edge unbound and prove the start branch one step later.
unexpected_run_asserts_after_stop_proof_timeStatus rises against an off command at t=300. Assertions leave the timer edge unbound and prove unexpected run one step later.
healthy_start_proves_just_before_the_windowIndependent proof follows 90 s after command, one sample before the 120 s window, so no alarm appears.
healthy_stop_coasts_down_just_before_the_windowA wheel status remains true for 90 s after command-off, then drops before stop_proof_time.
command_chatter_restarts_the_start_timerA one-sample command drop resets the start clock. The true assertion moves to one step after the restarted timer edge.
status_chatter_never_maturesStatus alternates every 90 s against a held-on command; no fail-to-start interval survives the 120 s window.
direction_flip_never_asserts_bothA mature fail-to-start becomes unexpected run at t=600. The first flag clears immediately and the second serves its own window.
restart_into_a_mismatch_serves_the_full_windowThe model initializes with status on and command off. delayOnInit keeps the first 120 s quiet.
proof_recovery_clears_on_the_same_tickA mature fail-to-start clears immediately when independent proof arrives; the delay does not impose an off-delay.
unexpected_run_recovery_clears_on_the_same_tickA mature unexpected-run mismatch clears immediately when independent proof drops; the stop lane has no off-delay.
start_proof_arrives_two_steps_after_the_boundaryProof arrives at t=180. Assertions skip the exact t=120 timer edge, prove the mismatch at t=150, then prove immediate recovery.
vectors.json
{
  "schema": "cxf-library/vectors/v1",
  "clock": {
    "step_s": 30,
    "horizon_s": 1800
  },
  "scenarios": [
    {
      "name": "agreement_in_both_states_is_silent",
      "description": "Command and independent status agree through one on/off cycle, covering both healthy truth-table rows.",
      "inputs": {
        "erv_recovery_cmd": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 300,
            "value": true
          },
          {
            "t": 900,
            "value": false
          }
        ],
        "erv_recovery_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 300,
            "value": true
          },
          {
            "t": 900,
            "value": false
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        }
      ]
    },
    {
      "name": "fail_to_start_asserts_after_start_proof_time",
      "description": "The command rises at t=300 and proof never follows. Assertions leave the exact 120 s timer edge unbound and prove the start branch one step later.",
      "inputs": {
        "erv_recovery_cmd": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 300,
            "value": true
          }
        ],
        "erv_recovery_status": false
      },
      "expect": [
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 390,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 450,
          "to_s": 1800,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 450,
          "to_s": 1800,
          "equals": true
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        }
      ]
    },
    {
      "name": "unexpected_run_asserts_after_stop_proof_time",
      "description": "Status rises against an off command at t=300. Assertions leave the timer edge unbound and prove unexpected run one step later.",
      "inputs": {
        "erv_recovery_cmd": false,
        "erv_recovery_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 300,
            "value": true
          }
        ]
      },
      "expect": [
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 390,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 450,
          "to_s": 1800,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 450,
          "to_s": 1800,
          "equals": true
        },
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        }
      ]
    },
    {
      "name": "healthy_start_proves_just_before_the_window",
      "description": "Independent proof follows 90 s after command, one sample before the 120 s window, so no alarm appears.",
      "inputs": {
        "erv_recovery_cmd": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 300,
            "value": true
          }
        ],
        "erv_recovery_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 390,
            "value": true
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        }
      ]
    },
    {
      "name": "healthy_stop_coasts_down_just_before_the_window",
      "description": "A wheel status remains true for 90 s after command-off, then drops before stop_proof_time.",
      "inputs": {
        "erv_recovery_cmd": [
          {
            "t": 0,
            "value": true
          },
          {
            "t": 300,
            "value": false
          }
        ],
        "erv_recovery_status": [
          {
            "t": 0,
            "value": true
          },
          {
            "t": 390,
            "value": false
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        }
      ]
    },
    {
      "name": "command_chatter_restarts_the_start_timer",
      "description": "A one-sample command drop resets the start clock. The true assertion moves to one step after the restarted timer edge.",
      "inputs": {
        "erv_recovery_cmd": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 300,
            "value": true
          },
          {
            "t": 390,
            "value": false
          },
          {
            "t": 480,
            "value": true
          }
        ],
        "erv_recovery_status": false
      },
      "expect": [
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 570,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 630,
          "to_s": 1800,
          "equals": true
        }
      ]
    },
    {
      "name": "status_chatter_never_matures",
      "description": "Status alternates every 90 s against a held-on command; no fail-to-start interval survives the 120 s window.",
      "inputs": {
        "erv_recovery_cmd": true,
        "erv_recovery_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 90,
            "value": true
          },
          {
            "t": 180,
            "value": false
          },
          {
            "t": 270,
            "value": true
          },
          {
            "t": 360,
            "value": false
          },
          {
            "t": 450,
            "value": true
          },
          {
            "t": 540,
            "value": false
          },
          {
            "t": 630,
            "value": true
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        }
      ]
    },
    {
      "name": "direction_flip_never_asserts_both",
      "description": "A mature fail-to-start becomes unexpected run at t=600. The first flag clears immediately and the second serves its own window.",
      "inputs": {
        "erv_recovery_cmd": [
          {
            "t": 0,
            "value": true
          },
          {
            "t": 600,
            "value": false
          }
        ],
        "erv_recovery_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 600,
            "value": true
          }
        ]
      },
      "expect": [
        {
          "output": "yFailToStart",
          "from_s": 150,
          "to_s": 570,
          "equals": true
        },
        {
          "output": "yFailToStart",
          "from_s": 600,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 690,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 750,
          "to_s": 1800,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 150,
          "to_s": 570,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 600,
          "to_s": 690,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 750,
          "to_s": 1800,
          "equals": true
        }
      ]
    },
    {
      "name": "restart_into_a_mismatch_serves_the_full_window",
      "description": "The model initializes with status on and command off. delayOnInit keeps the first 120 s quiet.",
      "inputs": {
        "erv_recovery_cmd": false,
        "erv_recovery_status": true
      },
      "expect": [
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 90,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 150,
          "to_s": 1800,
          "equals": true
        }
      ]
    },
    {
      "name": "proof_recovery_clears_on_the_same_tick",
      "description": "A mature fail-to-start clears immediately when independent proof arrives; the delay does not impose an off-delay.",
      "inputs": {
        "erv_recovery_cmd": true,
        "erv_recovery_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 600,
            "value": true
          }
        ]
      },
      "expect": [
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 90,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 150,
          "to_s": 570,
          "equals": true
        },
        {
          "output": "yFailToStart",
          "from_s": 600,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 600,
          "to_s": 1800,
          "equals": false
        }
      ]
    },
    {
      "name": "unexpected_run_recovery_clears_on_the_same_tick",
      "description": "A mature unexpected-run mismatch clears immediately when independent proof drops; the stop lane has no off-delay.",
      "inputs": {
        "erv_recovery_cmd": false,
        "erv_recovery_status": [
          {
            "t": 0,
            "value": true
          },
          {
            "t": 600,
            "value": false
          }
        ]
      },
      "expect": [
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 90,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 150,
          "to_s": 570,
          "equals": true
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 600,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 600,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        }
      ]
    },
    {
      "name": "start_proof_arrives_two_steps_after_the_boundary",
      "description": "Proof arrives at t=180. Assertions skip the exact t=120 timer edge, prove the mismatch at t=150, then prove immediate recovery.",
      "inputs": {
        "erv_recovery_cmd": true,
        "erv_recovery_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 180,
            "value": true
          }
        ]
      },
      "expect": [
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 90,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 150,
          "to_s": 150,
          "equals": true
        },
        {
          "output": "yFailToStart",
          "from_s": 180,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 150,
          "to_s": 150,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 180,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        }
      ]
    }
  ]
}

ERV-0005 — Supply/exhaust airflow imbalance

Statusverified — engine e2ff2f8, cxf:fnv1a128:5e63c030131568b1c91e53f28f1df15c, 2026-08-20
Severity3
Methodrule
Phase2
CategoryCOMFORT_ENERGY
ConfidenceMEDIUM
EstimationQUALITATIVE_ONLY
G36
Clusters
Suppresses
Suppressed by
RelatedERV-0001, ERV-0004, SYS-0008
Playbookserv-effectiveness
SourceLibrary-authored from conservation of flow and ERV commissioning practice: compare the two device-local air streams on one averaging basis, while retaining any intentional design offset as a site configuration obligation; Library precedents: ERV-0001 (device-local recovery boundaries and evaluability), SYS-0008 (two directional air-balance findings with per-condition persistence), and CHW-0005 (MultiplyByParameter plus Greater for a dynamic threshold); points/erv.points.json erv_supply_airflow and erv_exhaust_airflow — Brick 1.4.4 / ASHRAE 223 / QUDT-grounded stream and unit contract; NREL Standard Work Specification 6.0303.1p and PNNL Building America balanced-ventilation guidance — public support for balancing incoming/outgoing ERV flow for recovery and pressure control; DOE/NREL Ventilation Integrated Comfort System report pp.28-29 — public example of frost prevention intentionally unbalancing core-path airflow, requiring the host frost-mode exclusion
Operating statesERV enabled and intended to operate in a balanced-flow mode, after both fans/dampers have completed their normal start or transition

Preconditions (host-enforced): Both flow measurements must represent the same ERV, the streams that actually traverse the recovery device, and the same time-averaging basis in finite nonnegative L/s. Reversed polarity or signed bidirectional bindings are invalid; the graph intentionally does not repair them. minimum_evaluable_flow must be configured from this unit’s size and sensor accuracy before deployment. The unit must have a balanced-flow intent: where design calls for building pressurization or another nonzero offset, the host must normalize/bias the measurements or configure a separate approved limit. Exclude smoke control, kitchen/lab exhaust offsets, purge, commissioning/balancing, demand-control ramps, frost strategies that intentionally unbalance flow, and sensor calibration failures. erv_enabled is in-graph; all other mode and quality gates remain host-side.

Points: erv_supply_airflow, erv_exhaust_airflow, erv_enabled

Outputs:

  • yFault — True while one evaluable directional imbalance remains active and enabled continuously for sustained_duration
  • yFlowOk — Evaluability flag — true only when mean absolute flow exceeds minimum_evaluable_flow; false means NO_EVAL and the host must ignore yFault
  • ySupplyHigh — Immediate diagnostic flag — evaluable supply flow exceeds exhaust by more than max_imbalance_fraction of mean flow
  • yExhaustHigh — Immediate diagnostic flag — evaluable exhaust flow exceeds supply by more than max_imbalance_fraction of mean flow

Parameters:

NameDefaultUnitCXF pathDescription
max_imbalance_fraction0.151allowedDiff.kADOPTED_TUNABLE: directional difference allowed as a fraction of mean absolute flow. Must remain positive and be commissioned against design pressure/offset intent; 0.15 is an executable starting point, not a universal ventilation requirement.
minimum_evaluable_flow100.0L/sflowOk.tNO_PORTABLE_DEFAULT: runnable 100 L/s placeholder below which sensor noise and ratios are not trusted. Set a positive instance value from design flow, turndown, and both sensors’ usable range before deployment.
sustained_duration900.0ssupplyHeld.delayTime, exhaustHeld.delayTimeADOPTED_TUNABLE: continuous imbalance in one direction required before alarm (15 min). One card parameter drives both direction timers; hosts must set both paths together.

Description

Energy recovery depends on two comparable air streams. A failed fan, loaded filter, closed damper, poor balance, or bad sensor can leave one stream carrying substantially more air than the other, reducing useful recovery and pushing the building away from its intended pressure. This rule normalizes their difference by mean flow, rejects low-flow operation, and reports which stream is high.

Detection Logic

mean_flow    = (|erv_supply_airflow| + |erv_exhaust_airflow|) / 2
allowed_diff = max_imbalance_fraction × mean_flow

yFlowOk      = mean_flow > minimum_evaluable_flow
ySupplyHigh  = yFlowOk AND (erv_supply_airflow − erv_exhaust_airflow > allowed_diff)
yExhaustHigh = yFlowOk AND (erv_exhaust_airflow − erv_supply_airflow > allowed_diff)

yFault = (erv_enabled AND ySupplyHigh) sustained for sustained_duration
      OR (erv_enabled AND yExhaustHigh) sustained for sustained_duration

Block graph (rule.cxf.jsonld):

ERV-0005 block graph

The graph never divides: multiplying the positive evaluable mean by the allowed fraction is algebraically equivalent and remains defined at zero flow. Both comparisons and the flow floor are strict. Each direction owns a startup- conservative timer, so a direct reversal resets persistence rather than allowing two opposite short intervals to combine.

Possible Diagnoses

  1. Supply or exhaust fan failed, overridden, or running at the wrong speed
  2. Loaded filter/core, blocked intake/discharge, or closed/stuck damper
  3. Belt, wheel, or runaround-device problem changing system resistance
  4. Unit never balanced, or balancing changed after filter/fan modifications
  5. Airflow sensor bias, reversed polarity, mismatched averaging, or wrong unit
  6. Intentional pressure offset not represented in the binding/configuration

Energy Impact

COMFORT_ENERGY, MEDIUM confidence, QUALITATIVE_ONLY. The observed flow difference is direct, but its energy consequence is not: it depends on fan curves, envelope leakage, weather, pressure intent, and recovery effectiveness. The most valuable output is often operational — which air path to inspect first.

Emissions Impact

Scope 1 + 2, QUALITATIVE_EMISSIONS. Fan waste is scope 2; extra conditioning from lost recovery or pressure-driven outdoor air follows the building’s electric cooling and electric/fuel heating systems. No emissions value is inferred here.

Deviations

  • Division-free dynamic comparison. The roadmap writes difference / mean; this graph compares each difference with fraction × mean, exactly equivalent when evaluable and defined even when both flows are zero.
  • One timer per direction. A single delay after the directional OR would not reset on an instantaneous reversal, contradicting the required vector. The SYS-0008 per-condition idiom preserves the stated reset behavior.
  • ySupplyHigh/yExhaustHigh are immediate flow-gated diagnostics. They are not persistence outputs and are not gated by erv_enabled; yFault is. False direction flags never mean NO_EVAL — that meaning belongs only to yFlowOk.
  • Absolute values serve only mean-flow evaluability. Signed differences still choose direction, so a negative binding can look evaluable and alarm backwards; vectors pin this raw behavior and the host must reject such data.
  • 0.15 is ADOPTED_TUNABLE; 100 L/s is NO_PORTABLE_DEFAULT. Neither is called a standard requirement. Both must be reconciled with unit size, sensor range, and intentional pressure offset.
  • Frost-mode unbalance is explicitly excluded. DOE’s VICS testing shows a legitimate tempering strategy that changes the two core paths differently; evaluating that interval would diagnose the frost sequence as a flow fault.
  • No suppression or new cluster. ERV-0004 can coexist with balanced airflow, and degraded effectiveness can remain a real separate finding. The shared investigation order is documented in the playbook.
  • No empirical validation claim. The current EnergyPlus harness lacks two defensible device-local airflow measurements and operating-state mappings; synthetic vectors cover thresholds, evaluability, timing, and bad bindings.

Notes

Do not “fix” a design pressure offset by widening the threshold until every unit passes. Normalize to the intended offset first; the residual is the imbalance this rule is meant to detect.

Test Vectors

20 scenarios, clock step 30 s over 2700 s.

ScenarioDescription
balanced_positive_flows_are_healthyEqual 1000 L/s streams are evaluable, balanced, and quiet.
supply_high_asserts_after_its_own_windowSupply exceeds exhaust by 40% of mean flow. The direction flag is immediate; yFault waits the full startup-conservative 900 s.
exhaust_high_asserts_after_its_own_windowThe mirror direction is independently detected and timed.
imbalance_exactly_at_fraction_is_healthy1075 and 925 L/s produce mean=1000 and difference=150, exactly the 0.15 allowance. Reals.Greater is strict.
imbalance_just_beyond_fraction_alarmsKeeping mean at 1000 while moving the streams 0.1 L/s each side makes the difference 150.2, just beyond the allowance.
both_flows_below_minimum_are_no_evalEqual 80 L/s streams are balanced but below the instance’s evaluability floor. yFlowOk is false and the verdict is NO_EVAL, not healthy.
mean_exactly_at_minimum_blocks_a_large_ratioOne stream at 200 L/s and one at zero gives mean=100 exactly. The strict floor blocks both the direction output and alarm.
mean_just_above_minimum_is_evaluable200.2 L/s against zero gives mean=100.1, just above the floor; the supply direction now evaluates and matures.
both_flows_zero_are_safely_no_evalThe multiply-and-compare formulation contains no division: zero flows produce allowed difference zero, false directions, and yFlowOk false.
negative_flow_binding_produces_a_misleading_directionNegative flow violates the point contract. The raw graph uses magnitudes only for mean flow and reports exhaust high here, pinning why the host must reject signed/reversed bindings.
transient_imbalance_one_sample_short_stays_clearSupply is high for 870 s, one sample short of the window, then the streams rebalance.
direction_reversal_restarts_persistenceThe unit is continuously imbalanced but flips direction at t=600. Separate direction timers discard the first lane; the true assertion starts one step after the exhaust lane’s timer edge.
disabled_unit_exposes_direction_but_not_faultDirection and evaluability outputs describe the measurements even when disabled; the alarm’s armed branches remain false.
rebalance_clears_an_active_alarmA startup violation matures at t=900 and clears immediately when both streams return to 1000 L/s at t=1200.
flow_drop_forces_no_eval_and_clears_alarmAfter a mature imbalance, both streams fall below the minimum. yFlowOk and both direction outputs fall on the same tick as yFault.
intermittent_imbalance_does_not_accumulateThree short supply-high bursts separated by balanced intervals never mature the supply timer.
imbalance_just_inside_fraction_is_healthyKeeping mean at 1000 while using a 149.8 L/s difference places the signal just inside the strict 0.15 allowance.
mean_just_below_minimum_is_no_eval199.8 L/s against zero gives mean=99.9, just below the 100 L/s floor; the large raw ratio is blocked.
imbalance_clears_one_step_before_maturityBoth streams rebalance at t=870, one 30 s step before the 900 s timer edge, so yFault never matures.
imbalance_clears_two_steps_after_maturityThe direction remains active two steps beyond the 900 s edge. Assertions skip the boundary tick, prove yFault at t=930, and prove recovery at t=960.
vectors.json
{
  "schema": "cxf-library/vectors/v1",
  "clock": {
    "step_s": 30,
    "horizon_s": 2700
  },
  "scenarios": [
    {
      "name": "balanced_positive_flows_are_healthy",
      "description": "Equal 1000 L/s streams are evaluable, balanced, and quiet.",
      "inputs": {
        "erv_supply_airflow": 1000.0,
        "erv_exhaust_airflow": 1000.0,
        "erv_enabled": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2700,
          "equals": false
        },
        {
          "output": "ySupplyHigh",
          "from_s": 0,
          "to_s": 2700,
          "equals": false
        },
        {
          "output": "yExhaustHigh",
          "from_s": 0,
          "to_s": 2700,
          "equals": false
        },
        {
          "output": "yFlowOk",
          "from_s": 0,
          "to_s": 2700,
          "equals": true
        }
      ]
    },
    {
      "name": "supply_high_asserts_after_its_own_window",
      "description": "Supply exceeds exhaust by 40% of mean flow. The direction flag is immediate; yFault waits the full startup-conservative 900 s.",
      "inputs": {
        "erv_supply_airflow": 1200.0,
        "erv_exhaust_airflow": 800.0,
        "erv_enabled": true
      },
      "expect": [
        {
          "output": "yFlowOk",
          "from_s": 0,
          "to_s": 2700,
          "equals": true
        },
        {
          "output": "ySupplyHigh",
          "from_s": 0,
          "to_s": 2700,
          "equals": true
        },
        {
          "output": "yExhaustHigh",
          "from_s": 0,
          "to_s": 2700,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 870,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 930,
          "to_s": 2700,
          "equals": true
        }
      ]
    },
    {
      "name": "exhaust_high_asserts_after_its_own_window",
      "description": "The mirror direction is independently detected and timed.",
      "inputs": {
        "erv_supply_airflow": 800.0,
        "erv_exhaust_airflow": 1200.0,
        "erv_enabled": true
      },
      "expect": [
        {
          "output": "yFlowOk",
          "from_s": 0,
          "to_s": 2700,
          "equals": true
        },
        {
          "output": "ySupplyHigh",
          "from_s": 0,
          "to_s": 2700,
          "equals": false
        },
        {
          "output": "yExhaustHigh",
          "from_s": 0,
          "to_s": 2700,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 870,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 930,
          "to_s": 2700,
          "equals": true
        }
      ]
    },
    {
      "name": "imbalance_exactly_at_fraction_is_healthy",
      "description": "1075 and 925 L/s produce mean=1000 and difference=150, exactly the 0.15 allowance. Reals.Greater is strict.",
      "inputs": {
        "erv_supply_airflow": 1075.0,
        "erv_exhaust_airflow": 925.0,
        "erv_enabled": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2700,
          "equals": false
        },
        {
          "output": "ySupplyHigh",
          "from_s": 0,
          "to_s": 2700,
          "equals": false
        },
        {
          "output": "yExhaustHigh",
          "from_s": 0,
          "to_s": 2700,
          "equals": false
        },
        {
          "output": "yFlowOk",
          "from_s": 0,
          "to_s": 2700,
          "equals": true
        }
      ]
    },
    {
      "name": "imbalance_just_beyond_fraction_alarms",
      "description": "Keeping mean at 1000 while moving the streams 0.1 L/s each side makes the difference 150.2, just beyond the allowance.",
      "inputs": {
        "erv_supply_airflow": 1075.1,
        "erv_exhaust_airflow": 924.9,
        "erv_enabled": true
      },
      "expect": [
        {
          "output": "ySupplyHigh",
          "from_s": 0,
          "to_s": 2700,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 870,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 930,
          "to_s": 2700,
          "equals": true
        }
      ]
    },
    {
      "name": "both_flows_below_minimum_are_no_eval",
      "description": "Equal 80 L/s streams are balanced but below the instance's evaluability floor. yFlowOk is false and the verdict is NO_EVAL, not healthy.",
      "inputs": {
        "erv_supply_airflow": 80.0,
        "erv_exhaust_airflow": 80.0,
        "erv_enabled": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2700,
          "equals": false
        },
        {
          "output": "ySupplyHigh",
          "from_s": 0,
          "to_s": 2700,
          "equals": false
        },
        {
          "output": "yExhaustHigh",
          "from_s": 0,
          "to_s": 2700,
          "equals": false
        },
        {
          "output": "yFlowOk",
          "from_s": 0,
          "to_s": 2700,
          "equals": false
        }
      ]
    },
    {
      "name": "mean_exactly_at_minimum_blocks_a_large_ratio",
      "description": "One stream at 200 L/s and one at zero gives mean=100 exactly. The strict floor blocks both the direction output and alarm.",
      "inputs": {
        "erv_supply_airflow": 200.0,
        "erv_exhaust_airflow": 0.0,
        "erv_enabled": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2700,
          "equals": false
        },
        {
          "output": "ySupplyHigh",
          "from_s": 0,
          "to_s": 2700,
          "equals": false
        },
        {
          "output": "yExhaustHigh",
          "from_s": 0,
          "to_s": 2700,
          "equals": false
        },
        {
          "output": "yFlowOk",
          "from_s": 0,
          "to_s": 2700,
          "equals": false
        }
      ]
    },
    {
      "name": "mean_just_above_minimum_is_evaluable",
      "description": "200.2 L/s against zero gives mean=100.1, just above the floor; the supply direction now evaluates and matures.",
      "inputs": {
        "erv_supply_airflow": 200.2,
        "erv_exhaust_airflow": 0.0,
        "erv_enabled": true
      },
      "expect": [
        {
          "output": "yFlowOk",
          "from_s": 0,
          "to_s": 2700,
          "equals": true
        },
        {
          "output": "ySupplyHigh",
          "from_s": 0,
          "to_s": 2700,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 930,
          "to_s": 2700,
          "equals": true
        }
      ]
    },
    {
      "name": "both_flows_zero_are_safely_no_eval",
      "description": "The multiply-and-compare formulation contains no division: zero flows produce allowed difference zero, false directions, and yFlowOk false.",
      "inputs": {
        "erv_supply_airflow": 0.0,
        "erv_exhaust_airflow": 0.0,
        "erv_enabled": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2700,
          "equals": false
        },
        {
          "output": "ySupplyHigh",
          "from_s": 0,
          "to_s": 2700,
          "equals": false
        },
        {
          "output": "yExhaustHigh",
          "from_s": 0,
          "to_s": 2700,
          "equals": false
        },
        {
          "output": "yFlowOk",
          "from_s": 0,
          "to_s": 2700,
          "equals": false
        }
      ]
    },
    {
      "name": "negative_flow_binding_produces_a_misleading_direction",
      "description": "Negative flow violates the point contract. The raw graph uses magnitudes only for mean flow and reports exhaust high here, pinning why the host must reject signed/reversed bindings.",
      "inputs": {
        "erv_supply_airflow": -600.0,
        "erv_exhaust_airflow": -400.0,
        "erv_enabled": true
      },
      "expect": [
        {
          "output": "yFlowOk",
          "from_s": 0,
          "to_s": 2700,
          "equals": true
        },
        {
          "output": "ySupplyHigh",
          "from_s": 0,
          "to_s": 2700,
          "equals": false
        },
        {
          "output": "yExhaustHigh",
          "from_s": 0,
          "to_s": 2700,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 930,
          "to_s": 2700,
          "equals": true
        }
      ]
    },
    {
      "name": "transient_imbalance_one_sample_short_stays_clear",
      "description": "Supply is high for 870 s, one sample short of the window, then the streams rebalance.",
      "inputs": {
        "erv_supply_airflow": [
          {
            "t": 0,
            "value": 1000.0
          },
          {
            "t": 300,
            "value": 1200.0
          },
          {
            "t": 1170,
            "value": 1000.0
          }
        ],
        "erv_exhaust_airflow": [
          {
            "t": 0,
            "value": 1000.0
          },
          {
            "t": 300,
            "value": 800.0
          },
          {
            "t": 1170,
            "value": 1000.0
          }
        ],
        "erv_enabled": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2700,
          "equals": false
        }
      ]
    },
    {
      "name": "direction_reversal_restarts_persistence",
      "description": "The unit is continuously imbalanced but flips direction at t=600. Separate direction timers discard the first lane; the true assertion starts one step after the exhaust lane's timer edge.",
      "inputs": {
        "erv_supply_airflow": [
          {
            "t": 0,
            "value": 1200.0
          },
          {
            "t": 600,
            "value": 800.0
          }
        ],
        "erv_exhaust_airflow": [
          {
            "t": 0,
            "value": 800.0
          },
          {
            "t": 600,
            "value": 1200.0
          }
        ],
        "erv_enabled": true
      },
      "expect": [
        {
          "output": "ySupplyHigh",
          "from_s": 0,
          "to_s": 570,
          "equals": true
        },
        {
          "output": "ySupplyHigh",
          "from_s": 600,
          "to_s": 2700,
          "equals": false
        },
        {
          "output": "yExhaustHigh",
          "from_s": 0,
          "to_s": 570,
          "equals": false
        },
        {
          "output": "yExhaustHigh",
          "from_s": 600,
          "to_s": 2700,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1470,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 1530,
          "to_s": 2700,
          "equals": true
        }
      ]
    },
    {
      "name": "disabled_unit_exposes_direction_but_not_fault",
      "description": "Direction and evaluability outputs describe the measurements even when disabled; the alarm's armed branches remain false.",
      "inputs": {
        "erv_supply_airflow": 1200.0,
        "erv_exhaust_airflow": 800.0,
        "erv_enabled": false
      },
      "expect": [
        {
          "output": "yFlowOk",
          "from_s": 0,
          "to_s": 2700,
          "equals": true
        },
        {
          "output": "ySupplyHigh",
          "from_s": 0,
          "to_s": 2700,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2700,
          "equals": false
        }
      ]
    },
    {
      "name": "rebalance_clears_an_active_alarm",
      "description": "A startup violation matures at t=900 and clears immediately when both streams return to 1000 L/s at t=1200.",
      "inputs": {
        "erv_supply_airflow": [
          {
            "t": 0,
            "value": 1200.0
          },
          {
            "t": 1200,
            "value": 1000.0
          }
        ],
        "erv_exhaust_airflow": [
          {
            "t": 0,
            "value": 800.0
          },
          {
            "t": 1200,
            "value": 1000.0
          }
        ],
        "erv_enabled": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 870,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 930,
          "to_s": 1170,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 1200,
          "to_s": 2700,
          "equals": false
        }
      ]
    },
    {
      "name": "flow_drop_forces_no_eval_and_clears_alarm",
      "description": "After a mature imbalance, both streams fall below the minimum. yFlowOk and both direction outputs fall on the same tick as yFault.",
      "inputs": {
        "erv_supply_airflow": [
          {
            "t": 0,
            "value": 1200.0
          },
          {
            "t": 1200,
            "value": 50.0
          }
        ],
        "erv_exhaust_airflow": [
          {
            "t": 0,
            "value": 800.0
          },
          {
            "t": 1200,
            "value": 0.0
          }
        ],
        "erv_enabled": true
      },
      "expect": [
        {
          "output": "yFlowOk",
          "from_s": 0,
          "to_s": 1170,
          "equals": true
        },
        {
          "output": "yFlowOk",
          "from_s": 1200,
          "to_s": 2700,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 930,
          "to_s": 1170,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 1200,
          "to_s": 2700,
          "equals": false
        },
        {
          "output": "ySupplyHigh",
          "from_s": 1200,
          "to_s": 2700,
          "equals": false
        }
      ]
    },
    {
      "name": "intermittent_imbalance_does_not_accumulate",
      "description": "Three short supply-high bursts separated by balanced intervals never mature the supply timer.",
      "inputs": {
        "erv_supply_airflow": [
          {
            "t": 0,
            "value": 1000.0
          },
          {
            "t": 300,
            "value": 1200.0
          },
          {
            "t": 600,
            "value": 1000.0
          },
          {
            "t": 900,
            "value": 1200.0
          },
          {
            "t": 1200,
            "value": 1000.0
          },
          {
            "t": 1500,
            "value": 1200.0
          },
          {
            "t": 1800,
            "value": 1000.0
          }
        ],
        "erv_exhaust_airflow": [
          {
            "t": 0,
            "value": 1000.0
          },
          {
            "t": 300,
            "value": 800.0
          },
          {
            "t": 600,
            "value": 1000.0
          },
          {
            "t": 900,
            "value": 800.0
          },
          {
            "t": 1200,
            "value": 1000.0
          },
          {
            "t": 1500,
            "value": 800.0
          },
          {
            "t": 1800,
            "value": 1000.0
          }
        ],
        "erv_enabled": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2700,
          "equals": false
        }
      ]
    },
    {
      "name": "imbalance_just_inside_fraction_is_healthy",
      "description": "Keeping mean at 1000 while using a 149.8 L/s difference places the signal just inside the strict 0.15 allowance.",
      "inputs": {
        "erv_supply_airflow": 1074.9,
        "erv_exhaust_airflow": 925.1,
        "erv_enabled": true
      },
      "expect": [
        {
          "output": "yFlowOk",
          "from_s": 0,
          "to_s": 2700,
          "equals": true
        },
        {
          "output": "ySupplyHigh",
          "from_s": 0,
          "to_s": 2700,
          "equals": false
        },
        {
          "output": "yExhaustHigh",
          "from_s": 0,
          "to_s": 2700,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2700,
          "equals": false
        }
      ]
    },
    {
      "name": "mean_just_below_minimum_is_no_eval",
      "description": "199.8 L/s against zero gives mean=99.9, just below the 100 L/s floor; the large raw ratio is blocked.",
      "inputs": {
        "erv_supply_airflow": 199.8,
        "erv_exhaust_airflow": 0.0,
        "erv_enabled": true
      },
      "expect": [
        {
          "output": "yFlowOk",
          "from_s": 0,
          "to_s": 2700,
          "equals": false
        },
        {
          "output": "ySupplyHigh",
          "from_s": 0,
          "to_s": 2700,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2700,
          "equals": false
        }
      ]
    },
    {
      "name": "imbalance_clears_one_step_before_maturity",
      "description": "Both streams rebalance at t=870, one 30 s step before the 900 s timer edge, so yFault never matures.",
      "inputs": {
        "erv_supply_airflow": [
          {
            "t": 0,
            "value": 1200.0
          },
          {
            "t": 870,
            "value": 1000.0
          }
        ],
        "erv_exhaust_airflow": [
          {
            "t": 0,
            "value": 800.0
          },
          {
            "t": 870,
            "value": 1000.0
          }
        ],
        "erv_enabled": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2700,
          "equals": false
        },
        {
          "output": "ySupplyHigh",
          "from_s": 0,
          "to_s": 840,
          "equals": true
        },
        {
          "output": "ySupplyHigh",
          "from_s": 870,
          "to_s": 2700,
          "equals": false
        }
      ]
    },
    {
      "name": "imbalance_clears_two_steps_after_maturity",
      "description": "The direction remains active two steps beyond the 900 s edge. Assertions skip the boundary tick, prove yFault at t=930, and prove recovery at t=960.",
      "inputs": {
        "erv_supply_airflow": [
          {
            "t": 0,
            "value": 1200.0
          },
          {
            "t": 960,
            "value": 1000.0
          }
        ],
        "erv_exhaust_airflow": [
          {
            "t": 0,
            "value": 800.0
          },
          {
            "t": 960,
            "value": 1000.0
          }
        ],
        "erv_enabled": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 870,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 930,
          "to_s": 930,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 960,
          "to_s": 2700,
          "equals": false
        }
      ]
    }
  ]
}

FCU Fault Rules

Fan coil unit fault detection rules (FCU-*). Source grounding: HVAC FDD Reference v1.0 ch.12 and ASHRAE Guideline 36 §5.22.6 for the 001-range (the FCU analog of the AHU §5.16.14 set). FCUs are two-coil air handlers in miniature, distributed by the dozens; faults persist because nobody is watching any single unit.

Point dictionary: points/fcu.points.json.

Index

IDNameSevMethodStatus
FCU-0001Excessive operating state changes3ruleverified
FCU-0002SAT too low in full heating3ruleverified
FCU-0003SAT too high in full cooling3ruleverified
FCU-0004Inactive cooling coil temperature drop (leak)3ruleverified
FCU-0005Inactive heating coil temperature rise (leak)3ruleverified
FCU-0006FCU fan proof-of-operation failure2ruleverified
FCU-0007Simultaneous heating and cooling commands2ruleverified

Severity and method for FCU-0001..005 follow the reference’s ch.12 cards (its §5.8.5 index carries no severity column); FCU-0006 is a severity-2 library proof-of-operation adaptation and FCU-0007 is the command-level FCU adaptation of the simultaneous-conditioning signature. The first five rules are siblings of verified AHU patterns: FC-001 mirrors AHU-0004’s rolling transition counter, FC-002/003 mirror the AHU-0007/AHU-0013 saturated-coil pair, FC-004/005 mirror the AHU-0014/AHU-0015 inactive-coil signatures with rat/sat as the entering/leaving proxies.

Relationships

  • FCU-0001..007 share the fcu-faults playbook.
  • FCU-0006 compares the final fan command with independent proof. Its fail-to-start direction contests the airflow premise of FCU-0002..005; the unexpected-run direction can leave those temperature signatures meaningful, so the relationship is informational rather than a whole-rule suppression.
  • Passive/convection terminal units do not instantiate FCU-0006.
  • FCU-0007 detects a control-command conflict; FCU-0004/0005 detect thermal evidence while the corresponding command is closed. They are related and do not suppress one another. Intentional cooling-plus-reheat is host-excluded.
  • FCU-0007 shares the simultaneous-hc workflow but remains outside CLU-01: the cluster’s AHU-0016 trigger cannot causally clear a local FCU conflict.
  • FC-004/005 are the zone-scale members of the simultaneous-conditioning family (AHU-0016’s world): a leaking valve conditions air nobody asked to condition.

FCU-0001 — Excessive operating state changes

Statusverified — engine e2ff2f8, cxf:fnv1a128:db6929d05cee5a6cd064e2a6dcf3b1d3, 2026-08-17
Severity3
Methodrule
Phase1
CategoryCOMFORT_ENERGY
ConfidenceLOW
EstimationQUALITATIVE_ONLY
G36§5.22.6 FC#1
ClustersCLU-01
Suppresses
Suppressed by
RelatedAHU-0004, FCU-0006
Playbooksfcu-faults
SourceHVAC FDD Reference v1.0 §12, FCU-0001; G36 §5.22.6 FC#1; G36 §5.16.14 Table 5.16.14.7 (ΔOS MAX provenance, per Addendum u public review)
Operating statesOS 1–4 (all)

Preconditions (host-enforced): The operating_state encoding must be stable for the life of the deployment — re-mapping the enum mid-stream registers as a transition on every unit whose state moved, across the whole building at once. count_scale must equal count_window divided by the host’s tick interval; the shipped 20.0 is right only on a 180 s tick, and a wrong value produces a plausible number rather than an error (see Deviations). The host tick must sit inside 57.15 s ≤ dt ≤ 450 s for the count to be both retained and reachable. Operator activity is not a fault: commissioning, manual mode forcing, and scheduled occupancy testing must be excluded host-side, since a deliberate mode change counts the same as an oscillation — this matters more on an FCU than on an AHU, because a guest or tenant at a wall thermostat produces exactly that signal. Warm-up evaluability is signalled in-rule by yWindowFull: while it is false the count is an extrapolated rate rather than a completed-hour count, and the verdict is NO_EVAL, not healthy. The same is true whenever any gate above is unmet.

Points: operating_state

Outputs:

  • yFault — True while the number of operating-state transitions in the trailing count_window has stayed above os_max for at least alarm_delay, counted over a window that has actually filled
  • yWindowFull — Evaluability signal — true once model time has reached count_window, which is the tick the moving average starts dividing by the window rather than by elapsed time; false means NO_EVAL and the host must ignore yFault

Parameters:

NameDefaultUnitCXF pathDescription
os_max7.01/hcntHigh.tTransitions per hour above which the unit counts as unstable rather than load-following (the chapter’s OS_MAX, and G36’s ΔOS MAX)
count_window3600.0srate.delta, windowFull.delayTimeTrailing window the transitions are counted over (1 h). It also fixes the units of os_max and the length of the warm-up gate, so both paths must move together; a host that shortens it must retune count_scale with it and read os_max as transitions per window rather than per hour
count_scale20.01count.kRescales the moving average of the one-tick pulse train back into a transition count: k = count_window / host tick interval in seconds. The default 20.0 is 3600/180, correct only at a 180 s tick; a host on a different tick MUST retune this or every count is wrong by the ratio of the two intervals
alarm_delay3600.0spersist.delayTimeContinuous fault persistence required before the alarm asserts (the chapter’s AlarmDelay, 60 min)

Description

A fan coil unit that walks between heating, deadband, cooling and off more than a few times an hour is not following load. Load does not move that fast in a hotel room; what moves that fast is a threshold with nothing to hold the unit on one side of it — heating and cooling setpoints too close together, a zone sensor that jitters across the changeover point, or a wall thermostat someone keeps adjusting. Each transition strokes valves, restarts or respeeds the fan, and cools air that was being warmed a minute ago, so the energy goes into work that cancels itself. On one unit the numbers are small, which is exactly why the fault survives: a building with three hundred FCUs has some number oscillating right now and the only way to find them is to count. This is AHU-0004’s rule at zone scale, on faster equipment, which is why the tick band and the warm-up gate are stricter here.

Detection Logic

pulse        = (operating_state ≠ previous tick's operating_state)   one tick wide
count        = MovingAverage(pulse, count_window) × count_scale      transitions in the trailing hour
yWindowFull  = model time ≥ count_window                             (false ⇒ host reports NO_EVAL)
yFault       = (count > os_max AND yWindowFull), sustained continuously for alarm_delay

Block graph (rule.cxf.jsonld):

FCU-0001 block graph

chg asks only whether the state value moved; its up and down outputs are declared for completeness and left unconnected, since which state the unit moved to says nothing about whether it is oscillating. Reals.MovingAverage is a continuous-time integral mean, so a one-tick pulse encloses one tick interval of area and count_scale = count_window / dt converts the pulse average back into a transition count — making count_scale a property of the host’s clock rather than of the building, with a legal tick band at both ends (see Deviations). cntHigh is strict: exactly seven transitions an hour reads clear, eight alarms, and the arithmetic lands on the boundary exactly. The top branch is what AHU-0004 does not have: alwaysOnwindowFull goes true at exactly t = 3600 s, the instant the moving average stops dividing by elapsed time and starts dividing by the window. It gates the alarm and leaves the block as yWindowFull, so a host that reads it learns the difference between “not faulted” and “cannot tell yet”. The earliest possible assertion is therefore 7200 s: one hour to fill the window, one hour of sustained excess. Both delays carry delayOnInit = true.

Possible Diagnoses

  1. Deadband between heating and cooling too narrow — the condition that ends the heating mode is the condition that starts the cooling one, so the unit flips back as soon as it has finished acting
  2. Conflicting zone demands — a perimeter room with solar gain on one side and an exterior wall on the other asks for both, and the unit alternates
  3. Sensor noise causing mode oscillation — one poorly located or intermittent zone sensor crosses the changeover threshold every few minutes and the sequencing logic faithfully obeys

Energy Impact

COMFORT_ENERGY, LOW confidence, QUALITATIVE_ONLY. There is no waste term to compute from this rule’s inputs — it sees a state index and cannot say what any transition cost. The reference puts the loss at 1–3% of zone energy, split between actuator wear and coils charged then abandoned before the air stream has settled. Size the opportunity host-side per Energy Impact Reference §4.4 (unstable hours × FCU coil and fan power); this rule contributes the hours. LOW confidence: no controlled study isolates cycling losses from the deadband change that fixes them, and no PNNL measure covers zone-level sequencing stability. Climate-neutral.

Emissions Impact

Scope 1 + 2, QUALITATIVE_EMISSIONS, LOW confidence; on the order of 5–15 kg CO₂e/yr per FCU from cycling losses. Both scopes appear because the transitions cross between them — a unit oscillating between heating and cooling burns a little gas at the boiler and a little electricity at the chiller for the same hour of indecision. The magnitude is an order of magnitude, not an estimate; the reason to chase it is the fleet, where three hundred units at 10 kg CO₂e/yr is three tonnes against a setpoint change. Avoided-emissions basis: N/A.

Deviations

  • The reference card names no points; operating_state is our binding. The chapter 12 card states the logic and the tunables with no Required Points table, so this rule binds the host-derived integer point the FCU dictionary carries for the purpose. Only transitions are consumed and no value is ever interpreted, so any stable enumeration binds — the dictionary recommends G36 §5.22’s OS#1–#4 index and requires only that the encoding not change.
  • Rolling count built from a moving average, because the block set has no windowed counter. Integers.OnCounter counts monotonically from a reset, so a trailing-hour count would need a host-driven hourly reset — a tumbling count whose verdict depends on where the hour boundary fell.
  • count_scale is coupled to the host’s tick interval, and the default is not AHU-0004’s. k = count_window / dt, and 20.0 is correct only at a 180 s tick; AHU-0004 ships 12.0 for a 300 s tick, and copying a count_scale between the two cards is the most likely way to deploy this wrong. The failure is silent: an FCU changing state 20 times an hour, read on a 60 s tick with count_scale left at 20.0, reports a steady 6.7 per hour and never alarms.
  • The legal tick band is 57.15 s ≤ dt ≤ 450 s, and both ends bite. The lower end is the MovingAverage ring — 64 checkpoints, of which a window retains count_window/dt + 1, so dt ≥ 3600/63; past it the block silently drops the oldest in-window sample, shortening the window and inflating the count. The upper end is the change counter: at most one transition per tick, so a strict count > 7 needs dt ≤ 3600/8 = 450 s for 8 to be reachable.
  • AHU-0004 states the ring bound as 56.25 s (3600/64); the correct figure is 57.15 s (3600/63). The retained set includes one checkpoint at or before the window’s trailing edge, so a window spanning n ticks needs n + 1 slots. The difference matters only within a second of the bound.
  • A change counter aliases, and the count is clipped rather than wrong-signed. Above the ceiling the rule under-reports: at the shipped 180 s tick a unit changing state every 90 s shows at most 20/h, and a state that returns to its previous value within one tick shows nothing. Clipping delays nothing, since the alarm path is limited by the window and the delay rather than by how far above os_max the count sits, but a host that displays the count should say it is a floor.
  • Startup artifact (a): a spurious first-tick pulse, which costs nothing. Integers.Change compares against pre_u_start on the first tick, so a unit that loads in OS#3 registers a change at t = 0. It encloses no area (dt is zero on the first tick) and never reaches the count. chg.pre_u_start is written explicitly as 0 and is not a card parameter.
  • Startup artifact (b): the first hour reads as a rate, and here the graph handles it rather than the host. While t < count_window the moving average divides by elapsed time, so two changes in the first six minutes read as 20/h. AHU-0004 leaves that to a precondition; this card computes the condition in the graph because it is computable, and gate keeps the extrapolated rate off the persistence clock until the window has filled.
  • The window gate makes this rule slower than AHU-0004, deliberately. Earliest assertion is count_window + alarm_delay = 7200 s, where AHU-0004 can assert at 3900 s on a first-hour extrapolation its frontmatter then tells the host to discard. Requiring a completed hour is what “transitions per hour” actually says, at an hour of detection latency on a fault whose alarm delay is already an hour.
  • BooleanToIntegerIntegerToReal where Conversions.BooleanToReal would do it in one block. Kept for shape parity with AHU-0004, so the two cards read as the same rule; it costs one block instance and no behavior.
  • Strict > on a discrete count. Exactly 7 transitions an hour is clear and 8 alarms — the chapter’s > OS_MAX read literally — and the arithmetic is exact at the boundary rather than approximately exact.
  • The counting window is half-open. A transition exactly count_window old has just left the window. The reference is silent; it matters only on the threshold and errs toward silence.
  • alarm_delay equals count_window, both 60 min per the chapter’s tunables line. This is not G36’s 30-minute AlarmDelay; the chapter governs.
  • alwaysOn.k is written explicitly and is not a card parameter. Logical.Sources.Constant has no default for k, and exposing it would let a host switch the evaluability signal off, which is not a tuning decision.
  • The reference publishes no vectors, so the whole suite is authored from the equation, and every assertion edge was derived by replaying the graph at the pinned engine rev — the moving average’s warm-up and decay trajectories do not match hand-computed sample statistics.
  • CLU-01 membership. A zone unit swapping between heating and cooling every few minutes is doing both within any window long enough to matter, so AHU-0004’s syndrome argument carries over. The cluster index owner accepted the case and clusters/clusters.json lists FCU-0001 in CLU-01.
  • persist.delayOnInit = true and windowFull.delayOnInit = true (Modelica/CDL default is false), the library’s standing choice. windowFull depends on it for its meaning — at false the constant-true input would assert on tick 0 and the signal would say the window was full when it was empty.
  • related is the library’s, not the reference’s. The chapter 12 card carries no Related row; AHU-0004 is named as the same rule one level up the air path, one-way because that card’s frontmatter is not edited here. No intra-FCU link is claimed.
  • Severity 3 (warning) and method rule per the reference’s chapter 12 card and the FCU index; its §5.8.5 index carries no severity column. Operating states OS 1–4 are declared, not gated — the chapter marks the fault applicable in every state and there is nothing for the graph to exclude.

Notes

The fix is a deadband, and it is remote and free. Step 2.1 of the fcu-faults playbook puts the minimum at 2 °F (1 °C) between heating and cooling setpoints and says to check for sensor noise first, which is the right order: widening a deadband around a jittery input hides the noise without fixing it. Step 4.2 confirms at transitions back under 7/h, which is this rule reading clear.

The reason to run this rule is fleet triage — the list of which forty units out of three hundred are oscillating is worth an afternoon, because they will almost all share one cause. Sort by count, not by alarm: the number behind the boolean (count.y) is what ranks the work. count_scale and the tick belong on any deployment checklist; they are the only values here that are properties of the host’s clock rather than of the building, and the failure mode is silent.

Test Vectors

9 scenarios, clock step 180 s over 21600 s.

ScenarioDescription
stable_stateThe unit holds one operating state for the whole run. operating_state equals chg.pre_u_start, so Integers.Change never pulses and the rolling count sits at zero. yWindowFull is pinned on both sides of its only edge: false through 3420 s, true from exactly 3600 s, which is the tick the moving average’s divisor becomes count_window rather than elapsed time
startup_pulse_is_inertoperating_state is a constant 3, which differs from chg.pre_u_start (0), so Integers.Change emits a spurious pulse on tick 0. It costs nothing: MovingAverage integrates u.dt and dt is zero on the first tick, so the pulse encloses no area and count stays at 0.0 for the whole run
four_transitions_per_hourThe unit walks OS 1-2-3-4-1 every 900 s: four transitions an hour, ordinary for a zone tracking load. count holds at exactly 4.0 once the window has filled and yFault never rises
seven_per_hour_stays_clearBoundary, low side. Seven transitions on consecutive ticks at the top of every hour, so every completed trailing hour holds exactly seven. count lands on exactly 7.0 and os_max is a strict ‘>’, so seven an hour reads clear forever. The first hour reads 20.0 - the extrapolated pace of a seven-tick burst - and yWindowFull is false there, which is precisely the case the window gate exists to swallow
eight_per_hour_tripsBoundary, high side. The same shape with eight transitions an hour: count sits on exactly 8.0 from the first completed window. gate rises at 3600 s (the tick yWindowFull matures, cntHigh already being true) and yFault follows one alarm_delay later at exactly 7200 s - the earliest instant this rule can assert anything
sustained_thrash_at_nyquist_ceilingoperating_state alternates on every tick - 20 transitions an hour at the 180 s tick, which is exactly the change-counter ceiling count_window/dt. count reads 20.0 and cannot read higher however fast the real unit cycles; the rule still trips at 7200 s, the same instant as the eight-per-hour case, because the alarm path is limited by the window and the delay rather than by how far above os_max the count sits
warmup_burst_masked_by_window_gateStartup artifact: nine transitions inside the first half hour read as a 20/hr pace while the moving average is still dividing by elapsed time. cntHigh is true from the second tick, but yWindowFull is false until 3600 s so gate stays down. At 3600 s the completed window genuinely holds nine transitions and gate rises - then the burst ages out and the count falls to 7.0 by 3960 s, so gate was true at two ticks out of the 3600 s alarm_delay and nothing is ever reported
thrash_stops_before_delayTransient: fourteen transitions from 3600 s to 5940 s, then the unit settles. count crosses os_max at 4860 s and decays back through 7.0 at 8280 s as the pulses leave the trailing hour, so gate held from 4860 s to 8100 s - 3240 s, ninety percent of alarm_delay - and yFault never rises. This is the vector that fails first if persist is given an off-delay or if the count is made to decay any slower
burst_ages_out_of_windowA two-hour thrash (3600 s to 10800 s) is long enough: gate rises at 4860 s and yFault asserts at 8460 s. When the thrash stops the count does not drop at once - it decays as pulses leave the trailing hour, reaching 7.0 at 13140 s, and the alarm clears on that tick because TrueDelay has no off-delay
vectors.json
{
  "schema": "cxf-library/vectors/v1",
  "clock": {
    "step_s": 180,
    "horizon_s": 21600
  },
  "scenarios": [
    {
      "name": "stable_state",
      "description": "The unit holds one operating state for the whole run. operating_state equals chg.pre_u_start, so Integers.Change never pulses and the rolling count sits at zero. yWindowFull is pinned on both sides of its only edge: false through 3420 s, true from exactly 3600 s, which is the tick the moving average's divisor becomes count_window rather than elapsed time",
      "inputs": {
        "operating_state": 0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 21600,
          "equals": false
        },
        {
          "output": "yWindowFull",
          "from_s": 0,
          "to_s": 3420,
          "equals": false
        },
        {
          "output": "yWindowFull",
          "from_s": 3600,
          "to_s": 21600,
          "equals": true
        }
      ]
    },
    {
      "name": "startup_pulse_is_inert",
      "description": "operating_state is a constant 3, which differs from chg.pre_u_start (0), so Integers.Change emits a spurious pulse on tick 0. It costs nothing: MovingAverage integrates u.dt and dt is zero on the first tick, so the pulse encloses no area and count stays at 0.0 for the whole run",
      "inputs": {
        "operating_state": 3
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 21600,
          "equals": false
        }
      ]
    },
    {
      "name": "four_transitions_per_hour",
      "description": "The unit walks OS 1-2-3-4-1 every 900 s: four transitions an hour, ordinary for a zone tracking load. count holds at exactly 4.0 once the window has filled and yFault never rises",
      "inputs": {
        "operating_state": [
          {
            "t": 0,
            "value": 1
          },
          {
            "t": 900,
            "value": 2
          },
          {
            "t": 1800,
            "value": 3
          },
          {
            "t": 2700,
            "value": 4
          },
          {
            "t": 3600,
            "value": 1
          },
          {
            "t": 4500,
            "value": 2
          },
          {
            "t": 5400,
            "value": 3
          },
          {
            "t": 6300,
            "value": 4
          },
          {
            "t": 7200,
            "value": 1
          },
          {
            "t": 8100,
            "value": 2
          },
          {
            "t": 9000,
            "value": 3
          },
          {
            "t": 9900,
            "value": 4
          },
          {
            "t": 10800,
            "value": 1
          },
          {
            "t": 11700,
            "value": 2
          },
          {
            "t": 12600,
            "value": 3
          },
          {
            "t": 13500,
            "value": 4
          },
          {
            "t": 14400,
            "value": 1
          },
          {
            "t": 15300,
            "value": 2
          },
          {
            "t": 16200,
            "value": 3
          },
          {
            "t": 17100,
            "value": 4
          },
          {
            "t": 18000,
            "value": 1
          },
          {
            "t": 18900,
            "value": 2
          },
          {
            "t": 19800,
            "value": 3
          },
          {
            "t": 20700,
            "value": 4
          },
          {
            "t": 21600,
            "value": 1
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 21600,
          "equals": false
        }
      ]
    },
    {
      "name": "seven_per_hour_stays_clear",
      "description": "Boundary, low side. Seven transitions on consecutive ticks at the top of every hour, so every completed trailing hour holds exactly seven. count lands on exactly 7.0 and os_max is a strict '>', so seven an hour reads clear forever. The first hour reads 20.0 - the extrapolated pace of a seven-tick burst - and yWindowFull is false there, which is precisely the case the window gate exists to swallow",
      "inputs": {
        "operating_state": [
          {
            "t": 0,
            "value": 2
          },
          {
            "t": 180,
            "value": 1
          },
          {
            "t": 360,
            "value": 2
          },
          {
            "t": 540,
            "value": 1
          },
          {
            "t": 720,
            "value": 2
          },
          {
            "t": 900,
            "value": 1
          },
          {
            "t": 1080,
            "value": 2
          },
          {
            "t": 3600,
            "value": 1
          },
          {
            "t": 3780,
            "value": 2
          },
          {
            "t": 3960,
            "value": 1
          },
          {
            "t": 4140,
            "value": 2
          },
          {
            "t": 4320,
            "value": 1
          },
          {
            "t": 4500,
            "value": 2
          },
          {
            "t": 4680,
            "value": 1
          },
          {
            "t": 7200,
            "value": 2
          },
          {
            "t": 7380,
            "value": 1
          },
          {
            "t": 7560,
            "value": 2
          },
          {
            "t": 7740,
            "value": 1
          },
          {
            "t": 7920,
            "value": 2
          },
          {
            "t": 8100,
            "value": 1
          },
          {
            "t": 8280,
            "value": 2
          },
          {
            "t": 10800,
            "value": 1
          },
          {
            "t": 10980,
            "value": 2
          },
          {
            "t": 11160,
            "value": 1
          },
          {
            "t": 11340,
            "value": 2
          },
          {
            "t": 11520,
            "value": 1
          },
          {
            "t": 11700,
            "value": 2
          },
          {
            "t": 11880,
            "value": 1
          },
          {
            "t": 14400,
            "value": 2
          },
          {
            "t": 14580,
            "value": 1
          },
          {
            "t": 14760,
            "value": 2
          },
          {
            "t": 14940,
            "value": 1
          },
          {
            "t": 15120,
            "value": 2
          },
          {
            "t": 15300,
            "value": 1
          },
          {
            "t": 15480,
            "value": 2
          },
          {
            "t": 18000,
            "value": 1
          },
          {
            "t": 18180,
            "value": 2
          },
          {
            "t": 18360,
            "value": 1
          },
          {
            "t": 18540,
            "value": 2
          },
          {
            "t": 18720,
            "value": 1
          },
          {
            "t": 18900,
            "value": 2
          },
          {
            "t": 19080,
            "value": 1
          },
          {
            "t": 21600,
            "value": 2
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 21600,
          "equals": false
        },
        {
          "output": "yWindowFull",
          "from_s": 0,
          "to_s": 3420,
          "equals": false
        },
        {
          "output": "yWindowFull",
          "from_s": 3600,
          "to_s": 21600,
          "equals": true
        }
      ]
    },
    {
      "name": "eight_per_hour_trips",
      "description": "Boundary, high side. The same shape with eight transitions an hour: count sits on exactly 8.0 from the first completed window. gate rises at 3600 s (the tick yWindowFull matures, cntHigh already being true) and yFault follows one alarm_delay later at exactly 7200 s - the earliest instant this rule can assert anything",
      "inputs": {
        "operating_state": [
          {
            "t": 0,
            "value": 2
          },
          {
            "t": 180,
            "value": 1
          },
          {
            "t": 360,
            "value": 2
          },
          {
            "t": 540,
            "value": 1
          },
          {
            "t": 720,
            "value": 2
          },
          {
            "t": 900,
            "value": 1
          },
          {
            "t": 1080,
            "value": 2
          },
          {
            "t": 1260,
            "value": 1
          },
          {
            "t": 3600,
            "value": 2
          },
          {
            "t": 3780,
            "value": 1
          },
          {
            "t": 3960,
            "value": 2
          },
          {
            "t": 4140,
            "value": 1
          },
          {
            "t": 4320,
            "value": 2
          },
          {
            "t": 4500,
            "value": 1
          },
          {
            "t": 4680,
            "value": 2
          },
          {
            "t": 4860,
            "value": 1
          },
          {
            "t": 7200,
            "value": 2
          },
          {
            "t": 7380,
            "value": 1
          },
          {
            "t": 7560,
            "value": 2
          },
          {
            "t": 7740,
            "value": 1
          },
          {
            "t": 7920,
            "value": 2
          },
          {
            "t": 8100,
            "value": 1
          },
          {
            "t": 8280,
            "value": 2
          },
          {
            "t": 8460,
            "value": 1
          },
          {
            "t": 10800,
            "value": 2
          },
          {
            "t": 10980,
            "value": 1
          },
          {
            "t": 11160,
            "value": 2
          },
          {
            "t": 11340,
            "value": 1
          },
          {
            "t": 11520,
            "value": 2
          },
          {
            "t": 11700,
            "value": 1
          },
          {
            "t": 11880,
            "value": 2
          },
          {
            "t": 12060,
            "value": 1
          },
          {
            "t": 14400,
            "value": 2
          },
          {
            "t": 14580,
            "value": 1
          },
          {
            "t": 14760,
            "value": 2
          },
          {
            "t": 14940,
            "value": 1
          },
          {
            "t": 15120,
            "value": 2
          },
          {
            "t": 15300,
            "value": 1
          },
          {
            "t": 15480,
            "value": 2
          },
          {
            "t": 15660,
            "value": 1
          },
          {
            "t": 18000,
            "value": 2
          },
          {
            "t": 18180,
            "value": 1
          },
          {
            "t": 18360,
            "value": 2
          },
          {
            "t": 18540,
            "value": 1
          },
          {
            "t": 18720,
            "value": 2
          },
          {
            "t": 18900,
            "value": 1
          },
          {
            "t": 19080,
            "value": 2
          },
          {
            "t": 19260,
            "value": 1
          },
          {
            "t": 21600,
            "value": 2
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7020,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 7380,
          "to_s": 21600,
          "equals": true
        }
      ]
    },
    {
      "name": "sustained_thrash_at_nyquist_ceiling",
      "description": "operating_state alternates on every tick - 20 transitions an hour at the 180 s tick, which is exactly the change-counter ceiling count_window/dt. count reads 20.0 and cannot read higher however fast the real unit cycles; the rule still trips at 7200 s, the same instant as the eight-per-hour case, because the alarm path is limited by the window and the delay rather than by how far above os_max the count sits",
      "inputs": {
        "operating_state": [
          {
            "t": 0,
            "value": 2
          },
          {
            "t": 180,
            "value": 1
          },
          {
            "t": 360,
            "value": 2
          },
          {
            "t": 540,
            "value": 1
          },
          {
            "t": 720,
            "value": 2
          },
          {
            "t": 900,
            "value": 1
          },
          {
            "t": 1080,
            "value": 2
          },
          {
            "t": 1260,
            "value": 1
          },
          {
            "t": 1440,
            "value": 2
          },
          {
            "t": 1620,
            "value": 1
          },
          {
            "t": 1800,
            "value": 2
          },
          {
            "t": 1980,
            "value": 1
          },
          {
            "t": 2160,
            "value": 2
          },
          {
            "t": 2340,
            "value": 1
          },
          {
            "t": 2520,
            "value": 2
          },
          {
            "t": 2700,
            "value": 1
          },
          {
            "t": 2880,
            "value": 2
          },
          {
            "t": 3060,
            "value": 1
          },
          {
            "t": 3240,
            "value": 2
          },
          {
            "t": 3420,
            "value": 1
          },
          {
            "t": 3600,
            "value": 2
          },
          {
            "t": 3780,
            "value": 1
          },
          {
            "t": 3960,
            "value": 2
          },
          {
            "t": 4140,
            "value": 1
          },
          {
            "t": 4320,
            "value": 2
          },
          {
            "t": 4500,
            "value": 1
          },
          {
            "t": 4680,
            "value": 2
          },
          {
            "t": 4860,
            "value": 1
          },
          {
            "t": 5040,
            "value": 2
          },
          {
            "t": 5220,
            "value": 1
          },
          {
            "t": 5400,
            "value": 2
          },
          {
            "t": 5580,
            "value": 1
          },
          {
            "t": 5760,
            "value": 2
          },
          {
            "t": 5940,
            "value": 1
          },
          {
            "t": 6120,
            "value": 2
          },
          {
            "t": 6300,
            "value": 1
          },
          {
            "t": 6480,
            "value": 2
          },
          {
            "t": 6660,
            "value": 1
          },
          {
            "t": 6840,
            "value": 2
          },
          {
            "t": 7020,
            "value": 1
          },
          {
            "t": 7200,
            "value": 2
          },
          {
            "t": 7380,
            "value": 1
          },
          {
            "t": 7560,
            "value": 2
          },
          {
            "t": 7740,
            "value": 1
          },
          {
            "t": 7920,
            "value": 2
          },
          {
            "t": 8100,
            "value": 1
          },
          {
            "t": 8280,
            "value": 2
          },
          {
            "t": 8460,
            "value": 1
          },
          {
            "t": 8640,
            "value": 2
          },
          {
            "t": 8820,
            "value": 1
          },
          {
            "t": 9000,
            "value": 2
          },
          {
            "t": 9180,
            "value": 1
          },
          {
            "t": 9360,
            "value": 2
          },
          {
            "t": 9540,
            "value": 1
          },
          {
            "t": 9720,
            "value": 2
          },
          {
            "t": 9900,
            "value": 1
          },
          {
            "t": 10080,
            "value": 2
          },
          {
            "t": 10260,
            "value": 1
          },
          {
            "t": 10440,
            "value": 2
          },
          {
            "t": 10620,
            "value": 1
          },
          {
            "t": 10800,
            "value": 2
          },
          {
            "t": 10980,
            "value": 1
          },
          {
            "t": 11160,
            "value": 2
          },
          {
            "t": 11340,
            "value": 1
          },
          {
            "t": 11520,
            "value": 2
          },
          {
            "t": 11700,
            "value": 1
          },
          {
            "t": 11880,
            "value": 2
          },
          {
            "t": 12060,
            "value": 1
          },
          {
            "t": 12240,
            "value": 2
          },
          {
            "t": 12420,
            "value": 1
          },
          {
            "t": 12600,
            "value": 2
          },
          {
            "t": 12780,
            "value": 1
          },
          {
            "t": 12960,
            "value": 2
          },
          {
            "t": 13140,
            "value": 1
          },
          {
            "t": 13320,
            "value": 2
          },
          {
            "t": 13500,
            "value": 1
          },
          {
            "t": 13680,
            "value": 2
          },
          {
            "t": 13860,
            "value": 1
          },
          {
            "t": 14040,
            "value": 2
          },
          {
            "t": 14220,
            "value": 1
          },
          {
            "t": 14400,
            "value": 2
          },
          {
            "t": 14580,
            "value": 1
          },
          {
            "t": 14760,
            "value": 2
          },
          {
            "t": 14940,
            "value": 1
          },
          {
            "t": 15120,
            "value": 2
          },
          {
            "t": 15300,
            "value": 1
          },
          {
            "t": 15480,
            "value": 2
          },
          {
            "t": 15660,
            "value": 1
          },
          {
            "t": 15840,
            "value": 2
          },
          {
            "t": 16020,
            "value": 1
          },
          {
            "t": 16200,
            "value": 2
          },
          {
            "t": 16380,
            "value": 1
          },
          {
            "t": 16560,
            "value": 2
          },
          {
            "t": 16740,
            "value": 1
          },
          {
            "t": 16920,
            "value": 2
          },
          {
            "t": 17100,
            "value": 1
          },
          {
            "t": 17280,
            "value": 2
          },
          {
            "t": 17460,
            "value": 1
          },
          {
            "t": 17640,
            "value": 2
          },
          {
            "t": 17820,
            "value": 1
          },
          {
            "t": 18000,
            "value": 2
          },
          {
            "t": 18180,
            "value": 1
          },
          {
            "t": 18360,
            "value": 2
          },
          {
            "t": 18540,
            "value": 1
          },
          {
            "t": 18720,
            "value": 2
          },
          {
            "t": 18900,
            "value": 1
          },
          {
            "t": 19080,
            "value": 2
          },
          {
            "t": 19260,
            "value": 1
          },
          {
            "t": 19440,
            "value": 2
          },
          {
            "t": 19620,
            "value": 1
          },
          {
            "t": 19800,
            "value": 2
          },
          {
            "t": 19980,
            "value": 1
          },
          {
            "t": 20160,
            "value": 2
          },
          {
            "t": 20340,
            "value": 1
          },
          {
            "t": 20520,
            "value": 2
          },
          {
            "t": 20700,
            "value": 1
          },
          {
            "t": 20880,
            "value": 2
          },
          {
            "t": 21060,
            "value": 1
          },
          {
            "t": 21240,
            "value": 2
          },
          {
            "t": 21420,
            "value": 1
          },
          {
            "t": 21600,
            "value": 2
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7020,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 7380,
          "to_s": 21600,
          "equals": true
        }
      ]
    },
    {
      "name": "warmup_burst_masked_by_window_gate",
      "description": "Startup artifact: nine transitions inside the first half hour read as a 20/hr pace while the moving average is still dividing by elapsed time. cntHigh is true from the second tick, but yWindowFull is false until 3600 s so gate stays down. At 3600 s the completed window genuinely holds nine transitions and gate rises - then the burst ages out and the count falls to 7.0 by 3960 s, so gate was true at two ticks out of the 3600 s alarm_delay and nothing is ever reported",
      "inputs": {
        "operating_state": [
          {
            "t": 0,
            "value": 1
          },
          {
            "t": 180,
            "value": 2
          },
          {
            "t": 360,
            "value": 1
          },
          {
            "t": 540,
            "value": 2
          },
          {
            "t": 720,
            "value": 1
          },
          {
            "t": 900,
            "value": 2
          },
          {
            "t": 1080,
            "value": 1
          },
          {
            "t": 1260,
            "value": 2
          },
          {
            "t": 1440,
            "value": 1
          },
          {
            "t": 1620,
            "value": 2
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 21600,
          "equals": false
        },
        {
          "output": "yWindowFull",
          "from_s": 0,
          "to_s": 3420,
          "equals": false
        },
        {
          "output": "yWindowFull",
          "from_s": 3600,
          "to_s": 21600,
          "equals": true
        }
      ]
    },
    {
      "name": "thrash_stops_before_delay",
      "description": "Transient: fourteen transitions from 3600 s to 5940 s, then the unit settles. count crosses os_max at 4860 s and decays back through 7.0 at 8280 s as the pulses leave the trailing hour, so gate held from 4860 s to 8100 s - 3240 s, ninety percent of alarm_delay - and yFault never rises. This is the vector that fails first if persist is given an off-delay or if the count is made to decay any slower",
      "inputs": {
        "operating_state": [
          {
            "t": 0,
            "value": 1
          },
          {
            "t": 3600,
            "value": 2
          },
          {
            "t": 3780,
            "value": 1
          },
          {
            "t": 3960,
            "value": 2
          },
          {
            "t": 4140,
            "value": 1
          },
          {
            "t": 4320,
            "value": 2
          },
          {
            "t": 4500,
            "value": 1
          },
          {
            "t": 4680,
            "value": 2
          },
          {
            "t": 4860,
            "value": 1
          },
          {
            "t": 5040,
            "value": 2
          },
          {
            "t": 5220,
            "value": 1
          },
          {
            "t": 5400,
            "value": 2
          },
          {
            "t": 5580,
            "value": 1
          },
          {
            "t": 5760,
            "value": 2
          },
          {
            "t": 5940,
            "value": 1
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 21600,
          "equals": false
        }
      ]
    },
    {
      "name": "burst_ages_out_of_window",
      "description": "A two-hour thrash (3600 s to 10800 s) is long enough: gate rises at 4860 s and yFault asserts at 8460 s. When the thrash stops the count does not drop at once - it decays as pulses leave the trailing hour, reaching 7.0 at 13140 s, and the alarm clears on that tick because TrueDelay has no off-delay",
      "inputs": {
        "operating_state": [
          {
            "t": 0,
            "value": 1
          },
          {
            "t": 3600,
            "value": 2
          },
          {
            "t": 3780,
            "value": 1
          },
          {
            "t": 3960,
            "value": 2
          },
          {
            "t": 4140,
            "value": 1
          },
          {
            "t": 4320,
            "value": 2
          },
          {
            "t": 4500,
            "value": 1
          },
          {
            "t": 4680,
            "value": 2
          },
          {
            "t": 4860,
            "value": 1
          },
          {
            "t": 5040,
            "value": 2
          },
          {
            "t": 5220,
            "value": 1
          },
          {
            "t": 5400,
            "value": 2
          },
          {
            "t": 5580,
            "value": 1
          },
          {
            "t": 5760,
            "value": 2
          },
          {
            "t": 5940,
            "value": 1
          },
          {
            "t": 6120,
            "value": 2
          },
          {
            "t": 6300,
            "value": 1
          },
          {
            "t": 6480,
            "value": 2
          },
          {
            "t": 6660,
            "value": 1
          },
          {
            "t": 6840,
            "value": 2
          },
          {
            "t": 7020,
            "value": 1
          },
          {
            "t": 7200,
            "value": 2
          },
          {
            "t": 7380,
            "value": 1
          },
          {
            "t": 7560,
            "value": 2
          },
          {
            "t": 7740,
            "value": 1
          },
          {
            "t": 7920,
            "value": 2
          },
          {
            "t": 8100,
            "value": 1
          },
          {
            "t": 8280,
            "value": 2
          },
          {
            "t": 8460,
            "value": 1
          },
          {
            "t": 8640,
            "value": 2
          },
          {
            "t": 8820,
            "value": 1
          },
          {
            "t": 9000,
            "value": 2
          },
          {
            "t": 9180,
            "value": 1
          },
          {
            "t": 9360,
            "value": 2
          },
          {
            "t": 9540,
            "value": 1
          },
          {
            "t": 9720,
            "value": 2
          },
          {
            "t": 9900,
            "value": 1
          },
          {
            "t": 10080,
            "value": 2
          },
          {
            "t": 10260,
            "value": 1
          },
          {
            "t": 10440,
            "value": 2
          },
          {
            "t": 10620,
            "value": 1
          },
          {
            "t": 10800,
            "value": 2
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 8280,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 8640,
          "to_s": 12780,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 13320,
          "to_s": 21600,
          "equals": false
        }
      ]
    }
  ]
}

FCU-0002 — SAT too low in full heating

Statusverified — engine e2ff2f8, cxf:fnv1a128:59e930986b1feb94a2708f0b1cb1f1e1, 2026-08-17
Severity3
Methodrule
Phase1
CategoryEXCESS_CONSUMPTION
ConfidenceMEDIUM
EstimationPROXY_ESTIMATION
G36§5.22.6 FC#2
Clusters
Suppresses
Suppressed by
RelatedAHU-0007, FCU-0006
Playbooksfcu-faults
SourceHVAC FDD Reference v1.0 §12, FCU-0002; G36 §5.22.6 FC#2; G36 §5.16.14 FC#7 and Table 5.16.14.7 (εSAT and AlarmDelay provenance, per Addendum u public review)
Operating statesOS 1 (Heating) — host-gated

Preconditions (host-enforced): Evaluate only in OS#1, the heating state: in deadband or cooling the discharge is meant to sit below any heating setpoint, and the rule would be comparing against a number nothing is chasing. The fan must be running and delivering air, since a discharge temperature means nothing in a still coil — an FCU whose fan has failed while its loop pins the valve open can produce exactly this signature, with a downstream sensor drifting toward duct ambient, and that is a fan fault, not a coil fault. Suspend evaluation for a mode delay after any change of operating state, while the valve is stroking and the coil is still charging; G36 uses 30 min for an AHU and an FCU coil settles faster, but the graph carries no such timer. sat_sp must be the discharge setpoint the unit is actively controlling to, not a design constant, or the comparison is against a fiction. sat must be trustworthy; nothing in this rule cross-checks it, and a discharge sensor reading 6 °C low fabricates this fault on a healthy unit every heating hour. Coil saturation is signalled in-rule by yHtgFullOk: when it is false the rule is making no claim, and the verdict is NO_EVAL rather than healthy.

Points: sat, sat_sp, htg_vlv_cmd

Outputs:

  • yFault — True while sat has stayed more than epsilon_sat below sat_sp with the heating valve commanded above hc_full_threshold, for at least alarm_delay
  • yHtgFullOk — Evaluability signal — true when htg_vlv_cmd exceeds hc_full_threshold, i.e. the coil has no capacity left to give; false means the unit is not in full heating, so the verdict is NO_EVAL and the host must ignore yFault

Parameters:

NameDefaultUnitCXF pathDescription
epsilon_sat1.0°CspMiss.tShortfall below the discharge setpoint that counts as a real miss rather than sensor error (the chapter’s ε_sat). ADOPTED — the chapter states no value; 1.0 °C is G36’s εSAT at its Table 5.16.14.7 default and the library’s house convention (see Deviations)
hc_full_threshold99.0%htgFull.tHeating valve command above which the coil is treated as having no capacity left (the chapter’s htg_vlv_cmd ≥ 99%). Hosts whose valve command is quantized to whole percent should retune to 98.9, since the comparison is strict
alarm_delay1800.0spersist.delayTimeContinuous fault persistence required before the alarm asserts. ADOPTED — the chapter states no AlarmDelay for this card; 1800 s is G36’s AlarmDelay default (30 min), the same value the AHU sibling AHU-0007 carries

Description

The valve is wide open and the air coming off the coil is still too cold. That is the whole finding, and it is worth reporting because it is the one heating complaint a fan coil cannot argue with: the loop has spent everything it has and the discharge is still short, so the unit runs its fan for less heat than it was designed to move and the zone makes up the difference from a baseboard, a space heater, or not at all. The causes divide by where they sit — upstream (hot water arriving too cold, which affects every FCU on the riser at once), at the unit (a fouled or air-locked coil, a valve that reports open and is not), or at the sensor (a discharge thermistor drifted low, inventing the whole thing). This is AHU-0007’s rule at zone scale; what changes is the sensor set, since an FCU has no mixed-air temperature to check the coil against and the shortfall is measured against setpoint alone.

Detection Logic

yHtgFullOk = htg_vlv_cmd > hc_full_threshold        (false ⇒ host reports NO_EVAL)
yFault     = ((sat_sp − sat) > epsilon_sat AND yHtgFullOk)
             sustained continuously for alarm_delay

Block graph (rule.cxf.jsonld):

FCU-0002 block graph

spGap subtracts in the direction that keeps the threshold positive — sat < sat_sp − ε and sat_sp − sat > ε are the same statement, and the second needs no negative constant. htgFull is both a term of the fault condition and the boundary output yHtgFullOk, the same test read two ways: a valve at 60% with the discharge 6 °C short is not a healthy unit, it is a unit this rule has no opinion about, because the loop still has capacity and the shortfall could be a slow morning. False yHtgFullOk means the rule made no claim, and the host must publish NO_EVAL rather than healthy. Both comparisons are strict: a shortfall of exactly 1.0 °C is not a fault and a valve reporting exactly 99.0% is not full, which matters on quantized valve commands (see Deviations). persist requires 30 continuous minutes, riding out a morning recovery from night setback; recovery is immediate because TrueDelay has no off-delay, and delayOnInit = true holds the window across a restart.

Possible Diagnoses

  1. Heating coil fouled or air-locked — the air side blocked with lint and dust, which an FCU filter in a hotel room reliably supplies, or an air pocket on the water side that a bleed valve clears in minutes
  2. HW supply temperature too low — a plant or reset-schedule finding, not a unit finding. Check this first: it explains every unit on the riser at once
  3. Heating valve stuck closed — the command reads 100% and the plug has not moved; the temperature across the coil piping separates this from a fouled coil in thirty seconds at the unit
  4. SAT sensor out of calibration — a thermistor reading low invents this fault and nothing in the rule can see it. Suspect it first when one unit reports and its neighbours on the same riser do not

Energy Impact

EXCESS_CONSUMPTION, MEDIUM confidence, PROXY_ESTIMATION. The reference gives 2–5% of zone heating energy. PROXY because the rule sees three signals and none of them is airflow or power: the heat not delivered is fcu_airflow × ρ × cp × (sat_sp − sat), with airflow coming from the nameplate. MEDIUM confidence because what the shortfall costs depends on what picks up the load — an unheated hotel room at 3 a.m. costs nothing, the same room with an electric space heater under the desk costs a great deal. Heating-dominant by construction: the rule only evaluates in the heating state.

Emissions Impact

Scope 1, PROXY_EMISSIONS, MEDIUM confidence; typically 50–400 kg CO₂e/yr per FCU. Scope 1 because the heat a fan coil fails to deliver is heat a boiler burned gas to make — the emissions are the combustion the plant did on this unit’s behalf, whether or not the air ever carried it. Avoided-emissions basis is the marginal operating emissions rate (MOER) where the makeup heat is electric; where it is more of the same hot water, the saving is fuel at the boiler.

Deviations

  • The chapter states no tunables for this card at all — it carries the equation and the operating state and nothing else — so all three parameters here are adopted rather than transcribed, and each is named as such in params.
  • epsilon_sat = 1.0 °C, adopted from G36. The chapter’s equation names ε_sat and never defines it; G36 Table 5.16.14.7 puts εSAT at 1 °C (2 °F), AHU-0007 carries that value, and it is the library’s house convention. It is a sensor-error allowance, not a performance target: raise it and the rule tolerates an underperforming coil, lower it and it becomes a thermistor-accuracy alarm.
  • alarm_delay = 1800 s, adopted from G36 and not from the chapter. The chapter gives FCU-0001 60 min and this card none, so the sibling’s number is not automatically right. 1800 s is G36’s own default and matches AHU-0007; 30 minutes is long for a coil that charges in a few minutes, so a site can safely shorten it once the mode-delay precondition is enforced.
  • hc_full_threshold compares strictly, so the chapter’s ≥ 99% is read as > 99%. CDL Reals has no GreaterEqual. On an analog signal the difference is measure-zero, but valve commands are frequently quantized to whole percent, which makes it a real blind spot: a host whose command saturates at an integer 99 will never evaluate this rule, and must set hc_full_threshold to 98.9.
  • epsilon_sat compares strictly too, so a shortfall of exactly 1.0 °C is clear and 1.1 °C alarms. Measure-zero on a real-valued temperature, erring toward silence.
  • The subtraction runs sat_sp − sat. The chapter writes sat < (sat_sp − ε_sat), which read literally needs a negative threshold on a signed difference; reversing the operands keeps epsilon_sat positive and is algebraically identical.
  • yHtgFullOk is exposed as a boundary output; AHU-0007 exposes only yFault. The valve conjunct is computable in-rule and is the whole applicability test for this card, so SCHEMA.md’s rule for in-rule evaluability applies. It is a threshold comparison rather than an echo of the input — a host reading it learns the coil is saturated, which htg_vlv_cmd alone does not say without knowing hc_full_threshold. Shape precedent: VFD-0001’s yCmdOk.
  • The valve conjunct is the chapter’s htg_vlv_cmd ≥ 99%, not “commanded open”. A rule that fired on any open valve would report every FCU in its morning pull-up.
  • The chapter’s runtime estimator is transcribed but is not scale-invariant. waste_kw ≈ (sat_sp − sat) / sat_sp × fcu_htg_capacity_kw divides a temperature difference by a temperature, so a 6 °C shortfall against a 35 °C setpoint reads 17% of capacity and the same physical state in kelvin reads 1.9%. It is kept because the chapter is the source of record, with the dimensionally sound makeup term beside it; a host accumulating energy should use the second.
  • No coil-entering temperature is available, so there is no companion test. G36 pairs FC#7 with FC#5 (SAT above MAT) on an air handler, catching a coil doing nothing at all rather than merely too little. The FCU dictionary carries rat as the entering proxy (FCU-0004/FCU-0005 use it that way) but this card’s equation does not, and it adds no points the reference did not give it. Recorded as the obvious upgrade: sat > rat in heating needs no adopted constants.
  • Operating-state gating and the mode delay live in frontmatter, not in the graph. The graph is silent outside full heating only because the valve conjunct happens to be false there, and that coincidence is not a substitute for the host’s OS#1 gate — a unit can saturate its heating valve while the sequence is in a state where the discharge setpoint means something else.
  • The reference publishes no vectors, so the whole suite is authored from the equation and every assertion edge was checked by replaying the graph at the pinned engine rev.
  • Frontmatter clusters is empty. The reference lists no cluster for this fault and this card does not invent one; the relationship to the rest of the FCU family is carried by the shared playbook.
  • persist.delayOnInit = true (Modelica/CDL default is false), the library’s standing choice: a unit already short of setpoint at full valve waits out the alarm delay instead of alarming on the first tick after a restart.
  • Severity 3 (warning) and method rule, per the reference’s chapter 12 card and the FCU index; its §5.8.5 index carries no severity column.

Notes

Order the service by scope. Step 2.2 of the fcu-faults playbook is the plant check — is the hot water supply temperature adequate? — and it belongs first because it is free, remote, and explains the correlated case: if every FCU on a riser reports at once, no coil is fouled, a reset schedule is. If one unit reports and its neighbours do not, step 3.1 is the visit — bleed the coil, then clean it, then verify the valve actually strokes. Diagnosis 4 sits outside that order, and the cheapest way to rule it in is to compare the discharge reading against return air with the valve closed.

Expect FCU-0005 on the same units in the opposite season: a heating valve worn enough not to seat is a valve that will also fail to open fully, and the same seat that leaks in July starves the coil in January.

Test Vectors

12 scenarios, clock step 300 s over 5400 s.

ScenarioDescription
part_load_heating_holding_setpointHealthy OS#1 operation: the valve is at 60% and sat is 0.5 C under a 35.0 C discharge setpoint. Neither half of the conjunction is true - the loop has capacity left and the error is inside epsilon_sat. yHtgFullOk is false throughout, so a host reading it correctly records NO_EVAL rather than a clean bill of health
full_heating_still_on_setpointThe valve is wide open on a design-day morning but sat is only 0.5 C short - a unit sized close to its design point, not a broken one. Saturation alone is not a fault, and yHtgFullOk true with yFault false is the one combination that means healthy
part_load_missing_setpointsat is 6 C short of setpoint with the valve at 60%. The coil still has capacity, so the shortfall is a control-loop finding rather than a capacity finding and this rule makes no claim: yFault false because yHtgFullOk is false
full_heating_missing_setpointThe reportable case: valve at 100% and sat 6 C below setpoint. The coil is saturated and still losing, so the shortfall is capacity, hot water supply, or sensing. Alarms one alarm_delay (1800 s) after the condition appears
error_exactly_at_thresholdThreshold edge, low side: sat_sp - sat = 1.0 C exactly with the valve at 100%. spMiss is a strict ‘>’, so an error sitting precisely on epsilon_sat reads healthy
error_just_over_thresholdThreshold edge, high side: sat_sp - sat = 1.1 C clears the strict comparison and alarms after alarm_delay
valve_exactly_at_full_thresholdThreshold edge, low side on the valve: the command reports exactly 99.0% with a 6 C error present. htgFull is a strict ‘>’, so the chapter’s ‘htg_vlv_cmd >= 99%’ reads as not-full here - the case a host with integer-quantized valve feedback must retune hc_full_threshold to 98.9 to catch. yHtgFullOk false says NO_EVAL, which is the honest verdict
valve_just_over_full_thresholdThreshold edge, high side on the valve: 99.5% clears the strict comparison, so the same 6 C error alarms after alarm_delay
morning_warmup_clears_before_delayTransient: the coil pins at 100% and sat sits 6 C short through a cold start, then reaches 34.5 C at t = 1200 s. Twenty minutes is short of alarm_delay, so an FCU recovering from night setback never alarms
valve_modulates_off_before_delayTransient on the other input: the 6 C error holds but the valve backs off to 70% at t = 1200 s, which means the loop was never out of capacity. Both halves must be true at once, so persistence restarts and no alarm follows. yHtgFullOk drops with the valve, telling the host the rule stopped evaluating rather than cleared
fault_clears_on_recoveryA sustained shortfall alarms at 1800 s; hot water returns at t = 3000 s and sat reaches 34.5 C, dropping the alarm on that tick - TrueDelay has no off-delay
valve_closed_unit_offThe FCU is idle: the valve is at 0% and sat has drifted to room temperature, 15 C below the discharge setpoint. yHtgFullOk is false and yFault stays false - the rule is structurally silent on a unit that is not heating, which is NO_EVAL and not a health claim
vectors.json
{
  "schema": "cxf-library/vectors/v1",
  "clock": {
    "step_s": 300,
    "horizon_s": 5400
  },
  "scenarios": [
    {
      "name": "part_load_heating_holding_setpoint",
      "description": "Healthy OS#1 operation: the valve is at 60% and sat is 0.5 C under a 35.0 C discharge setpoint. Neither half of the conjunction is true - the loop has capacity left and the error is inside epsilon_sat. yHtgFullOk is false throughout, so a host reading it correctly records NO_EVAL rather than a clean bill of health",
      "inputs": {
        "sat": 34.5,
        "sat_sp": 35.0,
        "htg_vlv_cmd": 60.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        },
        {
          "output": "yHtgFullOk",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        }
      ]
    },
    {
      "name": "full_heating_still_on_setpoint",
      "description": "The valve is wide open on a design-day morning but sat is only 0.5 C short - a unit sized close to its design point, not a broken one. Saturation alone is not a fault, and yHtgFullOk true with yFault false is the one combination that means healthy",
      "inputs": {
        "sat": 34.5,
        "sat_sp": 35.0,
        "htg_vlv_cmd": 100.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        },
        {
          "output": "yHtgFullOk",
          "from_s": 0,
          "to_s": 5400,
          "equals": true
        }
      ]
    },
    {
      "name": "part_load_missing_setpoint",
      "description": "sat is 6 C short of setpoint with the valve at 60%. The coil still has capacity, so the shortfall is a control-loop finding rather than a capacity finding and this rule makes no claim: yFault false because yHtgFullOk is false",
      "inputs": {
        "sat": 29.0,
        "sat_sp": 35.0,
        "htg_vlv_cmd": 60.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        },
        {
          "output": "yHtgFullOk",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        }
      ]
    },
    {
      "name": "full_heating_missing_setpoint",
      "description": "The reportable case: valve at 100% and sat 6 C below setpoint. The coil is saturated and still losing, so the shortfall is capacity, hot water supply, or sensing. Alarms one alarm_delay (1800 s) after the condition appears",
      "inputs": {
        "sat": 29.0,
        "sat_sp": 35.0,
        "htg_vlv_cmd": 100.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1500,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2100,
          "to_s": 5400,
          "equals": true
        },
        {
          "output": "yHtgFullOk",
          "from_s": 0,
          "to_s": 5400,
          "equals": true
        }
      ]
    },
    {
      "name": "error_exactly_at_threshold",
      "description": "Threshold edge, low side: sat_sp - sat = 1.0 C exactly with the valve at 100%. spMiss is a strict '>', so an error sitting precisely on epsilon_sat reads healthy",
      "inputs": {
        "sat": 34.0,
        "sat_sp": 35.0,
        "htg_vlv_cmd": 100.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        },
        {
          "output": "yHtgFullOk",
          "from_s": 0,
          "to_s": 5400,
          "equals": true
        }
      ]
    },
    {
      "name": "error_just_over_threshold",
      "description": "Threshold edge, high side: sat_sp - sat = 1.1 C clears the strict comparison and alarms after alarm_delay",
      "inputs": {
        "sat": 33.9,
        "sat_sp": 35.0,
        "htg_vlv_cmd": 100.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1500,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2100,
          "to_s": 5400,
          "equals": true
        },
        {
          "output": "yHtgFullOk",
          "from_s": 0,
          "to_s": 5400,
          "equals": true
        }
      ]
    },
    {
      "name": "valve_exactly_at_full_threshold",
      "description": "Threshold edge, low side on the valve: the command reports exactly 99.0% with a 6 C error present. htgFull is a strict '>', so the chapter's 'htg_vlv_cmd >= 99%' reads as not-full here - the case a host with integer-quantized valve feedback must retune hc_full_threshold to 98.9 to catch. yHtgFullOk false says NO_EVAL, which is the honest verdict",
      "inputs": {
        "sat": 29.0,
        "sat_sp": 35.0,
        "htg_vlv_cmd": 99.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        },
        {
          "output": "yHtgFullOk",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        }
      ]
    },
    {
      "name": "valve_just_over_full_threshold",
      "description": "Threshold edge, high side on the valve: 99.5% clears the strict comparison, so the same 6 C error alarms after alarm_delay",
      "inputs": {
        "sat": 29.0,
        "sat_sp": 35.0,
        "htg_vlv_cmd": 99.5
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1500,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2100,
          "to_s": 5400,
          "equals": true
        },
        {
          "output": "yHtgFullOk",
          "from_s": 0,
          "to_s": 5400,
          "equals": true
        }
      ]
    },
    {
      "name": "morning_warmup_clears_before_delay",
      "description": "Transient: the coil pins at 100% and sat sits 6 C short through a cold start, then reaches 34.5 C at t = 1200 s. Twenty minutes is short of alarm_delay, so an FCU recovering from night setback never alarms",
      "inputs": {
        "sat": [
          {
            "t": 0,
            "value": 29.0
          },
          {
            "t": 1200,
            "value": 34.5
          }
        ],
        "sat_sp": 35.0,
        "htg_vlv_cmd": 100.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        },
        {
          "output": "yHtgFullOk",
          "from_s": 0,
          "to_s": 5400,
          "equals": true
        }
      ]
    },
    {
      "name": "valve_modulates_off_before_delay",
      "description": "Transient on the other input: the 6 C error holds but the valve backs off to 70% at t = 1200 s, which means the loop was never out of capacity. Both halves must be true at once, so persistence restarts and no alarm follows. yHtgFullOk drops with the valve, telling the host the rule stopped evaluating rather than cleared",
      "inputs": {
        "sat": 29.0,
        "sat_sp": 35.0,
        "htg_vlv_cmd": [
          {
            "t": 0,
            "value": 100.0
          },
          {
            "t": 1200,
            "value": 70.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        },
        {
          "output": "yHtgFullOk",
          "from_s": 0,
          "to_s": 900,
          "equals": true
        },
        {
          "output": "yHtgFullOk",
          "from_s": 1200,
          "to_s": 5400,
          "equals": false
        }
      ]
    },
    {
      "name": "fault_clears_on_recovery",
      "description": "A sustained shortfall alarms at 1800 s; hot water returns at t = 3000 s and sat reaches 34.5 C, dropping the alarm on that tick - TrueDelay has no off-delay",
      "inputs": {
        "sat": [
          {
            "t": 0,
            "value": 29.0
          },
          {
            "t": 3000,
            "value": 34.5
          }
        ],
        "sat_sp": 35.0,
        "htg_vlv_cmd": 100.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1500,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2100,
          "to_s": 2700,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 3300,
          "to_s": 5400,
          "equals": false
        },
        {
          "output": "yHtgFullOk",
          "from_s": 0,
          "to_s": 5400,
          "equals": true
        }
      ]
    },
    {
      "name": "valve_closed_unit_off",
      "description": "The FCU is idle: the valve is at 0% and sat has drifted to room temperature, 15 C below the discharge setpoint. yHtgFullOk is false and yFault stays false - the rule is structurally silent on a unit that is not heating, which is NO_EVAL and not a health claim",
      "inputs": {
        "sat": 20.0,
        "sat_sp": 35.0,
        "htg_vlv_cmd": 0.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        },
        {
          "output": "yHtgFullOk",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        }
      ]
    }
  ]
}

FCU-0003 — SAT too high in full cooling

Statusverified — engine e2ff2f8, cxf:fnv1a128:f33110926b9fc6d206913b6ce4dea351, 2026-08-17
Severity3
Methodrule
Phase1
CategoryEXCESS_CONSUMPTION
ConfidenceMEDIUM
EstimationPROXY_ESTIMATION
G36§5.22.6 FC#3
Clusters
Suppresses
Suppressed by
RelatedFCU-0002, FCU-0004, FCU-0006
Playbooksfcu-faults
SourceHVAC FDD Reference v1.0 §12, FCU-0003; G36 §5.22.6 FC#3 (cited by the reference; clause text not in hand — see Deviations); G36 Addendum u, Table 5.16.14.5 (εSAT = 1 °C, AlarmDelay = 30 min — the adopted defaults); NISTIR 7365 (the provenance Addendum u gives for those defaults)
Operating statesOS#3 (cooling) — host-gated

Preconditions (host-enforced): The fan must be running and the unit in its cooling operating state. A fan coil with a stopped fan holds stagnant air on the discharge sensor, which reads at room temperature and looks exactly like a coil that has lost capacity. Suspend evaluation for a settling period after any mode change, occupancy transition, or valve-sequence changeover, while the coil has not caught up. clg_vlv_cmd must be the command the FCU controller is issuing, not a position feedback: this rule asks whether the loop has run out of capacity to ask for, and a feedback that disagrees with its command is a stuck-actuator finding. sat_sp must be a discharge setpoint the sequence is actually holding — many fan coils control to zone temperature and have no discharge setpoint at all, and a host that synthesizes one from the zone setpoint will manufacture faults every mild afternoon; on such a unit omit the rule rather than bind it. Where the host also runs FCU-0004, treat a concurrent cooling-coil leak as a separate finding rather than an explanation of this one; the leak makes the discharge colder, not warmer. When any gate is unmet the verdict is NO_EVAL, not healthy.

Points: sat, sat_sp, clg_vlv_cmd

Outputs:

  • yFault — True while sat has stayed more than sat_error_threshold above sat_sp with the cooling valve commanded above cc_full_threshold, for at least alarm_delay
  • yClgFullOk — True while clg_vlv_cmd is above cc_full_threshold — the cooling loop has run out of capacity to ask for, so a setpoint miss is interpretable as a defect. False means the loop still has valve left to give and this rule has no verdict; the host reports NO_EVAL, not healthy

Parameters:

NameDefaultUnitCXF pathDescription
sat_error_threshold1.0°CspMiss.tThe reference’s ε_sat — the amount by which the discharge may exceed its setpoint before the miss is real rather than sensor error. ADOPTED: this card states no value (see Deviations). 1.0 °C is G36 Addendum u’s εSAT, the supply-air sensor accuracy allowance whose provenance is NISTIR 7365, and is the same value AHU-0013 ships. A site with a calibrated discharge sensor may lower it; raising it to quiet a hunting loop hides a tuning problem instead of fixing it
cc_full_threshold99.0%clgFull.tCooling coil command above which the loop is treated as having no capacity left to ask for (the reference’s clg_vlv_cmd ≥ 99%, as a strict > — see Deviations)
alarm_delay1800.0spersist.delayTimeContinuous fault persistence required before the alarm asserts. ADOPTED: this card states no AlarmDelay (see Deviations). 1800 s is the AlarmDelay default in Addendum u’s AHU AFDD tables and the value AHU-0013 ships; the reference’s own FCU-0001 uses 3600 s, which is that rule’s one-hour counting window rather than a chapter-wide default

Description

The chilled-water valve is wide open and the air leaving the fan coil is still above its setpoint. The loop has already asked for everything it has, so whatever is wrong is not tuning: either the coil cannot deliver, the chilled water behind it cannot, the fan is not moving air across it, or the sensor reporting the miss is wrong. On a fan coil the fault is quiet in a way it never is on an air handler — one unit under-cooling one hotel room produces a guest complaint, not a trend review — and the reference’s chapter introduction is blunt about why: fan coils are distributed by the dozens, rarely instrumented well, and faults on them persist for long periods. Two to five percent of a zone’s cooling energy is small until it is multiplied by three hundred rooms. This is the cooling-side member of the reference’s FCU-0002/FCU-0003 pair, the fan coil analog of AHU-0007/AHU-0013.

Detection Logic

yClgFullOk = clg_vlv_cmd > cc_full_threshold      (false ⇒ host reports NO_EVAL)
sp_gap     = sat − sat_sp

yFault = (sp_gap > sat_error_threshold) AND yClgFullOk,
         sustained continuously for alarm_delay

Block graph (rule.cxf.jsonld):

FCU-0003 block graph

spGap and spMiss are the reference’s sat > (sat_sp + ε_sat) rearranged so the allowance stays a single positive number at one CXF path. clgFull is the half that gives the miss its meaning — a discharge above setpoint at a part-open valve is a control loop doing its job, and only a loop that has run out of coil is evidence of a defect. It feeds both and also leaves the block as yClgFullOk, so a host can tell the two silences apart: silence with yClgFullOk true is a coil keeping up, silence with it false is a loop that still has valve left to give, about which this rule has nothing to say. Both comparisons are strict, so a miss sitting exactly on 1.0 °C and a command parked exactly on 99.0% both read healthy — the second matters on quantized commands (see Deviations). persist requires 30 continuous minutes, which separates a failed coil from a morning pulldown or a room coming out of setback; delayOnInit = true puts the alarm at exactly 1800 s from a miss already present at load. The graph is FCU-0002’s mirror — same five blocks, same two outputs, same three parameters, with the subtraction and the valve swapped.

Possible Diagnoses

Transcribed from the reference’s FCU-0003 card:

  1. Cooling coil fouled
  2. CHW supply temperature too high
  3. Cooling valve stuck closed
  4. Fan speed too low for cooling demand

Two readings that list needs. The reference gives FCU-0002 “SAT sensor out of calibration” and does not repeat it here, but a discharge sensor reading high produces this trace exactly and is the cheapest thing to eliminate; G36’s cooling-side equivalent (§5.16.14 FC#13) leads with SAT sensor error, so check the sensor first. And diagnosis 4 reads backwards at first: lower airflow across a fixed coil makes the discharge colder. The case that presents here is the extreme one — a fan stopped, off its belt, or blocked by a fouled filter leaves near-stagnant air on the discharge sensor, which reads close to room temperature while the valve pins open. That is why fan status is the first precondition.

Energy Impact

EXCESS_CONSUMPTION, MEDIUM confidence, PROXY_ESTIMATION, savings range 2–5% of zone cooling energy — the reference’s own profile, with no PNNL EEM mapped. PROXY because the rule reads two temperatures and a command, not airflow, capacity or power, so both the reference’s (sat − sat_sp) / sat_sp × fcu_clg_capacity_kw and the scale-free enthalpy form need a nameplate the rule cannot see. What the range buys depends on the diagnosis: if a heating valve is leaking on the same unit the shortfall is paid for twice (FCU-0005 sees that), while a fouled coil or warm chilled water wastes nothing at the coil and moves the cost to a zone that stays warm and a plant that runs longer. Cooling-dominant.

Emissions Impact

Scope 2, PROXY_EMISSIONS, MEDIUM confidence; the reference gives 50–400 kg CO₂e/yr per fan coil, MOER basis. Everything this fault spends is purchased electricity at the chiller and pumps. Note the direction of the accounting: emissions can rise when the fault is fixed, because a coil restored to capacity finally delivers the cooling the room was asking for. The honest claim is that this rule buys comfort and diagnosis, and the avoided-emissions half belongs to whatever waste the repair uncovers — most often a leaking heating valve on the same unit.

Deviations

  • Both tunables are adopted, not transcribed. The reference’s card states an equation and an operating state and stops. This card adopts 1.0 °C and 1800 s from G36 Addendum u’s AHU AFDD tables (εSAT = 1 °C, AlarmDelay = 30 min, provenance NISTIR 7365), the values AHU-0013 ships. The delay is the more debatable — a fan coil settles far faster than an air handler, so a site can drop it to 900 s — and the chapter’s one published AlarmDelay (FCU-0001’s 60 min) is a counting window, not evidence for this card.
  • clg_vlv_cmd ≥ 99% becomes a strict > 99.0. CDL Reals has no GreaterEqual, so a command parked at exactly 99.000% reads as not-saturated. Measure-zero on a modulating command and it errs toward silence, but fan coils are the worst offenders for quantization: a host binding integer percent, or a controller that clamps to a rounded 99, should retune cc_full_threshold to 98.9, and a unit whose “valve” is a two-position solenoid should bind at something like 50% or omit the rule.
  • The setpoint comparison is rewritten as a gap comparison, the same rearrangement as AHU-0013 and AHU-0001: subtracting first keeps the allowance the positive number the reference names, retunable at one CXF path. The two forms can differ by one ulp on a value straddling the threshold, which is not observable at 1 °C on a sensor rated to ±1 °C.
  • The strict comparison at spMiss likewise makes a miss of exactly 1.0 °C healthy and 1.1 °C a fault — measure zero, same direction of error.
  • G36 §5.22.6 is cited but was not read. The G36 material available to this library is Addendum u, which carries the AHU AFDD sections but not the fan coil section, so the equation, operating state and diagnosis list are the reference’s transcription of G36 and the adopted defaults come from the AHU tables. This is the card’s largest blind spot: different published defaults would retune without touching the graph, but a specified averaging treatment would be structural — see the next item.
  • Instantaneous samples, with no averaging. The reference writes the equation on instantaneous points and this card implements exactly that. Every AHU AFDD section of Addendum u computes 5-minute rolling averages, so §5.22.6 plausibly does too; if it does, this rule differs the way AHU-0013 documents. Averaging tolerates a signal whose mean sits outside the bound while it keeps crossing back, persistence does not, so an oscillating discharge can hide indefinitely. A steady miss against a saturated valve reads the same either way.
  • Operating states and the settling window are host-side preconditions. A verdict outside the cooling state or inside a transition window is NO_EVAL. Fan status matters more here than on an air handler and leads the list. The dictionary now defines canonical fan_status, but this older graph does not consume it; the host must gate evaluation on trusted fan proof.
  • yClgFullOk is the library’s, not the reference’s. Exposing the saturation conjunct as a boundary output adds no logic and changes no verdict; it lets the host distinguish “the coil is keeping up” from “the loop has not saturated, ask me later”, which are the same yFault = false and mean different things. Same stance and wiring as FCU-0002’s yHtgFullOk.
  • The chapter’s asymmetries are carried, not smoothed. This card follows the chapter wherever it differs from FCU-0002: Scope 2 against Scope 1, cooling-dominant against heating-dominant, OS#3 against OS#1, and a diagnosis list that trades “SAT sensor out of calibration” for “fan speed too low”. Only the diagnosis swap looks like an authoring slip, and Possible Diagnoses says so in prose instead of editing the list.
  • The reference’s runtime formula is transcribed with a caveat rather than corrected. Dividing a temperature difference by a temperature level makes the answer depend on whether the setpoint is in °C, K or °F; read literally in the library’s units it is a fractional-shortfall heuristic. energy_impact.runtime_estimation keeps the published form, flags the scale dependence, and adds the scale-free enthalpy-flow expression AHU-0013 uses.
  • Severity 3, category, confidence and estimation method are the reference’s (3/warning, EXCESS_CONSUMPTION, MEDIUM, PROXY_ESTIMATION), corroborated by the chapter README; the air-handler parent AHU-0013 also sits at 3.
  • The reference publishes no vectors for this card, so vectors.json is authored from the equation.
  • persist.delayOnInit = true (Modelica/CDL default is false), the library’s standing choice: a violation already present at load waits out the full 30 minutes instead of alarming on the first tick after a restart.
  • Frontmatter clusters, suppresses and suppressed_by are empty. The cluster set defines no FCU cluster and this card does not edit it. The relationships to FCU-0002 (the mirror), FCU-0004 (a cooling leak, which makes the discharge colder and so cannot explain this fault) and FCU-0005 (a heating leak, which can) are carried by related and the shared playbook.

Notes

Read yClgFullOk before reading yFault. On a modulating fan coil it is false most of the day, and every yFault = false under it means “not evaluated”, not “the coil is keeping up”.

The setpoint this rule compares against is the one the sequence is actually holding, which makes it quietly dependent on how the fan coil is controlled. A unit that controls to zone temperature with no discharge setpoint has nothing to bind to sat_sp, and a host that fabricates one — from the zone setpoint, or a design value nobody is holding — will produce a standing alarm every mild afternoon. Omit the rule on such a unit rather than bind it; that is the common case on older fan coils.

The fcu-faults playbook orders the service, and its step 1.2 has the right instruction: rule out the plant first, because a CHW riser running warm produces this fault simultaneously on every fan coil it serves, and the fleet-wide pattern is the cheapest discriminator available. A single unit alarming alone points at the coil, the valve, or the fan on that unit; step 3.1 covers the coil work.

Test Vectors

15 scenarios, clock step 60 s over 5400 s.

ScenarioDescription
cooling_loop_modulating_on_setpointOrdinary zone cooling: the discharge sits on its 13 °C setpoint with the chilled-water valve at 55%. The loop has reserve in both directions, which is what a healthy fan coil looks like, and yClgFullOk is false because a loop that has not saturated is one this rule has no verdict about
valve_wide_open_holding_setpointThe valve is at 100% and the discharge is 0.5 °C high — a coil sized close to its design point on a design day. Half a degree is inside ε_sat, so this is a unit at capacity rather than a unit that has lost capacity, and here the silence is a health claim: yClgFullOk is true
sat_error_just_below_thresholdOne tenth inside the allowance: sat − sat_sp = 0.9 °C at a saturated valve. Silent, and the closest healthy case to the boundary
sat_error_exactly_at_thresholdThreshold edge: sat − sat_sp = 1.0 °C exactly, the whole ε_sat allowance, with the valve at 100%. spMiss is a strict >, so an error sitting precisely on the sensor allowance reads healthy
sat_error_just_over_thresholdThreshold edge, other side: 1.1 °C above setpoint at a 100% valve clears the strict comparison on the first tick, and with delayOnInit the alarm asserts at exactly one alarm_delay (1800 s)
valve_just_below_full_thresholdCommand edge: a 5 °C setpoint miss with the valve at 98.9%. Below cc_full_threshold the loop still has something to ask for, so yClgFullOk is false and this rule has no verdict — the miss is real and belongs to a loop-response rule, not to this one
valve_exactly_at_full_thresholdCommand edge: the same 5 °C miss with the valve parked on exactly 99.0%. clgFull is a strict >, so a command sitting on cc_full_threshold does not count as saturated and the rule stays silent where the reference’s ≥ 99% would report
valve_just_over_full_thresholdCommand edge, third side: the same 5 °C miss at 99.5% clears the strict comparison, yClgFullOk goes true, and the alarm lands after alarm_delay
coil_saturated_and_missing_setpointThe motivating case: the valve is wide open and the discharge is stuck 5 °C above setpoint. The loop has asked for everything it has and the air has not moved — chilled water too warm, a fouled coil, a valve that reports open and is not, or a stalled fan leaving stagnant room air on the sensor
miss_opens_mid_runA rising edge mid-run rather than at load: the unit holds setpoint at a saturated valve until the chilled-water riser warms at t = 600 s, after which the discharge sits 5 °C high. The alarm lands at exactly 600 + 1800 = 2400 s
morning_pulldown_clears_before_delayTransient: the valve pins at 100% and the discharge runs 5 °C high while the coil pulls a hotel room down after an unoccupied night, reaching setpoint at t = 1200 s. A pulldown is shorter than alarm_delay, so nothing alarms
fault_clears_when_chilled_water_returnsA saturated valve missing setpoint alarms at 1800 s; the riser comes back at t = 3000 s and the discharge reaches setpoint. TrueDelay has no off-delay, so the alarm drops on that tick
valve_backs_off_before_delayRecovery through the command term: the discharge stays 5 °C high but the valve modulates back to 80% at t = 1200 s. yClgFullOk goes false, persistence restarts, and no alarm follows — a loop with reserve left is a loop still working, and the saturation half is what makes the setpoint miss evidence of a defect
setpoint_reset_upward_clears_the_missThe setpoint moves, not the air: the discharge holds 18 °C at a saturated valve while the sequence resets sat_sp from 13 °C to 17.5 °C at t = 1200 s. The remaining 0.5 °C gap is inside ε_sat, so the rule falls silent — the comparison is against the active setpoint, which is why the preconditions insist on the one the sequence is actually holding
hunting_loop_never_alarmsA hunting cooling loop swings the discharge between 18 °C and 12.8 °C on a 20-minute period with the valve pinned open. Its mean sits well above setpoint and a 5-minute rolling average would report the fault, but persistence restarts on every compliant tick, so this rule stays silent — the limit of substituting TrueDelay for averaging (see Deviations)
vectors.json
{
  "schema": "cxf-library/vectors/v1",
  "clock": {
    "step_s": 60,
    "horizon_s": 5400
  },
  "scenarios": [
    {
      "name": "cooling_loop_modulating_on_setpoint",
      "description": "Ordinary zone cooling: the discharge sits on its 13 \u00b0C setpoint with the chilled-water valve at 55%. The loop has reserve in both directions, which is what a healthy fan coil looks like, and yClgFullOk is false because a loop that has not saturated is one this rule has no verdict about",
      "inputs": {
        "sat": 13.0,
        "sat_sp": 13.0,
        "clg_vlv_cmd": 55.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        },
        {
          "output": "yClgFullOk",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        }
      ]
    },
    {
      "name": "valve_wide_open_holding_setpoint",
      "description": "The valve is at 100% and the discharge is 0.5 \u00b0C high \u2014 a coil sized close to its design point on a design day. Half a degree is inside \u03b5_sat, so this is a unit at capacity rather than a unit that has lost capacity, and here the silence is a health claim: yClgFullOk is true",
      "inputs": {
        "sat": 13.5,
        "sat_sp": 13.0,
        "clg_vlv_cmd": 100.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        },
        {
          "output": "yClgFullOk",
          "from_s": 0,
          "to_s": 5400,
          "equals": true
        }
      ]
    },
    {
      "name": "sat_error_just_below_threshold",
      "description": "One tenth inside the allowance: sat \u2212 sat_sp = 0.9 \u00b0C at a saturated valve. Silent, and the closest healthy case to the boundary",
      "inputs": {
        "sat": 13.9,
        "sat_sp": 13.0,
        "clg_vlv_cmd": 100.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        },
        {
          "output": "yClgFullOk",
          "from_s": 0,
          "to_s": 5400,
          "equals": true
        }
      ]
    },
    {
      "name": "sat_error_exactly_at_threshold",
      "description": "Threshold edge: sat \u2212 sat_sp = 1.0 \u00b0C exactly, the whole \u03b5_sat allowance, with the valve at 100%. `spMiss` is a strict `>`, so an error sitting precisely on the sensor allowance reads healthy",
      "inputs": {
        "sat": 14.0,
        "sat_sp": 13.0,
        "clg_vlv_cmd": 100.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        },
        {
          "output": "yClgFullOk",
          "from_s": 0,
          "to_s": 5400,
          "equals": true
        }
      ]
    },
    {
      "name": "sat_error_just_over_threshold",
      "description": "Threshold edge, other side: 1.1 \u00b0C above setpoint at a 100% valve clears the strict comparison on the first tick, and with delayOnInit the alarm asserts at exactly one alarm_delay (1800 s)",
      "inputs": {
        "sat": 14.1,
        "sat_sp": 13.0,
        "clg_vlv_cmd": 100.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1740,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 1800,
          "to_s": 5400,
          "equals": true
        },
        {
          "output": "yClgFullOk",
          "from_s": 0,
          "to_s": 5400,
          "equals": true
        }
      ]
    },
    {
      "name": "valve_just_below_full_threshold",
      "description": "Command edge: a 5 \u00b0C setpoint miss with the valve at 98.9%. Below cc_full_threshold the loop still has something to ask for, so yClgFullOk is false and this rule has no verdict \u2014 the miss is real and belongs to a loop-response rule, not to this one",
      "inputs": {
        "sat": 18.0,
        "sat_sp": 13.0,
        "clg_vlv_cmd": 98.9
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        },
        {
          "output": "yClgFullOk",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        }
      ]
    },
    {
      "name": "valve_exactly_at_full_threshold",
      "description": "Command edge: the same 5 \u00b0C miss with the valve parked on exactly 99.0%. `clgFull` is a strict `>`, so a command sitting on cc_full_threshold does not count as saturated and the rule stays silent where the reference's `\u2265 99%` would report",
      "inputs": {
        "sat": 18.0,
        "sat_sp": 13.0,
        "clg_vlv_cmd": 99.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        },
        {
          "output": "yClgFullOk",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        }
      ]
    },
    {
      "name": "valve_just_over_full_threshold",
      "description": "Command edge, third side: the same 5 \u00b0C miss at 99.5% clears the strict comparison, yClgFullOk goes true, and the alarm lands after alarm_delay",
      "inputs": {
        "sat": 18.0,
        "sat_sp": 13.0,
        "clg_vlv_cmd": 99.5
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1740,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 1800,
          "to_s": 5400,
          "equals": true
        },
        {
          "output": "yClgFullOk",
          "from_s": 0,
          "to_s": 5400,
          "equals": true
        }
      ]
    },
    {
      "name": "coil_saturated_and_missing_setpoint",
      "description": "The motivating case: the valve is wide open and the discharge is stuck 5 \u00b0C above setpoint. The loop has asked for everything it has and the air has not moved \u2014 chilled water too warm, a fouled coil, a valve that reports open and is not, or a stalled fan leaving stagnant room air on the sensor",
      "inputs": {
        "sat": 18.0,
        "sat_sp": 13.0,
        "clg_vlv_cmd": 100.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1740,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 1800,
          "to_s": 5400,
          "equals": true
        },
        {
          "output": "yClgFullOk",
          "from_s": 0,
          "to_s": 5400,
          "equals": true
        }
      ]
    },
    {
      "name": "miss_opens_mid_run",
      "description": "A rising edge mid-run rather than at load: the unit holds setpoint at a saturated valve until the chilled-water riser warms at t = 600 s, after which the discharge sits 5 \u00b0C high. The alarm lands at exactly 600 + 1800 = 2400 s",
      "inputs": {
        "sat": [
          {
            "t": 0,
            "value": 13.0
          },
          {
            "t": 600,
            "value": 18.0
          }
        ],
        "sat_sp": 13.0,
        "clg_vlv_cmd": 100.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2340,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2400,
          "to_s": 5400,
          "equals": true
        },
        {
          "output": "yClgFullOk",
          "from_s": 0,
          "to_s": 5400,
          "equals": true
        }
      ]
    },
    {
      "name": "morning_pulldown_clears_before_delay",
      "description": "Transient: the valve pins at 100% and the discharge runs 5 \u00b0C high while the coil pulls a hotel room down after an unoccupied night, reaching setpoint at t = 1200 s. A pulldown is shorter than alarm_delay, so nothing alarms",
      "inputs": {
        "sat": [
          {
            "t": 0,
            "value": 18.0
          },
          {
            "t": 1200,
            "value": 13.2
          }
        ],
        "sat_sp": 13.0,
        "clg_vlv_cmd": 100.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        },
        {
          "output": "yClgFullOk",
          "from_s": 0,
          "to_s": 5400,
          "equals": true
        }
      ]
    },
    {
      "name": "fault_clears_when_chilled_water_returns",
      "description": "A saturated valve missing setpoint alarms at 1800 s; the riser comes back at t = 3000 s and the discharge reaches setpoint. `TrueDelay` has no off-delay, so the alarm drops on that tick",
      "inputs": {
        "sat": [
          {
            "t": 0,
            "value": 18.0
          },
          {
            "t": 3000,
            "value": 13.0
          }
        ],
        "sat_sp": 13.0,
        "clg_vlv_cmd": 100.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1740,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 1800,
          "to_s": 2940,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 3000,
          "to_s": 5400,
          "equals": false
        },
        {
          "output": "yClgFullOk",
          "from_s": 0,
          "to_s": 5400,
          "equals": true
        }
      ]
    },
    {
      "name": "valve_backs_off_before_delay",
      "description": "Recovery through the command term: the discharge stays 5 \u00b0C high but the valve modulates back to 80% at t = 1200 s. yClgFullOk goes false, persistence restarts, and no alarm follows \u2014 a loop with reserve left is a loop still working, and the saturation half is what makes the setpoint miss evidence of a defect",
      "inputs": {
        "sat": 18.0,
        "sat_sp": 13.0,
        "clg_vlv_cmd": [
          {
            "t": 0,
            "value": 100.0
          },
          {
            "t": 1200,
            "value": 80.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        },
        {
          "output": "yClgFullOk",
          "from_s": 0,
          "to_s": 1140,
          "equals": true
        },
        {
          "output": "yClgFullOk",
          "from_s": 1200,
          "to_s": 5400,
          "equals": false
        }
      ]
    },
    {
      "name": "setpoint_reset_upward_clears_the_miss",
      "description": "The setpoint moves, not the air: the discharge holds 18 \u00b0C at a saturated valve while the sequence resets sat_sp from 13 \u00b0C to 17.5 \u00b0C at t = 1200 s. The remaining 0.5 \u00b0C gap is inside \u03b5_sat, so the rule falls silent \u2014 the comparison is against the active setpoint, which is why the preconditions insist on the one the sequence is actually holding",
      "inputs": {
        "sat": 18.0,
        "sat_sp": [
          {
            "t": 0,
            "value": 13.0
          },
          {
            "t": 1200,
            "value": 17.5
          }
        ],
        "clg_vlv_cmd": 100.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        },
        {
          "output": "yClgFullOk",
          "from_s": 0,
          "to_s": 5400,
          "equals": true
        }
      ]
    },
    {
      "name": "hunting_loop_never_alarms",
      "description": "A hunting cooling loop swings the discharge between 18 \u00b0C and 12.8 \u00b0C on a 20-minute period with the valve pinned open. Its mean sits well above setpoint and a 5-minute rolling average would report the fault, but persistence restarts on every compliant tick, so this rule stays silent \u2014 the limit of substituting TrueDelay for averaging (see Deviations)",
      "inputs": {
        "sat": [
          {
            "t": 0,
            "value": 18.0
          },
          {
            "t": 600,
            "value": 12.8
          },
          {
            "t": 1200,
            "value": 18.0
          },
          {
            "t": 1800,
            "value": 12.8
          },
          {
            "t": 2400,
            "value": 18.0
          },
          {
            "t": 3000,
            "value": 12.8
          },
          {
            "t": 3600,
            "value": 18.0
          },
          {
            "t": 4200,
            "value": 12.8
          },
          {
            "t": 4800,
            "value": 18.0
          }
        ],
        "sat_sp": 13.0,
        "clg_vlv_cmd": 100.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        },
        {
          "output": "yClgFullOk",
          "from_s": 0,
          "to_s": 5400,
          "equals": true
        }
      ]
    }
  ]
}

FCU-0004 — Inactive cooling coil temperature drop (leak)

Statusverified — engine e2ff2f8, cxf:fnv1a128:40d3e69b8b4c5f2aa7e5f24c9981671b, 2026-08-17
Severity3
Methodrule
Phase1
CategoryCRITICAL_WASTE
ConfidenceHIGH
EstimationDIRECT_MEASUREMENT
G36§5.22.6 FC#4
Clusters
Suppresses
Suppressed by
RelatedFCU-0005, FCU-0002, FCU-0006, FCU-0007
Playbooksfcu-faults
SourceHVAC FDD Reference v1.0 §12, FCU-0004; G36 §5.22.6 FC#4 (the chapter’s cited source; clause text not available — see Deviations); G36 Addendum u, Table 5.16.14.5 (εRAT, εSAT, ΔTSF — the threshold composition) and §5.17.4.5 FC#3 (the RAT/SAT-proxied form of the same test); PNNL EEM-03 (fix leaking valves)
Operating statesOS#2 (no active coils) — host-gated

Preconditions (host-enforced): The fan must be running. rat and sat are a coil entering and leaving temperature only while air is crossing the coil; on a cycling-fan FCU the discharge sensor sits in stagnant air over a coil full of chilled water between cycles and reads several degrees cold, which is this fault’s exact signature and none of its meaning. This graph does not consume the dictionary’s canonical fan_cmd or fan_status, so the host must gate it on trusted fan proof; an FCU-0006 fail-to-start makes this verdict NO_EVAL (fan_off_standing_water_reads_as_a_leak pins the consequence). Suspend evaluation for a settling window after the cooling valve closes — a coil surrendering the chilled water standing in it shows the same drop for several minutes. The heating coil should also be off: a heating call warms the discharge and can only hide this fault, never fabricate it, so the masking is a miss rather than a false alarm, but a host that wants OS#2 as the reference scopes it should gate on htg_vlv_cmd too, which this rule does not read. Both sensors must be in the airstream and trustworthy: a rat bound to a wall-mounted space sensor, a discharge sensor in a plenum shared with another unit, or a cabinet drawing ducted outdoor air upstream of the coil all break the binding with no other symptom, and the outdoor-air case biases this rule toward false alarms in winter. Nothing in this rule cross-checks either sensor. When any gate is unmet the verdict is NO_EVAL, not healthy — as it is whenever the in-rule output yCmdOk reads false.

Points: clg_vlv_cmd, sat, rat

Outputs:

  • yFault — True while the valve has been commanded shut and sat has stayed more than inactive_coil_threshold below rat, continuously for at least alarm_delay
  • yCmdOk — True while clg_vlv_cmd is below cmd_closed_threshold — the coil is commanded shut and the drop across it is therefore interpretable. False means the coil is allowed to be cooling and this rule has no verdict; the host reports NO_EVAL, not healthy

Parameters:

NameDefaultUnitCXF pathDescription
inactive_coil_threshold3.0°CdropBig.tDrop from entering to leaving air that stops being sensor error and starts being a leak. ADOPTED — the reference names the parameter in the equation and publishes no value for it (see Deviations). 3.0 °C is the rounded G36-style composition for FCU-grade instrumentation, sqrt(e_ret² + e_sup²) + dTSF, and it is deliberately the same number FCU-0005 ships because the reference names one parameter for both equations. Note the direction: on this cooling-side rule the fan’s rise works against the measured drop, so the shipped value fires at about 4 °C of true coil work
cmd_closed_threshold1.0%vlvShut.tCommand below which the cooling valve counts as commanded shut. ADOPTED — the reference writes the test as clg_vlv_cmd = 0%, which is not a comparison a real-valued signal supports (see Deviations). 1.0% is deliberately tighter than AHU-0016’s 5% open threshold: this rule needs the valve to be at rest, not merely nearly closed
alarm_delay1800.0spersist.delayTimeContinuous violation required before the alarm asserts (30 min). ADOPTED — the reference publishes no tunables line for this card (see Deviations); the value is the AHU twin’s G36 AlarmDelay

Description

A fan coil’s chilled-water valve is small, and when its seat wears the leak is correspondingly small: a few degrees of cooling on air the sequence believes is passing an inert coil. In the heating season that is the expensive version — the heating coil warms the air, the leaking cooling coil takes part of it back, the zone holds setpoint, and the only evidence is a boiler and a chiller both working slightly harder than the room requires. Nobody complains, so nobody looks, which is why the fault is worth a rule rather than a walkthrough: fan coils are deployed by the hundred, and one leaking seat wastes an amount too small to see on a bill and too tedious to find by hand. The chapter puts one leaking valve at 3–10% of zone cooling energy and 100–800 kg CO₂e a year, mapped to PNNL EEM-03. Because the rule reads temperatures rather than flows it cannot separate a worn seat from an actuator short of its close position from a three-way bypass that is not sealing; all three are on the diagnosis list.

Detection Logic

yCmdOk = clg_vlv_cmd < cmd_closed_threshold          (false ⇒ host reports NO_EVAL)
drop   = rat − sat

yFault = (drop > inactive_coil_threshold) AND yCmdOk,
         sustained continuously for alarm_delay

Block graph (rule.cxf.jsonld):

FCU-0004 block graph

drop subtracts in the reference’s own order (entering_temp − leaving_temp), which makes the sign of this rule the opposite of FCU-0005’s. vlvShut feeds gate and also leaves the block as yCmdOk, so a host can tell the two silences apart: quiet with yCmdOk true is a coil shut and behaving, quiet with it false is a coil that was asked to cool, about which this rule has no opinion. On this side of the pair the fan works against the signal — with both valves shut a healthy unit reads rat − sat slightly negative, because the fan puts its shaft work into the air — so a leak must overcome that rise and then clear the sensor allowance on top, and the shipped 3.0 °C takes roughly 4 °C of real coil work to trip where the same number on the heating-side twin trips at about 2 °C. Both comparisons are strict. persist requires 30 continuous minutes, separating a leaking seat from a coil giving up the chilled water standing in it after a call ends; recovery is immediate, and delayOnInit = true holds the window across a restart.

Possible Diagnoses

Transcribed from the reference’s FCU-0004 card:

  1. Cooling coil valve leaking through — the worn or eroded seat; the common case, priced by the playbook at $150–$600 to replace
  2. Valve not fully closing (mechanical) — the actuator has lost its close position or binds short of the seat, distinguishable on site by stroking it against feedback
  3. Three-way valve bypass not sealing — nothing about the valve’s travel looks wrong, which is why this one survives an actuator check

A fourth belongs in the operator’s head though the reference does not list it: either sensor being wrong produces this trace with a perfectly good valve. A return sensor reading high and a discharge sensor reading low are indistinguishable here and both are cheap to check against a portable reference, which is why G36 §5.16.14 puts the two sensor errors ahead of the valve in its own diagnosis order.

Energy Impact

CRITICAL_WASTE, HIGH confidence, DIRECT_MEASUREMENT, 3–10% of zone cooling energy, mapped by the reference to PNNL EEM-03 (fix leaking valves). DIRECT_MEASUREMENT is honest here: the two temperatures the rule reads are the measurement, and the runtime formula converts them to thermal power with one substitution — design airflow for measured, since an FCU has no flow station. HIGH confidence because a sustained drop across a coil commanded shut has no benign explanation other than a sensor or a stopped fan. Heating-dominant per the chapter, which reads oddly for a cooling fault until the operating state is taken into account: the hours a leaking chilled-water valve does the most damage are the hours a heating coil is fighting it, and those are the hours the cooling valve is commanded shut for weeks at a time. In deep cooling weather the same leak hides behind legitimate calls for cooling.

Emissions Impact

Scope 2, DIRECT_EMISSIONS, HIGH confidence; typically 100–800 kg CO₂e/yr per unit of parasitic cooling, MOER basis. The unwanted cooling is purchased electricity at the chiller and pumps, so the cooling half of the exchange is Scope 2 on every site. The heating that cancels it follows whatever the plant burns and is FCU-0005’s half to claim. Where the cause turns out to be a sensor or a stopped fan there is nothing to attribute at all.

Deviations

  • inactive_coil_threshold is adopted, not transcribed. The reference names the parameter and publishes no tunables line for this card. 3.0 °C is G36’s composition for the same fault on an air handler — sqrt(εSAT² + εRAT²) + ΔTSF, the form Addendum u §5.17.4.5 FC#3 uses on the same sensor pair — which gives 2.41 at that clause’s 1 °C values and 2.98 with the ±1.4 °C-class sensors an FCU typically carries. It is also the value FCU-0005 ships, because the reference names one parameter for both equations. (Only FC#3’s right-hand side is borrowed; its operands read as a drop under a description that says rise, so this card’s sign comes from the reference’s entering_temp − leaving_temp.)
  • Fan heat is inside the measurement, and here it works against the signal. Neither the chapter nor the playbook mentions fan heat; the term comes from G36’s ΔTSF via AHU-0014. The fan raises the discharge and the leaking coil lowers it, so rat − sat under-reports the true coil drop by one fan rise: 3.0 °C measured is about 4 °C of coil work, making this rule about half as sensitive in coil terms as its twin. The asymmetry is real, not an authoring slip; a host wanting matched sensitivity lowers this threshold by one fan-heat term.
  • clg_vlv_cmd = 0% becomes < 1.0%. CDL Reals has no equality block, and equality on a float from a BAS would be the wrong test anyway — controllers write 0.0001% and round-tripped analog values land near but not on zero. 1.0% is tighter than AHU-0016’s 5% “open” threshold because that rule needs the valve to be doing something and this one needs it to be doing nothing. On a two-position solenoid the command is 0 or 100 and the test is exact.
  • alarm_delay is adopted at 30 minutes. The reference publishes no delay here; FCU-0001’s 60 min belongs to a transition counter and says nothing about a coil. 1800 s is the AHU twin’s G36 AlarmDelay and it does identifiable work — riding out the residual cold a just-closed coil gives up. A site whose coils purge in five minutes can cut it and detect leaks sooner.
  • Strict comparisons at both boundaries. A drop sitting exactly on 3.0 °C is not a fault and a command sitting exactly on 1.0% is not shut. The reference writes > for the temperature test; the command side is the adopted test above. Both err toward silence, and a host binding coarsely quantized temperatures should retune down.
  • The G36 clause is the chapter’s citation, carried forward unverified. The library’s G36 material is Addendum u, which carries no fan coil section, so everything claimed as transcribed comes from the reference’s ch.12 card and the g36 field is provenance the reference asserts. If §5.22.6 states averaging windows, epsilons or an alarm delay, they will correct the adopted values above.
  • rat and sat stand in for the coil entering and leaving temperatures, as the FCU point dictionary directs, so the rule sees the whole air path: the fan is inside the measurement (handled by the fan-heat term) and so is any duct after the coil (not handled, and it biases this rule quieter). One configuration breaks the binding in the dangerous direction — a cabinet drawing outdoor air upstream of the coil presents a mixture colder than the return sensor reads, so rat − sat shows a winter drop with no leak at all.
  • The fan-running gate remains host-side. The dictionary now defines fan_cmd and fan_status for FCU-0006, but this graph does not consume them. A cycling-fan FCU evaluated between cycles is the realistic way to get a false alarm out of this rule (fan_off_standing_water_reads_as_a_leak pins it). Gate on trusted fan proof and treat FCU-0006 fail-to-start as NO_EVAL here.
  • yCmdOk is the library’s, not the reference’s. Exposing the command conjunct as a boundary output adds no logic and changes no verdict; it lets the host distinguish “the coil is shut and quiet” from “the coil is cooling, ask me later”, which are the same yFault = false and mean opposite things. Same wiring as FCU-0005’s yCmdOk and RTU-0002’s yStageOk.
  • Instantaneous samples instead of rolling averages. G36’s AHU set computes every signal as a 5-minute rolling average; whether §5.22.6 does is unknown here. Persistence is not equivalent — averaging tolerates a signal whose mean sits outside the bound while it keeps crossing back, so a leak modulated by riser pressure or a valve hunting around its seat can hide indefinitely. A steady leak reads the same either way.
  • A leak is only visible between calls for cooling. The rule is silent whenever the valve is open, so a valve that leaks all summer is detected in autumn and one on a unit in continuous cooling is never detected at all. That is inherent in the reference’s equation, and it is the structural reason the chapter calls the fault heating-dominant.
  • Operating state OS#2 is host-enforced, and only half of it is testable here. The graph’s command test covers the cooling half of “no active coils”; the heating half is not tested, and that gap is benign — an active heating coil raises sat and can only silence this rule, never trip it. Masking costs detections, not credibility.
  • The runtime formula is extended. To the chapter’s (entering_temp − leaving_temp) × fcu_airflow × cp_air this card adds air density (the product needs mass flow) and adds the fan’s rise back, since the measured drop is the coil’s work reduced by it — a quarter of the answer at the shipped threshold.
  • Severity 3 is the reference’s, and it disagrees with the AHU twin. AHU-0014/AHU-0015 carry severity 2, assigned by this library because the AHU reference has no card to state one. The difference is defensible on scale and is recorded rather than smoothed, because a host ranking a mixed fleet by severity will see the same physics at two levels.
  • clusters is empty. The chapter README calls FCU-0004/FCU-0005 the zone-scale members of the simultaneous-conditioning family, but clusters/clusters.json lists only AHU rules there and this card does not edit the cluster set. The relationship is carried by related and the playbook.
  • persist.delayOnInit = true (Modelica/CDL default is false), the library’s standing choice: a leak already present when the controller restarts waits out the full 30 minutes instead of alarming on the first tick.
  • The reference publishes no vectors for this card, so vectors.json is authored from the equation.

Notes

Read yCmdOk before reading yFault. On a unit in cooling season it will be false most of the day, and every yFault = false under it means “not evaluated”, not “no leak”.

This card is one half of a pair the reference states symmetrically, and the asymmetries are what to hold in mind: the sign of the subtraction is reversed (rat − sat here, sat − rat in FCU-0005), fan heat pushes the two measurements in opposite directions so the shared 3.0 °C is not a shared sensitivity, and the emissions scope differs because the plants differ. Everything else is identical, because nothing in the chapter distinguishes them.

The fcu-faults playbook orders the service. Step 1.3 is the manual version of this rule — command the valve to 0% and measure across the coil, where any measurable change confirms the leak, a sharper test than this rule ships because a technician knows the fan is running and can put a calibrated probe on both sides. Step 2.3 is the remote workaround: lock the cooling valve out for the heating season while the unit waits for parts. Step 3.4 ranks a fleet by |temp_change| × airflow × cp_air, which is this card’s runtime estimator. Expect FCU-0002 on the same unit in heating weather — if both are active, this one is the cause and that one the consequence.

Test Vectors

15 scenarios, clock step 60 s over 5400 s.

ScenarioDescription
healthy_unit_shows_fan_heat_onlyBoth valves shut and the fan running: 22.0 °C room air arrives at the discharge sensor at 22.5 °C. The only thing the air crossed was the fan, which put its shaft work into the stream, and the cooling coil took nothing back. drop = rat − sat = −0.5 °C, the healthy signature of a tight chilled-water valve
drop_just_below_thresholdOne tenth inside the allowance: rat − sat = 2.9 °C at a valve commanded shut. Silent — and worth reading with the fan-heat direction in mind, because the fan warms the air between the two sensors, so a measured 2.9 °C is a true coil drop of nearly 4 °C that this rule does not report (see Deviations)
drop_exactly_at_thresholdThreshold edge: rat − sat = 3.0 °C exactly. dropBig is a strict >, which is the comparison the reference writes, so a drop sitting precisely on inactive_coil_threshold reads healthy
drop_just_over_thresholdThreshold edge, other side: rat − sat = 3.1 °C clears the strict comparison on the first tick, and with delayOnInit the alarm asserts at exactly one alarm_delay (1800 s)
cmd_just_below_closed_thresholdCommand edge: a 6 °C drop with clg_vlv_cmd at 0.9%. Below cmd_closed_threshold the valve counts as shut, so yCmdOk holds and the alarm lands after alarm_delay. Between 0% and 1% a leak and a trickle of commanded flow are indistinguishable to this rule
cmd_exactly_at_closed_thresholdCommand edge: the same 6 °C drop with the command sitting on exactly 1.0%. vlvShut is a strict <, so the valve does not count as shut, yCmdOk goes false, and the rule has no verdict — the host reports NO_EVAL rather than health
cmd_just_above_closed_thresholdCommand edge, third side: 1.1% with the same drop. Silent for the same reason, and pinned so a future retune of cmd_closed_threshold cannot move the boundary unnoticed
valve_open_and_cooling_normallyA fan coil doing its job: the valve is commanded 60% open and pulls 24 °C room air down to 14 °C. The largest drop in the set raises nothing, because yCmdOk is false and this rule has nothing to say about a coil that was asked to cool
chilled_water_valve_leaking_throughThe motivating case: the valve is commanded 0% and 22 °C room air still arrives at 16 °C. Correcting for the fan’s own rise, the coil is pulling something over 6 °C out of air nobody asked to cool — a worn seat, an actuator short of its close position, or a three-way bypass that is not sealing
leak_starts_mid_runA rising edge mid-run rather than at load: the unit runs healthy on fan heat alone and the riser is pressurized at t = 600 s, after which the drop holds at 6 °C. The alarm lands at exactly 600 + 1800 = 2400 s
transient_clears_before_alarm_delayTransient: a 6 °C drop persists for 1200 s while the chilled water standing in a just-closed coil gives up its cold, then the discharge returns to room air plus fan heat. Anything shorter than alarm_delay never reaches the operator, which is the delay’s main job on this fault
fault_clears_on_recoveryA sustained 6 °C drop alarms at 1800 s; the coil is isolated at t = 3000 s and the discharge climbs back to room air plus fan heat. TrueDelay has no off-delay, so the alarm drops on that tick and the accumulated time is discarded
zone_calls_for_cooling_before_alarmRecovery through the command term: the drop never changes, but the zone calls for cooling at t = 1200 s and the valve opens to 40%. yCmdOk goes false, persistence restarts, and the leak that was 10 minutes from being reported is not reported. On a unit cycling in and out of cooling, this rule only ever sees leaks that outlast a full closed period
fan_off_standing_water_reads_as_a_leakThe known hole, pinned so it cannot change silently: with the fan stopped, the discharge sensor sits in still air over a coil full of chilled water and reads 15 °C while the return sensor reads the room at 22 °C. No air is moving and nothing is being wasted, but the rule alarms. Canonical fan status now exists for FCU-0006, but this graph does not consume it, so this gate remains the host’s — see preconditions
oscillating_drop_never_alarmsA leak modulated by riser pressure swings the drop between 6 °C and −0.5 °C on a 20-minute period. Its mean is well outside the allowance and a 5-minute rolling average would report the fault, but persistence restarts on every compliant tick, so this rule stays silent — the limit of substituting TrueDelay for averaging (see Deviations)
vectors.json
{
  "schema": "cxf-library/vectors/v1",
  "clock": {
    "step_s": 60,
    "horizon_s": 5400
  },
  "scenarios": [
    {
      "name": "healthy_unit_shows_fan_heat_only",
      "description": "Both valves shut and the fan running: 22.0 \u00b0C room air arrives at the discharge sensor at 22.5 \u00b0C. The only thing the air crossed was the fan, which put its shaft work into the stream, and the cooling coil took nothing back. `drop` = rat \u2212 sat = \u22120.5 \u00b0C, the healthy signature of a tight chilled-water valve",
      "inputs": {
        "clg_vlv_cmd": 0.0,
        "sat": 22.5,
        "rat": 22.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        },
        {
          "output": "yCmdOk",
          "from_s": 0,
          "to_s": 5400,
          "equals": true
        }
      ]
    },
    {
      "name": "drop_just_below_threshold",
      "description": "One tenth inside the allowance: rat \u2212 sat = 2.9 \u00b0C at a valve commanded shut. Silent \u2014 and worth reading with the fan-heat direction in mind, because the fan warms the air between the two sensors, so a measured 2.9 \u00b0C is a true coil drop of nearly 4 \u00b0C that this rule does not report (see Deviations)",
      "inputs": {
        "clg_vlv_cmd": 0.0,
        "sat": 19.1,
        "rat": 22.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        },
        {
          "output": "yCmdOk",
          "from_s": 0,
          "to_s": 5400,
          "equals": true
        }
      ]
    },
    {
      "name": "drop_exactly_at_threshold",
      "description": "Threshold edge: rat \u2212 sat = 3.0 \u00b0C exactly. `dropBig` is a strict `>`, which is the comparison the reference writes, so a drop sitting precisely on inactive_coil_threshold reads healthy",
      "inputs": {
        "clg_vlv_cmd": 0.0,
        "sat": 19.0,
        "rat": 22.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        },
        {
          "output": "yCmdOk",
          "from_s": 0,
          "to_s": 5400,
          "equals": true
        }
      ]
    },
    {
      "name": "drop_just_over_threshold",
      "description": "Threshold edge, other side: rat \u2212 sat = 3.1 \u00b0C clears the strict comparison on the first tick, and with delayOnInit the alarm asserts at exactly one alarm_delay (1800 s)",
      "inputs": {
        "clg_vlv_cmd": 0.0,
        "sat": 18.9,
        "rat": 22.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1740,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 1800,
          "to_s": 5400,
          "equals": true
        },
        {
          "output": "yCmdOk",
          "from_s": 0,
          "to_s": 5400,
          "equals": true
        }
      ]
    },
    {
      "name": "cmd_just_below_closed_threshold",
      "description": "Command edge: a 6 \u00b0C drop with clg_vlv_cmd at 0.9%. Below cmd_closed_threshold the valve counts as shut, so yCmdOk holds and the alarm lands after alarm_delay. Between 0% and 1% a leak and a trickle of commanded flow are indistinguishable to this rule",
      "inputs": {
        "clg_vlv_cmd": 0.9,
        "sat": 16.0,
        "rat": 22.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1740,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 1800,
          "to_s": 5400,
          "equals": true
        },
        {
          "output": "yCmdOk",
          "from_s": 0,
          "to_s": 5400,
          "equals": true
        }
      ]
    },
    {
      "name": "cmd_exactly_at_closed_threshold",
      "description": "Command edge: the same 6 \u00b0C drop with the command sitting on exactly 1.0%. `vlvShut` is a strict `<`, so the valve does not count as shut, yCmdOk goes false, and the rule has no verdict \u2014 the host reports NO_EVAL rather than health",
      "inputs": {
        "clg_vlv_cmd": 1.0,
        "sat": 16.0,
        "rat": 22.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        },
        {
          "output": "yCmdOk",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        }
      ]
    },
    {
      "name": "cmd_just_above_closed_threshold",
      "description": "Command edge, third side: 1.1% with the same drop. Silent for the same reason, and pinned so a future retune of cmd_closed_threshold cannot move the boundary unnoticed",
      "inputs": {
        "clg_vlv_cmd": 1.1,
        "sat": 16.0,
        "rat": 22.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        },
        {
          "output": "yCmdOk",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        }
      ]
    },
    {
      "name": "valve_open_and_cooling_normally",
      "description": "A fan coil doing its job: the valve is commanded 60% open and pulls 24 \u00b0C room air down to 14 \u00b0C. The largest drop in the set raises nothing, because yCmdOk is false and this rule has nothing to say about a coil that was asked to cool",
      "inputs": {
        "clg_vlv_cmd": 60.0,
        "sat": 14.0,
        "rat": 24.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        },
        {
          "output": "yCmdOk",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        }
      ]
    },
    {
      "name": "chilled_water_valve_leaking_through",
      "description": "The motivating case: the valve is commanded 0% and 22 \u00b0C room air still arrives at 16 \u00b0C. Correcting for the fan's own rise, the coil is pulling something over 6 \u00b0C out of air nobody asked to cool \u2014 a worn seat, an actuator short of its close position, or a three-way bypass that is not sealing",
      "inputs": {
        "clg_vlv_cmd": 0.0,
        "sat": 16.0,
        "rat": 22.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1740,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 1800,
          "to_s": 5400,
          "equals": true
        },
        {
          "output": "yCmdOk",
          "from_s": 0,
          "to_s": 5400,
          "equals": true
        }
      ]
    },
    {
      "name": "leak_starts_mid_run",
      "description": "A rising edge mid-run rather than at load: the unit runs healthy on fan heat alone and the riser is pressurized at t = 600 s, after which the drop holds at 6 \u00b0C. The alarm lands at exactly 600 + 1800 = 2400 s",
      "inputs": {
        "clg_vlv_cmd": 0.0,
        "sat": [
          {
            "t": 0,
            "value": 22.5
          },
          {
            "t": 600,
            "value": 16.0
          }
        ],
        "rat": 22.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2340,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2400,
          "to_s": 5400,
          "equals": true
        },
        {
          "output": "yCmdOk",
          "from_s": 0,
          "to_s": 5400,
          "equals": true
        }
      ]
    },
    {
      "name": "transient_clears_before_alarm_delay",
      "description": "Transient: a 6 \u00b0C drop persists for 1200 s while the chilled water standing in a just-closed coil gives up its cold, then the discharge returns to room air plus fan heat. Anything shorter than alarm_delay never reaches the operator, which is the delay's main job on this fault",
      "inputs": {
        "clg_vlv_cmd": 0.0,
        "sat": [
          {
            "t": 0,
            "value": 16.0
          },
          {
            "t": 1200,
            "value": 22.5
          }
        ],
        "rat": 22.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        },
        {
          "output": "yCmdOk",
          "from_s": 0,
          "to_s": 5400,
          "equals": true
        }
      ]
    },
    {
      "name": "fault_clears_on_recovery",
      "description": "A sustained 6 \u00b0C drop alarms at 1800 s; the coil is isolated at t = 3000 s and the discharge climbs back to room air plus fan heat. `TrueDelay` has no off-delay, so the alarm drops on that tick and the accumulated time is discarded",
      "inputs": {
        "clg_vlv_cmd": 0.0,
        "sat": [
          {
            "t": 0,
            "value": 16.0
          },
          {
            "t": 3000,
            "value": 22.5
          }
        ],
        "rat": 22.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1740,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 1800,
          "to_s": 2940,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 3000,
          "to_s": 5400,
          "equals": false
        },
        {
          "output": "yCmdOk",
          "from_s": 0,
          "to_s": 5400,
          "equals": true
        }
      ]
    },
    {
      "name": "zone_calls_for_cooling_before_alarm",
      "description": "Recovery through the command term: the drop never changes, but the zone calls for cooling at t = 1200 s and the valve opens to 40%. yCmdOk goes false, persistence restarts, and the leak that was 10 minutes from being reported is not reported. On a unit cycling in and out of cooling, this rule only ever sees leaks that outlast a full closed period",
      "inputs": {
        "clg_vlv_cmd": [
          {
            "t": 0,
            "value": 0.0
          },
          {
            "t": 1200,
            "value": 40.0
          }
        ],
        "sat": 16.0,
        "rat": 22.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        },
        {
          "output": "yCmdOk",
          "from_s": 0,
          "to_s": 1140,
          "equals": true
        },
        {
          "output": "yCmdOk",
          "from_s": 1200,
          "to_s": 5400,
          "equals": false
        }
      ]
    },
    {
      "name": "fan_off_standing_water_reads_as_a_leak",
      "description": "The known hole, pinned so it cannot change silently: with the fan stopped, the discharge sensor sits in still air over a coil full of chilled water and reads 15 \u00b0C while the return sensor reads the room at 22 \u00b0C. No air is moving and nothing is being wasted, but the rule alarms. Canonical fan status now exists for FCU-0006, but this graph does not consume it, so this gate remains the host's \u2014 see preconditions",
      "inputs": {
        "clg_vlv_cmd": 0.0,
        "sat": 15.0,
        "rat": 22.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1740,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 1800,
          "to_s": 5400,
          "equals": true
        },
        {
          "output": "yCmdOk",
          "from_s": 0,
          "to_s": 5400,
          "equals": true
        }
      ]
    },
    {
      "name": "oscillating_drop_never_alarms",
      "description": "A leak modulated by riser pressure swings the drop between 6 \u00b0C and \u22120.5 \u00b0C on a 20-minute period. Its mean is well outside the allowance and a 5-minute rolling average would report the fault, but persistence restarts on every compliant tick, so this rule stays silent \u2014 the limit of substituting TrueDelay for averaging (see Deviations)",
      "inputs": {
        "clg_vlv_cmd": 0.0,
        "sat": [
          {
            "t": 0,
            "value": 16.0
          },
          {
            "t": 600,
            "value": 22.5
          },
          {
            "t": 1200,
            "value": 16.0
          },
          {
            "t": 1800,
            "value": 22.5
          },
          {
            "t": 2400,
            "value": 16.0
          },
          {
            "t": 3000,
            "value": 22.5
          },
          {
            "t": 3600,
            "value": 16.0
          },
          {
            "t": 4200,
            "value": 22.5
          },
          {
            "t": 4800,
            "value": 16.0
          }
        ],
        "rat": 22.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        },
        {
          "output": "yCmdOk",
          "from_s": 0,
          "to_s": 5400,
          "equals": true
        }
      ]
    }
  ]
}

FCU-0005 — Inactive heating coil temperature rise (leak)

Statusverified — engine e2ff2f8, cxf:fnv1a128:77f1afc9046249364d915e9e86936853, 2026-08-17
Severity3
Methodrule
Phase1
CategoryCRITICAL_WASTE
ConfidenceHIGH
EstimationDIRECT_MEASUREMENT
G36§5.22.6 FC#5
Clusters
Suppresses
Suppressed by
RelatedFCU-0004, FCU-0003, FCU-0006, FCU-0007, FPB-0003
Playbooksfcu-faults
SourceHVAC FDD Reference v1.0 §12, FCU-0005; G36 §5.22.6 FC#5 (the chapter’s cited source; clause text not available — see Deviations); PNNL EEM-03 (fix leaking valves)
Operating statesOS#2 (no active coils) — host-gated

Preconditions (host-enforced): The fan must be running. sat is only a coil leaving temperature while air is moving over the coil; on a cycling-fan FCU the discharge sensor sits in stagnant duct air between cycles and reads whatever the coil above it is doing, which is this fault’s exact signature and none of its meaning. This graph does not consume the dictionary’s canonical fan_cmd or fan_status, so the host must gate it on trusted fan proof; an FCU-0006 fail-to-start makes this verdict NO_EVAL (see Deviations). Suspend evaluation for a settling window after the heating valve closes — a coil giving up the hot water standing in it shows the same rise for several minutes, and alarm_delay is sized to ride out the usual case rather than to replace the gate. rat and sat must both be trustworthy and must both be in the airstream: this binding uses them as the coil entering and leaving temperatures, so a return sensor mounted on the wall as a space sensor, or a discharge sensor in a supply plenum shared with another unit, breaks the premise with no other symptom. Nothing in this rule cross-checks either sensor. When any gate is unmet the verdict is NO_EVAL, not healthy — as it is whenever the in-rule output yCmdOk reads false.

Points: htg_vlv_cmd, sat, rat

Outputs:

  • yFault — True while the valve has been commanded shut and sat has stayed more than inactive_coil_threshold above rat, continuously for at least alarm_delay
  • yCmdOk — True while htg_vlv_cmd is below cmd_closed_threshold — the coil is commanded shut and the rise across it is therefore interpretable. False means the coil is allowed to be heating and this rule has no verdict; the host reports NO_EVAL, not healthy

Parameters:

NameDefaultUnitCXF pathDescription
inactive_coil_threshold3.0°CriseBig.tRise from entering to leaving air that stops being fan heat and sensor error and starts being a leak. ADOPTED — the reference names the parameter in the equation and publishes no value for it (see Deviations). 3.0 °C is the rounded G36-style composition for FCU-grade instrumentation: sqrt(e_ret² + e_sup²) + dTSF with a ±1.4 °C-class sensor pair and 1 °C of fan heat. Because the fan sits inside the measurement, the shipped value fires at about 2 °C of true coil rise; a site that measures its own fan rise substitutes it in the sum directly
cmd_closed_threshold1.0%vlvShut.tCommand below which the heating valve counts as commanded shut. ADOPTED — the reference writes the test as htg_vlv_cmd = 0%, which is not a comparison a real-valued signal supports (see Deviations). 1.0% is deliberately tighter than AHU-0016’s 5% open threshold: this rule needs the valve to be at rest, not merely nearly closed
alarm_delay1800.0spersist.delayTimeContinuous violation required before the alarm asserts (30 min). ADOPTED — the reference publishes no tunables line for this card (see Deviations); the value is the AHU twin’s G36 AlarmDelay

Description

A fan coil’s heating valve is a small two-way valve on a small coil, and when its seat wears the leak is correspondingly small: a few degrees of rise across a coil the sequence believes is shut. Nothing about the unit looks broken — the fan runs, the valve reports 0%, the zone holds setpoint, because the cooling coil or the neighbouring units quietly take the extra heat back out. Nobody complains, so nobody looks, which is why this is worth a rule rather than a walkthrough: FCUs are deployed by the hundred in hotels and apartments, each wasting an amount too small to notice on a bill and too tedious to find by hand. The chapter puts one leaking valve at 3–10% of zone heating energy and 100–800 kg CO₂e a year. Reading temperatures rather than flows, the rule cannot separate a worn seat, a valve that never quite closes, and hot water thermosiphoning through a vertically piped coil with the valve shut and blameless; all three are on the diagnosis list.

Detection Logic

yCmdOk = htg_vlv_cmd < cmd_closed_threshold   (false ⇒ host reports NO_EVAL)
rise   = sat − rat

yFault = (rise > inactive_coil_threshold) AND yCmdOk,
         sustained continuously for alarm_delay

Block graph (rule.cxf.jsonld):

FCU-0005 block graph

rise subtracts in the reference’s own order (leaving_temp − entering_temp), which is what makes the sign of this rule the opposite of FCU-0004’s. vlvShut feeds gate and also leaves the block as yCmdOk, so a host can tell the two silences apart: quiet with yCmdOk true is a coil shut and behaving, quiet with it false is a coil being asked to heat, about which this rule has nothing to say. The allowance is where the physics lives. On a healthy FCU with both valves shut, sat − rat is not zero — the fan sits between the two sensors and puts its shaft work into the air — so with a pair of zone-grade sensors each allowed to be off by more than a degree, a healthy unit can read nearly 3 °C with nothing running. With the fan inside the measurement the shipped 3.0 °C fires when the coil’s own contribution passes roughly 2 °C, and a bigger fan raises the floor it has to clear; sites that measure their fan rise should put their number in the sum. Both comparisons are strict. persist requires 30 continuous minutes, separating a leaking seat from a coil surrendering the hot water standing in it after a call ends; recovery is immediate on the tick the rise falls back inside the allowance or the valve opens, and delayOnInit = true holds the window across a restart.

Possible Diagnoses

Transcribed from the reference’s FCU-0005 card:

  1. Heating coil valve leaking through — the worn or eroded seat; the common case, priced by the playbook at $150–$600 to replace
  2. Valve not fully closing (mechanical) — the actuator has lost its close position or binds short of the seat, distinguishable on site by stroking it against feedback
  3. Gravity circulation through the coil — hot water thermosiphoning up through a vertically piped coil with the valve genuinely shut. Nothing is broken and a new valve fixes nothing; the fix is a check valve or a piping change, and the playbook flags this specifically for multi-story buildings

A fourth belongs in the operator’s head though the reference does not list it: either sensor being wrong produces this trace with a perfectly good valve. A return sensor reading low and a discharge sensor reading high are indistinguishable here and both are cheap to check against a portable reference, which is why G36 §5.16.14 puts the two sensor errors ahead of the valve in its own diagnosis order.

Energy Impact

CRITICAL_WASTE, HIGH confidence, DIRECT_MEASUREMENT, 3–10% of zone heating energy, mapped by the reference to PNNL EEM-03 (fix leaking valves). DIRECT_MEASUREMENT is honest here: the two temperatures the rule reads are the measurement, and the runtime formula converts them to thermal power with one substitution — design airflow for measured, since an FCU has no flow station. HIGH confidence because a sustained rise across a coil commanded shut has no benign explanation other than a sensor. Cooling-dominant per the chapter, and the reason also explains when the rule can see anything: heat leaking into a zone in cooling season is paid for twice, and that is the season the heating valve is commanded shut for weeks at a time — precisely the state yCmdOk requires. In deep heating weather the same leak hides behind legitimate calls for heat.

Emissions Impact

Scope 1, DIRECT_EMISSIONS, HIGH confidence; typically 100–800 kg CO₂e/yr per unit of parasitic heating, MOER basis. Scope 1 is the reference’s assignment and it assumes a fuel-fired hot water plant; on a site whose hot water comes from an electric boiler or a heat pump the same leaked heat is Scope 2, and the cooling that removes it is Scope 2 either way, so an all-electric building reads the whole exchange as Scope 2. Where the cause turns out to be a sensor there is nothing to attribute at all.

Deviations

  • inactive_coil_threshold is adopted, not transcribed. The reference names the parameter and publishes no tunables line for this card. 3.0 °C uses G36 §5.16.14’s composition — root-sum-square of the two sensor errors plus a fan-heat term — which at the ±1.4 °C-class sensors an FCU typically carries and 1 °C of fan rise gives 2.98. The AHU twin AHU-0015 ships 4.1623 for the same composition because it reads the coil through a mixed-air sensor G36 allows to be off by 3 °C, so the FCU threshold is legitimately tighter. A site with matched ±0.5 °C sensors and a measured 0.4 °C fan rise gets ≈ 1.1.
  • Fan heat is inside the measurement, and it is where this pair stops being symmetric. Neither the chapter nor the playbook mentions fan heat; the term comes from G36’s ΔTSF via AHU-0015. Here the measured rise is the true coil rise plus the fan’s, so 3.0 °C trips at about 2 °C of coil rise, while on the cooling-side twin the fan’s rise hides part of the drop and the same threshold needs about 4 °C — so the one number the reference asks for makes the heating rule roughly twice as sensitive. A host wanting matched sensitivity raises this threshold or lowers FCU-0004’s by one fan-heat term.
  • htg_vlv_cmd = 0% becomes < 1.0%. CDL Reals has no equality block, and equality on a float from a BAS would be the wrong test anyway — controllers write 0.0001% and round-tripped analog values land near but not on zero. 1.0% is tighter than AHU-0016’s 5% “open” threshold because that rule needs the valve to be doing something and this one needs it to be doing nothing.
  • alarm_delay is adopted at 30 minutes. The reference publishes no delay here; FCU-0001’s 60 min belongs to a transition counter and says nothing about a coil. 1800 s is the AHU twin’s G36 AlarmDelay and it does identifiable work — riding out the residual heat a just-closed coil gives up. A site whose coils purge in five minutes can cut it and detect leaks sooner.
  • Strict comparisons at both boundaries. A rise sitting exactly on 3.0 °C is not a fault and a command sitting exactly on 1.0% is not shut. The reference writes > for the temperature test; the command side is the adopted test above. Both err toward silence, and a host binding coarsely quantized temperatures should retune down.
  • The G36 clause is the chapter’s citation, carried forward unverified. The library’s G36 material covers §5.16.14 (the AHU set) and not the FCU set, so everything claimed as transcribed comes from the reference’s ch.12 card and the g36 field is provenance the reference asserts. If §5.22.6 states averaging windows, epsilons or an alarm delay, they will correct the adopted values above.
  • rat and sat stand in for the coil entering and leaving temperatures, as the FCU point dictionary directs, so the rule sees the whole air path: the fan is inside the measurement (handled by the fan-heat term) and so is any duct after the coil (not handled, and it biases this rule slightly louder). Two configurations break the binding outright — a rat bound to a wall-mounted space sensor, and a four-pipe unit taking ducted outdoor air upstream of the coils.
  • The fan-running gate remains host-side. The dictionary now defines fan_cmd and fan_status for FCU-0006, but this graph does not consume them. A cycling-fan FCU evaluated between cycles is the realistic way to get a false alarm out of this rule. Gate on trusted fan proof and treat FCU-0006 fail-to-start as NO_EVAL here.
  • yCmdOk is the library’s, not the reference’s. Exposing the command conjunct as a boundary output adds no logic and changes no verdict; it lets the host distinguish “the coil is shut and quiet” from “the coil is heating, ask me later”, which are the same yFault = false and mean opposite things. Same wiring as RTU-0002’s yStageOk and AHU-0021’s yTempDeltaOk.
  • Instantaneous samples instead of rolling averages. G36’s AHU set computes every signal as a 5-minute rolling average; whether §5.22.6 does is unknown here. Persistence is not equivalent — averaging tolerates a signal whose mean sits outside the bound while it keeps crossing back, so a valve hunting around its seat can hide indefinitely. A steady leak reads the same either way.
  • A leak is only visible between calls for heat. The rule is silent whenever the valve is open, so a valve that leaks all winter is detected in spring and one on a unit in continuous heating is never detected at all. That is inherent in the reference’s equation, and it is the structural reason the chapter calls the fault cooling-dominant.
  • Severity 3 is the reference’s, and it disagrees with the AHU twin. AHU-0014/AHU-0015 carry severity 2, assigned by this library because the AHU reference has no card to state one. The difference is defensible on scale and is recorded rather than smoothed, because a host ranking a mixed fleet by severity will see the same physics at two levels.
  • Operating state OS#2 is host-enforced. The graph’s command test covers the heating half of “no active coils”; the cooling half is not tested and does not need to be, since an active cooling coil drives sat below rat and can only silence this rule, never trip it.
  • The runtime formula is extended. To the chapter’s (leaving_temp − entering_temp) × fcu_airflow × cp_air this card adds air density (the product needs mass flow) and subtracts the fan’s rise, which is in the measured difference and is none of the coil’s doing — a third of the answer at the threshold.
  • clusters is empty. The chapter README calls FCU-0004/FCU-0005 the zone-scale members of the simultaneous-conditioning family, but clusters/clusters.json lists only AHU rules under CLU-01 and this card does not edit the cluster set. The relationship is carried by related and the playbook.
  • persist.delayOnInit = true (Modelica/CDL default is false), the library’s standing choice: a leak already present when the controller restarts waits out the full 30 minutes instead of alarming on the first tick.
  • The reference publishes no vectors for this card, so vectors.json is authored from the equation.

Notes

Read yCmdOk before reading yFault. On a unit in heating season it will be false most of the day, and every yFault = false under it means “not evaluated”, not “no leak”.

This card is one half of a pair the reference states symmetrically, and the places where the pair is not symmetric are worth carrying into FCU-0004: the sign of the subtraction (sat − rat here, rat − sat there), the direction fan heat pushes the measurement (into this rule’s threshold, against that one’s), and the emissions scope (Scope 1 here for a fuel-fired plant, Scope 2 there for the chiller). Everything else is identical, because nothing in the chapter distinguishes them.

The fcu-faults playbook orders the service. Step 1.3 is the manual version of this rule — command the valve to 0% and measure across the coil, where any measurable change confirms the leak — and it is where the gravity-circulation check lives, the one diagnosis a new valve will not fix. Step 2.3 is the remote workaround: lock the heating valve out for the season, which on a fleet firing a dozen units at once is the difference between a summer of parasitic heating and none. Step 3.4 ranks that fleet by |temp_change| × airflow × cp_air, this card’s runtime estimator. Expect FCU-0003 on the same unit in cooling weather — if both are active, this one is the cause and that one the consequence.

Test Vectors

14 scenarios, clock step 60 s over 5400 s.

ScenarioDescription
healthy_unit_shows_fan_heat_onlyThe pin this rule exists to keep clear. Valve commanded shut, and the air still leaves the unit 1.0 °C warmer than it entered because the fan sits in the airstream between the two sensors. Every healthy FCU in the fleet reads like this in OS#2; inactive_coil_threshold carries the fan-heat term precisely so it stays quiet.
rise_just_below_thresholdThreshold edge from inside: sat − rat = 2.9 °C, one tenth under the allowance. Fan heat plus a pair of zone-grade sensors leaning the wrong way lands here, which is exactly what the allowance is sized to absorb.
rise_exactly_at_thresholdThreshold edge, on the line: sat − rat = 3.0 °C exactly. riseBig is a strict > because CDL Reals has no GreaterEqual, so a rise sitting precisely on inactive_coil_threshold reads healthy. The reference writes > here too, so this side agrees with it (see Deviations).
rise_just_over_thresholdThreshold edge from outside: 3.1 °C clears the strict comparison on the first tick, and with delayOnInit the alarm lands exactly one alarm_delay later, at t = 1800 s. Both sides of the delay edge are pinned at adjacent ticks.
cmd_just_below_closed_thresholdCommand edge from inside: 0.9% is below cmd_closed_threshold, so the valve counts as commanded shut and a 6 °C rise is a leak. This is the case a host sees when its BAS writes a small non-zero null value rather than a clean 0%.
cmd_exactly_at_closed_thresholdCommand edge, on the line: htg_vlv_cmd = 1.0% exactly. vlvShut is a strict <, so the valve does not count as shut and the same 6 °C rise raises nothing. yCmdOk goes false, which is the rule telling the host this is NO_EVAL rather than a clean bill of health.
cmd_just_above_closed_thresholdCommand edge from outside, pinning the third side: 1.1% with the same 6 °C rise. A valve cracked open at 1.1% is modulating, not shut, and the coil is allowed to be doing something.
valve_open_and_heating_normallyThe unit doing its job: valve at 60% and a 13 °C rise across the coil. This is the largest rise in the vector set and it is not a fault — the command is what separates parasitic heat from heat somebody asked for.
gravity_circulation_overnightDiagnosis 3, and the playbook’s multi-story warning: with the pump running and the valve shut, hot water thermosiphons up through a vertically piped coil and the discharge sits 5 °C above the return all night. Nothing about the valve is broken — a check valve is the fix — and this rule cannot tell that apart from a worn seat, which is why both are on the diagnosis list.
leak_starts_mid_runThe rising edge is mid-run rather than at load: the unit runs on fan heat alone until t = 600 s, then the seat lets go and the rise jumps to 6 °C. TrueDelay measures from that edge, so the alarm lands at 600 + 1800 = 2400 s exactly.
transient_clears_before_alarm_delayResidual heat, not a leak: hot water standing in a just-closed coil gives up a 6 °C rise for 1200 s and then the unit settles back to fan heat. Anything shorter than alarm_delay never reaches the operator, which is most of what the 30 minutes buys.
fault_clears_on_recoveryA sustained 6 °C rise alarms at 1800 s; the coil is isolated at t = 3000 s and the rise falls back to fan heat. TrueDelay has no off-delay, so yFault drops on that same tick.
zone_calls_for_heat_before_alarmRecovery through the command term instead of the temperature term: the rise never changes, but at t = 1200 s the zone calls and the valve is driven to 55%. The conjunction releases, the timer is discarded, and yCmdOk marks the rest of the run NO_EVAL. A leak that only ever shows itself between calls for heat is invisible to this rule — see Deviations.
oscillating_rise_never_alarmsThe known miss, pinned so it cannot change silently: the rise swings between 6 °C and 1 °C on a 20-minute period, so its mean is well outside the allowance while no single episode reaches alarm_delay. A rolling average would report this; persistence restarts on every compliant tick and stays silent (see Deviations).
vectors.json
{
  "schema": "cxf-library/vectors/v1",
  "clock": {
    "step_s": 60,
    "horizon_s": 5400
  },
  "scenarios": [
    {
      "name": "healthy_unit_shows_fan_heat_only",
      "description": "The pin this rule exists to keep clear. Valve commanded shut, and the air still leaves the unit 1.0 \u00b0C warmer than it entered because the fan sits in the airstream between the two sensors. Every healthy FCU in the fleet reads like this in OS#2; inactive_coil_threshold carries the fan-heat term precisely so it stays quiet.",
      "inputs": {
        "htg_vlv_cmd": 0.0,
        "rat": 22.0,
        "sat": 23.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        },
        {
          "output": "yCmdOk",
          "from_s": 0,
          "to_s": 5400,
          "equals": true
        }
      ]
    },
    {
      "name": "rise_just_below_threshold",
      "description": "Threshold edge from inside: sat \u2212 rat = 2.9 \u00b0C, one tenth under the allowance. Fan heat plus a pair of zone-grade sensors leaning the wrong way lands here, which is exactly what the allowance is sized to absorb.",
      "inputs": {
        "htg_vlv_cmd": 0.0,
        "rat": 22.0,
        "sat": 24.9
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        },
        {
          "output": "yCmdOk",
          "from_s": 0,
          "to_s": 5400,
          "equals": true
        }
      ]
    },
    {
      "name": "rise_exactly_at_threshold",
      "description": "Threshold edge, on the line: sat \u2212 rat = 3.0 \u00b0C exactly. `riseBig` is a strict `>` because CDL Reals has no GreaterEqual, so a rise sitting precisely on inactive_coil_threshold reads healthy. The reference writes `>` here too, so this side agrees with it (see Deviations).",
      "inputs": {
        "htg_vlv_cmd": 0.0,
        "rat": 22.0,
        "sat": 25.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        },
        {
          "output": "yCmdOk",
          "from_s": 0,
          "to_s": 5400,
          "equals": true
        }
      ]
    },
    {
      "name": "rise_just_over_threshold",
      "description": "Threshold edge from outside: 3.1 \u00b0C clears the strict comparison on the first tick, and with delayOnInit the alarm lands exactly one alarm_delay later, at t = 1800 s. Both sides of the delay edge are pinned at adjacent ticks.",
      "inputs": {
        "htg_vlv_cmd": 0.0,
        "rat": 22.0,
        "sat": 25.1
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1740,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 1800,
          "to_s": 5400,
          "equals": true
        },
        {
          "output": "yCmdOk",
          "from_s": 0,
          "to_s": 5400,
          "equals": true
        }
      ]
    },
    {
      "name": "cmd_just_below_closed_threshold",
      "description": "Command edge from inside: 0.9% is below cmd_closed_threshold, so the valve counts as commanded shut and a 6 \u00b0C rise is a leak. This is the case a host sees when its BAS writes a small non-zero null value rather than a clean 0%.",
      "inputs": {
        "htg_vlv_cmd": 0.9,
        "rat": 22.0,
        "sat": 28.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1740,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 1800,
          "to_s": 5400,
          "equals": true
        },
        {
          "output": "yCmdOk",
          "from_s": 0,
          "to_s": 5400,
          "equals": true
        }
      ]
    },
    {
      "name": "cmd_exactly_at_closed_threshold",
      "description": "Command edge, on the line: htg_vlv_cmd = 1.0% exactly. `vlvShut` is a strict `<`, so the valve does not count as shut and the same 6 \u00b0C rise raises nothing. yCmdOk goes false, which is the rule telling the host this is NO_EVAL rather than a clean bill of health.",
      "inputs": {
        "htg_vlv_cmd": 1.0,
        "rat": 22.0,
        "sat": 28.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        },
        {
          "output": "yCmdOk",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        }
      ]
    },
    {
      "name": "cmd_just_above_closed_threshold",
      "description": "Command edge from outside, pinning the third side: 1.1% with the same 6 \u00b0C rise. A valve cracked open at 1.1% is modulating, not shut, and the coil is allowed to be doing something.",
      "inputs": {
        "htg_vlv_cmd": 1.1,
        "rat": 22.0,
        "sat": 28.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        },
        {
          "output": "yCmdOk",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        }
      ]
    },
    {
      "name": "valve_open_and_heating_normally",
      "description": "The unit doing its job: valve at 60% and a 13 \u00b0C rise across the coil. This is the largest rise in the vector set and it is not a fault \u2014 the command is what separates parasitic heat from heat somebody asked for.",
      "inputs": {
        "htg_vlv_cmd": 60.0,
        "rat": 22.0,
        "sat": 35.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        },
        {
          "output": "yCmdOk",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        }
      ]
    },
    {
      "name": "gravity_circulation_overnight",
      "description": "Diagnosis 3, and the playbook's multi-story warning: with the pump running and the valve shut, hot water thermosiphons up through a vertically piped coil and the discharge sits 5 \u00b0C above the return all night. Nothing about the valve is broken \u2014 a check valve is the fix \u2014 and this rule cannot tell that apart from a worn seat, which is why both are on the diagnosis list.",
      "inputs": {
        "htg_vlv_cmd": 0.0,
        "rat": 21.0,
        "sat": 26.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1740,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 1800,
          "to_s": 5400,
          "equals": true
        },
        {
          "output": "yCmdOk",
          "from_s": 0,
          "to_s": 5400,
          "equals": true
        }
      ]
    },
    {
      "name": "leak_starts_mid_run",
      "description": "The rising edge is mid-run rather than at load: the unit runs on fan heat alone until t = 600 s, then the seat lets go and the rise jumps to 6 \u00b0C. TrueDelay measures from that edge, so the alarm lands at 600 + 1800 = 2400 s exactly.",
      "inputs": {
        "htg_vlv_cmd": 0.0,
        "rat": 22.0,
        "sat": [
          {
            "t": 0,
            "value": 23.0
          },
          {
            "t": 600,
            "value": 28.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2340,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2400,
          "to_s": 5400,
          "equals": true
        },
        {
          "output": "yCmdOk",
          "from_s": 0,
          "to_s": 5400,
          "equals": true
        }
      ]
    },
    {
      "name": "transient_clears_before_alarm_delay",
      "description": "Residual heat, not a leak: hot water standing in a just-closed coil gives up a 6 \u00b0C rise for 1200 s and then the unit settles back to fan heat. Anything shorter than alarm_delay never reaches the operator, which is most of what the 30 minutes buys.",
      "inputs": {
        "htg_vlv_cmd": 0.0,
        "rat": 22.0,
        "sat": [
          {
            "t": 0,
            "value": 28.0
          },
          {
            "t": 1200,
            "value": 23.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        },
        {
          "output": "yCmdOk",
          "from_s": 0,
          "to_s": 5400,
          "equals": true
        }
      ]
    },
    {
      "name": "fault_clears_on_recovery",
      "description": "A sustained 6 \u00b0C rise alarms at 1800 s; the coil is isolated at t = 3000 s and the rise falls back to fan heat. TrueDelay has no off-delay, so yFault drops on that same tick.",
      "inputs": {
        "htg_vlv_cmd": 0.0,
        "rat": 22.0,
        "sat": [
          {
            "t": 0,
            "value": 28.0
          },
          {
            "t": 3000,
            "value": 23.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1740,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 1800,
          "to_s": 2940,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 3000,
          "to_s": 5400,
          "equals": false
        },
        {
          "output": "yCmdOk",
          "from_s": 0,
          "to_s": 5400,
          "equals": true
        }
      ]
    },
    {
      "name": "zone_calls_for_heat_before_alarm",
      "description": "Recovery through the command term instead of the temperature term: the rise never changes, but at t = 1200 s the zone calls and the valve is driven to 55%. The conjunction releases, the timer is discarded, and yCmdOk marks the rest of the run NO_EVAL. A leak that only ever shows itself between calls for heat is invisible to this rule \u2014 see Deviations.",
      "inputs": {
        "rat": 22.0,
        "sat": 28.0,
        "htg_vlv_cmd": [
          {
            "t": 0,
            "value": 0.0
          },
          {
            "t": 1200,
            "value": 55.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        },
        {
          "output": "yCmdOk",
          "from_s": 0,
          "to_s": 1140,
          "equals": true
        },
        {
          "output": "yCmdOk",
          "from_s": 1200,
          "to_s": 5400,
          "equals": false
        }
      ]
    },
    {
      "name": "oscillating_rise_never_alarms",
      "description": "The known miss, pinned so it cannot change silently: the rise swings between 6 \u00b0C and 1 \u00b0C on a 20-minute period, so its mean is well outside the allowance while no single episode reaches alarm_delay. A rolling average would report this; persistence restarts on every compliant tick and stays silent (see Deviations).",
      "inputs": {
        "htg_vlv_cmd": 0.0,
        "rat": 22.0,
        "sat": [
          {
            "t": 0,
            "value": 28.0
          },
          {
            "t": 600,
            "value": 23.0
          },
          {
            "t": 1200,
            "value": 28.0
          },
          {
            "t": 1800,
            "value": 23.0
          },
          {
            "t": 2400,
            "value": 28.0
          },
          {
            "t": 3000,
            "value": 23.0
          },
          {
            "t": 3600,
            "value": 28.0
          },
          {
            "t": 4200,
            "value": 23.0
          },
          {
            "t": 4800,
            "value": 28.0
          },
          {
            "t": 5400,
            "value": 23.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        },
        {
          "output": "yCmdOk",
          "from_s": 0,
          "to_s": 5400,
          "equals": true
        }
      ]
    }
  ]
}

FCU-0006 — FCU fan proof-of-operation failure

Statusverified — engine e2ff2f8, cxf:fnv1a128:8c4af7be56126b213921f77982c3f3e7, 2026-08-20
Severity2
Methodrule
Phase2
CategoryPROTECTIVE
ConfidenceHIGH
EstimationPROXY_ESTIMATION
G36
Clusters
Suppresses
Suppressed by
RelatedFCU-0001, FCU-0002, FCU-0003, FCU-0004, FCU-0005
Playbooksproof-of-operation, fcu-faults
SourceLibrary proof-of-operation precedents AHU-0039, TOWER-0004, PMP-0003, and HW-0009 — final command versus independent status, separate direction timers, and initialization-safe persistence; ASHRAE Guideline 36-2021 section 5.1.6 — equipment is proven when its digital status matches the state set by its digital command; semantic grounding only, not a source for these shipped timer values; Library-authored FCU fan timing adaptation; no cited source publishes 60 s start and 60 s stop proof limits as portable values
Operating statesall states in which this FCU has an actively controlled fan and its final individual fan command and independent run proof are authoritative

Preconditions (host-enforced): The FCU must have an actively commanded fan; passive/convection units are not applicable. Bind fan_cmd to the final fan output after occupancy, mode, condensate, freeze, and local interlock logic, not to unit enable or thermostat demand. Bind fan_status to independent motor-current, airflow, speed, rotation, or auxiliary-contact proof for the same fan, never to a command echo. Exclude fan coast-down, exercise, maintenance, condensate-alarm shutdown, freeze protection, and local hand operation unless represented in the final command. For ECM and multi-speed fans, Boolean normalization proves only operation and must not hide a failed requested speed stage. Inputs must be fresh and aligned; unmet obligations are NO_EVAL, not healthy.

Points: fan_cmd, fan_status

Outputs:

  • yFault — True while either final command/status mismatch has matured through its own proof timer
  • yFailToStart — Diagnostic direction flag; true after a final start command remains without independent run proof for start_proof_time. False never means NO_EVAL
  • yUnexpectedRun — Diagnostic direction flag; true after independent operation continues without a final run command for stop_proof_time. False never means NO_EVAL

Parameters:

NameDefaultUnitCXF pathDescription
start_proof_time60.0sstartProof.delayTimeADOPTED_TUNABLE final-command-to-proof allowance. Commission above normal device response, acceleration, proof pickup, and telemetry latency.
stop_proof_time60.0sstopProof.delayTimeADOPTED_TUNABLE command-off-to-proof-dropout allowance. Commission above normal deceleration, coast-down, proof dropout, and telemetry latency; intentional sequence operation must remain represented in the final command.

Description

This rule checks whether the FCU fan did what its final Boolean command requested. Commanded on without independent proof is a fail-to-start; proven on without command is unexpected operation. The direction identifies the mismatch, not its cause, and neither diagnostic output is an evaluability gate.

Detection Logic

fail_to_start  = fan_cmd AND NOT fan_status
unexpected_run = NOT fan_cmd AND fan_status

yFailToStart   = fail_to_start sustained for start_proof_time
yUnexpectedRun = unexpected_run sustained for stop_proof_time
yFault         = yFailToStart OR yUnexpectedRun

FCU-0006 block graph

Each direction has its own TrueDelay(delayOnInit=true). Agreement clears both lanes immediately. A direct mismatch reversal clears the old diagnostic and starts the other timer from zero; elapsed time never transfers between lanes.

Possible Diagnoses

  1. Failed motor, ECM, controller, breaker, contactor, or output wiring.
  2. Seized fan wheel, failed belt, coupling, or bearing.
  3. Condensate, freeze, or local safety interlock omitted from command.
  4. Bad current, airflow, speed, rotation, or auxiliary-contact proof.
  5. Local thermostat, hand switch, or independent controller owns the fan.

Energy Impact

The effect is direction-dependent. Unexpected operation can waste measured electrical energy during the mismatch. Fail-to-start is primarily availability, comfort, and diagnostic-coverage loss; these two booleans cannot price it.

Emissions Impact

Scope 2 is proxy-only for unexpected operation: multiply independently measured device kW by mismatch hours and an appropriate operating emissions factor. Do not claim avoided energy or emissions for fail-to-start without another model.

Deviations

  • Both timers are adopted commissioning values. No cited source establishes universal FCU fan proof windows. Configure them independently around the actual sequence, proof device, sampling, and network latency.
  • The command is final and device-scoped. An upstream enable, demand, or fleet request can disagree with status while downstream logic works correctly.
  • Status is independent proof. Command echo makes the graph tautological; proof type determines whether electrical operation, rotation, or delivery was actually demonstrated.
  • No whole-rule suppression is encoded. Fail-to-start can invalidate another rule’s running premise, but unexpected operation may leave that rule physically meaningful; current metadata cannot suppress by direction.
  • delayOnInit=true is explicit on both lanes. Evaluator restart into an existing mismatch must serve the full configured proof time.
  • No empirical FPR or TPR is claimed. Current simulation telemetry cannot provide both an independent final command and field-like proof for this device.
  • Passive fan coils are explicitly not applicable. A host must not synthesize a command/status pair for a unit that moves air only by convection.
  • The Boolean pair proves fan operation, not the requested ECM or multi-speed stage. A wrong-stage failure needs speed or stage feedback and is outside this graph.

Notes

Read the direction before interpreting FCU-0002 through FCU-0005: fail-to-start removes their airflow premise, while unexpected operation can leave their temperature signatures physically meaningful.

Test Vectors

23 scenarios, clock step 10 s over 2400 s.

ScenarioDescription
agreement_stoppedCommand and independent proof agree off; every direction remains clear.
agreement_runningCommand and independent proof agree on; every direction remains clear.
fail_to_start_at_initializationA standing commanded-on/unproved mismatch is clear one tick before 60 s, asserts exactly at 60 s, and remains true after the initialization-safe start timer.
unexpected_run_at_initializationA standing command-off/proven-on mismatch is clear one tick before 60 s, asserts exactly at 60 s, and remains true after the initialization-safe stop timer.
normal_delayed_start_and_stopStatus proves one tick inside both configured allowances, so ordinary start and stop latency never alarms.
start_mismatch_ends_one_tick_before_proofA start mismatch beginning at 300 s clears at 350 s, one 10 s evaluator step before maturity, and never alarms.
start_mismatch_ends_at_proof_boundaryThe start mismatch clears exactly 60 s after it begins. Agreement on the maturity tick wins and no alarm is asserted.
stop_mismatch_ends_one_tick_before_proofA stop mismatch beginning at 300 s clears at 350 s, one 10 s evaluator step before maturity, and never alarms.
stop_mismatch_ends_at_proof_boundaryThe stop mismatch clears exactly 60 s after it begins. Agreement on the maturity tick wins and no alarm is asserted.
mature_start_alarm_recoversA fail-to-start persists past its proof time, then clears immediately when independent proof arrives.
mature_stop_alarm_recoversAn unexpected run persists past its proof time, then clears immediately when proof drops.
pending_direction_reversal_starts_fresh_laneCommand and status swap while fail-to-start is pending. The old lane resets and the unexpected-run lane must serve its complete independent timer.
mature_direction_reversal_hands_offA mature fail-to-start reverses directly into unexpected-run. The old flag clears on the reversal tick, yFault has a real gap, and the opposite lane serves a fresh complete stop timer.
two_short_start_mismatches_do_not_accumulateTwo commanded-on/unproved episodes are separated by agreement; elapsed time cannot transfer across the reset.
two_short_stop_mismatches_do_not_accumulateTwo proven-on/uncommanded episodes are separated by agreement; elapsed time cannot transfer across the reset.
command_chatter_never_maturesThe final command chatters faster than either proof window while status is off; no continuous mismatch matures.
status_chatter_never_maturesIndependent proof chatters against a held-on command; every unproved interval clears before the start timer.
unexpected_run_chatter_never_maturesIndependent proof chatters on against a held-off command; every unexpected-run interval clears before the stop timer.
upstream_enable_raw_false_positiveAn upstream enable held true while downstream logic correctly withholds operation produces a raw fail-to-start alarm; this pins why only the final command is a valid binding.
command_echo_raw_blind_spotA status echo follows the command while the device is mechanically stopped. The pair agrees and the graph stays silent, pinning why proof must be independent.
manual_local_run_is_host_excludedA locally commanded device is proven on while the BAS final command is off. The raw unexpected-run alarm is correct; approved service or hand operation is host NO_EVAL.
wrong_speed_stage_is_boolean_blind_spotAn ECM is commanded to high speed but independent Boolean proof reports only that it runs at some speed. Both booleans agree, so a failed requested stage is outside this operation-only rule.
passive_unit_is_not_applicableA passive fan coil has no honest final fan command or proof. Synthetic disagreement would alarm raw, demonstrating why the host must not instantiate this rule on convection-only units.
vectors.json
{
  "schema": "cxf-library/vectors/v1",
  "clock": {
    "step_s": 10,
    "horizon_s": 2400
  },
  "scenarios": [
    {
      "name": "agreement_stopped",
      "description": "Command and independent proof agree off; every direction remains clear.",
      "inputs": {
        "fan_cmd": false,
        "fan_status": false
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        }
      ]
    },
    {
      "name": "agreement_running",
      "description": "Command and independent proof agree on; every direction remains clear.",
      "inputs": {
        "fan_cmd": true,
        "fan_status": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        }
      ]
    },
    {
      "name": "fail_to_start_at_initialization",
      "description": "A standing commanded-on/unproved mismatch is clear one tick before 60 s, asserts exactly at 60 s, and remains true after the initialization-safe start timer.",
      "inputs": {
        "fan_cmd": true,
        "fan_status": false
      },
      "expect": [
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 50,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 60,
          "to_s": 2400,
          "equals": true
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 50,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 60,
          "to_s": 2400,
          "equals": true
        }
      ]
    },
    {
      "name": "unexpected_run_at_initialization",
      "description": "A standing command-off/proven-on mismatch is clear one tick before 60 s, asserts exactly at 60 s, and remains true after the initialization-safe stop timer.",
      "inputs": {
        "fan_cmd": false,
        "fan_status": true
      },
      "expect": [
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 50,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 60,
          "to_s": 2400,
          "equals": true
        },
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 50,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 60,
          "to_s": 2400,
          "equals": true
        }
      ]
    },
    {
      "name": "normal_delayed_start_and_stop",
      "description": "Status proves one tick inside both configured allowances, so ordinary start and stop latency never alarms.",
      "inputs": {
        "fan_cmd": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 300,
            "value": true
          },
          {
            "t": 1200,
            "value": false
          }
        ],
        "fan_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 350,
            "value": true
          },
          {
            "t": 1250,
            "value": false
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        }
      ]
    },
    {
      "name": "start_mismatch_ends_one_tick_before_proof",
      "description": "A start mismatch beginning at 300 s clears at 350 s, one 10 s evaluator step before maturity, and never alarms.",
      "inputs": {
        "fan_cmd": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 300,
            "value": true
          }
        ],
        "fan_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 350,
            "value": true
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        }
      ]
    },
    {
      "name": "start_mismatch_ends_at_proof_boundary",
      "description": "The start mismatch clears exactly 60 s after it begins. Agreement on the maturity tick wins and no alarm is asserted.",
      "inputs": {
        "fan_cmd": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 300,
            "value": true
          }
        ],
        "fan_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 360,
            "value": true
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        }
      ]
    },
    {
      "name": "stop_mismatch_ends_one_tick_before_proof",
      "description": "A stop mismatch beginning at 300 s clears at 350 s, one 10 s evaluator step before maturity, and never alarms.",
      "inputs": {
        "fan_cmd": [
          {
            "t": 0,
            "value": true
          },
          {
            "t": 300,
            "value": false
          }
        ],
        "fan_status": [
          {
            "t": 0,
            "value": true
          },
          {
            "t": 350,
            "value": false
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        }
      ]
    },
    {
      "name": "stop_mismatch_ends_at_proof_boundary",
      "description": "The stop mismatch clears exactly 60 s after it begins. Agreement on the maturity tick wins and no alarm is asserted.",
      "inputs": {
        "fan_cmd": [
          {
            "t": 0,
            "value": true
          },
          {
            "t": 300,
            "value": false
          }
        ],
        "fan_status": [
          {
            "t": 0,
            "value": true
          },
          {
            "t": 360,
            "value": false
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        }
      ]
    },
    {
      "name": "mature_start_alarm_recovers",
      "description": "A fail-to-start persists past its proof time, then clears immediately when independent proof arrives.",
      "inputs": {
        "fan_cmd": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 300,
            "value": true
          }
        ],
        "fan_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 460,
            "value": true
          }
        ]
      },
      "expect": [
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 350,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 360,
          "to_s": 450,
          "equals": true
        },
        {
          "output": "yFailToStart",
          "from_s": 460,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 350,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 360,
          "to_s": 450,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 460,
          "to_s": 2400,
          "equals": false
        }
      ]
    },
    {
      "name": "mature_stop_alarm_recovers",
      "description": "An unexpected run persists past its proof time, then clears immediately when proof drops.",
      "inputs": {
        "fan_cmd": [
          {
            "t": 0,
            "value": true
          },
          {
            "t": 300,
            "value": false
          }
        ],
        "fan_status": [
          {
            "t": 0,
            "value": true
          },
          {
            "t": 460,
            "value": false
          }
        ]
      },
      "expect": [
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 350,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 360,
          "to_s": 450,
          "equals": true
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 460,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 350,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 360,
          "to_s": 450,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 460,
          "to_s": 2400,
          "equals": false
        }
      ]
    },
    {
      "name": "pending_direction_reversal_starts_fresh_lane",
      "description": "Command and status swap while fail-to-start is pending. The old lane resets and the unexpected-run lane must serve its complete independent timer.",
      "inputs": {
        "fan_cmd": [
          {
            "t": 0,
            "value": true
          },
          {
            "t": 40,
            "value": false
          }
        ],
        "fan_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 40,
            "value": true
          }
        ]
      },
      "expect": [
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 90,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 100,
          "to_s": 2400,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 90,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 100,
          "to_s": 2400,
          "equals": true
        }
      ]
    },
    {
      "name": "mature_direction_reversal_hands_off",
      "description": "A mature fail-to-start reverses directly into unexpected-run. The old flag clears on the reversal tick, yFault has a real gap, and the opposite lane serves a fresh complete stop timer.",
      "inputs": {
        "fan_cmd": [
          {
            "t": 0,
            "value": true
          },
          {
            "t": 400,
            "value": false
          }
        ],
        "fan_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 400,
            "value": true
          }
        ]
      },
      "expect": [
        {
          "output": "yFailToStart",
          "from_s": 60,
          "to_s": 390,
          "equals": true
        },
        {
          "output": "yFailToStart",
          "from_s": 400,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 450,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 460,
          "to_s": 2400,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 60,
          "to_s": 390,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 400,
          "to_s": 450,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 460,
          "to_s": 2400,
          "equals": true
        }
      ]
    },
    {
      "name": "two_short_start_mismatches_do_not_accumulate",
      "description": "Two commanded-on/unproved episodes are separated by agreement; elapsed time cannot transfer across the reset.",
      "inputs": {
        "fan_cmd": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 300,
            "value": true
          },
          {
            "t": 340,
            "value": false
          },
          {
            "t": 900,
            "value": true
          },
          {
            "t": 940,
            "value": false
          }
        ],
        "fan_status": false
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        }
      ]
    },
    {
      "name": "two_short_stop_mismatches_do_not_accumulate",
      "description": "Two proven-on/uncommanded episodes are separated by agreement; elapsed time cannot transfer across the reset.",
      "inputs": {
        "fan_cmd": false,
        "fan_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 300,
            "value": true
          },
          {
            "t": 340,
            "value": false
          },
          {
            "t": 900,
            "value": true
          },
          {
            "t": 940,
            "value": false
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        }
      ]
    },
    {
      "name": "command_chatter_never_matures",
      "description": "The final command chatters faster than either proof window while status is off; no continuous mismatch matures.",
      "inputs": {
        "fan_cmd": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 300,
            "value": true
          },
          {
            "t": 340,
            "value": false
          },
          {
            "t": 600,
            "value": true
          },
          {
            "t": 640,
            "value": false
          },
          {
            "t": 900,
            "value": true
          },
          {
            "t": 940,
            "value": false
          }
        ],
        "fan_status": false
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        }
      ]
    },
    {
      "name": "status_chatter_never_matures",
      "description": "Independent proof chatters against a held-on command; every unproved interval clears before the start timer.",
      "inputs": {
        "fan_cmd": true,
        "fan_status": [
          {
            "t": 0,
            "value": true
          },
          {
            "t": 300,
            "value": false
          },
          {
            "t": 340,
            "value": true
          },
          {
            "t": 600,
            "value": false
          },
          {
            "t": 640,
            "value": true
          },
          {
            "t": 900,
            "value": false
          },
          {
            "t": 940,
            "value": true
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        }
      ]
    },
    {
      "name": "unexpected_run_chatter_never_matures",
      "description": "Independent proof chatters on against a held-off command; every unexpected-run interval clears before the stop timer.",
      "inputs": {
        "fan_cmd": false,
        "fan_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 300,
            "value": true
          },
          {
            "t": 340,
            "value": false
          },
          {
            "t": 600,
            "value": true
          },
          {
            "t": 640,
            "value": false
          },
          {
            "t": 900,
            "value": true
          },
          {
            "t": 940,
            "value": false
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        }
      ]
    },
    {
      "name": "upstream_enable_raw_false_positive",
      "description": "An upstream enable held true while downstream logic correctly withholds operation produces a raw fail-to-start alarm; this pins why only the final command is a valid binding.",
      "inputs": {
        "fan_cmd": true,
        "fan_status": false
      },
      "expect": [
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 50,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 60,
          "to_s": 2400,
          "equals": true
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 50,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 60,
          "to_s": 2400,
          "equals": true
        }
      ]
    },
    {
      "name": "command_echo_raw_blind_spot",
      "description": "A status echo follows the command while the device is mechanically stopped. The pair agrees and the graph stays silent, pinning why proof must be independent.",
      "inputs": {
        "fan_cmd": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 300,
            "value": true
          }
        ],
        "fan_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 300,
            "value": true
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        }
      ]
    },
    {
      "name": "manual_local_run_is_host_excluded",
      "description": "A locally commanded device is proven on while the BAS final command is off. The raw unexpected-run alarm is correct; approved service or hand operation is host NO_EVAL.",
      "inputs": {
        "fan_cmd": false,
        "fan_status": true
      },
      "expect": [
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 50,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 60,
          "to_s": 2400,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 50,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 60,
          "to_s": 2400,
          "equals": true
        }
      ]
    },
    {
      "name": "wrong_speed_stage_is_boolean_blind_spot",
      "description": "An ECM is commanded to high speed but independent Boolean proof reports only that it runs at some speed. Both booleans agree, so a failed requested stage is outside this operation-only rule.",
      "inputs": {
        "fan_cmd": true,
        "fan_status": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        }
      ]
    },
    {
      "name": "passive_unit_is_not_applicable",
      "description": "A passive fan coil has no honest final fan command or proof. Synthetic disagreement would alarm raw, demonstrating why the host must not instantiate this rule on convection-only units.",
      "inputs": {
        "fan_cmd": true,
        "fan_status": false
      },
      "expect": [
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 50,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 60,
          "to_s": 2400,
          "equals": true
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 50,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 60,
          "to_s": 2400,
          "equals": true
        }
      ]
    }
  ]
}

FCU-0007 — Simultaneous heating and cooling commands

Statusverified — engine e2ff2f8, cxf:fnv1a128:4b59bb37f9d6537178e60d22896cd2d6, 2026-08-20
Severity2
Methodrule
Phase2
CategoryCRITICAL_WASTE
ConfidenceHIGH
EstimationQUALITATIVE_ONLY
G36
Clusters
Suppresses
Suppressed by
RelatedFCU-0004, FCU-0005
Playbookssimultaneous-hc, fcu-faults
SourceHVAC FDD Reference v1.0 ch.12 and library AHU-0016 — ordinary simultaneous heating/cooling command overlap is an actionable control-waste signature; Library-authored FCU command-level adaptation; no cited source publishes the shipped 10% thresholds or 300 s duration as portable values
Operating statesnormal automatic FCU heating or cooling when simultaneous cooling-plus-reheat is not an intended humidity-control sequence

Preconditions (host-enforced): htg_vlv_cmd and clg_vlv_cmd must be physical 0-100% valve commands for heating and cooling coils on the same FCU, with known scaling and fail positions. Exclude valve exercise, freeze protection, commissioning tests, and intentional dehumidification/cooling-plus-reheat. If simultaneous conditioning is a designed mode, provide a host mode gate or do not instantiate this rule. Inputs must be fresh and aligned; unmet obligations are NO_EVAL, not healthy.

Points: htg_vlv_cmd, clg_vlv_cmd

Outputs:

  • yFault — True after both same-FCU valve commands remain strictly above their active thresholds for sustained_duration
  • yHeatingActive — Diagnostic sub-condition flag; true while the heating command is strictly above threshold. False never means NO_EVAL
  • yCoolingActive — Diagnostic sub-condition flag; true while the cooling command is strictly above threshold. False never means NO_EVAL

Parameters:

NameDefaultUnitCXF pathDescription
heating_active_threshold10.0%heatingActive.tADOPTED_TUNABLE material heating-command threshold; equality is clear. Commission above leakage, actuator minimum, and command noise.
cooling_active_threshold10.0%coolingActive.tADOPTED_TUNABLE material cooling-command threshold; equality is clear. Commission above leakage, actuator minimum, and command noise.
sustained_duration300.0spersist.delayTimeLIBRARY_PRECEDENT continuous overlap duration; verify against normal FCU mode transitions and sampling.

Description

This rule detects a same-FCU heating-valve command and cooling-valve command materially open together. It reports a command conflict, not physical valve position or coil heat transfer. FCU-0004/0005 remain useful for distinguishing unintended thermal effect with a nominally closed command.

Detection Logic

heating_active = htg_vlv_cmd > heating_active_threshold
cooling_active = clg_vlv_cmd > cooling_active_threshold

yHeatingActive = heating_active
yCoolingActive = cooling_active
yFault = (heating_active AND cooling_active) sustained for sustained_duration

FCU-0007 block graph

Both comparisons are strict. TrueDelay(delayOnInit=true) starts only while both subconditions are true; either command clearing resets the timer and a mature alarm clears immediately.

Possible Diagnoses

  1. Heating and cooling PID loops overlap or lack an interlock
  2. Occupancy, mode, or setpoint transition leaves both outputs active
  3. BAS priority-array override holds one command open
  4. Wrong point scaling or commands bound from different FCUs
  5. Intentional dehumidification/reheat, freeze, or exercise mode not host-gated

Energy Impact

Simultaneous commands can add and remove heat at the same terminal, increasing plant and fan energy. Commands alone do not prove valve position, water flow, or thermal transfer, so this card makes no portable savings claim.

Emissions Impact

Scope 1 and/or 2 effects depend on the serving heating and cooling plants. Quantify only after measuring delivered heat and avoided plant input energy.

Deviations

  • Thresholds and duration are library defaults. The source supports the signature, not universal 10% or 300 s values; commission all three.
  • Intentional cooling-plus-reheat is host-excluded. The graph has no mode input and intentionally alarms on that raw overlap when the host gate is absent.
  • CLU-01 is unchanged. Its AHU-0016 trigger cannot causally clear a local FCU command conflict under the cluster contract; shared workflow is represented by the simultaneous-hc playbook instead.
  • Leak rules are not suppressed. A command conflict and physical heat transfer with a closed command are different evidence and can co-occur.
  • No empirical FPR or TPR is claimed. No current harness exposes both physical same-FCU valve commands without inference; validation is synthetic only.

Test Vectors

12 scenarios, clock step 60 s over 720 s.

ScenarioDescription
both_closedOrdinary idle operation keeps both direction flags and the alarm clear.
heating_onlyA material heating command without cooling is valid single-mode operation.
cooling_onlyA material cooling command without heating is valid single-mode operation.
both_exactly_at_thresholdStrict comparisons keep equality clear on both lanes.
one_just_above_one_at_thresholdOne active lane cannot mature a simultaneous-command fault.
both_just_above_sustainedOne representable increment above both thresholds matures only after persistence.
overlap_ends_one_step_before_persistenceA conflict ending one evaluator step before 300 s never matures.
mature_overlap_recovers_when_cooling_clearsA mature fault clears immediately when either material command clears.
two_short_overlaps_resetTwo sub-duration conflicts separated by either valve clearing do not accumulate time.
initialization_overlap_serves_full_delaydelayOnInit prevents an immediate alarm when the evaluator starts inside a conflict.
heating_reset_then_cooling_overlapClearing the heating lane resets the timer before a later overlap.
intentional_dehumidification_raw_caseThe raw graph alarms on intentional cooling-plus-reheat; the host must exclude that mode or not instantiate this rule.
vectors.json
{
  "schema": "cxf-library/vectors/v1",
  "clock": {
    "step_s": 60,
    "horizon_s": 720
  },
  "scenarios": [
    {
      "name": "both_closed",
      "description": "Ordinary idle operation keeps both direction flags and the alarm clear.",
      "inputs": {
        "htg_vlv_cmd": 0.0,
        "clg_vlv_cmd": 0.0
      },
      "expect": [
        {
          "output": "yHeatingActive",
          "from_s": 0,
          "to_s": 720,
          "equals": false
        },
        {
          "output": "yCoolingActive",
          "from_s": 0,
          "to_s": 720,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 720,
          "equals": false
        }
      ]
    },
    {
      "name": "heating_only",
      "description": "A material heating command without cooling is valid single-mode operation.",
      "inputs": {
        "htg_vlv_cmd": 40.0,
        "clg_vlv_cmd": 0.0
      },
      "expect": [
        {
          "output": "yHeatingActive",
          "from_s": 0,
          "to_s": 720,
          "equals": true
        },
        {
          "output": "yCoolingActive",
          "from_s": 0,
          "to_s": 720,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 720,
          "equals": false
        }
      ]
    },
    {
      "name": "cooling_only",
      "description": "A material cooling command without heating is valid single-mode operation.",
      "inputs": {
        "htg_vlv_cmd": 0.0,
        "clg_vlv_cmd": 40.0
      },
      "expect": [
        {
          "output": "yHeatingActive",
          "from_s": 0,
          "to_s": 720,
          "equals": false
        },
        {
          "output": "yCoolingActive",
          "from_s": 0,
          "to_s": 720,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 720,
          "equals": false
        }
      ]
    },
    {
      "name": "both_exactly_at_threshold",
      "description": "Strict comparisons keep equality clear on both lanes.",
      "inputs": {
        "htg_vlv_cmd": 10.0,
        "clg_vlv_cmd": 10.0
      },
      "expect": [
        {
          "output": "yHeatingActive",
          "from_s": 0,
          "to_s": 720,
          "equals": false
        },
        {
          "output": "yCoolingActive",
          "from_s": 0,
          "to_s": 720,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 720,
          "equals": false
        }
      ]
    },
    {
      "name": "one_just_above_one_at_threshold",
      "description": "One active lane cannot mature a simultaneous-command fault.",
      "inputs": {
        "htg_vlv_cmd": 10.1,
        "clg_vlv_cmd": 10.0
      },
      "expect": [
        {
          "output": "yHeatingActive",
          "from_s": 0,
          "to_s": 720,
          "equals": true
        },
        {
          "output": "yCoolingActive",
          "from_s": 0,
          "to_s": 720,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 720,
          "equals": false
        }
      ]
    },
    {
      "name": "both_just_above_sustained",
      "description": "One representable increment above both thresholds matures only after persistence.",
      "inputs": {
        "htg_vlv_cmd": 10.1,
        "clg_vlv_cmd": 10.1
      },
      "expect": [
        {
          "output": "yHeatingActive",
          "from_s": 0,
          "to_s": 720,
          "equals": true
        },
        {
          "output": "yCoolingActive",
          "from_s": 0,
          "to_s": 720,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 240,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 360,
          "to_s": 720,
          "equals": true
        }
      ]
    },
    {
      "name": "overlap_ends_one_step_before_persistence",
      "description": "A conflict ending one evaluator step before 300 s never matures.",
      "inputs": {
        "htg_vlv_cmd": 25.0,
        "clg_vlv_cmd": [
          {
            "t": 0,
            "value": 25.0
          },
          {
            "t": 240,
            "value": 0.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 720,
          "equals": false
        },
        {
          "output": "yHeatingActive",
          "from_s": 0,
          "to_s": 720,
          "equals": true
        },
        {
          "output": "yCoolingActive",
          "from_s": 0,
          "to_s": 180,
          "equals": true
        },
        {
          "output": "yCoolingActive",
          "from_s": 240,
          "to_s": 720,
          "equals": false
        }
      ]
    },
    {
      "name": "mature_overlap_recovers_when_cooling_clears",
      "description": "A mature fault clears immediately when either material command clears.",
      "inputs": {
        "htg_vlv_cmd": 25.0,
        "clg_vlv_cmd": [
          {
            "t": 0,
            "value": 25.0
          },
          {
            "t": 420,
            "value": 0.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 240,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 360,
          "to_s": 360,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 420,
          "to_s": 720,
          "equals": false
        },
        {
          "output": "yHeatingActive",
          "from_s": 0,
          "to_s": 720,
          "equals": true
        },
        {
          "output": "yCoolingActive",
          "from_s": 0,
          "to_s": 360,
          "equals": true
        },
        {
          "output": "yCoolingActive",
          "from_s": 420,
          "to_s": 720,
          "equals": false
        }
      ]
    },
    {
      "name": "two_short_overlaps_reset",
      "description": "Two sub-duration conflicts separated by either valve clearing do not accumulate time.",
      "inputs": {
        "htg_vlv_cmd": 25.0,
        "clg_vlv_cmd": [
          {
            "t": 0,
            "value": 25.0
          },
          {
            "t": 180,
            "value": 0.0
          },
          {
            "t": 300,
            "value": 25.0
          },
          {
            "t": 480,
            "value": 0.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 720,
          "equals": false
        }
      ]
    },
    {
      "name": "initialization_overlap_serves_full_delay",
      "description": "delayOnInit prevents an immediate alarm when the evaluator starts inside a conflict.",
      "inputs": {
        "htg_vlv_cmd": 30.0,
        "clg_vlv_cmd": 30.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 240,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 360,
          "to_s": 720,
          "equals": true
        },
        {
          "output": "yHeatingActive",
          "from_s": 0,
          "to_s": 720,
          "equals": true
        },
        {
          "output": "yCoolingActive",
          "from_s": 0,
          "to_s": 720,
          "equals": true
        }
      ]
    },
    {
      "name": "heating_reset_then_cooling_overlap",
      "description": "Clearing the heating lane resets the timer before a later overlap.",
      "inputs": {
        "htg_vlv_cmd": [
          {
            "t": 0,
            "value": 25.0
          },
          {
            "t": 180,
            "value": 0.0
          },
          {
            "t": 360,
            "value": 25.0
          }
        ],
        "clg_vlv_cmd": 25.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 600,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 720,
          "to_s": 720,
          "equals": true
        }
      ]
    },
    {
      "name": "intentional_dehumidification_raw_case",
      "description": "The raw graph alarms on intentional cooling-plus-reheat; the host must exclude that mode or not instantiate this rule.",
      "inputs": {
        "htg_vlv_cmd": 35.0,
        "clg_vlv_cmd": 60.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 240,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 360,
          "to_s": 720,
          "equals": true
        },
        {
          "output": "yHeatingActive",
          "from_s": 0,
          "to_s": 720,
          "equals": true
        },
        {
          "output": "yCoolingActive",
          "from_s": 0,
          "to_s": 720,
          "equals": true
        }
      ]
    }
  ]
}

FPB Fault Rules

Fan-powered terminal rules (FPB-*) cover series and parallel fan-powered VAV terminal units, also called fan-powered boxes, fan-powered terminal units, or FPUs. Ordinary VAV boxes remain in vav; fan coils remain in fcu.

Point dictionary: points/fpb.points.json. Brick 1.4.4 provides only generic Terminal_Unit; ASHRAE 223 PPR2.1 provides exact FanPoweredTerminal. Series/parallel identity is deployment topology.

ConcernSeries FPBParallel FPB
Fan locationIn the primary/discharge path, generally continuous when occupiedParallel plenum-air branch, often heating/low-flow operation
Primary airflowThrough primary damperThrough primary damper
Reheat coilTypically downstream of fanIn fan/reheat branch before mixing, per actual unit topology
Fan proof ruleApplicable when commandedApplicable when commanded
Airflow trackingPrimary airflow onlyPrimary airflow only; does not prove fan-branch airflow
Reheat delta-T bindingDirect coil inlet/outletMust remain branch-local or use a validated derived point

Excluded: ordinary single-duct VAV boxes without terminal fans, FCUs, induction units without commanded fans, dual-duct boxes without explicit compatibility, and electric-reheat-only units for the hydronic valve rule.

Index

IDNameSevMethodStatus
FPB-0001Terminal fan proof-of-operation failure2ruleverified
FPB-0002Primary airflow tracking failure3ruleverified
FPB-0003Reheat valve closed with unintended temperature rise3ruleverified
FPB-0004Terminal fan airflow degradation3statisticalverified
FPB-0005Primary airflow sensor disagreement3metaverified
FPB-0006Reheat-coil heat-transfer degradation3statisticalverified

Minimum telemetry tiers

TierPointsSupported rules
Basicfan command/status, primary flow/setpointFPB-0001..0002
Reheat+ valve and coil inlet/outlet temperatures+ FPB-0003
Performance+ fan airflow/expected airflow and expected coil delta-T+ FPB-0004, FPB-0006
Sensor redundancy+ independent primary airflow reference+ FPB-0005

Expected/reference points are host-derived contracts, not guesses. Each deployment records known-good fit data, model/version, inputs, accuracy, same-path scope, freshness, domain checks, and update/freeze policy. A model using the point it is supposed to check is circular and invalid.

Source and validation posture

The LBNL simulated FPU dataset (DOI 10.25984/1881324) documents PFPU/SFPU topology and publishes HVACSIM+ cases spanning 365 days at one-minute resolution, 109 points, fault-free cases, and ten fault categories. It is a future validation source, not evidence for the library-authored thresholds in this slice; the adapter is deferred to PR11.

Relationships

  • FPB-0001 supplies direction-sensitive fan-proof context; no whole-rule suppression is safe for FPB-0002/0003.
  • FPB-0002 is the primary-stream sibling of VAV-0004 and relates upstream AHU static/reset signatures without claiming their causes.
  • FPB-0003 is the coil-local leakage sibling of FCU-0005/VAV-0009; FPB-0006 asks the opposite full-command performance question. Neither joins CLU-01.
  • FPB-0004 separates proven operation from delivered fan-path airflow. FPB-0001 remains direction-sensitive context, not a whole-rule suppressor.
  • FPB-0005’s ambiguous adjudication reports disagreement without automatically choosing the physical sensor or host reference as the failed member.

FPB-0001 — Terminal fan proof-of-operation failure

Statusverified — engine e2ff2f8, cxf:fnv1a128:b7e84c2382c4a0204115bd7efa28032d, 2026-08-20
Severity2
Methodrule
Phase2
CategoryPROTECTIVE
ConfidenceHIGH
EstimationPROXY_ESTIMATION
G36
Clusters
Suppresses
Suppressed by
RelatedFPB-0002, FPB-0003, FPB-0004
Playbooksproof-of-operation, fan-powered-terminal-faults
SourceLibrary proof-of-operation precedents AHU-0039, TOWER-0004, PMP-0003, HW-0009, and FCU-0006 — final command versus independent status with separate direction timers; ASHRAE Guideline 36-2021 section 5.1.6 — equipment proof semantics only; no portable terminal-fan timer values; LBNL FDD simulated FPU dataset, DOI 10.25984/1881324 — documented PFPU/SFPU topologies and future validation source, not threshold evidence
Operating statesseries or parallel fan-powered terminal states in which the final terminal-fan command and same-fan independent proof are authoritative

Preconditions (host-enforced): Bind fan_cmd after subtype sequence ownership, fan delay, occupancy, low-flow/heating logic, and normal interlocks. Bind independent fan_status for the same fan; command echo is invalid. A series fan may run continuously while occupied, whereas a parallel fan may legitimately remain off in many modes. Exclude smoke/emergency, freeze/condensate protection, exercise, maintenance, local hand operation, and any intentional fan delay not represented in the final command. Inputs must be fresh and aligned; unmet obligations are NO_EVAL, not healthy.

Points: fan_cmd, fan_status

Outputs:

  • yFault — True while either terminal-fan command/proof mismatch has matured through its own timer
  • yFailToStart — Diagnostic direction flag; true after a final start command remains without independent proof for start_proof_time. False never means NO_EVAL
  • yUnexpectedRun — Diagnostic direction flag; true after independent operation continues without a final run command for stop_proof_time. False never means NO_EVAL

Parameters:

NameDefaultUnitCXF pathDescription
start_proof_time60.0sstartProof.delayTimeADOPTED_TUNABLE final-command-to-proof allowance; commission above normal fan response, proof pickup, and telemetry latency.
stop_proof_time60.0sstopProof.delayTimeADOPTED_TUNABLE command-off-to-proof-dropout allowance; commission above coast-down, proof dropout, and telemetry latency.

Description

This rule checks whether the fan inside a series or parallel fan-powered terminal did what its final Boolean command requested. Direction identifies the observed mismatch, not its cause or the subtype’s expected operating schedule.

Detection Logic

fail_to_start  = fan_cmd AND NOT fan_status
unexpected_run = NOT fan_cmd AND fan_status
yFailToStart   = fail_to_start sustained for start_proof_time
yUnexpectedRun = unexpected_run sustained for stop_proof_time
yFault         = yFailToStart OR yUnexpectedRun

FPB-0001 block graph

Independent TrueDelay(delayOnInit=true) lanes prevent elapsed time from crossing a mismatch-direction reversal. Agreement clears both lanes immediately.

Possible Diagnoses

  1. Failed motor, ECM, contactor, controller output, breaker, or wiring.
  2. Seized wheel, failed bearing, belt, coupling, or fan relay.
  3. Bad current, airflow, pressure, speed, rotation, or auxiliary-contact proof.
  4. Local hand/thermostat ownership or a subtype-specific interlock omitted from command.
  5. Wrong terminal or upstream AHU fan bound to either canonical point.

Energy Impact

Unexpected operation can waste terminal-fan electricity and alter delivered heating or airflow. Fail-to-start is principally a comfort and availability finding; this Boolean pair cannot price its zone or plant effect.

Emissions Impact

Scope 2 proxy applies only to unexpected operation using measured fan kW, mismatch hours, and the applicable electricity factor.

Deviations

  • Both 60 s timers are adopted. No cited source establishes portable FPB proof windows.
  • Subtype schedule remains host-side. The same graph is valid for a continuously running series fan and an intermittently commanded parallel fan because it judges agreement, not when command should be on.
  • Final command and independent proof are mandatory. Upstream enable creates false positives; command echo creates a blind spot.
  • No whole-rule suppression is encoded. Fail-to-start can remove another rule’s premise while unexpected run can leave that rule physically meaningful.
  • No empirical FPR or TPR is claimed in this slice. The LBNL adapter and exact dataset-to-canonical-point mappings are deferred to PR11.

Test Vectors

25 scenarios, clock step 10 s over 2400 s.

ScenarioDescription
agreement_stoppedCommand and independent proof agree off; every direction remains clear.
agreement_runningCommand and independent proof agree on; every direction remains clear.
fail_to_start_at_initializationA standing commanded-on/unproved mismatch is pinned one tick before and one tick after the 60 s edge; the exact edge is intentionally unasserted.
unexpected_run_at_initializationA standing command-off/proven-on mismatch is pinned one tick before and one tick after the 60 s edge; the exact edge is intentionally unasserted.
normal_delayed_start_and_stopStatus proves one tick inside both configured allowances, so ordinary start and stop latency never alarms.
start_mismatch_ends_one_tick_before_proofA start mismatch beginning at 300 s clears at 350 s, one 10 s evaluator step before maturity, and never alarms.
start_mismatch_ends_at_proof_boundaryThe start mismatch clears exactly 60 s after it begins. Agreement on the maturity tick wins and no alarm is asserted. The exact maturity tick is intentionally left unasserted; clear behavior is pinned on both sides.
stop_mismatch_ends_one_tick_before_proofA stop mismatch beginning at 300 s clears at 350 s, one 10 s evaluator step before maturity, and never alarms.
stop_mismatch_ends_at_proof_boundaryThe stop mismatch clears exactly 60 s after it begins. Agreement on the maturity tick wins and no alarm is asserted. The exact maturity tick is intentionally left unasserted; clear behavior is pinned on both sides.
mature_start_alarm_recoversA fail-to-start persists past its proof time, then clears immediately when independent proof arrives.
mature_stop_alarm_recoversAn unexpected run persists past its proof time, then clears immediately when proof drops.
pending_direction_reversal_starts_fresh_laneCommand and status swap while fail-to-start is pending. The old lane resets and the unexpected-run lane must serve its complete independent timer.
mature_direction_reversal_hands_offA mature fail-to-start reverses directly into unexpected-run. The old flag clears on the reversal tick, yFault has a real gap, and the opposite lane serves a fresh complete stop timer.
two_short_start_mismatches_do_not_accumulateTwo commanded-on/unproved episodes are separated by agreement; elapsed time cannot transfer across the reset.
two_short_stop_mismatches_do_not_accumulateTwo proven-on/uncommanded episodes are separated by agreement; elapsed time cannot transfer across the reset.
command_chatter_never_maturesThe final command chatters faster than either proof window while status is off; no continuous mismatch matures.
status_chatter_never_maturesIndependent proof chatters against a held-on command; every unproved interval clears before the start timer.
unexpected_run_chatter_never_maturesIndependent proof chatters on against a held-off command; every unexpected-run interval clears before the stop timer.
upstream_enable_raw_false_positiveAn upstream enable held true while downstream logic correctly withholds operation produces a raw fail-to-start alarm; this pins why only the final command is a valid binding.
command_echo_raw_blind_spotA status echo follows the command while the device is mechanically stopped. The pair agrees and the graph stays silent, pinning why proof must be independent.
manual_local_run_is_host_excludedA locally commanded device is proven on while the BAS final command is off. The raw unexpected-run alarm is correct; approved service or hand operation is host NO_EVAL.
wrong_speed_stage_is_boolean_blind_spotAn ECM is commanded to high speed but independent Boolean proof reports only that it runs at some speed. Both booleans agree, so a failed requested stage is outside this operation-only rule.
ordinary_vav_without_terminal_fan_is_not_applicableAn ordinary VAV has no honest terminal-fan command/proof pair. Synthetic disagreement alarms raw, demonstrating why the host must not instantiate the FPB family there.
series_terminal_continuous_occupied_fanA series fan-powered terminal may command and prove its fan continuously while occupied; command/proof agreement is healthy.
parallel_terminal_idle_fan_offA parallel terminal may legitimately leave its fan off outside heating or low-primary-flow operation; agreement off is healthy.
vectors.json
{
  "schema": "cxf-library/vectors/v1",
  "clock": {
    "step_s": 10,
    "horizon_s": 2400
  },
  "scenarios": [
    {
      "name": "agreement_stopped",
      "description": "Command and independent proof agree off; every direction remains clear.",
      "inputs": {
        "fan_cmd": false,
        "fan_status": false
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        }
      ]
    },
    {
      "name": "agreement_running",
      "description": "Command and independent proof agree on; every direction remains clear.",
      "inputs": {
        "fan_cmd": true,
        "fan_status": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        }
      ]
    },
    {
      "name": "fail_to_start_at_initialization",
      "description": "A standing commanded-on/unproved mismatch is pinned one tick before and one tick after the 60 s edge; the exact edge is intentionally unasserted.",
      "inputs": {
        "fan_cmd": true,
        "fan_status": false
      },
      "expect": [
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 50,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 70,
          "to_s": 2400,
          "equals": true
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 50,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 70,
          "to_s": 2400,
          "equals": true
        }
      ]
    },
    {
      "name": "unexpected_run_at_initialization",
      "description": "A standing command-off/proven-on mismatch is pinned one tick before and one tick after the 60 s edge; the exact edge is intentionally unasserted.",
      "inputs": {
        "fan_cmd": false,
        "fan_status": true
      },
      "expect": [
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 50,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 70,
          "to_s": 2400,
          "equals": true
        },
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 50,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 70,
          "to_s": 2400,
          "equals": true
        }
      ]
    },
    {
      "name": "normal_delayed_start_and_stop",
      "description": "Status proves one tick inside both configured allowances, so ordinary start and stop latency never alarms.",
      "inputs": {
        "fan_cmd": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 300,
            "value": true
          },
          {
            "t": 1200,
            "value": false
          }
        ],
        "fan_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 350,
            "value": true
          },
          {
            "t": 1250,
            "value": false
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        }
      ]
    },
    {
      "name": "start_mismatch_ends_one_tick_before_proof",
      "description": "A start mismatch beginning at 300 s clears at 350 s, one 10 s evaluator step before maturity, and never alarms.",
      "inputs": {
        "fan_cmd": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 300,
            "value": true
          }
        ],
        "fan_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 350,
            "value": true
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        }
      ]
    },
    {
      "name": "start_mismatch_ends_at_proof_boundary",
      "description": "The start mismatch clears exactly 60 s after it begins. Agreement on the maturity tick wins and no alarm is asserted. The exact maturity tick is intentionally left unasserted; clear behavior is pinned on both sides.",
      "inputs": {
        "fan_cmd": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 300,
            "value": true
          }
        ],
        "fan_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 360,
            "value": true
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 350,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 370,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 350,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 370,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 350,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 370,
          "to_s": 2400,
          "equals": false
        }
      ]
    },
    {
      "name": "stop_mismatch_ends_one_tick_before_proof",
      "description": "A stop mismatch beginning at 300 s clears at 350 s, one 10 s evaluator step before maturity, and never alarms.",
      "inputs": {
        "fan_cmd": [
          {
            "t": 0,
            "value": true
          },
          {
            "t": 300,
            "value": false
          }
        ],
        "fan_status": [
          {
            "t": 0,
            "value": true
          },
          {
            "t": 350,
            "value": false
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        }
      ]
    },
    {
      "name": "stop_mismatch_ends_at_proof_boundary",
      "description": "The stop mismatch clears exactly 60 s after it begins. Agreement on the maturity tick wins and no alarm is asserted. The exact maturity tick is intentionally left unasserted; clear behavior is pinned on both sides.",
      "inputs": {
        "fan_cmd": [
          {
            "t": 0,
            "value": true
          },
          {
            "t": 300,
            "value": false
          }
        ],
        "fan_status": [
          {
            "t": 0,
            "value": true
          },
          {
            "t": 360,
            "value": false
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 350,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 370,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 350,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 370,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 350,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 370,
          "to_s": 2400,
          "equals": false
        }
      ]
    },
    {
      "name": "mature_start_alarm_recovers",
      "description": "A fail-to-start persists past its proof time, then clears immediately when independent proof arrives.",
      "inputs": {
        "fan_cmd": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 300,
            "value": true
          }
        ],
        "fan_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 460,
            "value": true
          }
        ]
      },
      "expect": [
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 350,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 370,
          "to_s": 450,
          "equals": true
        },
        {
          "output": "yFailToStart",
          "from_s": 460,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 350,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 370,
          "to_s": 450,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 460,
          "to_s": 2400,
          "equals": false
        }
      ]
    },
    {
      "name": "mature_stop_alarm_recovers",
      "description": "An unexpected run persists past its proof time, then clears immediately when proof drops.",
      "inputs": {
        "fan_cmd": [
          {
            "t": 0,
            "value": true
          },
          {
            "t": 300,
            "value": false
          }
        ],
        "fan_status": [
          {
            "t": 0,
            "value": true
          },
          {
            "t": 460,
            "value": false
          }
        ]
      },
      "expect": [
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 350,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 370,
          "to_s": 450,
          "equals": true
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 460,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 350,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 370,
          "to_s": 450,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 460,
          "to_s": 2400,
          "equals": false
        }
      ]
    },
    {
      "name": "pending_direction_reversal_starts_fresh_lane",
      "description": "Command and status swap while fail-to-start is pending. The old lane resets and the unexpected-run lane must serve its complete independent timer.",
      "inputs": {
        "fan_cmd": [
          {
            "t": 0,
            "value": true
          },
          {
            "t": 40,
            "value": false
          }
        ],
        "fan_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 40,
            "value": true
          }
        ]
      },
      "expect": [
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 90,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 110,
          "to_s": 2400,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 90,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 110,
          "to_s": 2400,
          "equals": true
        }
      ]
    },
    {
      "name": "mature_direction_reversal_hands_off",
      "description": "A mature fail-to-start reverses directly into unexpected-run. The old flag clears on the reversal tick, yFault has a real gap, and the opposite lane serves a fresh complete stop timer.",
      "inputs": {
        "fan_cmd": [
          {
            "t": 0,
            "value": true
          },
          {
            "t": 400,
            "value": false
          }
        ],
        "fan_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 400,
            "value": true
          }
        ]
      },
      "expect": [
        {
          "output": "yFailToStart",
          "from_s": 70,
          "to_s": 390,
          "equals": true
        },
        {
          "output": "yFailToStart",
          "from_s": 400,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 450,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 470,
          "to_s": 2400,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 70,
          "to_s": 390,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 400,
          "to_s": 450,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 470,
          "to_s": 2400,
          "equals": true
        }
      ]
    },
    {
      "name": "two_short_start_mismatches_do_not_accumulate",
      "description": "Two commanded-on/unproved episodes are separated by agreement; elapsed time cannot transfer across the reset.",
      "inputs": {
        "fan_cmd": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 300,
            "value": true
          },
          {
            "t": 340,
            "value": false
          },
          {
            "t": 900,
            "value": true
          },
          {
            "t": 940,
            "value": false
          }
        ],
        "fan_status": false
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        }
      ]
    },
    {
      "name": "two_short_stop_mismatches_do_not_accumulate",
      "description": "Two proven-on/uncommanded episodes are separated by agreement; elapsed time cannot transfer across the reset.",
      "inputs": {
        "fan_cmd": false,
        "fan_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 300,
            "value": true
          },
          {
            "t": 340,
            "value": false
          },
          {
            "t": 900,
            "value": true
          },
          {
            "t": 940,
            "value": false
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        }
      ]
    },
    {
      "name": "command_chatter_never_matures",
      "description": "The final command chatters faster than either proof window while status is off; no continuous mismatch matures.",
      "inputs": {
        "fan_cmd": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 300,
            "value": true
          },
          {
            "t": 340,
            "value": false
          },
          {
            "t": 600,
            "value": true
          },
          {
            "t": 640,
            "value": false
          },
          {
            "t": 900,
            "value": true
          },
          {
            "t": 940,
            "value": false
          }
        ],
        "fan_status": false
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        }
      ]
    },
    {
      "name": "status_chatter_never_matures",
      "description": "Independent proof chatters against a held-on command; every unproved interval clears before the start timer.",
      "inputs": {
        "fan_cmd": true,
        "fan_status": [
          {
            "t": 0,
            "value": true
          },
          {
            "t": 300,
            "value": false
          },
          {
            "t": 340,
            "value": true
          },
          {
            "t": 600,
            "value": false
          },
          {
            "t": 640,
            "value": true
          },
          {
            "t": 900,
            "value": false
          },
          {
            "t": 940,
            "value": true
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        }
      ]
    },
    {
      "name": "unexpected_run_chatter_never_matures",
      "description": "Independent proof chatters on against a held-off command; every unexpected-run interval clears before the stop timer.",
      "inputs": {
        "fan_cmd": false,
        "fan_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 300,
            "value": true
          },
          {
            "t": 340,
            "value": false
          },
          {
            "t": 600,
            "value": true
          },
          {
            "t": 640,
            "value": false
          },
          {
            "t": 900,
            "value": true
          },
          {
            "t": 940,
            "value": false
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        }
      ]
    },
    {
      "name": "upstream_enable_raw_false_positive",
      "description": "An upstream enable held true while downstream logic correctly withholds operation produces a raw fail-to-start alarm; this pins why only the final command is a valid binding.",
      "inputs": {
        "fan_cmd": true,
        "fan_status": false
      },
      "expect": [
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 50,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 70,
          "to_s": 2400,
          "equals": true
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 50,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 70,
          "to_s": 2400,
          "equals": true
        }
      ]
    },
    {
      "name": "command_echo_raw_blind_spot",
      "description": "A status echo follows the command while the device is mechanically stopped. The pair agrees and the graph stays silent, pinning why proof must be independent.",
      "inputs": {
        "fan_cmd": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 300,
            "value": true
          }
        ],
        "fan_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 300,
            "value": true
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        }
      ]
    },
    {
      "name": "manual_local_run_is_host_excluded",
      "description": "A locally commanded device is proven on while the BAS final command is off. The raw unexpected-run alarm is correct; approved service or hand operation is host NO_EVAL.",
      "inputs": {
        "fan_cmd": false,
        "fan_status": true
      },
      "expect": [
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 50,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 70,
          "to_s": 2400,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 50,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 70,
          "to_s": 2400,
          "equals": true
        }
      ]
    },
    {
      "name": "wrong_speed_stage_is_boolean_blind_spot",
      "description": "An ECM is commanded to high speed but independent Boolean proof reports only that it runs at some speed. Both booleans agree, so a failed requested stage is outside this operation-only rule.",
      "inputs": {
        "fan_cmd": true,
        "fan_status": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        }
      ]
    },
    {
      "name": "ordinary_vav_without_terminal_fan_is_not_applicable",
      "description": "An ordinary VAV has no honest terminal-fan command/proof pair. Synthetic disagreement alarms raw, demonstrating why the host must not instantiate the FPB family there.",
      "inputs": {
        "fan_cmd": true,
        "fan_status": false
      },
      "expect": [
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 50,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 70,
          "to_s": 2400,
          "equals": true
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 50,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 70,
          "to_s": 2400,
          "equals": true
        }
      ]
    },
    {
      "name": "series_terminal_continuous_occupied_fan",
      "description": "A series fan-powered terminal may command and prove its fan continuously while occupied; command/proof agreement is healthy.",
      "inputs": {
        "fan_cmd": true,
        "fan_status": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        }
      ]
    },
    {
      "name": "parallel_terminal_idle_fan_off",
      "description": "A parallel terminal may legitimately leave its fan off outside heating or low-primary-flow operation; agreement off is healthy.",
      "inputs": {
        "fan_cmd": false,
        "fan_status": false
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        }
      ]
    }
  ]
}

FPB-0002 — Primary airflow tracking failure

Statusverified — engine e2ff2f8, cxf:fnv1a128:5c3e3827711a6dd02246c4398a19d26c, 2026-08-20
Severity3
Methodrule
Phase2
CategoryCOMFORT_ENERGY
ConfidenceHIGH
EstimationQUALITATIVE_ONLY
G36
Clusters
Suppresses
Suppressed by
RelatedFPB-0001, FPB-0003, FPB-0004, FPB-0005, VAV-0004, AHU-0001, AHU-0024, AHU-0031
Playbooksfan-powered-terminal-faults
SourceLibrary executable precedent VAV-0004 — active-airflow-setpoint tracking semantics and two-sided diagnostic direction; Buildings.Controls.OBC.ASHRAE.G36 terminal-unit sequences — primary airflow measurement and active setpoint are distinct terminal control points; mechanism only; LBNL FDD simulated FPU dataset, DOI 10.25984/1881324 — PFPU/SFPU topology and future validation source, not threshold evidence
Operating statesenabled series or parallel FPB in a settled occupied/ventilation/control state where active primary airflow tracking is expected

Preconditions (host-enforced): primary_airflow must be the AHU-fed primary inlet stream, excluding induced/plenum fan flow; primary_airflow_sp must be the settled final active target, not a design minimum/maximum. The upstream AHU fan and sufficient static pressure must be available. Validate airflow units, calibration and K-factor, controller pressure-independence, and data freshness. Exclude AHU shutdown, startup, setpoint ramps, balancing, overrides, and maintenance. minimum_airflow_sp is adoption-blocking and must be commissioned. ySetpointOk covers only the numerical setpoint floor; any other unmet host obligation is NO_EVAL, not healthy.

Points: primary_airflow, primary_airflow_sp

Outputs:

  • yFault — True after primary airflow remains more than the allowed fraction above or below its active setpoint for sustained_duration
  • ySetpointOk — Evaluability flag — true only when primary_airflow_sp is strictly above minimum_airflow_sp. FALSE MEANS NO_EVAL, regardless of yFault
  • yFlowLow — Diagnostic direction flag; true immediately when an evaluable flow is more than the allowed fraction below target. False never means NO_EVAL
  • yFlowHigh — Diagnostic direction flag; true immediately when an evaluable flow is more than the allowed fraction above target. False never means NO_EVAL

Parameters:

NameDefaultUnitCXF pathDescription
minimum_airflow_sp50.0L/ssetpointOk.tNO_PORTABLE_DEFAULT and adoption-blocking floor below which relative tracking is not evaluated; equality is NO_EVAL.
max_tracking_error_fraction0.21allowedError.kADOPTED_TUNABLE maximum absolute residual as a fraction of the positive active setpoint; both comparisons are strict.
sustained_duration600.0spersist.delayTimeADOPTED_TUNABLE continuous out-of-band duration; commission above box response and active-setpoint settling.

Description

This rule detects primary air delivered materially above or below the active FPB target. It deliberately measures only the AHU-fed inlet stream; neither a series fan’s total discharge nor a parallel fan’s induced branch is equivalent.

Detection Logic

setpoint_ok  = primary_airflow_sp > minimum_airflow_sp
allowed      = primary_airflow_sp * max_tracking_error_fraction
flow_high    = (primary_airflow - primary_airflow_sp) > allowed
flow_low     = (primary_airflow_sp - primary_airflow) > allowed
yFlowHigh    = setpoint_ok AND flow_high
yFlowLow     = setpoint_ok AND flow_low
yFault       = setpoint_ok AND (flow_high OR flow_low),
               sustained for sustained_duration

FPB-0002 block graph

The positive setpoint floor defines the valid domain. Cross-multiplied residuals avoid Divide entirely, so zero setpoint cannot evaluate an unsafe denominator.

Possible Diagnoses

  1. Stuck, disconnected, or miscalibrated primary-air damper/actuator.
  2. Insufficient or excessive upstream duct static pressure or failed AHU reset.
  3. Blocked inlet, damaged flow ring, wrong K-factor, or sensor bias.
  4. Stale, misbound, or incorrect active setpoint/units.
  5. Pressure-dependent controller or subtype total-flow point bound as primary flow.

Energy Impact

High primary flow can raise AHU fan, cooling, and reheat energy; low flow can miss ventilation and comfort targets. The signature alone does not identify which effect is avoidable, so the card remains qualitative.

Emissions Impact

Scope 1/2 qualitative by the serving heating/cooling system. Quantify only after isolating the cause and measuring the affected fan or thermal input.

Deviations

  • The graph does not divide. The brief’s fractional-error equation is algebraically equivalent in the enforced positive-setpoint domain; cross-multiplication makes zero-denominator safety structural.
  • 50 L/s is not portable. It is an adoption-blocking placeholder because meaningful minimum flow scales with box size and ventilation design.
  • 20% and 600 s are adopted. Sources support the mechanism, not universal thresholds.
  • Direction handoff preserves persistence. One timer follows the low/high OR; a continuously out-of-band sampled reversal remains one tracking fault.
  • ySetpointOk is not the whole host gate. AHU availability, settled target, calibration, and controller behavior remain external obligations.
  • No empirical FPR or TPR is claimed. The LBNL replay adapter is deferred to PR11.

Test Vectors

15 scenarios, clock step 60 s over 1800 s.

ScenarioDescription
tracking_normallyMeasured primary airflow equals its active target.
sustained_low_flowA 20.1% deficit drives only the low diagnostic and matures after the 600 s persistence margin.
sustained_high_flowA 20.1% excess drives only the high diagnostic and matures independently.
low_error_exactly_at_thresholdAt exactly 80 L/s against 100 L/s, residual equals allowance and the strict comparison is clear.
high_error_exactly_at_thresholdAt exactly 120 L/s against 100 L/s, residual equals allowance and the strict comparison is clear.
setpoint_below_minimumA 49.9 L/s target is below the evaluability floor; all diagnostic and fault outputs stay false.
setpoint_exactly_at_minimumThe strict floor leaves exactly 50 L/s non-evaluable.
setpoint_just_above_minimumOne representable increment above the floor is numerically evaluable when flow tracks it.
zero_setpoint_zero_flowThe zero/zero case cannot divide because this graph has no Divide block; the floor reports NO_EVAL.
violation_ends_one_step_before_persistenceA deficit clears at 540 s, one evaluator step before 600 s, and never matures.
mature_low_fault_recoversA mature low-flow alarm clears immediately when measured flow returns to target.
two_short_episodes_resetTwo four-minute deficits separated by compliant tracking cannot accumulate age.
sampled_direction_handoff_preserves_ageA direct sampled low-to-high handoff stays continuously outside the band, so the shared persistence timer keeps its age while direction flags swap.
active_setpoint_ramp_raw_transientAn unexcluded setpoint step creates a short raw deficit; the host must wait for the active target and controller to settle.
upstream_ahu_off_raw_false_positiveWith an active target left behind while the AHU is off, the raw graph alarms; the host must report NO_EVAL for unavailable upstream fan/static pressure.
vectors.json
{
  "schema": "cxf-library/vectors/v1",
  "clock": {
    "step_s": 60,
    "horizon_s": 1800
  },
  "scenarios": [
    {
      "name": "tracking_normally",
      "description": "Measured primary airflow equals its active target.",
      "inputs": {
        "primary_airflow": 100.0,
        "primary_airflow_sp": 100.0
      },
      "expect": [
        {
          "output": "ySetpointOk",
          "from_s": 0,
          "to_s": 1800,
          "equals": true
        },
        {
          "output": "yFlowLow",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yFlowHigh",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        }
      ]
    },
    {
      "name": "sustained_low_flow",
      "description": "A 20.1% deficit drives only the low diagnostic and matures after the 600 s persistence margin.",
      "inputs": {
        "primary_airflow": 79.9,
        "primary_airflow_sp": 100.0
      },
      "expect": [
        {
          "output": "ySetpointOk",
          "from_s": 0,
          "to_s": 1800,
          "equals": true
        },
        {
          "output": "yFlowLow",
          "from_s": 0,
          "to_s": 1800,
          "equals": true
        },
        {
          "output": "yFlowHigh",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 540,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 660,
          "to_s": 1800,
          "equals": true
        }
      ]
    },
    {
      "name": "sustained_high_flow",
      "description": "A 20.1% excess drives only the high diagnostic and matures independently.",
      "inputs": {
        "primary_airflow": 120.1,
        "primary_airflow_sp": 100.0
      },
      "expect": [
        {
          "output": "ySetpointOk",
          "from_s": 0,
          "to_s": 1800,
          "equals": true
        },
        {
          "output": "yFlowLow",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yFlowHigh",
          "from_s": 0,
          "to_s": 1800,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 540,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 660,
          "to_s": 1800,
          "equals": true
        }
      ]
    },
    {
      "name": "low_error_exactly_at_threshold",
      "description": "At exactly 80 L/s against 100 L/s, residual equals allowance and the strict comparison is clear.",
      "inputs": {
        "primary_airflow": 80.0,
        "primary_airflow_sp": 100.0
      },
      "expect": [
        {
          "output": "ySetpointOk",
          "from_s": 0,
          "to_s": 1800,
          "equals": true
        },
        {
          "output": "yFlowLow",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yFlowHigh",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        }
      ]
    },
    {
      "name": "high_error_exactly_at_threshold",
      "description": "At exactly 120 L/s against 100 L/s, residual equals allowance and the strict comparison is clear.",
      "inputs": {
        "primary_airflow": 120.0,
        "primary_airflow_sp": 100.0
      },
      "expect": [
        {
          "output": "ySetpointOk",
          "from_s": 0,
          "to_s": 1800,
          "equals": true
        },
        {
          "output": "yFlowLow",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yFlowHigh",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        }
      ]
    },
    {
      "name": "setpoint_below_minimum",
      "description": "A 49.9 L/s target is below the evaluability floor; all diagnostic and fault outputs stay false.",
      "inputs": {
        "primary_airflow": 0.0,
        "primary_airflow_sp": 49.9
      },
      "expect": [
        {
          "output": "ySetpointOk",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yFlowLow",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yFlowHigh",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        }
      ]
    },
    {
      "name": "setpoint_exactly_at_minimum",
      "description": "The strict floor leaves exactly 50 L/s non-evaluable.",
      "inputs": {
        "primary_airflow": 0.0,
        "primary_airflow_sp": 50.0
      },
      "expect": [
        {
          "output": "ySetpointOk",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yFlowLow",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yFlowHigh",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        }
      ]
    },
    {
      "name": "setpoint_just_above_minimum",
      "description": "One representable increment above the floor is numerically evaluable when flow tracks it.",
      "inputs": {
        "primary_airflow": 50.1,
        "primary_airflow_sp": 50.1
      },
      "expect": [
        {
          "output": "ySetpointOk",
          "from_s": 0,
          "to_s": 1800,
          "equals": true
        },
        {
          "output": "yFlowLow",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yFlowHigh",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        }
      ]
    },
    {
      "name": "zero_setpoint_zero_flow",
      "description": "The zero/zero case cannot divide because this graph has no Divide block; the floor reports NO_EVAL.",
      "inputs": {
        "primary_airflow": 0.0,
        "primary_airflow_sp": 0.0
      },
      "expect": [
        {
          "output": "ySetpointOk",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yFlowLow",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yFlowHigh",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        }
      ]
    },
    {
      "name": "violation_ends_one_step_before_persistence",
      "description": "A deficit clears at 540 s, one evaluator step before 600 s, and never matures.",
      "inputs": {
        "primary_airflow": [
          {
            "t": 0,
            "value": 70.0
          },
          {
            "t": 540,
            "value": 100.0
          }
        ],
        "primary_airflow_sp": 100.0
      },
      "expect": [
        {
          "output": "yFlowLow",
          "from_s": 0,
          "to_s": 480,
          "equals": true
        },
        {
          "output": "yFlowLow",
          "from_s": 540,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yFlowHigh",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        }
      ]
    },
    {
      "name": "mature_low_fault_recovers",
      "description": "A mature low-flow alarm clears immediately when measured flow returns to target.",
      "inputs": {
        "primary_airflow": [
          {
            "t": 0,
            "value": 70.0
          },
          {
            "t": 900,
            "value": 100.0
          }
        ],
        "primary_airflow_sp": 100.0
      },
      "expect": [
        {
          "output": "yFlowLow",
          "from_s": 0,
          "to_s": 840,
          "equals": true
        },
        {
          "output": "yFlowLow",
          "from_s": 900,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yFlowHigh",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 540,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 660,
          "to_s": 840,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 900,
          "to_s": 1800,
          "equals": false
        }
      ]
    },
    {
      "name": "two_short_episodes_reset",
      "description": "Two four-minute deficits separated by compliant tracking cannot accumulate age.",
      "inputs": {
        "primary_airflow": [
          {
            "t": 0,
            "value": 70.0
          },
          {
            "t": 240,
            "value": 100.0
          },
          {
            "t": 480,
            "value": 70.0
          },
          {
            "t": 720,
            "value": 100.0
          }
        ],
        "primary_airflow_sp": 100.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        }
      ]
    },
    {
      "name": "sampled_direction_handoff_preserves_age",
      "description": "A direct sampled low-to-high handoff stays continuously outside the band, so the shared persistence timer keeps its age while direction flags swap.",
      "inputs": {
        "primary_airflow": [
          {
            "t": 0,
            "value": 70.0
          },
          {
            "t": 300,
            "value": 130.0
          }
        ],
        "primary_airflow_sp": 100.0
      },
      "expect": [
        {
          "output": "yFlowLow",
          "from_s": 0,
          "to_s": 240,
          "equals": true
        },
        {
          "output": "yFlowLow",
          "from_s": 300,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yFlowHigh",
          "from_s": 0,
          "to_s": 240,
          "equals": false
        },
        {
          "output": "yFlowHigh",
          "from_s": 300,
          "to_s": 1800,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 540,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 660,
          "to_s": 1800,
          "equals": true
        }
      ]
    },
    {
      "name": "active_setpoint_ramp_raw_transient",
      "description": "An unexcluded setpoint step creates a short raw deficit; the host must wait for the active target and controller to settle.",
      "inputs": {
        "primary_airflow": 60.0,
        "primary_airflow_sp": [
          {
            "t": 0,
            "value": 60.0
          },
          {
            "t": 300,
            "value": 100.0
          },
          {
            "t": 780,
            "value": 60.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        }
      ]
    },
    {
      "name": "upstream_ahu_off_raw_false_positive",
      "description": "With an active target left behind while the AHU is off, the raw graph alarms; the host must report NO_EVAL for unavailable upstream fan/static pressure.",
      "inputs": {
        "primary_airflow": 0.0,
        "primary_airflow_sp": 100.0
      },
      "expect": [
        {
          "output": "ySetpointOk",
          "from_s": 0,
          "to_s": 1800,
          "equals": true
        },
        {
          "output": "yFlowLow",
          "from_s": 0,
          "to_s": 1800,
          "equals": true
        },
        {
          "output": "yFlowHigh",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 540,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 660,
          "to_s": 1800,
          "equals": true
        }
      ]
    }
  ]
}

FPB-0003 — Reheat valve closed with unintended temperature rise

Statusverified — engine e2ff2f8, cxf:fnv1a128:4d6cece087e01b0d5e88794f4e8f2d30, 2026-08-20
Severity3
Methodrule
Phase2
CategoryCRITICAL_WASTE
ConfidenceMEDIUM
EstimationPROXY_ESTIMATION
G36
Clusters
Suppresses
Suppressed by
RelatedFPB-0001, FPB-0002, FPB-0006, FCU-0005, VAV-0009
Playbooksfan-powered-terminal-faults
SourceLibrary leak-mechanism precedents FCU-0005 and VAV-0009 — air temperature rise across reheat while the hydronic valve is commanded shut; LBNL FDD simulated FPU dataset, DOI 10.25984/1881324 — documented PFPU fan/reheat branch and SFPU downstream-fan reheat topology; future validation source only; Library-authored instantaneous coil-local adaptation; no cited source publishes 5%, 3 K, or 600 s as portable FPB limits
Operating stateshydronic-reheat FPB states with proven airflow through the reheat coil and no legitimate reheat request

Preconditions (host-enforced): Apply only to hydronic reheat with available hot water at meaningful temperature and pressure. fan_status must prove airflow through the evaluated coil path. Bind physical coil-local temperatures: SFPU downstream of the series fan/immediately upstream of the coil and immediately at coil outlet; PFPU immediately around the fan/reheat branch before primary/branch mixing. A mixed zone-discharge proxy is invalid unless the host supplies a validated derived coil-leaving estimate. Exclude freeze/exercise/commissioning, intentional minimum valve position, sensor faults, and warm-soak after a prior heating call. Inputs must be fresh and aligned; unmet obligations are NO_EVAL, not healthy.

Points: rht_vlv_cmd, rht_coil_entering_temp, rht_coil_leaving_temp, fan_status

Outputs:

  • yFault — True after proven coil airflow, a closed valve command, and material coil-local temperature rise persist for sustained_duration
  • yValveClosed — Diagnostic sub-condition flag; true when rht_vlv_cmd is strictly below valve_closed_threshold. False never means NO_EVAL
  • yTemperatureRise — Diagnostic sub-condition flag; true when coil leaving minus entering temperature is strictly above temperature_rise_threshold. False never means NO_EVAL

Parameters:

NameDefaultUnitCXF pathDescription
valve_closed_threshold5.0%valveClosed.tADOPTED_TUNABLE command below which the reheat valve counts as closed; equality is not closed.
temperature_rise_threshold3.0KriseHigh.tADOPTED_TUNABLE coil-local rise allowance above sensor error, fan heat, piping migration, and normal residual heat; equality is clear.
sustained_duration600.0spersist.delayTimeADOPTED_TUNABLE continuous signature duration; no existing 600 s leakage rule or cited source establishes a portable precedent.

Description

This rule identifies heat added across a hydronic FPB reheat coil while its valve is commanded shut and airflow through that coil is proven. Coil-local measurement is essential, especially before PFPU branch air mixes with primary air.

Detection Logic

valve_closed = rht_vlv_cmd < valve_closed_threshold
rise         = rht_coil_leaving_temp - rht_coil_entering_temp
rise_high    = rise > temperature_rise_threshold
yValveClosed = valve_closed
yTemperatureRise = rise_high
yFault = fan_status AND valve_closed AND rise_high,
         sustained for sustained_duration

FPB-0003 block graph

Both thresholds are strict. The complete three-part candidate feeds one TrueDelay(delayOnInit=true); loss of any premise clears a mature alarm immediately.

Possible Diagnoses

  1. Valve seat passing from wear, debris, fouling, or unsuitable close-off pressure.
  2. Actuator/linkage not reaching the seat despite a closed command.
  3. Manual bypass, three-way piping, or unintended gravity circulation.
  4. Residual hot-water availability or warm-soak not excluded by the host.
  5. Entering/leaving sensor bias, swap, or PFPU mixed-discharge misbinding.

Energy Impact

Leaked reheat can be paid for twice when primary cooling removes it again. Temperature rise is only a proxy; PFPU primary airflow is not automatically the branch coil airflow required to turn that rise into thermal power.

Emissions Impact

Scope 1 and/or 2 depends on heating and cooling sources. Use validated coil airflow, rise, runtime, and source-specific factors before quantifying savings.

Deviations

  • All three defaults are adopted. The brief labeled 600 s as library precedent, but no existing 600 s leakage rule supports that claim; it is recorded as ADOPTED_TUNABLE instead.
  • The rule is hydronic-only. Electric reheat needs proof/status logic and safety treatment rather than a fictitious valve command.
  • Temperatures are coil-local. This is stricter than common VAV/FCU proxies and prevents PFPU branch mixing from erasing the signature.
  • Fan proof is in-graph, but other gates remain host-side. Hot-water availability, warm-soak, freeze/exercise, and point quality still determine evaluability.
  • The rule is related to leak siblings but not placed in CLU-01. A single AHU simultaneous-command repair cannot reliably clear a physically passing terminal valve.
  • No empirical FPR or TPR is claimed. LBNL replay and mapping are deferred to PR11.

Test Vectors

16 scenarios, clock step 60 s over 1800 s.

ScenarioDescription
closed_valve_no_riseA shut valve with equal coil inlet/outlet temperature is healthy.
open_valve_with_riseHeating with an open valve is legitimate and cannot enter the leak candidate.
fan_off_with_riseA temperature difference without proven branch airflow is not evaluated by the fault output.
sustained_closed_valve_riseClosed valve, proven fan, and a 3.1 K rise mature after the 600 s persistence margin.
valve_exactly_at_closed_thresholdExactly 5% is not closed under the strict less-than comparison.
valve_just_below_closed_thresholdAt 4.9%, the valve is classified closed and a sustained 3.1 K rise alarms.
rise_exactly_at_thresholdExactly 3.0 K is clear under the strict greater-than comparison.
rise_just_above_thresholdOne tenth kelvin above the rise threshold exercises the positive edge.
negative_temperature_riseLeaving air colder than entering air cannot satisfy the unintended-heating direction.
violation_ends_one_step_before_persistenceResidual rise clears at 540 s, one evaluator step before persistence, so no alarm matures.
mature_fault_recovers_with_temperatureA mature alarm clears immediately when the coil rise disappears.
mature_fault_recovers_when_valve_opensA legitimate call for heat clears the mature leak verdict on the same tick.
mature_fault_recovers_when_fan_stopsLoss of airflow proof removes evaluability from the verdict and clears a mature alarm.
two_short_rise_episodes_resetTwo short rises separated by a no-rise interval cannot accumulate timer age.
normal_residual_heat_raw_transientA four-minute post-call warm-coil transient is rejected by persistence; hosts should still exclude known warm-soak windows.
pfpu_mixed_discharge_is_invalid_bindingA diluted mixed discharge can hide a real branch-local coil rise; this raw clear result pins why PFPU sensors must remain immediately around the reheat coil.
vectors.json
{
  "schema": "cxf-library/vectors/v1",
  "clock": {
    "step_s": 60,
    "horizon_s": 1800
  },
  "scenarios": [
    {
      "name": "closed_valve_no_rise",
      "description": "A shut valve with equal coil inlet/outlet temperature is healthy.",
      "inputs": {
        "rht_vlv_cmd": 0.0,
        "rht_coil_entering_temp": 20.0,
        "rht_coil_leaving_temp": 20.0,
        "fan_status": true
      },
      "expect": [
        {
          "output": "yValveClosed",
          "from_s": 0,
          "to_s": 1800,
          "equals": true
        },
        {
          "output": "yTemperatureRise",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        }
      ]
    },
    {
      "name": "open_valve_with_rise",
      "description": "Heating with an open valve is legitimate and cannot enter the leak candidate.",
      "inputs": {
        "rht_vlv_cmd": 50.0,
        "rht_coil_entering_temp": 20.0,
        "rht_coil_leaving_temp": 25.0,
        "fan_status": true
      },
      "expect": [
        {
          "output": "yValveClosed",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yTemperatureRise",
          "from_s": 0,
          "to_s": 1800,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        }
      ]
    },
    {
      "name": "fan_off_with_rise",
      "description": "A temperature difference without proven branch airflow is not evaluated by the fault output.",
      "inputs": {
        "rht_vlv_cmd": 0.0,
        "rht_coil_entering_temp": 20.0,
        "rht_coil_leaving_temp": 25.0,
        "fan_status": false
      },
      "expect": [
        {
          "output": "yValveClosed",
          "from_s": 0,
          "to_s": 1800,
          "equals": true
        },
        {
          "output": "yTemperatureRise",
          "from_s": 0,
          "to_s": 1800,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        }
      ]
    },
    {
      "name": "sustained_closed_valve_rise",
      "description": "Closed valve, proven fan, and a 3.1 K rise mature after the 600 s persistence margin.",
      "inputs": {
        "rht_vlv_cmd": 0.0,
        "rht_coil_entering_temp": 20.0,
        "rht_coil_leaving_temp": 23.1,
        "fan_status": true
      },
      "expect": [
        {
          "output": "yValveClosed",
          "from_s": 0,
          "to_s": 1800,
          "equals": true
        },
        {
          "output": "yTemperatureRise",
          "from_s": 0,
          "to_s": 1800,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 540,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 660,
          "to_s": 1800,
          "equals": true
        }
      ]
    },
    {
      "name": "valve_exactly_at_closed_threshold",
      "description": "Exactly 5% is not closed under the strict less-than comparison.",
      "inputs": {
        "rht_vlv_cmd": 5.0,
        "rht_coil_entering_temp": 20.0,
        "rht_coil_leaving_temp": 25.0,
        "fan_status": true
      },
      "expect": [
        {
          "output": "yValveClosed",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yTemperatureRise",
          "from_s": 0,
          "to_s": 1800,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        }
      ]
    },
    {
      "name": "valve_just_below_closed_threshold",
      "description": "At 4.9%, the valve is classified closed and a sustained 3.1 K rise alarms.",
      "inputs": {
        "rht_vlv_cmd": 4.9,
        "rht_coil_entering_temp": 20.0,
        "rht_coil_leaving_temp": 23.1,
        "fan_status": true
      },
      "expect": [
        {
          "output": "yValveClosed",
          "from_s": 0,
          "to_s": 1800,
          "equals": true
        },
        {
          "output": "yTemperatureRise",
          "from_s": 0,
          "to_s": 1800,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 540,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 660,
          "to_s": 1800,
          "equals": true
        }
      ]
    },
    {
      "name": "rise_exactly_at_threshold",
      "description": "Exactly 3.0 K is clear under the strict greater-than comparison.",
      "inputs": {
        "rht_vlv_cmd": 0.0,
        "rht_coil_entering_temp": 20.0,
        "rht_coil_leaving_temp": 23.0,
        "fan_status": true
      },
      "expect": [
        {
          "output": "yValveClosed",
          "from_s": 0,
          "to_s": 1800,
          "equals": true
        },
        {
          "output": "yTemperatureRise",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        }
      ]
    },
    {
      "name": "rise_just_above_threshold",
      "description": "One tenth kelvin above the rise threshold exercises the positive edge.",
      "inputs": {
        "rht_vlv_cmd": 0.0,
        "rht_coil_entering_temp": 20.0,
        "rht_coil_leaving_temp": 23.1,
        "fan_status": true
      },
      "expect": [
        {
          "output": "yTemperatureRise",
          "from_s": 0,
          "to_s": 1800,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 540,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 660,
          "to_s": 1800,
          "equals": true
        }
      ]
    },
    {
      "name": "negative_temperature_rise",
      "description": "Leaving air colder than entering air cannot satisfy the unintended-heating direction.",
      "inputs": {
        "rht_vlv_cmd": 0.0,
        "rht_coil_entering_temp": 20.0,
        "rht_coil_leaving_temp": 18.0,
        "fan_status": true
      },
      "expect": [
        {
          "output": "yValveClosed",
          "from_s": 0,
          "to_s": 1800,
          "equals": true
        },
        {
          "output": "yTemperatureRise",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        }
      ]
    },
    {
      "name": "violation_ends_one_step_before_persistence",
      "description": "Residual rise clears at 540 s, one evaluator step before persistence, so no alarm matures.",
      "inputs": {
        "rht_vlv_cmd": 0.0,
        "rht_coil_entering_temp": 20.0,
        "rht_coil_leaving_temp": [
          {
            "t": 0,
            "value": 24.0
          },
          {
            "t": 540,
            "value": 20.0
          }
        ],
        "fan_status": true
      },
      "expect": [
        {
          "output": "yTemperatureRise",
          "from_s": 0,
          "to_s": 480,
          "equals": true
        },
        {
          "output": "yTemperatureRise",
          "from_s": 540,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        }
      ]
    },
    {
      "name": "mature_fault_recovers_with_temperature",
      "description": "A mature alarm clears immediately when the coil rise disappears.",
      "inputs": {
        "rht_vlv_cmd": 0.0,
        "rht_coil_entering_temp": 20.0,
        "rht_coil_leaving_temp": [
          {
            "t": 0,
            "value": 24.0
          },
          {
            "t": 900,
            "value": 20.0
          }
        ],
        "fan_status": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 540,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 660,
          "to_s": 840,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 900,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yTemperatureRise",
          "from_s": 0,
          "to_s": 840,
          "equals": true
        },
        {
          "output": "yTemperatureRise",
          "from_s": 900,
          "to_s": 1800,
          "equals": false
        }
      ]
    },
    {
      "name": "mature_fault_recovers_when_valve_opens",
      "description": "A legitimate call for heat clears the mature leak verdict on the same tick.",
      "inputs": {
        "rht_vlv_cmd": [
          {
            "t": 0,
            "value": 0.0
          },
          {
            "t": 900,
            "value": 50.0
          }
        ],
        "rht_coil_entering_temp": 20.0,
        "rht_coil_leaving_temp": 24.0,
        "fan_status": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 540,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 660,
          "to_s": 840,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 900,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yValveClosed",
          "from_s": 0,
          "to_s": 840,
          "equals": true
        },
        {
          "output": "yValveClosed",
          "from_s": 900,
          "to_s": 1800,
          "equals": false
        }
      ]
    },
    {
      "name": "mature_fault_recovers_when_fan_stops",
      "description": "Loss of airflow proof removes evaluability from the verdict and clears a mature alarm.",
      "inputs": {
        "rht_vlv_cmd": 0.0,
        "rht_coil_entering_temp": 20.0,
        "rht_coil_leaving_temp": 24.0,
        "fan_status": [
          {
            "t": 0,
            "value": true
          },
          {
            "t": 900,
            "value": false
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 540,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 660,
          "to_s": 840,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 900,
          "to_s": 1800,
          "equals": false
        }
      ]
    },
    {
      "name": "two_short_rise_episodes_reset",
      "description": "Two short rises separated by a no-rise interval cannot accumulate timer age.",
      "inputs": {
        "rht_vlv_cmd": 0.0,
        "rht_coil_entering_temp": 20.0,
        "rht_coil_leaving_temp": [
          {
            "t": 0,
            "value": 24.0
          },
          {
            "t": 240,
            "value": 20.0
          },
          {
            "t": 480,
            "value": 24.0
          },
          {
            "t": 720,
            "value": 20.0
          }
        ],
        "fan_status": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        }
      ]
    },
    {
      "name": "normal_residual_heat_raw_transient",
      "description": "A four-minute post-call warm-coil transient is rejected by persistence; hosts should still exclude known warm-soak windows.",
      "inputs": {
        "rht_vlv_cmd": 0.0,
        "rht_coil_entering_temp": 20.0,
        "rht_coil_leaving_temp": [
          {
            "t": 0,
            "value": 24.0
          },
          {
            "t": 240,
            "value": 20.0
          }
        ],
        "fan_status": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        }
      ]
    },
    {
      "name": "pfpu_mixed_discharge_is_invalid_binding",
      "description": "A diluted mixed discharge can hide a real branch-local coil rise; this raw clear result pins why PFPU sensors must remain immediately around the reheat coil.",
      "inputs": {
        "rht_vlv_cmd": 0.0,
        "rht_coil_entering_temp": 20.0,
        "rht_coil_leaving_temp": 22.0,
        "fan_status": true
      },
      "expect": [
        {
          "output": "yValveClosed",
          "from_s": 0,
          "to_s": 1800,
          "equals": true
        },
        {
          "output": "yTemperatureRise",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        }
      ]
    }
  ]
}

FPB-0004 — Terminal fan airflow degradation

Statusverified — engine e2ff2f8, cxf:fnv1a128:6329b30093556d09175f8d877b734a2a, 2026-08-20
Severity3
Methodstatistical
Phase3
CategoryEFFICIENCY_LOSS
ConfidenceMEDIUM
EstimationPROXY_ESTIMATION
G36
Clusters
Suppresses
Suppressed by
RelatedFPB-0001, FPB-0002, FPB-0005, FPB-0006
Playbooksfan-powered-terminal-faults
SourceLBNL FDD simulated FPU dataset, DOI 10.25984/1881324 — PFPU/SFPU topology and restricted-fan-flow fault class; future empirical replay source, not threshold evidence; Library host-baseline precedent PMP-0006 — positive-baseline cross-multiplied residual, numerical validity output, and explicit model-readiness obligations; Library-authored thresholds and persistence; no cited source publishes 50 L/s, 20%, or 900 s as portable FPB limits
Operating statesstable automatic PFPU or SFPU fan operation with proven fan status and a ready same-path expected-airflow model

Preconditions (host-enforced): fan_airflow_expected is host-fitted on a known-good period, frozen/versioned, fresh, and in-domain for the current fan command/speed, pressure, subtype, damper/topology, and operating state. Actual and expected must represent the same fan path: PFPU fan/plenum branch; SFPU series-fan path. fan_status must prove the same fan. Exclude startup, speed/mode/damper transitions, smoke operation, maintenance, local control, and invalid airflow sensing. minimum_expected_airflow is adoption-blocking. yBaselineOk proves only numerical positivity; other unmet obligations are NO_EVAL, not healthy.

Points: fan_status, fan_airflow, fan_airflow_expected

Outputs:

  • yFault — True after proven fan operation and a valid expected baseline remain more than the allowed airflow fraction above actual delivery for sustained_duration
  • yBaselineOk — Evaluability flag — true only when fan_airflow_expected is strictly above minimum_expected_airflow. FALSE MEANS NO_EVAL
  • yAirflowLow — Diagnostic sub-condition flag; true when a numerically valid expected baseline exceeds actual fan-path airflow by more than the allowed fraction. False never means NO_EVAL

Parameters:

NameDefaultUnitCXF pathDescription
minimum_expected_airflow50.0L/sbaselineOk.tNO_PORTABLE_DEFAULT and adoption-blocking expected-airflow floor; equality is NO_EVAL.
max_airflow_drop_fraction0.21allowance.kADOPTED_TUNABLE maximum actual-flow deficit as a fraction of positive expected flow; strict comparison.
sustained_duration900.0spersist.delayTimeADOPTED_TUNABLE continuous low-delivery duration; commission against fan and baseline response.

Description

This rule detects fan-path airflow materially below a known-good, current-condition baseline while the terminal fan is proven on. It reports delivery degradation, not a specific dirty filter, fan, belt, damper, voltage, or sensor cause.

Detection Logic

baseline_ok = fan_airflow_expected > minimum_expected_airflow
residual    = fan_airflow_expected - fan_airflow
allowance   = fan_airflow_expected * max_airflow_drop_fraction
airflow_low = baseline_ok AND residual > allowance
yBaselineOk = baseline_ok
yAirflowLow = airflow_low
yFault      = fan_status AND airflow_low, sustained for sustained_duration

FPB-0004 block graph

Cross-multiplication makes the relative test safe without a Divide; all comparisons are strict and persistence uses delayOnInit=true.

Possible Diagnoses

  1. Restricted intake/discharge, dirty filter, or obstructed fan-path damper.
  2. Fouled/damaged wheel, slipping belt/coupling, low speed, voltage, or torque limit.
  3. Different fan configuration, pressure, or subtype state than the baseline.
  4. Actual/expected path mismatch, bad sensor, stale model, or degraded training data.

Energy Impact

Low delivered airflow can extend fan/reheat/primary-air operation or miss load. The residual is a delivery proxy, not measured power; no kWh is claimed.

Emissions Impact

Scope 2 qualitative through fan and compensating HVAC electricity.

Deviations

  • No Divide block is used. Cross-multiplication is equivalent in the positive expected-flow domain and structurally safe at zero.
  • The expected point is site-fitted, not a universal fan curve. Readiness, training, features, path scope, and domain checks remain host obligations.
  • All defaults require commissioning. 50 L/s is adoption-blocking; 20% and 900 s are adopted tunables.
  • Proof remains related rather than a suppressor. FPB-0001 fail-to-start invalidates the premise, but unexpected run can still leave this degradation signature meaningful.
  • No empirical FPR/TPR is claimed. LBNL mapping/replay is deferred to PR11.

Test Vectors

15 scenarios, clock step 60 s over 2100 s.

ScenarioDescription
expected_airflow_deliveredFan-path airflow equals the valid expected baseline.
baseline_zero_is_no_evalA zero expected baseline is numerically invalid without any Divide evaluation.
baseline_below_minimum49.9 L/s lies below the strict baseline floor.
baseline_exactly_at_minimumExactly 50 L/s is NO_EVAL under the strict comparison.
baseline_just_above_minimum50.1 L/s is evaluable when actual airflow matches it.
drop_exactly_at_thresholdAn actual 80 against expected 100 makes residual equal the 20 allowance; strict Greater remains clear.
drop_just_beyond_threshold79.9 against 100 drives the low-flow diagnostic and matures outside the 900 s edge margin.
actual_above_expectedMore airflow than expected is outside this one-sided degradation rule.
fan_off_low_actualThe baseline-conditioned diagnostic remains visible, but a stopped fan cannot produce the degradation verdict.
violation_ends_one_step_before_persistenceLow airflow clears at 840 s, one evaluator step before the 900 s delay, and never alarms.
mature_fault_recoversA mature airflow degradation alarm clears immediately when actual delivery recovers.
two_short_drops_resetTwo six-minute deficits separated by recovery do not accumulate age.
expected_baseline_change_resets_candidateAn in-domain expected-baseline change removes the residual and resets persistence.
pfpu_total_discharge_bad_bindingA mixed total-discharge point can match the expected branch flow while the fan branch is restricted; the clear raw result pins the path-scope blind spot.
sfpu_duplicate_primary_sensor_bad_bindingUsing the same primary sensor for actual and expected makes a tautological clear result; topology/model independence is host-enforced.
vectors.json
{
  "schema": "cxf-library/vectors/v1",
  "clock": {
    "step_s": 60,
    "horizon_s": 2100
  },
  "scenarios": [
    {
      "name": "expected_airflow_delivered",
      "description": "Fan-path airflow equals the valid expected baseline.",
      "inputs": {
        "fan_status": true,
        "fan_airflow": 100.0,
        "fan_airflow_expected": 100.0
      },
      "expect": [
        {
          "output": "yBaselineOk",
          "from_s": 0,
          "to_s": 2100,
          "equals": true
        },
        {
          "output": "yAirflowLow",
          "from_s": 0,
          "to_s": 2100,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2100,
          "equals": false
        }
      ]
    },
    {
      "name": "baseline_zero_is_no_eval",
      "description": "A zero expected baseline is numerically invalid without any Divide evaluation.",
      "inputs": {
        "fan_status": true,
        "fan_airflow": 0.0,
        "fan_airflow_expected": 0.0
      },
      "expect": [
        {
          "output": "yBaselineOk",
          "from_s": 0,
          "to_s": 2100,
          "equals": false
        },
        {
          "output": "yAirflowLow",
          "from_s": 0,
          "to_s": 2100,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2100,
          "equals": false
        }
      ]
    },
    {
      "name": "baseline_below_minimum",
      "description": "49.9 L/s lies below the strict baseline floor.",
      "inputs": {
        "fan_status": true,
        "fan_airflow": 0.0,
        "fan_airflow_expected": 49.9
      },
      "expect": [
        {
          "output": "yBaselineOk",
          "from_s": 0,
          "to_s": 2100,
          "equals": false
        },
        {
          "output": "yAirflowLow",
          "from_s": 0,
          "to_s": 2100,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2100,
          "equals": false
        }
      ]
    },
    {
      "name": "baseline_exactly_at_minimum",
      "description": "Exactly 50 L/s is NO_EVAL under the strict comparison.",
      "inputs": {
        "fan_status": true,
        "fan_airflow": 0.0,
        "fan_airflow_expected": 50.0
      },
      "expect": [
        {
          "output": "yBaselineOk",
          "from_s": 0,
          "to_s": 2100,
          "equals": false
        },
        {
          "output": "yAirflowLow",
          "from_s": 0,
          "to_s": 2100,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2100,
          "equals": false
        }
      ]
    },
    {
      "name": "baseline_just_above_minimum",
      "description": "50.1 L/s is evaluable when actual airflow matches it.",
      "inputs": {
        "fan_status": true,
        "fan_airflow": 50.1,
        "fan_airflow_expected": 50.1
      },
      "expect": [
        {
          "output": "yBaselineOk",
          "from_s": 0,
          "to_s": 2100,
          "equals": true
        },
        {
          "output": "yAirflowLow",
          "from_s": 0,
          "to_s": 2100,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2100,
          "equals": false
        }
      ]
    },
    {
      "name": "drop_exactly_at_threshold",
      "description": "An actual 80 against expected 100 makes residual equal the 20 allowance; strict Greater remains clear.",
      "inputs": {
        "fan_status": true,
        "fan_airflow": 80.0,
        "fan_airflow_expected": 100.0
      },
      "expect": [
        {
          "output": "yBaselineOk",
          "from_s": 0,
          "to_s": 2100,
          "equals": true
        },
        {
          "output": "yAirflowLow",
          "from_s": 0,
          "to_s": 2100,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2100,
          "equals": false
        }
      ]
    },
    {
      "name": "drop_just_beyond_threshold",
      "description": "79.9 against 100 drives the low-flow diagnostic and matures outside the 900 s edge margin.",
      "inputs": {
        "fan_status": true,
        "fan_airflow": 79.9,
        "fan_airflow_expected": 100.0
      },
      "expect": [
        {
          "output": "yBaselineOk",
          "from_s": 0,
          "to_s": 2100,
          "equals": true
        },
        {
          "output": "yAirflowLow",
          "from_s": 0,
          "to_s": 2100,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 840,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 960,
          "to_s": 2100,
          "equals": true
        }
      ]
    },
    {
      "name": "actual_above_expected",
      "description": "More airflow than expected is outside this one-sided degradation rule.",
      "inputs": {
        "fan_status": true,
        "fan_airflow": 120.0,
        "fan_airflow_expected": 100.0
      },
      "expect": [
        {
          "output": "yBaselineOk",
          "from_s": 0,
          "to_s": 2100,
          "equals": true
        },
        {
          "output": "yAirflowLow",
          "from_s": 0,
          "to_s": 2100,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2100,
          "equals": false
        }
      ]
    },
    {
      "name": "fan_off_low_actual",
      "description": "The baseline-conditioned diagnostic remains visible, but a stopped fan cannot produce the degradation verdict.",
      "inputs": {
        "fan_status": false,
        "fan_airflow": 0.0,
        "fan_airflow_expected": 100.0
      },
      "expect": [
        {
          "output": "yBaselineOk",
          "from_s": 0,
          "to_s": 2100,
          "equals": true
        },
        {
          "output": "yAirflowLow",
          "from_s": 0,
          "to_s": 2100,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2100,
          "equals": false
        }
      ]
    },
    {
      "name": "violation_ends_one_step_before_persistence",
      "description": "Low airflow clears at 840 s, one evaluator step before the 900 s delay, and never alarms.",
      "inputs": {
        "fan_status": true,
        "fan_airflow": [
          {
            "t": 0,
            "value": 70.0
          },
          {
            "t": 840,
            "value": 100.0
          }
        ],
        "fan_airflow_expected": 100.0
      },
      "expect": [
        {
          "output": "yAirflowLow",
          "from_s": 0,
          "to_s": 780,
          "equals": true
        },
        {
          "output": "yAirflowLow",
          "from_s": 840,
          "to_s": 2100,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2100,
          "equals": false
        }
      ]
    },
    {
      "name": "mature_fault_recovers",
      "description": "A mature airflow degradation alarm clears immediately when actual delivery recovers.",
      "inputs": {
        "fan_status": true,
        "fan_airflow": [
          {
            "t": 0,
            "value": 70.0
          },
          {
            "t": 1200,
            "value": 100.0
          }
        ],
        "fan_airflow_expected": 100.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 840,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 960,
          "to_s": 1140,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 1200,
          "to_s": 2100,
          "equals": false
        },
        {
          "output": "yAirflowLow",
          "from_s": 0,
          "to_s": 1140,
          "equals": true
        },
        {
          "output": "yAirflowLow",
          "from_s": 1200,
          "to_s": 2100,
          "equals": false
        }
      ]
    },
    {
      "name": "two_short_drops_reset",
      "description": "Two six-minute deficits separated by recovery do not accumulate age.",
      "inputs": {
        "fan_status": true,
        "fan_airflow": [
          {
            "t": 0,
            "value": 70.0
          },
          {
            "t": 360,
            "value": 100.0
          },
          {
            "t": 600,
            "value": 70.0
          },
          {
            "t": 960,
            "value": 100.0
          }
        ],
        "fan_airflow_expected": 100.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2100,
          "equals": false
        }
      ]
    },
    {
      "name": "expected_baseline_change_resets_candidate",
      "description": "An in-domain expected-baseline change removes the residual and resets persistence.",
      "inputs": {
        "fan_status": true,
        "fan_airflow": 79.9,
        "fan_airflow_expected": [
          {
            "t": 0,
            "value": 100.0
          },
          {
            "t": 600,
            "value": 75.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2100,
          "equals": false
        },
        {
          "output": "yAirflowLow",
          "from_s": 0,
          "to_s": 540,
          "equals": true
        },
        {
          "output": "yAirflowLow",
          "from_s": 600,
          "to_s": 2100,
          "equals": false
        }
      ]
    },
    {
      "name": "pfpu_total_discharge_bad_binding",
      "description": "A mixed total-discharge point can match the expected branch flow while the fan branch is restricted; the clear raw result pins the path-scope blind spot.",
      "inputs": {
        "fan_status": true,
        "fan_airflow": 100.0,
        "fan_airflow_expected": 100.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2100,
          "equals": false
        }
      ]
    },
    {
      "name": "sfpu_duplicate_primary_sensor_bad_binding",
      "description": "Using the same primary sensor for actual and expected makes a tautological clear result; topology/model independence is host-enforced.",
      "inputs": {
        "fan_status": true,
        "fan_airflow": 90.0,
        "fan_airflow_expected": 90.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2100,
          "equals": false
        }
      ]
    }
  ]
}

FPB-0005 — Primary airflow sensor disagreement

Statusverified — engine e2ff2f8, cxf:fnv1a128:dea3bb3d4caf1dc61fdbb108eaaa20e3, 2026-08-20
Severity3
Methodmeta
Phase3
CategoryPROTECTIVE
ConfidenceMEDIUM
EstimationQUALITATIVE_ONLY
G36
Clusters
Suppresses
Suppressed by
RelatedFPB-0002, FPB-0004
Playbooksfan-powered-terminal-faults, sensor-drift
SourceLBNL FDD simulated FPU dataset, DOI 10.25984/1881324 — primary-airflow sensor-bias fault class; future empirical replay source, not threshold evidence; Library sensor-health precedents SYS-0005 and SYS-0006 — independent-reference comparison, directional residuals, and explicit adjudication uncertainty; Library-authored thresholds and persistence; no cited source publishes 50 L/s, 15%, or 900 s as portable FPB limits
Operating statessteady FPB primary-air operation with sufficient reference flow and an independent, ready same-stream reference

Preconditions (host-enforced): primary_airflow_reference must be independent of primary_airflow, known-good/in-domain, documented, fresh, and time-aligned. Valid sources include a certified redundant sensor, calibrated independent damper/pressure model, or validated upstream branch balance; a calculation that consumes primary_airflow is circular and invalid. Both points must use L/s and the same primary inlet stream/location. Exclude startup, setpoint/damper/static-pressure transitions the reference cannot follow, AHU shutdown, and insufficient steady flow. minimum_reference_airflow is adoption-blocking. yReferenceOk covers numerical positivity only. Because adjudicates.verdict is ambiguous, yFault establishes disagreement but does not by itself authorize automatic invalidation of primary_airflow; deployment must separately certify the reference before directional adjudication.

Points: primary_airflow, primary_airflow_reference

Outputs:

  • yFault — True after the primary airflow measurement and independent reference remain more than the allowed fraction apart for sustained_duration; the verdict does not prove which value is wrong
  • yReferenceOk — Evaluability flag — true only when primary_airflow_reference is strictly above minimum_reference_airflow. FALSE MEANS NO_EVAL
  • yPositiveBias — Diagnostic direction flag; true when the measurement is above the valid reference by more than the allowed fraction. False never means NO_EVAL
  • yNegativeBias — Diagnostic direction flag; true when the measurement is below the valid reference by more than the allowed fraction. False never means NO_EVAL

Parameters:

NameDefaultUnitCXF pathDescription
minimum_reference_airflow50.0L/sreferenceOk.tNO_PORTABLE_DEFAULT and adoption-blocking reference-flow floor; equality is NO_EVAL.
max_disagreement_fraction0.151allowance.kADOPTED_TUNABLE allowed absolute residual as a fraction of positive reference flow; both comparisons are strict.
sustained_duration900.0spersist.delayTimeADOPTED_TUNABLE continuous disagreement duration; commission above measurement/reference lag and normal control transitions.

Description

This rule gives the canonical primary-airflow sensor an independent second opinion. Sustained disagreement is a sensor-integrity finding, but an ordinary host-derived reference can be wrong too, so the verdict remains ambiguous.

Detection Logic

reference_ok = primary_airflow_reference > minimum_reference_airflow
allowance    = primary_airflow_reference * max_disagreement_fraction
positive     = (primary_airflow - primary_airflow_reference) > allowance
negative     = (primary_airflow_reference - primary_airflow) > allowance
yPositiveBias = reference_ok AND positive
yNegativeBias = reference_ok AND negative
yReferenceOk  = reference_ok
yFault = reference_ok AND (positive OR negative), sustained for sustained_duration

FPB-0005 block graph

No Divide exists. One timer follows the direction OR, so a sampled sign reversal without reconvergence preserves age; any in-band sample resets it.

Possible Diagnoses

  1. Primary airflow sensor bias, scaling/K-factor, tubing, or pickup fault.
  2. Reference sensor/model bias, stale version, or out-of-domain extrapolation.
  3. Circular reference, different stream/location, time misalignment, or unit mismatch.
  4. Real transient the reference cannot reproduce.

Energy Impact

The disagreement itself uses no energy. Impact is whatever wrong airflow control or downstream diagnosis the erroneous member causes.

Emissions Impact

Scope 1/2 qualitative through downstream heating, cooling, and fan decisions.

Deviations

  • Ambiguous means no automatic victim. The graph’s input roles differ, but the reference contract is not inherently trusted enough to invalidate primary_airflow without deployment certification.
  • No Divide block is used. Positive-reference cross-multiplication is safe at zero and negative raw references.
  • All defaults require commissioning. The floor is adoption-blocking; 15% and 900 s are adopted.
  • FPB-0002 is not automatically suppressed. A biased measurement can explain tracking error, but an ambiguous reference cannot silently erase it.
  • No empirical FPR/TPR is claimed. LBNL replay is deferred to PR11.

Test Vectors

17 scenarios, clock step 60 s over 2100 s.

ScenarioDescription
exact_agreementMeasurement and independent reference agree.
sustained_positive_biasA 15.1% high measurement drives only the positive direction and matures outside the delay edge.
sustained_negative_biasA 15.1% low measurement drives only the negative direction.
positive_exact_thresholdExactly +15% is clear under strict Greater.
negative_exact_thresholdExactly -15% is clear under the mirrored strict comparison.
reference_below_minimum49.9 L/s is below the reference floor and all bias outputs are gated false.
reference_exactly_at_minimumExactly 50 L/s is NO_EVAL.
reference_just_above_minimum50.1 L/s is evaluable when measurement agrees.
zero_referenceZero reference cannot trigger Divide because the graph contains none; it is NO_EVAL.
negative_reference_raw_caseA negative reference remains numerically safe and non-evaluable.
violation_ends_one_step_before_persistenceDisagreement clears at 840 s, one step before persistence, and never matures.
mature_disagreement_recoversA mature bias alarm clears immediately when the measurement reconverges.
two_short_disagreements_resetSeparated short disagreements do not accumulate timer age.
direction_handoff_preserves_ageA direct positive-to-negative handoff stays continuously outside the band, so the shared timer preserves age while diagnostics swap.
time_alignment_step_transientReference moves two samples before measurement; the temporary disagreement is rejected by persistence, but alignment remains a host obligation.
circular_reference_blind_spotA reference calculated directly from the accused point agrees perfectly and hides any bias; the clear result pins why circular evidence is invalid.
unit_mismatch_raw_false_positiveNumerically mismatched units look like a permanent positive bias and alarm raw; equal units are host-enforced.
vectors.json
{
  "schema": "cxf-library/vectors/v1",
  "clock": {
    "step_s": 60,
    "horizon_s": 2100
  },
  "scenarios": [
    {
      "name": "exact_agreement",
      "description": "Measurement and independent reference agree.",
      "inputs": {
        "primary_airflow": 100.0,
        "primary_airflow_reference": 100.0
      },
      "expect": [
        {
          "output": "yReferenceOk",
          "from_s": 0,
          "to_s": 2100,
          "equals": true
        },
        {
          "output": "yPositiveBias",
          "from_s": 0,
          "to_s": 2100,
          "equals": false
        },
        {
          "output": "yNegativeBias",
          "from_s": 0,
          "to_s": 2100,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2100,
          "equals": false
        }
      ]
    },
    {
      "name": "sustained_positive_bias",
      "description": "A 15.1% high measurement drives only the positive direction and matures outside the delay edge.",
      "inputs": {
        "primary_airflow": 115.1,
        "primary_airflow_reference": 100.0
      },
      "expect": [
        {
          "output": "yReferenceOk",
          "from_s": 0,
          "to_s": 2100,
          "equals": true
        },
        {
          "output": "yPositiveBias",
          "from_s": 0,
          "to_s": 2100,
          "equals": true
        },
        {
          "output": "yNegativeBias",
          "from_s": 0,
          "to_s": 2100,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 840,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 960,
          "to_s": 2100,
          "equals": true
        }
      ]
    },
    {
      "name": "sustained_negative_bias",
      "description": "A 15.1% low measurement drives only the negative direction.",
      "inputs": {
        "primary_airflow": 84.9,
        "primary_airflow_reference": 100.0
      },
      "expect": [
        {
          "output": "yReferenceOk",
          "from_s": 0,
          "to_s": 2100,
          "equals": true
        },
        {
          "output": "yPositiveBias",
          "from_s": 0,
          "to_s": 2100,
          "equals": false
        },
        {
          "output": "yNegativeBias",
          "from_s": 0,
          "to_s": 2100,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 840,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 960,
          "to_s": 2100,
          "equals": true
        }
      ]
    },
    {
      "name": "positive_exact_threshold",
      "description": "Exactly +15% is clear under strict Greater.",
      "inputs": {
        "primary_airflow": 115.0,
        "primary_airflow_reference": 100.0
      },
      "expect": [
        {
          "output": "yPositiveBias",
          "from_s": 0,
          "to_s": 2100,
          "equals": false
        },
        {
          "output": "yNegativeBias",
          "from_s": 0,
          "to_s": 2100,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2100,
          "equals": false
        }
      ]
    },
    {
      "name": "negative_exact_threshold",
      "description": "Exactly -15% is clear under the mirrored strict comparison.",
      "inputs": {
        "primary_airflow": 85.0,
        "primary_airflow_reference": 100.0
      },
      "expect": [
        {
          "output": "yPositiveBias",
          "from_s": 0,
          "to_s": 2100,
          "equals": false
        },
        {
          "output": "yNegativeBias",
          "from_s": 0,
          "to_s": 2100,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2100,
          "equals": false
        }
      ]
    },
    {
      "name": "reference_below_minimum",
      "description": "49.9 L/s is below the reference floor and all bias outputs are gated false.",
      "inputs": {
        "primary_airflow": 100.0,
        "primary_airflow_reference": 49.9
      },
      "expect": [
        {
          "output": "yReferenceOk",
          "from_s": 0,
          "to_s": 2100,
          "equals": false
        },
        {
          "output": "yPositiveBias",
          "from_s": 0,
          "to_s": 2100,
          "equals": false
        },
        {
          "output": "yNegativeBias",
          "from_s": 0,
          "to_s": 2100,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2100,
          "equals": false
        }
      ]
    },
    {
      "name": "reference_exactly_at_minimum",
      "description": "Exactly 50 L/s is NO_EVAL.",
      "inputs": {
        "primary_airflow": 100.0,
        "primary_airflow_reference": 50.0
      },
      "expect": [
        {
          "output": "yReferenceOk",
          "from_s": 0,
          "to_s": 2100,
          "equals": false
        },
        {
          "output": "yPositiveBias",
          "from_s": 0,
          "to_s": 2100,
          "equals": false
        },
        {
          "output": "yNegativeBias",
          "from_s": 0,
          "to_s": 2100,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2100,
          "equals": false
        }
      ]
    },
    {
      "name": "reference_just_above_minimum",
      "description": "50.1 L/s is evaluable when measurement agrees.",
      "inputs": {
        "primary_airflow": 50.1,
        "primary_airflow_reference": 50.1
      },
      "expect": [
        {
          "output": "yReferenceOk",
          "from_s": 0,
          "to_s": 2100,
          "equals": true
        },
        {
          "output": "yPositiveBias",
          "from_s": 0,
          "to_s": 2100,
          "equals": false
        },
        {
          "output": "yNegativeBias",
          "from_s": 0,
          "to_s": 2100,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2100,
          "equals": false
        }
      ]
    },
    {
      "name": "zero_reference",
      "description": "Zero reference cannot trigger Divide because the graph contains none; it is NO_EVAL.",
      "inputs": {
        "primary_airflow": 100.0,
        "primary_airflow_reference": 0.0
      },
      "expect": [
        {
          "output": "yReferenceOk",
          "from_s": 0,
          "to_s": 2100,
          "equals": false
        },
        {
          "output": "yPositiveBias",
          "from_s": 0,
          "to_s": 2100,
          "equals": false
        },
        {
          "output": "yNegativeBias",
          "from_s": 0,
          "to_s": 2100,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2100,
          "equals": false
        }
      ]
    },
    {
      "name": "negative_reference_raw_case",
      "description": "A negative reference remains numerically safe and non-evaluable.",
      "inputs": {
        "primary_airflow": 100.0,
        "primary_airflow_reference": -10.0
      },
      "expect": [
        {
          "output": "yReferenceOk",
          "from_s": 0,
          "to_s": 2100,
          "equals": false
        },
        {
          "output": "yPositiveBias",
          "from_s": 0,
          "to_s": 2100,
          "equals": false
        },
        {
          "output": "yNegativeBias",
          "from_s": 0,
          "to_s": 2100,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2100,
          "equals": false
        }
      ]
    },
    {
      "name": "violation_ends_one_step_before_persistence",
      "description": "Disagreement clears at 840 s, one step before persistence, and never matures.",
      "inputs": {
        "primary_airflow": [
          {
            "t": 0,
            "value": 120.0
          },
          {
            "t": 840,
            "value": 100.0
          }
        ],
        "primary_airflow_reference": 100.0
      },
      "expect": [
        {
          "output": "yPositiveBias",
          "from_s": 0,
          "to_s": 780,
          "equals": true
        },
        {
          "output": "yPositiveBias",
          "from_s": 840,
          "to_s": 2100,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2100,
          "equals": false
        }
      ]
    },
    {
      "name": "mature_disagreement_recovers",
      "description": "A mature bias alarm clears immediately when the measurement reconverges.",
      "inputs": {
        "primary_airflow": [
          {
            "t": 0,
            "value": 120.0
          },
          {
            "t": 1200,
            "value": 100.0
          }
        ],
        "primary_airflow_reference": 100.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 840,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 960,
          "to_s": 1140,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 1200,
          "to_s": 2100,
          "equals": false
        },
        {
          "output": "yPositiveBias",
          "from_s": 0,
          "to_s": 1140,
          "equals": true
        },
        {
          "output": "yPositiveBias",
          "from_s": 1200,
          "to_s": 2100,
          "equals": false
        },
        {
          "output": "yNegativeBias",
          "from_s": 0,
          "to_s": 2100,
          "equals": false
        }
      ]
    },
    {
      "name": "two_short_disagreements_reset",
      "description": "Separated short disagreements do not accumulate timer age.",
      "inputs": {
        "primary_airflow": [
          {
            "t": 0,
            "value": 120.0
          },
          {
            "t": 360,
            "value": 100.0
          },
          {
            "t": 600,
            "value": 120.0
          },
          {
            "t": 960,
            "value": 100.0
          }
        ],
        "primary_airflow_reference": 100.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2100,
          "equals": false
        }
      ]
    },
    {
      "name": "direction_handoff_preserves_age",
      "description": "A direct positive-to-negative handoff stays continuously outside the band, so the shared timer preserves age while diagnostics swap.",
      "inputs": {
        "primary_airflow": [
          {
            "t": 0,
            "value": 120.0
          },
          {
            "t": 480,
            "value": 80.0
          }
        ],
        "primary_airflow_reference": 100.0
      },
      "expect": [
        {
          "output": "yPositiveBias",
          "from_s": 0,
          "to_s": 420,
          "equals": true
        },
        {
          "output": "yPositiveBias",
          "from_s": 480,
          "to_s": 2100,
          "equals": false
        },
        {
          "output": "yNegativeBias",
          "from_s": 0,
          "to_s": 420,
          "equals": false
        },
        {
          "output": "yNegativeBias",
          "from_s": 480,
          "to_s": 2100,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 840,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 960,
          "to_s": 2100,
          "equals": true
        }
      ]
    },
    {
      "name": "time_alignment_step_transient",
      "description": "Reference moves two samples before measurement; the temporary disagreement is rejected by persistence, but alignment remains a host obligation.",
      "inputs": {
        "primary_airflow_reference": [
          {
            "t": 0,
            "value": 100.0
          },
          {
            "t": 300,
            "value": 140.0
          }
        ],
        "primary_airflow": [
          {
            "t": 0,
            "value": 100.0
          },
          {
            "t": 420,
            "value": 140.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2100,
          "equals": false
        }
      ]
    },
    {
      "name": "circular_reference_blind_spot",
      "description": "A reference calculated directly from the accused point agrees perfectly and hides any bias; the clear result pins why circular evidence is invalid.",
      "inputs": {
        "primary_airflow": 130.0,
        "primary_airflow_reference": 130.0
      },
      "expect": [
        {
          "output": "yReferenceOk",
          "from_s": 0,
          "to_s": 2100,
          "equals": true
        },
        {
          "output": "yPositiveBias",
          "from_s": 0,
          "to_s": 2100,
          "equals": false
        },
        {
          "output": "yNegativeBias",
          "from_s": 0,
          "to_s": 2100,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2100,
          "equals": false
        }
      ]
    },
    {
      "name": "unit_mismatch_raw_false_positive",
      "description": "Numerically mismatched units look like a permanent positive bias and alarm raw; equal units are host-enforced.",
      "inputs": {
        "primary_airflow": 211.9,
        "primary_airflow_reference": 100.0
      },
      "expect": [
        {
          "output": "yReferenceOk",
          "from_s": 0,
          "to_s": 2100,
          "equals": true
        },
        {
          "output": "yPositiveBias",
          "from_s": 0,
          "to_s": 2100,
          "equals": true
        },
        {
          "output": "yNegativeBias",
          "from_s": 0,
          "to_s": 2100,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 840,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 960,
          "to_s": 2100,
          "equals": true
        }
      ]
    }
  ]
}

FPB-0006 — Reheat-coil heat-transfer degradation

Statusverified — engine e2ff2f8, cxf:fnv1a128:573e7d690f4b0f4e699a19df07db6869, 2026-08-20
Severity3
Methodstatistical
Phase3
CategoryCOMFORT_ENERGY
ConfidenceMEDIUM
EstimationPROXY_ESTIMATION
G36
Clusters
Suppresses
Suppressed by
RelatedFPB-0003, FPB-0004, HW-0010
Playbooksfan-powered-terminal-faults
SourceLBNL FDD simulated FPU dataset, DOI 10.25984/1881324 — PFPU/SFPU reheat topology and coil-fouling fault classes; future empirical replay source, not threshold evidence; Library host-baseline precedent PMP-0006 and coil-performance precedents AHU-0038/CHW-0005/CHW-0006 — positive baseline, in-domain gate, and non-causal degradation naming; Library-authored thresholds and persistence; no cited source publishes 90%, 3 K, 30%, or 900 s as portable FPB limits
Operating stateshydronic-reheat FPB at stable near-full heat with proven coil-path airflow and a ready expected-delta-T model

Preconditions (host-enforced): Apply only to hydronic reheat. Hot-water supply must be available at adequate temperature, flow, and pressure; plant tracking faults make this verdict NO_EVAL. rht_delta_t_expected must be trained on known-good operation, frozen/versioned, fresh, positive, and in-domain for current fan/airflow, entering temperature, valve state, and hot-water condition. Bind the same coil-local temperatures required by FPB-0003, including PFPU branch-local sensors before mixing. fan_status must prove airflow through that coil path. Exclude startup warm-up, valve exercise, freeze protection, maintenance, unstable fan/airflow, sensor faults, and plant transitions. minimum_expected_delta_t is adoption-blocking; yBaselineOk proves only numerical positivity.

Points: rht_vlv_cmd, rht_coil_entering_temp, rht_coil_leaving_temp, rht_delta_t_expected, fan_status

Outputs:

  • yFault — True after proven fan operation, near-full heat command, a valid expected rise, and material actual-rise deficit persist for sustained_duration
  • yBaselineOk — Evaluability flag — true only when rht_delta_t_expected is strictly above minimum_expected_delta_t. FALSE MEANS NO_EVAL
  • yFullHeatCommand — Diagnostic sub-condition flag; true when rht_vlv_cmd is strictly above full_command_threshold. False never means NO_EVAL
  • yTemperatureRiseLow — Diagnostic sub-condition flag; true when a numerically valid expected rise exceeds actual coil rise by more than the allowed fraction. False never means NO_EVAL

Parameters:

NameDefaultUnitCXF pathDescription
full_command_threshold90.0%fullHeat.tADOPTED_TUNABLE command above which the valve counts as near-full heat; equality is clear.
minimum_expected_delta_t3.0KbaselineOk.tNO_PORTABLE_DEFAULT and adoption-blocking expected-rise floor; equality is NO_EVAL.
max_delta_t_drop_fraction0.31allowance.kADOPTED_TUNABLE maximum actual-rise deficit as a fraction of positive expected rise; strict comparison.
sustained_duration900.0spersist.delayTimeADOPTED_TUNABLE continuous low-transfer duration; commission against coil, valve, fan, and hot-water response.

Description

This rule detects actual reheat-coil air temperature rise materially below a known-good current-condition expectation at proven airflow and near-full valve command. It reports heat-transfer degradation without overclaiming fouling.

Detection Logic

actual_rise = rht_coil_leaving_temp - rht_coil_entering_temp
baseline_ok = rht_delta_t_expected > minimum_expected_delta_t
full_heat   = rht_vlv_cmd > full_command_threshold
allowance   = rht_delta_t_expected * max_delta_t_drop_fraction
rise_low    = baseline_ok AND (rht_delta_t_expected - actual_rise) > allowance
yBaselineOk = baseline_ok
yFullHeatCommand = full_heat
yTemperatureRiseLow = rise_low
yFault = fan_status AND full_heat AND rise_low, sustained for sustained_duration

FPB-0006 block graph

The relative deficit is cross-multiplied in the positive-baseline domain; there is no Divide. Persistence uses delayOnInit=true.

Possible Diagnoses

  1. Air- or water-side fouling, coil air bypass, or obstructed flow path.
  2. Low hot-water temperature/flow/pressure or serving-plant tracking failure.
  3. Valve/actuator/linkage not delivering the commanded full stroke.
  4. Excessive or mis-modeled airflow/fan condition.
  5. Sensor bias/location, actual/expected scope mismatch, or bad baseline model.

Energy Impact

Poor transfer can extend heating runtime or miss comfort. The temperature-rise deficit is a performance proxy, not automatically avoidable thermal energy.

Emissions Impact

Scope 1/2 qualitative by the hot-water source and compensating equipment.

Deviations

  • The rule names observable degradation, not fouling. Hydraulic, actuator, airflow, plant, sensor, and model causes share the signature.
  • No Divide block is used. Cross-multiplication is equivalent only in the positive expected-rise domain enforced by yBaselineOk.
  • The expected point is site-fitted. Known-good training, inputs, model readiness, and hot-water condition stay host-side.
  • All defaults require commissioning. 3 K is adoption-blocking; 90%, 30%, and 900 s are adopted tunables.
  • No empirical FPR/TPR is claimed. LBNL mapping/replay is deferred to PR11.

Test Vectors

18 scenarios, clock step 60 s over 2100 s.

ScenarioDescription
full_heat_meets_expected_riseFull heat with actual coil rise equal to its valid expected baseline is healthy.
baseline_zero_is_no_evalA zero expected delta-T is numerically invalid and safe without Divide.
baseline_just_below_minimumA 2.9 K expected delta-T is just below the adoption-blocking floor and remains NO_EVAL.
baseline_exactly_at_minimumExactly 3 K is NO_EVAL under strict Greater.
baseline_just_above_minimum3.1 K is evaluable when actual rise matches it.
partial_command_low_riseLow rise at partial valve command remains diagnostically visible but cannot mature a full-heat degradation fault.
command_exactly_at_full_thresholdExactly 90% is not full heat under the strict comparison.
drop_exactly_at_thresholdActual 7 K against expected 10 K makes residual equal the 3 K allowance and remains clear.
both_just_beyond_thresholdsCommand 90.1% and actual rise 6.9 K put both strict comparisons just beyond their boundaries.
zero_actual_riseZero actual rise is a strong low-transfer signature under an otherwise valid full-heat baseline.
negative_actual_riseA negative measured rise also trips the one-sided low-transfer residual; sensor binding is among the diagnoses.
fan_off_low_riseThe diagnostic remains visible, but no fan proof means the final verdict stays clear/NO_EVAL.
hot_water_unavailable_raw_signatureFull command with no hot-water availability produces the raw fault; the host must report NO_EVAL rather than diagnose coil degradation.
violation_ends_one_step_before_persistenceLow transfer clears at 840 s, one step before persistence, and never alarms.
mature_fault_recoversA mature degradation alarm clears when coil rise recovers to expected.
command_release_resets_timerValve command backs below full heat before persistence and resets the candidate.
two_short_low_transfer_episodesSeparated short degradation episodes cannot accumulate timer age.
expected_baseline_change_resets_candidateA valid expected-model change removes the residual and resets persistence.
vectors.json
{
  "schema": "cxf-library/vectors/v1",
  "clock": {
    "step_s": 60,
    "horizon_s": 2100
  },
  "scenarios": [
    {
      "name": "full_heat_meets_expected_rise",
      "description": "Full heat with actual coil rise equal to its valid expected baseline is healthy.",
      "inputs": {
        "rht_vlv_cmd": 100.0,
        "rht_coil_entering_temp": 20.0,
        "rht_coil_leaving_temp": 30.0,
        "rht_delta_t_expected": 10.0,
        "fan_status": true
      },
      "expect": [
        {
          "output": "yBaselineOk",
          "from_s": 0,
          "to_s": 2100,
          "equals": true
        },
        {
          "output": "yFullHeatCommand",
          "from_s": 0,
          "to_s": 2100,
          "equals": true
        },
        {
          "output": "yTemperatureRiseLow",
          "from_s": 0,
          "to_s": 2100,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2100,
          "equals": false
        }
      ]
    },
    {
      "name": "baseline_zero_is_no_eval",
      "description": "A zero expected delta-T is numerically invalid and safe without Divide.",
      "inputs": {
        "rht_vlv_cmd": 100.0,
        "rht_coil_entering_temp": 20.0,
        "rht_coil_leaving_temp": 20.0,
        "rht_delta_t_expected": 0.0,
        "fan_status": true
      },
      "expect": [
        {
          "output": "yBaselineOk",
          "from_s": 0,
          "to_s": 2100,
          "equals": false
        },
        {
          "output": "yTemperatureRiseLow",
          "from_s": 0,
          "to_s": 2100,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2100,
          "equals": false
        }
      ]
    },
    {
      "name": "baseline_just_below_minimum",
      "description": "A 2.9 K expected delta-T is just below the adoption-blocking floor and remains NO_EVAL.",
      "inputs": {
        "rht_vlv_cmd": 100.0,
        "rht_coil_entering_temp": 20.0,
        "rht_coil_leaving_temp": 20.0,
        "rht_delta_t_expected": 2.9,
        "fan_status": true
      },
      "expect": [
        {
          "output": "yBaselineOk",
          "from_s": 0,
          "to_s": 2100,
          "equals": false
        },
        {
          "output": "yTemperatureRiseLow",
          "from_s": 0,
          "to_s": 2100,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2100,
          "equals": false
        }
      ]
    },
    {
      "name": "baseline_exactly_at_minimum",
      "description": "Exactly 3 K is NO_EVAL under strict Greater.",
      "inputs": {
        "rht_vlv_cmd": 100.0,
        "rht_coil_entering_temp": 20.0,
        "rht_coil_leaving_temp": 20.0,
        "rht_delta_t_expected": 3.0,
        "fan_status": true
      },
      "expect": [
        {
          "output": "yBaselineOk",
          "from_s": 0,
          "to_s": 2100,
          "equals": false
        },
        {
          "output": "yTemperatureRiseLow",
          "from_s": 0,
          "to_s": 2100,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2100,
          "equals": false
        }
      ]
    },
    {
      "name": "baseline_just_above_minimum",
      "description": "3.1 K is evaluable when actual rise matches it.",
      "inputs": {
        "rht_vlv_cmd": 100.0,
        "rht_coil_entering_temp": 20.0,
        "rht_coil_leaving_temp": 23.1,
        "rht_delta_t_expected": 3.1,
        "fan_status": true
      },
      "expect": [
        {
          "output": "yBaselineOk",
          "from_s": 0,
          "to_s": 2100,
          "equals": true
        },
        {
          "output": "yTemperatureRiseLow",
          "from_s": 0,
          "to_s": 2100,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2100,
          "equals": false
        }
      ]
    },
    {
      "name": "partial_command_low_rise",
      "description": "Low rise at partial valve command remains diagnostically visible but cannot mature a full-heat degradation fault.",
      "inputs": {
        "rht_vlv_cmd": 60.0,
        "rht_coil_entering_temp": 20.0,
        "rht_coil_leaving_temp": 25.0,
        "rht_delta_t_expected": 10.0,
        "fan_status": true
      },
      "expect": [
        {
          "output": "yBaselineOk",
          "from_s": 0,
          "to_s": 2100,
          "equals": true
        },
        {
          "output": "yFullHeatCommand",
          "from_s": 0,
          "to_s": 2100,
          "equals": false
        },
        {
          "output": "yTemperatureRiseLow",
          "from_s": 0,
          "to_s": 2100,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2100,
          "equals": false
        }
      ]
    },
    {
      "name": "command_exactly_at_full_threshold",
      "description": "Exactly 90% is not full heat under the strict comparison.",
      "inputs": {
        "rht_vlv_cmd": 90.0,
        "rht_coil_entering_temp": 20.0,
        "rht_coil_leaving_temp": 25.0,
        "rht_delta_t_expected": 10.0,
        "fan_status": true
      },
      "expect": [
        {
          "output": "yFullHeatCommand",
          "from_s": 0,
          "to_s": 2100,
          "equals": false
        },
        {
          "output": "yTemperatureRiseLow",
          "from_s": 0,
          "to_s": 2100,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2100,
          "equals": false
        }
      ]
    },
    {
      "name": "drop_exactly_at_threshold",
      "description": "Actual 7 K against expected 10 K makes residual equal the 3 K allowance and remains clear.",
      "inputs": {
        "rht_vlv_cmd": 100.0,
        "rht_coil_entering_temp": 20.0,
        "rht_coil_leaving_temp": 27.0,
        "rht_delta_t_expected": 10.0,
        "fan_status": true
      },
      "expect": [
        {
          "output": "yBaselineOk",
          "from_s": 0,
          "to_s": 2100,
          "equals": true
        },
        {
          "output": "yFullHeatCommand",
          "from_s": 0,
          "to_s": 2100,
          "equals": true
        },
        {
          "output": "yTemperatureRiseLow",
          "from_s": 0,
          "to_s": 2100,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2100,
          "equals": false
        }
      ]
    },
    {
      "name": "both_just_beyond_thresholds",
      "description": "Command 90.1% and actual rise 6.9 K put both strict comparisons just beyond their boundaries.",
      "inputs": {
        "rht_vlv_cmd": 90.1,
        "rht_coil_entering_temp": 20.0,
        "rht_coil_leaving_temp": 26.9,
        "rht_delta_t_expected": 10.0,
        "fan_status": true
      },
      "expect": [
        {
          "output": "yBaselineOk",
          "from_s": 0,
          "to_s": 2100,
          "equals": true
        },
        {
          "output": "yFullHeatCommand",
          "from_s": 0,
          "to_s": 2100,
          "equals": true
        },
        {
          "output": "yTemperatureRiseLow",
          "from_s": 0,
          "to_s": 2100,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 840,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 960,
          "to_s": 2100,
          "equals": true
        }
      ]
    },
    {
      "name": "zero_actual_rise",
      "description": "Zero actual rise is a strong low-transfer signature under an otherwise valid full-heat baseline.",
      "inputs": {
        "rht_vlv_cmd": 100.0,
        "rht_coil_entering_temp": 20.0,
        "rht_coil_leaving_temp": 20.0,
        "rht_delta_t_expected": 10.0,
        "fan_status": true
      },
      "expect": [
        {
          "output": "yTemperatureRiseLow",
          "from_s": 0,
          "to_s": 2100,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 840,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 960,
          "to_s": 2100,
          "equals": true
        }
      ]
    },
    {
      "name": "negative_actual_rise",
      "description": "A negative measured rise also trips the one-sided low-transfer residual; sensor binding is among the diagnoses.",
      "inputs": {
        "rht_vlv_cmd": 100.0,
        "rht_coil_entering_temp": 20.0,
        "rht_coil_leaving_temp": 18.0,
        "rht_delta_t_expected": 10.0,
        "fan_status": true
      },
      "expect": [
        {
          "output": "yTemperatureRiseLow",
          "from_s": 0,
          "to_s": 2100,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 840,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 960,
          "to_s": 2100,
          "equals": true
        }
      ]
    },
    {
      "name": "fan_off_low_rise",
      "description": "The diagnostic remains visible, but no fan proof means the final verdict stays clear/NO_EVAL.",
      "inputs": {
        "rht_vlv_cmd": 100.0,
        "rht_coil_entering_temp": 20.0,
        "rht_coil_leaving_temp": 20.0,
        "rht_delta_t_expected": 10.0,
        "fan_status": false
      },
      "expect": [
        {
          "output": "yTemperatureRiseLow",
          "from_s": 0,
          "to_s": 2100,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2100,
          "equals": false
        }
      ]
    },
    {
      "name": "hot_water_unavailable_raw_signature",
      "description": "Full command with no hot-water availability produces the raw fault; the host must report NO_EVAL rather than diagnose coil degradation.",
      "inputs": {
        "rht_vlv_cmd": 100.0,
        "rht_coil_entering_temp": 20.0,
        "rht_coil_leaving_temp": 20.0,
        "rht_delta_t_expected": 10.0,
        "fan_status": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 840,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 960,
          "to_s": 2100,
          "equals": true
        }
      ]
    },
    {
      "name": "violation_ends_one_step_before_persistence",
      "description": "Low transfer clears at 840 s, one step before persistence, and never alarms.",
      "inputs": {
        "rht_vlv_cmd": 100.0,
        "rht_coil_entering_temp": 20.0,
        "rht_coil_leaving_temp": [
          {
            "t": 0,
            "value": 24.0
          },
          {
            "t": 840,
            "value": 30.0
          }
        ],
        "rht_delta_t_expected": 10.0,
        "fan_status": true
      },
      "expect": [
        {
          "output": "yTemperatureRiseLow",
          "from_s": 0,
          "to_s": 780,
          "equals": true
        },
        {
          "output": "yTemperatureRiseLow",
          "from_s": 840,
          "to_s": 2100,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2100,
          "equals": false
        }
      ]
    },
    {
      "name": "mature_fault_recovers",
      "description": "A mature degradation alarm clears when coil rise recovers to expected.",
      "inputs": {
        "rht_vlv_cmd": 100.0,
        "rht_coil_entering_temp": 20.0,
        "rht_coil_leaving_temp": [
          {
            "t": 0,
            "value": 24.0
          },
          {
            "t": 1200,
            "value": 30.0
          }
        ],
        "rht_delta_t_expected": 10.0,
        "fan_status": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 840,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 960,
          "to_s": 1140,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 1200,
          "to_s": 2100,
          "equals": false
        },
        {
          "output": "yTemperatureRiseLow",
          "from_s": 0,
          "to_s": 1140,
          "equals": true
        },
        {
          "output": "yTemperatureRiseLow",
          "from_s": 1200,
          "to_s": 2100,
          "equals": false
        }
      ]
    },
    {
      "name": "command_release_resets_timer",
      "description": "Valve command backs below full heat before persistence and resets the candidate.",
      "inputs": {
        "rht_vlv_cmd": [
          {
            "t": 0,
            "value": 100.0
          },
          {
            "t": 600,
            "value": 80.0
          }
        ],
        "rht_coil_entering_temp": 20.0,
        "rht_coil_leaving_temp": 24.0,
        "rht_delta_t_expected": 10.0,
        "fan_status": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2100,
          "equals": false
        },
        {
          "output": "yFullHeatCommand",
          "from_s": 0,
          "to_s": 540,
          "equals": true
        },
        {
          "output": "yFullHeatCommand",
          "from_s": 600,
          "to_s": 2100,
          "equals": false
        }
      ]
    },
    {
      "name": "two_short_low_transfer_episodes",
      "description": "Separated short degradation episodes cannot accumulate timer age.",
      "inputs": {
        "rht_vlv_cmd": 100.0,
        "rht_coil_entering_temp": 20.0,
        "rht_coil_leaving_temp": [
          {
            "t": 0,
            "value": 24.0
          },
          {
            "t": 360,
            "value": 30.0
          },
          {
            "t": 600,
            "value": 24.0
          },
          {
            "t": 960,
            "value": 30.0
          }
        ],
        "rht_delta_t_expected": 10.0,
        "fan_status": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2100,
          "equals": false
        }
      ]
    },
    {
      "name": "expected_baseline_change_resets_candidate",
      "description": "A valid expected-model change removes the residual and resets persistence.",
      "inputs": {
        "rht_vlv_cmd": 100.0,
        "rht_coil_entering_temp": 20.0,
        "rht_coil_leaving_temp": 26.9,
        "rht_delta_t_expected": [
          {
            "t": 0,
            "value": 10.0
          },
          {
            "t": 600,
            "value": 6.0
          }
        ],
        "fan_status": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2100,
          "equals": false
        },
        {
          "output": "yTemperatureRiseLow",
          "from_s": 0,
          "to_s": 540,
          "equals": true
        },
        {
          "output": "yTemperatureRiseLow",
          "from_s": 600,
          "to_s": 2100,
          "equals": false
        }
      ]
    }
  ]
}

HP Fault Rules

Heat pump fault detection rules (HP-*). Source grounding: HVAC FDD Reference v1.0 ch.11 (Heat Pumps — adapted authority; see each card’s Deviations section). Heat pumps extend the RTU fault set with defrost cycle monitoring and reversing-valve diagnostics; refrigerant undercharge is the most frequent fault (Barandier 2023).

Point dictionary: points/hp.points.json.

Index

IDNameSevMethodStatus
HP-0001COP degradation vs baseline3statisticalverified
HP-0002Defrost cycle anomaly3ruleverified
HP-0003Reversing valve fault2ruleverified
HP-0004Refrigerant undercharge (superheat/subcooling divergence)3ruleverified
HP-0005Refrigerant overcharge (subcooling high)3ruleverified
HP-0006Reversing-valve internal bypass leakage3ruleverified
HP-0007Heat-pump compressor proof-of-operation failure2ruleverified
HP-0008Auxiliary heat active above lockout with compressor running2ruleverified

Severity and method for HP-0001..006 follow the reference’s ch.11 cards (its §5.8.4 index carries no severity column); HP-0007 is a severity-2 library proof-of-operation adaptation and HP-0008 is a library excess-energy adaptation. HP-0001’s COP-vs-OAT baseline is host-fitted: the host runs the 14-day learning regression and writes the slope/intercept as rule parameters via set_param (R² > 0.6 precondition); the graph evaluates the fitted line — the library’s first host-learned baseline.

Relationships

  • HP-0001..008 share the heat-pump-faults playbook.
  • RTU-0001 (compressor short-cycling) applies to HP equipment per its reference card. HP-0007 now supplies the per-compressor command/proof pair; defrost, pump-down, OEM permissives, and restart logic remain explicit host obligations.
  • HP-0007 is related to every HP performance/refrigerant rule, but does not suppress them as a whole: fail-to-start can remove the running premise, while unexpected operation can leave their measurements meaningful.
  • HP-0008 evaluates concurrent compressor and independently proven auxiliary heat only against the configured site lockout. HP-0001/0002/0007 lead the capacity, defrost, and proof branches without automatically silencing it.

Refrigerant-side family (library-authored, batch 17)

HP-0004/HP-0005/HP-0006 are grounded in NIST SP 1087 (2008) via the adapt-tier program — the library’s first refrigerant-side rules, built on the suction/liquid-line temperatures and host-derived saturation temperatures (P-T lookup) landed in points/hp.points.json. All three replace the source’s conditions-regressed no-fault baseline with fixed commissioning placeholders (named simplification, RTU-0002 precedent). Wiring notes: a charge cluster (HP-0004 trigger → HP-0001 member) and HP-0001 suppressed_by: [HP-0004] are recorded candidates, left unwired pending a decision on cross-family suppression conventions.

HP-0001 — COP degradation vs baseline

Statusverified — engine e2ff2f8, cxf:fnv1a128:4e34c94f444594a4a9ccdce25c4eebef, 2026-08-17
Severity3
Methodstatistical
Phase2
CategoryEFFICIENCY_LOSS
ConfidenceMEDIUM
EstimationBASELINE_COMPARISON
G36
Clusters
Suppresses
Suppressed by
RelatedHP-0002, HP-0003, HP-0004, HP-0005, RTU-0002, HP-0007, HP-0008
Playbooksheat-pump-faults
SourceHVAC FDD Reference v1.0 §11, HP-0001; Barandier 2023; Barandier & Mendes 2024
Operating statesheating or cooling, evaluated separately — one rule instance per mode, each carrying that mode’s fitted line

Preconditions (host-enforced): The compressor must have run for min_runtime_for_eval (15 min) at its current capacity before the quotient means anything; a unit still pulling down after a start, or coming out of a defrost cycle, reads degraded on physics rather than on fault. The host owns the baseline: it runs the learning_period_days (14 d) regression of COP against oat for THIS mode, confirms R² > 0.6, and writes the result into cop_baseline_slope and cop_baseline_intercept with set_param. Until it has done so the rule is comparing against the shipped placeholders and means nothing (see Deviations). oat must also lie inside the range the line was fitted over — the graph extrapolates the line forever and says nothing about where the fit stops being physical. thermal_power is almost always a host-computed virtual point; its provenance is part of the R² precondition, not separate from it. Compressor evaluability is signalled in-rule by yPowerOk; when it is false the verdict is NO_EVAL, not healthy.

Points: thermal_power, elec_power, oat

Outputs:

  • yFault — True while the measured COP has stayed below cop_ratio_threshold × the fitted baseline for the current outdoor temperature, continuously for at least alarm_delay
  • yPowerOk — Evaluability signal — true when elec_power is above elec_power_min, the floor below which the COP quotient is meaningless; false means NO_EVAL and the host must ignore yFault

Parameters:

NameDefaultUnitCXF pathDescription
cop_baseline_slope0.081/°CoatSlope.kSlope of the host-fitted COP-vs-oat regression. PER-UNIT, PER-MODE SITE CONFIGURATION — the reference supplies a learned model, not a number, and the shipped 0.08 is a placeholder for a generic air-source heat pump in heating. Inherently signed; a cooling-mode fit is negative.
cop_baseline_intercept2.71expected.pIntercept of the same regression — the expected COP at oat = 0 °C. PER-UNIT, PER-MODE SITE CONFIGURATION on the same terms as the slope; the pair is only meaningful together.
cop_ratio_threshold0.851allowed.kFraction of the baseline COP the unit must stay above. 0.85 is the reference’s 15% degradation threshold written as a ratio: fault when measured < 0.85 × expected.
elec_power_min0.5kWpwrOk.tCompressor draw below which the COP quotient is not evaluated. Guards the division — at zero draw the quotient is NaN, and at a standby trickle it reads as total degradation. Retune to the smallest real compressor draw the unit produces at minimum capacity.
alarm_delay3600.0spersist.delayTimeContinuous degradation required before the alarm asserts (60 min)

Description

A heat pump has no COP it is supposed to hold — the same machine returns four units of heat per unit of electricity on a mild afternoon and two on a cold night, and neither number is a fault. What it has, per unit and per mode, is a line: COP against outdoor air temperature, fitted by the host over two weeks of normal operation. This rule evaluates that line, alarming when measured COP stays below 85% of what the line predicts at today’s oat. Everything statistical happens before the first tick. Barandier (2023) found refrigerant undercharge the most frequent heat pump fault, and it is what this rule sees best: COP falls across the whole operating range while no single reading looks wrong on its own.

Detection Logic

measured_cop = thermal_power / elec_power
expected_cop = cop_baseline_slope × oat + cop_baseline_intercept
allowed_cop  = cop_ratio_threshold × expected_cop

yPowerOk = elec_power > elec_power_min          (false ⇒ host reports NO_EVAL)
yFault   = measured_cop < allowed_cop AND yPowerOk,
           sustained continuously for alarm_delay

Block graph (rule.cxf.jsonld):

HP-0001 block graph

oatSlope and expected are the fitted line; allowed scales it by the tolerance, so the test is against a second line parallel to the first rather than against a number.

cop is the rule’s only division and its denominator goes to zero every time the compressor stops; pwrOk guards it. With both meters at zero the quotient is NaN; with a standby trickle and no heat output it is a clean, believable 0.0 — the more dangerous case, since nothing downstream would flag it. pwrOk drives both the boundary output yPowerOk and gate, so a unit below the floor holds yFault down and the host reads the silence as NO_EVAL, not healthy.

The comparison is strict, so a unit exactly on the allowed line reads healthy. persist requires 60 continuous minutes of shortfall — long enough to ride out a defrost cycle, a capacity step, or a load transient — and delayOnInit = true holds that window across a controller restart.

Possible Diagnoses

  1. Refrigerant undercharge — the most common heat pump fault (Barandier 2023); check subcooling and superheat at the service ports first
  2. Refrigerant overcharge, which degrades COP the same way and is less common
  3. Condenser or evaporator coil fouling — the pathology RTU-0002 detects from the air side; rule it out with a filter change before opening anything up
  4. Compressor degradation — worn valves or bearings raise amp draw against nameplate for the same delivered capacity
  5. Non-condensable gases in the refrigerant circuit, usually from a service procedure that skipped or shortened the evacuation

Energy Impact

EFFICIENCY_LOSS, MEDIUM confidence, BASELINE_COMPARISON. waste_kw = elec_power × (1 − measured_cop / expected_cop) — the share of the compressor’s draw that buys nothing. Range 5–25% of compressor energy (Barandier 2023), spanning a small charge loss at one end and a badly fouled coil or failing compressor at the other. MEDIUM for a structural reason: the baseline is the unit’s own recent behavior, so the rule measures degradation since the learning period — a heat pump commissioned undercharged learns an undercharged baseline and reads healthy forever.

Emissions Impact

Scope 2, PROXY_EMISSIONS, MEDIUM confidence; typically 300–2,500 kg CO₂e/yr for a commercial packaged heat pump. All of it is compressor electricity, so the avoided-emissions basis is the marginal operating emissions rate (MOER). Degradation costs most at the extremes of the outdoor temperature range, when the unit runs longest and the grid is dirtiest, so a charge correction is worth more than the annual average kWh figure suggests.

Deviations

  • cop_baseline_slope and cop_baseline_intercept ship as placeholders, not site values. The reference specifies a regression the host fits (14 days, R² > 0.6); the fitting lives in the host, the fitted line in the graph as two set_param targets. The shipped 0.08 /°C and 2.7 describe a generic air-source heat pump in heating so the card is runnable as delivered — a wrong pair fails silently: fit high and every hour alarms, fit low and nothing ever does.
  • A negative cop_baseline_slope is the documented exception to the library’s no-negative-parameters rule. A regression slope is inherently signed — positive for a heating fit, negative for a cooling fit — and one instance must accept either without being rewired; pushing the sign into the topology would mean two graphs for one fault.
  • The degradation test is a ratio, not the reference’s fraction. The reference writes (expected_cop − measured_cop) / expected_cop > 0.15; this computes measured_cop < 0.85 × expected_cop, the same predicate for any positive expected_cop and without a second division by a fitted line. Note the units: cop_ratio_threshold is the fraction retained (0.85), not the percentage lost — writing 15 into it makes the rule alarm permanently.
  • elec_power_min and yPowerOk are adopted, not transcribed. The reference names no power floor; its evaluability gate is min_runtime_for_eval, which the graph cannot see, and per SCHEMA.md a test computable from the rule’s own inputs belongs in the graph as a boundary output. The 0.5 kW default suits a small commercial packaged unit — raise it for a large compressor, lower it for a variable-speed unit that genuinely modulates below it.
  • min_runtime_for_eval (15 min) and learning_period_days (14 d) stay host preconditions. Both gate on things outside the graph’s view: time since a capacity transition, and an offline fitting run. The 60-minute alarm_delay covers post-start pull-down in steady operation but does not substitute — a unit that takes 20 minutes to settle spends a third of the window degraded.
  • The fitted line is extrapolated without limit. Nothing in the graph knows the temperature range the regression covered; far enough out a cooling-mode line goes negative, allowed with it, every positive measured COP clears and the rule goes quiet. No block expresses a domain guard, so it is a frontmatter precondition; a host can clamp oat with Reals.Limiter upstream.
  • Mode separation is instance-level. The reference evaluates heating and cooling separately and this rule has no mode input: one line, one alarm. A reversible unit runs two instances with two fitted pairs, and the host enables whichever matches the mode currently commanded.
  • Strict < at the allowance, where the playbook reads inclusively. The heat pump playbook’s step 1.a calls a 15% or greater drop below the baseline curve degradation, but CDL Reals has no LessEqual, so the strict form is the expressible one and a unit sitting exactly on the allowed line reads healthy. Both sides of the boundary are pinned; the disagreement is measure-zero.
  • method: statistical describes the baseline’s provenance, not the runtime. The graph performs one division, one multiply-add, one scale and one comparison. The coefficients come from a regression and the reference’s classification is recorded rather than relabelled; RTU-0002 carries the same note.
  • persist.delayOnInit = true (CDL default is false), the library’s standing choice: a unit already below its line at controller start waits out the full hour rather than alarming on the first tick.
  • Operating states and preconditions are declared in frontmatter for host enforcement rather than encoded in the block graph. Severity 3 and method: statistical are the reference’s chapter 11 card; its §5.8.4 index carries no severity column.
  • The reference publishes no test vectors, so every scenario in vectors.json is authored from the equation and replayed against the pinned engine rev.

Notes

Read yPowerOk before yFault. A repair and a compressor stop are indistinguishable in yFault alone — both drop the alarm — so a host that treats the falling edge as a fix will close this fault every time the unit finishes a cycle. The heat-pump-faults playbook orders the on-site work by prevalence and calls the fault resolved when COP returns to within 10% of the baseline, tighter than the 15% this rule alarms at. Do not re-fit the baseline while the fault is active: fitting from a degraded unit’s own history bakes the fault in as the new normal.

Test Vectors

13 scenarios, clock step 300 s over 9000 s.

ScenarioDescription
on_the_baseline_lineoat 10 °C puts the fitted baseline at 0.08 × 10 + 2.7 = 3.5. The unit delivers 7.0 kW thermal for 2.0 kW electrical, so measured COP is exactly 3.5 — no degradation at all, and the compressor is well above the evaluability floor.
degraded_at_mild_oatSame 10 °C day, same 2.0 kW draw, but only 5.6 kW of heat delivered: COP 2.8 against a 3.5 baseline is a 20% shortfall, past the 15% the reference allows. Both terms hold from t=0, so delayOnInit puts the alarm one alarm_delay in.
same_cop_is_healthy_on_a_cold_dayBaseline slope, low side: the identical COP 2.8 at oat −5 °C sits against a 2.3 baseline and is 22% ABOVE it. A heat pump is supposed to do worse when it is cold, and the fitted line is what says how much worse. Pairs with degraded_at_mild_oat — wire the slope backwards and one of the two flips.
same_cop_is_a_fault_on_a_warm_dayBaseline slope, high side: COP 3.5 was exactly on the line at 10 °C and is a fault at 20 °C, where the baseline has risen to 4.3 and the allowance to 3.655. Same two power readings, opposite verdicts, because the outdoor temperature moved.
cold_day_genuinely_degradedA real cold-weather fault: at oat −5 °C the baseline is 2.3 and the allowance 1.955, and the unit manages COP 1.8 (3.6 kW out of 2.0 kW in) — 22% below the line. Degradation is judged against the cold-weather expectation, not against a nameplate rating.
cop_exactly_at_the_allowanceThreshold edge: 5.95 kW over 2.0 kW is COP 2.975, exactly cop_ratio_threshold × expected (0.85 × 3.5). The comparison is a strict <, so a unit sitting precisely on the allowance reads healthy. Both operands are binary-exact here, so this is a true boundary pin and not a near miss.
cop_just_below_the_allowanceThreshold edge from the other side: 5.93 kW gives COP 2.965, one hundredth under the allowance, and the alarm lands after alarm_delay.
elec_power_exactly_at_the_floorEvaluability edge: the compressor draws exactly elec_power_min (0.5 kW) and delivers nothing, which reads as COP 0 — total degradation. The floor test is a strict >, so yPowerOk is false and yFault stays down: the host must report NO_EVAL, not a healthy heat pump.
elec_power_just_above_the_floorEvaluability edge from the other side: 0.51 kW clears the floor, so the same COP 0 is now a verdict the rule is willing to issue and the alarm matures. The two scenarios differ by 10 W of compressor draw and disagree completely — which is the point of publishing yPowerOk rather than burying the test.
compressor_off_divide_by_zeroThe case the floor exists for: the unit is off, both meters read 0.0, and the quotient is 0/0 = NaN. yPowerOk is false and yFault never asserts. Without the floor this rule would spend every idle hour reporting a catastrophically degraded heat pump.
transient_dip_never_alarmsThermal output drops to 5.6 kW for 1800 s — a defrost cycle, or a load step the capacity control has not caught up with — and recovers 1800 s short of alarm_delay. The timer resets on the recovery and nothing is reported.
alarm_clears_after_charge_restoredRecovery: a 20% shortfall alarms at 3600 s, the technician tops up the refrigerant charge at t = 5400 s (playbook step 2.1.1), and thermal output returns to 7.0 kW. yFault drops on that same tick — TrueDelay only delays the rising edge.
compressor_stop_forces_no_evalA matured fault is not reported as repaired when the compressor stops: at t = 5400 s both meters fall to standby, yPowerOk goes false and yFault drops with it. The distinction between this scenario and alarm_clears_after_charge_restored is invisible in yFault alone and is exactly what yPowerOk carries.
vectors.json
{
  "schema": "cxf-library/vectors/v1",
  "clock": {
    "step_s": 300,
    "horizon_s": 9000
  },
  "scenarios": [
    {
      "name": "on_the_baseline_line",
      "description": "oat 10 \u00b0C puts the fitted baseline at 0.08 \u00d7 10 + 2.7 = 3.5. The unit delivers 7.0 kW thermal for 2.0 kW electrical, so measured COP is exactly 3.5 \u2014 no degradation at all, and the compressor is well above the evaluability floor.",
      "inputs": {
        "oat": 10.0,
        "thermal_power": 7.0,
        "elec_power": 2.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 9000,
          "equals": false
        },
        {
          "output": "yPowerOk",
          "from_s": 0,
          "to_s": 9000,
          "equals": true
        }
      ]
    },
    {
      "name": "degraded_at_mild_oat",
      "description": "Same 10 \u00b0C day, same 2.0 kW draw, but only 5.6 kW of heat delivered: COP 2.8 against a 3.5 baseline is a 20% shortfall, past the 15% the reference allows. Both terms hold from t=0, so delayOnInit puts the alarm one alarm_delay in.",
      "inputs": {
        "oat": 10.0,
        "thermal_power": 5.6,
        "elec_power": 2.0
      },
      "expect": [
        {
          "output": "yPowerOk",
          "from_s": 0,
          "to_s": 9000,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3300,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 3900,
          "to_s": 9000,
          "equals": true
        }
      ]
    },
    {
      "name": "same_cop_is_healthy_on_a_cold_day",
      "description": "Baseline slope, low side: the identical COP 2.8 at oat \u22125 \u00b0C sits against a 2.3 baseline and is 22% ABOVE it. A heat pump is supposed to do worse when it is cold, and the fitted line is what says how much worse. Pairs with degraded_at_mild_oat \u2014 wire the slope backwards and one of the two flips.",
      "inputs": {
        "oat": -5.0,
        "thermal_power": 5.6,
        "elec_power": 2.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 9000,
          "equals": false
        }
      ]
    },
    {
      "name": "same_cop_is_a_fault_on_a_warm_day",
      "description": "Baseline slope, high side: COP 3.5 was exactly on the line at 10 \u00b0C and is a fault at 20 \u00b0C, where the baseline has risen to 4.3 and the allowance to 3.655. Same two power readings, opposite verdicts, because the outdoor temperature moved.",
      "inputs": {
        "oat": 20.0,
        "thermal_power": 7.0,
        "elec_power": 2.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3300,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 3900,
          "to_s": 9000,
          "equals": true
        }
      ]
    },
    {
      "name": "cold_day_genuinely_degraded",
      "description": "A real cold-weather fault: at oat \u22125 \u00b0C the baseline is 2.3 and the allowance 1.955, and the unit manages COP 1.8 (3.6 kW out of 2.0 kW in) \u2014 22% below the line. Degradation is judged against the cold-weather expectation, not against a nameplate rating.",
      "inputs": {
        "oat": -5.0,
        "thermal_power": 3.6,
        "elec_power": 2.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3300,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 3900,
          "to_s": 9000,
          "equals": true
        }
      ]
    },
    {
      "name": "cop_exactly_at_the_allowance",
      "description": "Threshold edge: 5.95 kW over 2.0 kW is COP 2.975, exactly cop_ratio_threshold \u00d7 expected (0.85 \u00d7 3.5). The comparison is a strict `<`, so a unit sitting precisely on the allowance reads healthy. Both operands are binary-exact here, so this is a true boundary pin and not a near miss.",
      "inputs": {
        "oat": 10.0,
        "thermal_power": 5.95,
        "elec_power": 2.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 9000,
          "equals": false
        }
      ]
    },
    {
      "name": "cop_just_below_the_allowance",
      "description": "Threshold edge from the other side: 5.93 kW gives COP 2.965, one hundredth under the allowance, and the alarm lands after alarm_delay.",
      "inputs": {
        "oat": 10.0,
        "thermal_power": 5.93,
        "elec_power": 2.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3300,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 3900,
          "to_s": 9000,
          "equals": true
        }
      ]
    },
    {
      "name": "elec_power_exactly_at_the_floor",
      "description": "Evaluability edge: the compressor draws exactly elec_power_min (0.5 kW) and delivers nothing, which reads as COP 0 \u2014 total degradation. The floor test is a strict `>`, so yPowerOk is false and yFault stays down: the host must report NO_EVAL, not a healthy heat pump.",
      "inputs": {
        "oat": 10.0,
        "thermal_power": 0.0,
        "elec_power": 0.5
      },
      "expect": [
        {
          "output": "yPowerOk",
          "from_s": 0,
          "to_s": 9000,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 9000,
          "equals": false
        }
      ]
    },
    {
      "name": "elec_power_just_above_the_floor",
      "description": "Evaluability edge from the other side: 0.51 kW clears the floor, so the same COP 0 is now a verdict the rule is willing to issue and the alarm matures. The two scenarios differ by 10 W of compressor draw and disagree completely \u2014 which is the point of publishing yPowerOk rather than burying the test.",
      "inputs": {
        "oat": 10.0,
        "thermal_power": 0.0,
        "elec_power": 0.51
      },
      "expect": [
        {
          "output": "yPowerOk",
          "from_s": 0,
          "to_s": 9000,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3300,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 3900,
          "to_s": 9000,
          "equals": true
        }
      ]
    },
    {
      "name": "compressor_off_divide_by_zero",
      "description": "The case the floor exists for: the unit is off, both meters read 0.0, and the quotient is 0/0 = NaN. yPowerOk is false and yFault never asserts. Without the floor this rule would spend every idle hour reporting a catastrophically degraded heat pump.",
      "inputs": {
        "oat": 10.0,
        "thermal_power": 0.0,
        "elec_power": 0.0
      },
      "expect": [
        {
          "output": "yPowerOk",
          "from_s": 0,
          "to_s": 9000,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 9000,
          "equals": false
        }
      ]
    },
    {
      "name": "transient_dip_never_alarms",
      "description": "Thermal output drops to 5.6 kW for 1800 s \u2014 a defrost cycle, or a load step the capacity control has not caught up with \u2014 and recovers 1800 s short of alarm_delay. The timer resets on the recovery and nothing is reported.",
      "inputs": {
        "oat": 10.0,
        "elec_power": 2.0,
        "thermal_power": [
          {
            "t": 0,
            "value": 7.0
          },
          {
            "t": 900,
            "value": 5.6
          },
          {
            "t": 2700,
            "value": 7.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 9000,
          "equals": false
        }
      ]
    },
    {
      "name": "alarm_clears_after_charge_restored",
      "description": "Recovery: a 20% shortfall alarms at 3600 s, the technician tops up the refrigerant charge at t = 5400 s (playbook step 2.1.1), and thermal output returns to 7.0 kW. yFault drops on that same tick \u2014 TrueDelay only delays the rising edge.",
      "inputs": {
        "oat": 10.0,
        "elec_power": 2.0,
        "thermal_power": [
          {
            "t": 0,
            "value": 5.6
          },
          {
            "t": 5400,
            "value": 7.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3300,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 3900,
          "to_s": 5100,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 5700,
          "to_s": 9000,
          "equals": false
        }
      ]
    },
    {
      "name": "compressor_stop_forces_no_eval",
      "description": "A matured fault is not reported as repaired when the compressor stops: at t = 5400 s both meters fall to standby, yPowerOk goes false and yFault drops with it. The distinction between this scenario and alarm_clears_after_charge_restored is invisible in yFault alone and is exactly what yPowerOk carries.",
      "inputs": {
        "oat": 10.0,
        "thermal_power": [
          {
            "t": 0,
            "value": 5.6
          },
          {
            "t": 5400,
            "value": 0.0
          }
        ],
        "elec_power": [
          {
            "t": 0,
            "value": 2.0
          },
          {
            "t": 5400,
            "value": 0.2
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3300,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 3900,
          "to_s": 5100,
          "equals": true
        },
        {
          "output": "yPowerOk",
          "from_s": 0,
          "to_s": 5100,
          "equals": true
        },
        {
          "output": "yPowerOk",
          "from_s": 5400,
          "to_s": 9000,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 5700,
          "to_s": 9000,
          "equals": false
        }
      ]
    }
  ]
}

HP-0002 — Defrost cycle anomaly

Statusverified — engine e2ff2f8, cxf:fnv1a128:1cb2669baf4a0c1ac7e2d345e6854370, 2026-08-17
Severity3
Methodrule
Phase2
CategoryEFFICIENCY_LOSS
ConfidenceMEDIUM
EstimationPROXY_ESTIMATION
G36
Clusters
Suppresses
Suppressed by
RelatedHP-0001, HP-0003, HP-0007, HP-0008
Playbooksheat-pump-faults
SourceHVAC FDD Reference v1.0 §11, HP-0002; HVAC FDD Reference v1.0, Remediation Playbooks pp. 169-170; Barandier 2023
Operating statesheating mode

Preconditions (host-enforced): The unit must be in heating mode with the compressor running — the reference’s operating state, and outside it defrost_status carries no information this rule can read. The host must report NO_EVAL for the first count_window (1 h) after engine start: while the moving average’s window fills, its divisor is elapsed time rather than the window, so the frequency branch reads an extrapolated rate instead of a completed-hour count, and alarm_delay (30 min) is too short to cover that hour on its own. The host tick interval must sit inside the band the count arithmetic and the edge counter jointly allow, and count_scale must be retuned with it (see Deviations). defrost_status must be the unit’s own defrost-active flag sampled faster than its shortest cycle; a defrost shorter than one tick is invisible to the counter.

Points: defrost_status, oat

Outputs:

  • yFault — True while at least one of the three defrost defects — too many cycles in the trailing hour, one cycle running past max_defrost_duration, or a cycle running at a mild outdoor temperature — has held continuously for alarm_delay

Parameters:

NameDefaultUnitCXF pathDescription
max_defrost_frequency4.01/hfreqHigh.tDefrost cycles per hour above which the cadence counts as excessive. Must stay strictly below the edge counter’s Nyquist ceiling of count_window / (2 × host tick) — 6/h at the default 3600 s window and 300 s tick
count_window3600.0srate.deltaTrailing window the defrost starts are counted over (1 h). It also fixes the units of max_defrost_frequency; a host that shortens it must retune count_scale with it and read the limit as cycles per window
count_scale12.01count.kRescales the moving average of the one-tick pulse train back into a cycle count: k = count_window / host tick interval in seconds. The default 12.0 is 3600/300, correct only at a 300 s tick; a host on a different tick MUST retune this or every count is wrong by the ratio of the two intervals
max_defrost_duration900.0stooLong.delayTimeContinuous defrost time above which one cycle counts as overlong (15 min)
defrost_unnecessary_temp7.0°CmildOat.tOutdoor temperature above which a coil should not need defrosting at all, so a cycle running here indicts the defrost sensor or board. Adopted from the reference’s remediation playbook, not from its tunables table
alarm_delay1800.0spersist.delayTimeContinuous fault persistence required before the alarm asserts (30 min)

Description

Defrost is a heat pump running backwards on purpose: the reversing valve swaps, the outdoor coil becomes a condenser, and for five to fifteen minutes the unit cools the outdoors with the heat it owed the building — usually with resistance heat propping up the supply air so nobody notices. The question is whether the unit is buying more of those minutes than the coil needs. There are three ways to buy too many and the reference tests all three: cycling too often, a cycle that will not terminate, and a cycle initiated where there is no ice to clear. They share a fault code because they share a service call — somebody opens the outdoor unit and looks at the coil, the defrost sensor, and the board. None of it is visible from the space, which is what makes this a metered-energy fault.

Detection Logic

starts   = rising edges of defrost_status
count    = MovingAverage(starts, count_window) × count_scale     cycles in the trailing hour

too_often = count > max_defrost_frequency
too_long  = defrost_status held continuously for max_defrost_duration
needless  = defrost_status AND oat > defrost_unnecessary_temp

yFault    = (too_often OR too_long OR needless) sustained continuously for alarm_delay

Block graph (rule.cxf.jsonld):

HP-0002 block graph

The frequency branch is AHU-0004’s counter idiom applied to a boolean point: dfStart reduces each cycle to a one-tick pulse and rate, a continuous-time mean of u·dt over the window, returns n · dt / count_window, which count_scale = count_window / dt = 3600/300 = 12 turns back into n. The count is therefore tied to the host’s tick interval in two ways a deployer must honour: count_scale retunes with the tick, and because the pulses are edges the fastest observable cadence is count_window / (2 × dt) — 6/h at the defaults, inside a legal tick band of 57.1–450 s (see Deviations).

tooLong needs no counting: a TrueDelay on defrost_status matures when one cycle has run continuously for max_defrost_duration and falls the instant the cycle ends — inclusive at its boundary, where the two threshold comparisons are strict. mildOat and needless are the third branch, and the conjunction matters: a mild outdoor temperature is not a fault and a defrost cycle is not a fault; a defrost cycle at a mild outdoor temperature is. persist applies the reference’s single 30-minute delay once, after the Or tree, so the branches accumulate rather than each having to persist alone.

Possible Diagnoses

  1. Outdoor coil heavily fouled or iced — the coil genuinely needs the cycles it is taking, and the fix is cleaning rather than controls
  2. Defrost sensor failure — the one cause that can produce all three branches, since it both initiates cycles that are not needed and fails to terminate the ones that are
  3. Defrost control board malfunction, including a time-initiated defrost timer left at a setting the manufacturer no longer recommends
  4. Refrigerant charge issue — low charge lowers coil temperature and brings on frost that would not otherwise form, showing here as cadence rather than as capacity (HP-0001 is the rule that sees it as capacity)

Energy Impact

EFFICIENCY_LOSS, MEDIUM confidence, PROXY_ESTIMATION. Every defrost cycle costs twice — the heat pulled back out of the building, plus the supplementary heat brought on to cover the gap — and the reference puts the excess at 3–10% of heating energy. waste_kw ≈ defrost_fraction × hp_heating_kw scales the unit’s heating draw by the share of hours spent in unnecessary defrost. PROXY, and MEDIUM, for the same reason: the cycle counts are solid but the share of them that was unnecessary is an inference, and a coil that is genuinely icing is being correctly served by cycles this rule flags. Heating-dominant, worst in the humid part of the season around 0–5 °C where frost forms fastest.

Emissions Impact

Scope 2, PROXY_EMISSIONS, MEDIUM confidence; typically 200–1,500 kg CO₂e/yr from excess defrost energy. All electric — compressor plus whatever supplementary heat covers the cycle — so the avoided-emissions basis is the marginal operating emissions rate (MOER). Cold mornings are both when the fault costs most and when the grid is dirtiest, which pushes the marginal figure above the average one.

Deviations

  • comp_status is dropped from the points list. The reference’s Required Points row lists it but its equation never uses it — all three branches read defrost_status and oat. Compressor state is what makes the operating state (heating mode) true, and operating states are declared in frontmatter here rather than binding a point the graph ignores. Precedent: VAV-0001 drops zone_airflow, AHU-0029 drops oat.
  • defrost_unnecessary_temp has no default in the reference’s tunables table — the card names it in the equation and then lists only the other three. The 7.0 °C shipped here comes from the same document’s remediation playbooks (step 1.b: defrost should not be needed above 7 °C / 45 °F), the nearest in-document authority and the number a technician would verify by hand.
  • The rolling count is built from a moving average, because the block set has no windowed counter. Integers.OnCounter counts monotonically from a reset, so “cycles in the trailing hour” would need a host-driven hourly reset — a tumbling window whose verdict depends on where the boundary fell. AHU-0004 established the idiom; Logical.Edge replaces Integers.Change because the counted signal is boolean.
  • count_scale is coupled to the host’s tick interval: k = count_window / dt, and the default 12.0 is correct only at the 300 s tick the vectors use. A host ticking every 60 s must set 60.0; leaving it at 12.0 reports a fifth of the true cadence and the branch never fires. The failure is silent — a mis-set scale still produces a plausible number — so it belongs on any deployment checklist.
  • The tick interval has a legal band, and its ceiling is half what a level counter would give. A rising edge needs one false sample between two true ones, so the most cycles observable in a window is count_window / (2 × dt) — 6/h at the defaults, and max_defrost_frequency must sit strictly below it (the shipped 4/h needs dt < 450 s). The floor is Reals.MovingAverage’s fixed 64-checkpoint ring: dt ≥ count_window / 63 ≈ 57.1 s.
  • Sampling floor on the point itself. A defrost cycle shorter than one tick is invisible to the edge counter — the point may never be sampled true. The point dictionary flags this on defrost_status; at a 300 s tick against a 5–10 minute cycle the margin is thin, and a host that can afford a 60 s tick should take it (and set count_scale = 60.0).
  • The first hour reads as a rate, not a count, and alarm_delay does not cover it. While t < count_window the moving average divides by elapsed time rather than by the window, so the first defrost start of a run reads as 12/h — the pace, extrapolated. AHU-0004’s alarm_delay equals its count_window, so delayOnInit blocks any verdict until the window fills; here it is 1800 s against 3600 s and does not, which is why the frontmatter requires the host to report NO_EVAL for the first hour.
  • The duration branch is inclusive at its boundary, where the reference is strict. The reference writes defrost_duration > max_defrost_duration, but Logical.TrueDelay asserts when accumulated true-time reaches delayTime, so the realized test is . Observable resolution is one tick either way, and the timer restarts from the next rising edge rather than resuming.
  • Strict > on the other two thresholds. CDL Reals has no GreaterEqual, so exactly 4 cycles an hour reads clear and 5 alarms, and a cycle at exactly 7.0 °C does not count as needless. The frequency boundary is unambiguous rather than measure-zero — the recovered count takes integer values in steady state — while the temperature boundary is measure-zero on a real-valued signal.
  • The third branch cannot reach a verdict on its own at the reference’s defaults, and that is arithmetic rather than a wiring choice. needless is true only while a cycle runs, the single AlarmDelay is 30 minutes, and a normal defrost is 5–15, so a cycle long enough to fill the persistence window has already tripped the duration branch. What the branch contributes is timing — it starts the clock at the beginning of a mild-weather cycle, landing the alarm 900 s sooner — which is why repeated mild-weather defrosting is reported through the frequency branch instead. Calling unnecessary defrost on its own evidence needs alarm_delay below a typical cycle length, at the cost of twitchier other branches.
  • One AlarmDelay on the disjunction, not three. The reference states a single 30-minute delay for the card and this rule applies it once, after the Or tree, so a cycle that is both overlong and needless does not need each branch to persist separately — only their union.
  • Logical.Edge’s pre_u_start is left at the CDL default (false), since the CXF contract sets only non-default parameters. The consequence is a spurious rising edge if the unit is already in defrost at controller start, and it costs nothing: the moving average integrates u·dt and dt is zero on the first tick, so that pulse encloses no area and never reaches the count.
  • delayOnInit = true on both TrueDelay instances (CDL default is false), the library’s standing choice. On persist a unit already faulted at load waits out the full 30 minutes; on tooLong a defrost already running is timed from the controller start rather than assumed to have run forever.
  • Operating state (heating mode) is declared in frontmatter for host enforcement rather than encoded in the block graph. Severity 3 and method: rule are the reference’s chapter 11 card; its §5.8.4 index carries no severity column.
  • The reference publishes no test vectors. Every scenario in vectors.json is authored from the equation, and every assertion edge was derived by replaying the graph at the pinned engine rev rather than by closed-form arithmetic — the moving average’s warm-up trajectory does not match hand-computed statistics.

Notes

The three branches point at different service work even though they share a playbook. Cadence with normal-length cycles is a coil or a charge problem; a cycle that will not terminate is the defrost sensor or the board, and the branch most likely to be a single failed part; cycles at mild outdoor temperatures are almost always the sensor reading low. Step 2.b of the heat-pump-faults playbook works that order, and its resolution test is deliberately stricter than this alarm — under 4 cycles/h and under 10 minutes each over 48 h, so a unit at 4 cycles an hour of 14 minutes apiece clears every branch here while spending a fifth of its heating hours in defrost. Check HP-0001 on the same unit before ordering a coil cleaning: low charge frosts a coil sooner, and if both rules fire the charge is the more likely root cause and the cheaper one to verify.

Test Vectors

13 scenarios, clock step 300 s over 12000 s.

ScenarioDescription
no_defrost_activityA cold day with the defrost sequence idle: no cycles, no duration, no mild-weather trigger. The rolling count sits at zero and all three branches stay down.
normal_defrost_cadenceHealthy winter operation: one 5-minute defrost every 30 minutes, so 2 cycles per hour against a limit of 4. The count reads 12/hr on the first pulse — the moving average divides by elapsed time until its window fills — but that artifact collapses within two ticks and never survives the 30-minute persistence.
four_cycles_per_hour_is_exactly_the_thresholdThreshold edge: the unit settles into a defrost every 15 minutes — exactly max_defrost_frequency — starting after the counting window has filled, so the reading is a completed-hour count rather than an extrapolated rate. count holds at precisely 4.0 and the strict > keeps it clear.
five_cycles_per_hour_is_the_first_faulted_cadenceThreshold edge from the other side, and the finest step a 300 s tick can resolve: a repeating 600/600/600/900/900 s spacing puts exactly 5 rising edges in every trailing hour. The count clears 4 at t = 6600 s and the alarm follows one alarm_delay later.
excessive_defrost_frequencyA coil icing up faster than it can be cleared: defrost every 10 minutes, 6 cycles per hour, beginning after the counting window has filled. The count crosses 4 at t = 6300 s — the fifth cycle of the run — and the alarm lands 1800 s after that.
defrost_overlongOne defrost cycle that never terminates. tooLong matures at 900 s and the alarm 1800 s after that, at t = 2700 s. The frequency branch contributes nothing: the single rising edge lands on tick 0, where the moving average’s integrand encloses no area.
defrost_released_on_the_maturity_tickDuration edge: the first cycle is observed true at t = 0, 300 and 600 s and false at 900 s — the tick the duration timer would have matured on — so it does not trip the branch. Defrost restarts at 1200 s and runs on, which matures tooLong at 2100 s and the alarm at 3900 s: 1200 s later than the uninterrupted case.
defrost_released_on_the_alarm_tickPersistence edge: a single defrost runs from t = 0 and ends at t = 2700 s, the exact tick the alarm would assert on. The input is already false when persist is evaluated, so nothing is ever reported — a 45-minute defrost that this rule declines to call.
defrost_released_one_tick_laterPersistence edge from the other side: the same defrost held 300 s longer asserts at t = 2700 s and drops again at 3000 s when the cycle ends. One tick of difference in the input is one tick of alarm.
oat_exactly_at_the_mild_thresholdMild-weather edge: a continuous defrost at exactly defrost_unnecessary_temp (7.0 °C). The comparison is a strict >, so the third branch stays down and the alarm arrives on the duration branch’s schedule at t = 2700 s.
mild_oat_advances_the_alarmMild-weather edge from the other side: one tenth of a degree warmer and the third branch is live from t = 0, so the alarm matures at 1800 s instead of 2700 s. Defrosting at 7.1 °C means the defrost sensor or board is calling for a cycle the coil cannot need.
mild_oat_short_cycles_never_persistThe third branch’s practical limit: 5-minute defrosts at 10 °C, twice an hour. Every cycle is needless and the branch says so, but it is true for one tick at a time and the 30-minute persistence never fills. On its own, branch 3 only reports a defrost that outlasts alarm_delay — see the card’s Deviations.
alarm_clears_when_defrost_endsRecovery: a stuck defrost alarms at 2700 s and the cycle finally terminates at 5400 s (the defrost sensor was replaced, or the board was reset). Both timers fall immediately and the alarm clears on that tick.
vectors.json
{
  "schema": "cxf-library/vectors/v1",
  "clock": {
    "step_s": 300,
    "horizon_s": 12000
  },
  "scenarios": [
    {
      "name": "no_defrost_activity",
      "description": "A cold day with the defrost sequence idle: no cycles, no duration, no mild-weather trigger. The rolling count sits at zero and all three branches stay down.",
      "inputs": {
        "oat": -5.0,
        "defrost_status": false
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 12000,
          "equals": false
        }
      ]
    },
    {
      "name": "normal_defrost_cadence",
      "description": "Healthy winter operation: one 5-minute defrost every 30 minutes, so 2 cycles per hour against a limit of 4. The count reads 12/hr on the first pulse \u2014 the moving average divides by elapsed time until its window fills \u2014 but that artifact collapses within two ticks and never survives the 30-minute persistence.",
      "inputs": {
        "oat": -5.0,
        "defrost_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 300,
            "value": true
          },
          {
            "t": 600,
            "value": false
          },
          {
            "t": 2100,
            "value": true
          },
          {
            "t": 2400,
            "value": false
          },
          {
            "t": 3900,
            "value": true
          },
          {
            "t": 4200,
            "value": false
          },
          {
            "t": 5700,
            "value": true
          },
          {
            "t": 6000,
            "value": false
          },
          {
            "t": 7500,
            "value": true
          },
          {
            "t": 7800,
            "value": false
          },
          {
            "t": 9300,
            "value": true
          },
          {
            "t": 9600,
            "value": false
          },
          {
            "t": 11100,
            "value": true
          },
          {
            "t": 11400,
            "value": false
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 12000,
          "equals": false
        }
      ]
    },
    {
      "name": "four_cycles_per_hour_is_exactly_the_threshold",
      "description": "Threshold edge: the unit settles into a defrost every 15 minutes \u2014 exactly max_defrost_frequency \u2014 starting after the counting window has filled, so the reading is a completed-hour count rather than an extrapolated rate. `count` holds at precisely 4.0 and the strict `>` keeps it clear.",
      "inputs": {
        "oat": -5.0,
        "defrost_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 3900,
            "value": true
          },
          {
            "t": 4200,
            "value": false
          },
          {
            "t": 4800,
            "value": true
          },
          {
            "t": 5100,
            "value": false
          },
          {
            "t": 5700,
            "value": true
          },
          {
            "t": 6000,
            "value": false
          },
          {
            "t": 6600,
            "value": true
          },
          {
            "t": 6900,
            "value": false
          },
          {
            "t": 7500,
            "value": true
          },
          {
            "t": 7800,
            "value": false
          },
          {
            "t": 8400,
            "value": true
          },
          {
            "t": 8700,
            "value": false
          },
          {
            "t": 9300,
            "value": true
          },
          {
            "t": 9600,
            "value": false
          },
          {
            "t": 10200,
            "value": true
          },
          {
            "t": 10500,
            "value": false
          },
          {
            "t": 11100,
            "value": true
          },
          {
            "t": 11400,
            "value": false
          },
          {
            "t": 12000,
            "value": true
          },
          {
            "t": 12300,
            "value": false
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 12000,
          "equals": false
        }
      ]
    },
    {
      "name": "five_cycles_per_hour_is_the_first_faulted_cadence",
      "description": "Threshold edge from the other side, and the finest step a 300 s tick can resolve: a repeating 600/600/600/900/900 s spacing puts exactly 5 rising edges in every trailing hour. The count clears 4 at t = 6600 s and the alarm follows one alarm_delay later.",
      "inputs": {
        "oat": -5.0,
        "defrost_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 3900,
            "value": true
          },
          {
            "t": 4200,
            "value": false
          },
          {
            "t": 4500,
            "value": true
          },
          {
            "t": 4800,
            "value": false
          },
          {
            "t": 5100,
            "value": true
          },
          {
            "t": 5400,
            "value": false
          },
          {
            "t": 5700,
            "value": true
          },
          {
            "t": 6000,
            "value": false
          },
          {
            "t": 6600,
            "value": true
          },
          {
            "t": 6900,
            "value": false
          },
          {
            "t": 7500,
            "value": true
          },
          {
            "t": 7800,
            "value": false
          },
          {
            "t": 8100,
            "value": true
          },
          {
            "t": 8400,
            "value": false
          },
          {
            "t": 8700,
            "value": true
          },
          {
            "t": 9000,
            "value": false
          },
          {
            "t": 9300,
            "value": true
          },
          {
            "t": 9600,
            "value": false
          },
          {
            "t": 10200,
            "value": true
          },
          {
            "t": 10500,
            "value": false
          },
          {
            "t": 11100,
            "value": true
          },
          {
            "t": 11400,
            "value": false
          },
          {
            "t": 11700,
            "value": true
          },
          {
            "t": 12000,
            "value": false
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 8100,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 8700,
          "to_s": 12000,
          "equals": true
        }
      ]
    },
    {
      "name": "excessive_defrost_frequency",
      "description": "A coil icing up faster than it can be cleared: defrost every 10 minutes, 6 cycles per hour, beginning after the counting window has filled. The count crosses 4 at t = 6300 s \u2014 the fifth cycle of the run \u2014 and the alarm lands 1800 s after that.",
      "inputs": {
        "oat": -5.0,
        "defrost_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 3900,
            "value": true
          },
          {
            "t": 4200,
            "value": false
          },
          {
            "t": 4500,
            "value": true
          },
          {
            "t": 4800,
            "value": false
          },
          {
            "t": 5100,
            "value": true
          },
          {
            "t": 5400,
            "value": false
          },
          {
            "t": 5700,
            "value": true
          },
          {
            "t": 6000,
            "value": false
          },
          {
            "t": 6300,
            "value": true
          },
          {
            "t": 6600,
            "value": false
          },
          {
            "t": 6900,
            "value": true
          },
          {
            "t": 7200,
            "value": false
          },
          {
            "t": 7500,
            "value": true
          },
          {
            "t": 7800,
            "value": false
          },
          {
            "t": 8100,
            "value": true
          },
          {
            "t": 8400,
            "value": false
          },
          {
            "t": 8700,
            "value": true
          },
          {
            "t": 9000,
            "value": false
          },
          {
            "t": 9300,
            "value": true
          },
          {
            "t": 9600,
            "value": false
          },
          {
            "t": 9900,
            "value": true
          },
          {
            "t": 10200,
            "value": false
          },
          {
            "t": 10500,
            "value": true
          },
          {
            "t": 10800,
            "value": false
          },
          {
            "t": 11100,
            "value": true
          },
          {
            "t": 11400,
            "value": false
          },
          {
            "t": 11700,
            "value": true
          },
          {
            "t": 12000,
            "value": false
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7800,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 8400,
          "to_s": 12000,
          "equals": true
        }
      ]
    },
    {
      "name": "defrost_overlong",
      "description": "One defrost cycle that never terminates. `tooLong` matures at 900 s and the alarm 1800 s after that, at t = 2700 s. The frequency branch contributes nothing: the single rising edge lands on tick 0, where the moving average's integrand encloses no area.",
      "inputs": {
        "oat": -5.0,
        "defrost_status": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 3000,
          "to_s": 12000,
          "equals": true
        }
      ]
    },
    {
      "name": "defrost_released_on_the_maturity_tick",
      "description": "Duration edge: the first cycle is observed true at t = 0, 300 and 600 s and false at 900 s \u2014 the tick the duration timer would have matured on \u2014 so it does not trip the branch. Defrost restarts at 1200 s and runs on, which matures `tooLong` at 2100 s and the alarm at 3900 s: 1200 s later than the uninterrupted case.",
      "inputs": {
        "oat": -5.0,
        "defrost_status": [
          {
            "t": 0,
            "value": true
          },
          {
            "t": 900,
            "value": false
          },
          {
            "t": 1200,
            "value": true
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 4200,
          "to_s": 12000,
          "equals": true
        }
      ]
    },
    {
      "name": "defrost_released_on_the_alarm_tick",
      "description": "Persistence edge: a single defrost runs from t = 0 and ends at t = 2700 s, the exact tick the alarm would assert on. The input is already false when persist is evaluated, so nothing is ever reported \u2014 a 45-minute defrost that this rule declines to call.",
      "inputs": {
        "oat": -5.0,
        "defrost_status": [
          {
            "t": 0,
            "value": true
          },
          {
            "t": 2700,
            "value": false
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 12000,
          "equals": false
        }
      ]
    },
    {
      "name": "defrost_released_one_tick_later",
      "description": "Persistence edge from the other side: the same defrost held 300 s longer asserts at t = 2700 s and drops again at 3000 s when the cycle ends. One tick of difference in the input is one tick of alarm.",
      "inputs": {
        "oat": -5.0,
        "defrost_status": [
          {
            "t": 0,
            "value": true
          },
          {
            "t": 3000,
            "value": false
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2700,
          "to_s": 2700,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 3000,
          "to_s": 12000,
          "equals": false
        }
      ]
    },
    {
      "name": "oat_exactly_at_the_mild_threshold",
      "description": "Mild-weather edge: a continuous defrost at exactly defrost_unnecessary_temp (7.0 \u00b0C). The comparison is a strict `>`, so the third branch stays down and the alarm arrives on the duration branch's schedule at t = 2700 s.",
      "inputs": {
        "oat": 7.0,
        "defrost_status": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 3000,
          "to_s": 12000,
          "equals": true
        }
      ]
    },
    {
      "name": "mild_oat_advances_the_alarm",
      "description": "Mild-weather edge from the other side: one tenth of a degree warmer and the third branch is live from t = 0, so the alarm matures at 1800 s instead of 2700 s. Defrosting at 7.1 \u00b0C means the defrost sensor or board is calling for a cycle the coil cannot need.",
      "inputs": {
        "oat": 7.1,
        "defrost_status": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1500,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2100,
          "to_s": 12000,
          "equals": true
        }
      ]
    },
    {
      "name": "mild_oat_short_cycles_never_persist",
      "description": "The third branch's practical limit: 5-minute defrosts at 10 \u00b0C, twice an hour. Every cycle is needless and the branch says so, but it is true for one tick at a time and the 30-minute persistence never fills. On its own, branch 3 only reports a defrost that outlasts alarm_delay \u2014 see the card's Deviations.",
      "inputs": {
        "oat": 10.0,
        "defrost_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 300,
            "value": true
          },
          {
            "t": 600,
            "value": false
          },
          {
            "t": 2100,
            "value": true
          },
          {
            "t": 2400,
            "value": false
          },
          {
            "t": 3900,
            "value": true
          },
          {
            "t": 4200,
            "value": false
          },
          {
            "t": 5700,
            "value": true
          },
          {
            "t": 6000,
            "value": false
          },
          {
            "t": 7500,
            "value": true
          },
          {
            "t": 7800,
            "value": false
          },
          {
            "t": 9300,
            "value": true
          },
          {
            "t": 9600,
            "value": false
          },
          {
            "t": 11100,
            "value": true
          },
          {
            "t": 11400,
            "value": false
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 12000,
          "equals": false
        }
      ]
    },
    {
      "name": "alarm_clears_when_defrost_ends",
      "description": "Recovery: a stuck defrost alarms at 2700 s and the cycle finally terminates at 5400 s (the defrost sensor was replaced, or the board was reset). Both timers fall immediately and the alarm clears on that tick.",
      "inputs": {
        "oat": -5.0,
        "defrost_status": [
          {
            "t": 0,
            "value": true
          },
          {
            "t": 5400,
            "value": false
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 3000,
          "to_s": 5100,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 5400,
          "to_s": 12000,
          "equals": false
        }
      ]
    }
  ]
}

HP-0003 — Reversing valve fault

Statusverified — engine e2ff2f8, cxf:fnv1a128:cca57c9ed8973afd8c50972d6e595dda, 2026-08-17
Severity2
Methodrule
Phase2
CategoryCRITICAL_WASTE
ConfidenceMEDIUM
EstimationPROXY_ESTIMATION
G36
Clusters
Suppresses
Suppressed by
RelatedHP-0001, HP-0002, HP-0006, HP-0007
Playbooksheat-pump-faults
SourceHVAC FDD Reference v1.0 §11, HP-0003; Barandier & Mendes 2024
Operating statesheating and cooling, compressor running (host-gated)

Preconditions (host-enforced): The compressor must be running. A heat pump idling on its indoor fan drifts its discharge toward room temperature, which reads as too warm for cooling and too cold for heating at the same time, so an unrunning unit must not be evaluated. The host must map its own mode enum onto heating_mode_code and cooling_mode_code and must report NO_EVAL — not healthy — for every other mode it can command (off, auto, emergency heat, dehumidify): the graph is structurally silent on codes it does not carry. Defrost is the sharp case: during a defrost cycle the unit deliberately runs the reversing valve in cooling while mode_command still reads HEATING, so the host should gate this rule on defrost_status. The rule survives a normal defrost only because alarm_delay outlasts it — see Deviations. sat must be trustworthy; nothing in this rule cross-checks it, and a discharge sensor reading 15 °C low fabricates a heating-mode fault on a healthy unit.

Points: mode_command, sat

Outputs:

  • yFault — True while the discharge temperature has contradicted a settled mode command continuously for at least alarm_delay

Parameters:

NameDefaultUnitCXF pathDescription
heating_mode_code11kHeat.kValue of mode_command meaning HEATING
cooling_mode_code21kCool.kValue of mode_command meaning COOLING
sat_cooling_max18.0°CsatHigh.tDischarge temperature above which the unit is not cooling, judged after the mode has settled. ADOPTED — the reference states no default (see Deviations)
sat_heating_min28.0°CsatLow.tDischarge temperature below which the unit is not heating, judged after the mode has settled. ADOPTED — the reference states no default (see Deviations)
mode_switch_settle_time600.0sheatSettled.delayTime, coolSettled.delayTimeHow long a mode command must stand before the discharge temperature is judged against it (10 min); binds both selector timers
alarm_delay900.0spersist.delayTimeContinuous contradiction required before the alarm asserts (15 min)

Description

A heat pump is one refrigeration circuit run in either direction, and the reversing valve chooses the direction. When the valve does not shift, the unit keeps doing what it was doing last: cooling a building that asked for heat, or heating one that asked to be cooled. Nothing looks broken — the compressor runs, the fans run, the controller reports the mode it commanded — and the zone thermostat responds by asking for more of what it is already not getting. That feedback loop is why this is severity 2. The unit is not merely inefficient, it is adding load in the wrong direction, so whatever else serves the space pays to undo the work; the reference’s estimator hp_capacity_kw × (1 + 1/COP) is exactly that accounting.

Detection Logic

in_heating = (mode_command = heating_mode_code) held for mode_switch_settle_time
in_cooling = (mode_command = cooling_mode_code) held for mode_switch_settle_time

heat_bad   = in_heating AND sat < sat_heating_min
cool_bad   = in_cooling AND sat > sat_cooling_max

yFault     = (heat_bad OR cool_bad) sustained continuously for alarm_delay

Block graph (rule.cxf.jsonld):

HP-0003 block graph

kHeat and kCool decode mode_command through two Integers.Equal selectors, and each selector passes through its own TrueDelay before it is allowed to judge anything. That settle delay is the whole reason the rule can be this simple: for ten minutes after a changeover the duct is still full of air from the previous mode, and a unit that switched correctly looks exactly like a unit that did not.

The two temperature tests are absolute limits rather than a comparison against return air, because rat is not among the points the reference gives this rule. satHigh and satLow are strict, so a discharge sitting exactly at 18.0 °C in cooling or 28.0 °C in heating reads healthy. Between the limits is a deliberate dead band: lukewarm air — 22 °C in either mode — satisfies neither test, so a partially shifting valve, or a unit so short of charge it barely moves heat either way, lives there and is HP-0001’s to find.

anyBad ORs the two branches so one output covers both directions, and persist requires 15 continuous minutes. The chain matters more than either delay alone: from a cold start in a bad mode the alarm lands at 1500 s, so nothing shorter than 25 minutes of continuous wrong-direction operation raises this fault — which is what keeps normal defrost cycles out of it, barely (see Deviations).

Possible Diagnoses

  1. Reversing valve stuck or failed — usually mechanically seized; replacement runs $500–$2,000 plus refrigerant recovery
  2. Reversing valve solenoid failure — the cheap end ($100–$300), and the one the playbook’s click test isolates in a minute
  3. Wiring issue between the controller and the solenoid: the valve is fine and never got the signal
  4. Refrigerant charge too low for the valve to shift — the valve needs a pressure differential to move, so check charge before condemning the valve
  5. Sequencing error upstream of the valve — the unit faithfully executing a mode the building did not want, usually a stuck mode_command in the BAS priority array

Energy Impact

CRITICAL_WASTE, MEDIUM confidence, PROXY_ESTIMATION. The reference gives 20–50% of mode energy and the estimator waste_kw = hp_capacity_kw × (1 + 1/COP), counting both the misdirected thermal output and the electricity that produced it. PROXY because the rule sees a command and a temperature, not capacity or power — hp_capacity_kw and COP come from the nameplate or from HP-0001’s fitted baseline. MEDIUM because the fault is unambiguous once detected but its cost depends on how long it ran and what else was compensating. Both climates: a heat pump has two ways to be in the wrong mode and this rule watches both.

Emissions Impact

Scope 2, PROXY_EMISSIONS, MEDIUM confidence; typically 1,000–6,000 kg CO₂e/yr while a unit runs in the wrong mode — the largest single-fault range in the heat pump chapter. An all-electric heat pump puts the whole impact in scope 2, and the avoided-emissions basis is the marginal operating emissions rate (MOER): a valve stuck in heating on a hot afternoon draws its worst power at the hour the grid is dirtiest.

Deviations

  • sat_cooling_max and sat_heating_min are adopted, not transcribed. The reference’s equation names both, but its tunables table lists only mode_switch_settle_time and AlarmDelay. The playbook states the test relative to return air, which this card’s point list does not carry, so fixed limits stand in: 18.0 °C is above any plausible cooling discharge (10–14 °C) and below occupied space temperature, 28.0 °C is below any plausible heating discharge (30–45 °C) and above room temperature. Both are judgment calls and both are wrong for low-lift and inverter-driven units at part load, which produce discharge temperatures near room temperature — retune or accept silence. The dead band between the limits is the honest cost of not having rat.
  • The reference’s “mode_command changed AND after mode_switch_settle_time” becomes a dwell test, not an edge test. The block graph has no “time since last change” quantity, so the condition is expressed as the selector having been continuously true for the settle time. The two agree where it matters — any change flips at least one selector and restarts its timer — and the dwell form is stronger at boot, where delayOnInit = true makes a unit already sitting in a mode wait out the settle rather than be judged on its first tick.
  • A normal defrost cycle is ridden out by alarm_delay, and only just. During defrost the unit reverses into cooling while mode_command still reads HEATING, so heatSettled is already mature and satLow goes true as soon as the discharge cools; only the 15-minute persist stands between a defrost and a false alarm, and HP-0002 puts max_defrost_duration at exactly 15 minutes. Hosts should gate on defrost_status or raise alarm_delay above their unit’s longest legitimate defrost. A defrost long enough to trip this rule is itself a fault, and HP-0002 will be reporting it.
  • rat is not consumed, because the reference’s required points for this card are mode_command and SAT only and the HP dictionary carries no rat entry to bind. Recorded as the obvious upgrade if it gains one: sat > rat in cooling / sat < rat in heating needs no adopted constants and has no dead band.
  • The 1/2 mode encoding is this library’s convention, matching the point dictionary’s mode_command entry, and both codes are parameters (kHeat.k, kCool.k) so a host with its own enum rebinds constants rather than editing the graph. Precedent: AHU-0029’s expected_mode.
  • An unmapped mode_command leaves the rule structurally silent. Both selectors go false and no discharge temperature can raise a fault, however wrong it is. That silence is NO_EVAL, not a health claim, and the host must treat it as such — the same stance as AHU-0029, which faces the identical gap.
  • Strict comparisons at both limits. CDL Reals has no GreaterEqual or LessEqual, so a discharge sitting exactly on a limit is not a fault. The disagreement is measure-zero on a real-valued signal, and both sides of both limits are pinned.
  • delayOnInit = true on all three timers (CDL default is false), the library’s standing choice: a unit already in the wrong mode when the controller restarted waits out the settle and the alarm delay instead of alarming on the first tick.
  • Operating states (compressor running) and the defrost exclusion live in frontmatter for host enforcement rather than in the block graph, per the library’s design stance.
  • Frontmatter clusters is empty: the reference lists no cluster for this fault and this card does not invent one. The relationship to HP-0001 and HP-0002 is carried by related and the shared playbook.
  • The reference publishes no test vectors for this card; every scenario in vectors.json is authored from the equation.

Notes

The heat-pump-faults playbook orders the service. Step 1.3 is the manual version of this rule — command a mode change, wait ten minutes, compare the discharge against return air. Step 2.3 is the diagnosis: listen for the solenoid click first, because no click is a $100–$300 solenoid rather than a $500–$2,000 valve body; then the wiring; then the charge. Expect company — a valve failing to shift cleanly usually degrades COP first, so HP-0001 may be reporting on the same unit, and a unit short enough of charge to stall the valve is short enough to lose its COP. Fix the charge before replacing anything.

Test Vectors

13 scenarios, clock step 60 s over 3600 s.

ScenarioDescription
cooling_mode_supply_air_coldHealthy cooling: the unit is commanded to COOLING (mode_command = 2) and the discharge reads 10 °C, well under sat_cooling_max. The reversing valve is where it was told to be.
heating_mode_supply_air_warmHealthy heating: mode_command = 1 with the discharge at 38 °C, well above sat_heating_min.
cooling_mode_valve_did_not_shiftThe unit is commanded to COOLING and the discharge sits at 24 °C — warmer than the space it is supposed to be cooling. The mode selector settles at t=600 s and the alarm lands one alarm_delay later, at t=1500 s.
heating_mode_valve_did_not_shiftThe mirror case: commanded to HEATING with the discharge at 14 °C, so the unit is air-conditioning a building that asked for heat.
cooling_sat_exactly_at_maxBoundary: COOLING with the discharge at exactly sat_cooling_max (18.0 °C). CDL Reals has no GreaterEqual, so the comparison is strict and a discharge sitting precisely on the limit is not a fault.
cooling_sat_just_above_maxBoundary from the other side: 18.1 °C in COOLING clears the strict comparison and alarms on the same schedule as the stuck-valve case.
heating_sat_exactly_at_minBoundary on the heating branch: HEATING with the discharge at exactly sat_heating_min (28.0 °C). The LessThreshold is strict, so the limit itself reads healthy.
heating_sat_just_below_minBoundary from the other side: 27.9 °C in HEATING trips the comparison and alarms after the settle and persistence delays.
mode_chatter_never_settlesThe unit flips between HEATING and COOLING every 540 s while the discharge holds 24 °C — a temperature that is too warm for cooling AND too cool for heating, so an unsettled rule would alarm continuously. Neither selector ever holds long enough to satisfy mode_switch_settle_time, so nothing is ever judged.
settled_after_mode_switchSettle edge from the long side: the unit runs HEATING until t=600 s (never settling, so the heating branch is never judged) and then switches to COOLING with the discharge holding 24 °C. The cooling selector settles 600 s after the switch and the alarm follows one alarm_delay after that.
settle_window_masks_pulldownThe reason the settle delay exists: the unit switches from HEATING to COOLING at t=300 s with 30 °C air still in the duct, and the coil pulls it down to 12 °C by t=840 s. The cooling selector does not settle until t=900 s, by which point the discharge is where cooling wants it, so the changeover transient raises nothing.
unmapped_mode_code_is_silentmode_command = 3 (a host code this rule does not encode — off, auto, or defrost) with the discharge at 24 °C, which would fault in either mapped mode. Both selectors are false, so the rule is structurally silent: that silence is NO_EVAL, not a health claim, and the host must treat it that way.
alarm_clears_when_valve_shiftsRecovery: the stuck-in-heating alarm asserts, then the technician frees the reversing valve at t=2400 s and the discharge drops to 12 °C. TrueDelay passes falling edges immediately, so yFault clears on that tick without waiting out another delay.
vectors.json
{
  "schema": "cxf-library/vectors/v1",
  "clock": {
    "step_s": 60,
    "horizon_s": 3600
  },
  "scenarios": [
    {
      "name": "cooling_mode_supply_air_cold",
      "description": "Healthy cooling: the unit is commanded to COOLING (mode_command = 2) and the discharge reads 10 \u00b0C, well under sat_cooling_max. The reversing valve is where it was told to be.",
      "inputs": {
        "mode_command": 2,
        "sat": 10.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "heating_mode_supply_air_warm",
      "description": "Healthy heating: mode_command = 1 with the discharge at 38 \u00b0C, well above sat_heating_min.",
      "inputs": {
        "mode_command": 1,
        "sat": 38.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "cooling_mode_valve_did_not_shift",
      "description": "The unit is commanded to COOLING and the discharge sits at 24 \u00b0C \u2014 warmer than the space it is supposed to be cooling. The mode selector settles at t=600 s and the alarm lands one alarm_delay later, at t=1500 s.",
      "inputs": {
        "mode_command": 2,
        "sat": 24.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1440,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 1560,
          "to_s": 3600,
          "equals": true
        }
      ]
    },
    {
      "name": "heating_mode_valve_did_not_shift",
      "description": "The mirror case: commanded to HEATING with the discharge at 14 \u00b0C, so the unit is air-conditioning a building that asked for heat.",
      "inputs": {
        "mode_command": 1,
        "sat": 14.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1440,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 1560,
          "to_s": 3600,
          "equals": true
        }
      ]
    },
    {
      "name": "cooling_sat_exactly_at_max",
      "description": "Boundary: COOLING with the discharge at exactly sat_cooling_max (18.0 \u00b0C). CDL Reals has no GreaterEqual, so the comparison is strict and a discharge sitting precisely on the limit is not a fault.",
      "inputs": {
        "mode_command": 2,
        "sat": 18.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "cooling_sat_just_above_max",
      "description": "Boundary from the other side: 18.1 \u00b0C in COOLING clears the strict comparison and alarms on the same schedule as the stuck-valve case.",
      "inputs": {
        "mode_command": 2,
        "sat": 18.1
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1440,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 1560,
          "to_s": 3600,
          "equals": true
        }
      ]
    },
    {
      "name": "heating_sat_exactly_at_min",
      "description": "Boundary on the heating branch: HEATING with the discharge at exactly sat_heating_min (28.0 \u00b0C). The LessThreshold is strict, so the limit itself reads healthy.",
      "inputs": {
        "mode_command": 1,
        "sat": 28.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "heating_sat_just_below_min",
      "description": "Boundary from the other side: 27.9 \u00b0C in HEATING trips the comparison and alarms after the settle and persistence delays.",
      "inputs": {
        "mode_command": 1,
        "sat": 27.9
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1440,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 1560,
          "to_s": 3600,
          "equals": true
        }
      ]
    },
    {
      "name": "mode_chatter_never_settles",
      "description": "The unit flips between HEATING and COOLING every 540 s while the discharge holds 24 \u00b0C \u2014 a temperature that is too warm for cooling AND too cool for heating, so an unsettled rule would alarm continuously. Neither selector ever holds long enough to satisfy mode_switch_settle_time, so nothing is ever judged.",
      "inputs": {
        "mode_command": [
          {
            "t": 0,
            "value": 2
          },
          {
            "t": 540,
            "value": 1
          },
          {
            "t": 1080,
            "value": 2
          },
          {
            "t": 1620,
            "value": 1
          },
          {
            "t": 2160,
            "value": 2
          },
          {
            "t": 2700,
            "value": 1
          },
          {
            "t": 3240,
            "value": 2
          }
        ],
        "sat": 24.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "settled_after_mode_switch",
      "description": "Settle edge from the long side: the unit runs HEATING until t=600 s (never settling, so the heating branch is never judged) and then switches to COOLING with the discharge holding 24 \u00b0C. The cooling selector settles 600 s after the switch and the alarm follows one alarm_delay after that.",
      "inputs": {
        "mode_command": [
          {
            "t": 0,
            "value": 1
          },
          {
            "t": 600,
            "value": 2
          }
        ],
        "sat": 24.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2040,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2160,
          "to_s": 3600,
          "equals": true
        }
      ]
    },
    {
      "name": "settle_window_masks_pulldown",
      "description": "The reason the settle delay exists: the unit switches from HEATING to COOLING at t=300 s with 30 \u00b0C air still in the duct, and the coil pulls it down to 12 \u00b0C by t=840 s. The cooling selector does not settle until t=900 s, by which point the discharge is where cooling wants it, so the changeover transient raises nothing.",
      "inputs": {
        "mode_command": [
          {
            "t": 0,
            "value": 1
          },
          {
            "t": 300,
            "value": 2
          }
        ],
        "sat": [
          {
            "t": 0,
            "value": 30.0
          },
          {
            "t": 840,
            "value": 12.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "unmapped_mode_code_is_silent",
      "description": "mode_command = 3 (a host code this rule does not encode \u2014 off, auto, or defrost) with the discharge at 24 \u00b0C, which would fault in either mapped mode. Both selectors are false, so the rule is structurally silent: that silence is NO_EVAL, not a health claim, and the host must treat it that way.",
      "inputs": {
        "mode_command": 3,
        "sat": 24.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "alarm_clears_when_valve_shifts",
      "description": "Recovery: the stuck-in-heating alarm asserts, then the technician frees the reversing valve at t=2400 s and the discharge drops to 12 \u00b0C. TrueDelay passes falling edges immediately, so yFault clears on that tick without waiting out another delay.",
      "inputs": {
        "mode_command": 2,
        "sat": [
          {
            "t": 0,
            "value": 24.0
          },
          {
            "t": 2400,
            "value": 12.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1440,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 1560,
          "to_s": 2340,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 2460,
          "to_s": 3600,
          "equals": false
        }
      ]
    }
  ]
}

HP-0004 — Refrigerant undercharge — superheat/subcooling divergence

Statusverified — engine e2ff2f8, cxf:fnv1a128:456b99147ff742525fe015d642f727d9, 2026-08-18
Severity3
Methodrule
Phase2
CategoryEFFICIENCY_LOSS
ConfidenceMEDIUM
EstimationPROXY_ESTIMATION
G36
Clusters
Suppresses
Suppressed by
RelatedHP-0001, HP-0005, RTU-0002, RTU-0008, HP-0007
Playbooksheat-pump-faults
SourceLibrary-authored — no reference card exists for this fault; NIST SP 1087, Kim, Yoon, Payne & Domanski, Cooling Mode Fault Detection and Diagnosis Method for a Residential Heat Pump, NIST, October 2008: §5.4.3 Table 5.2 (undercharge fault-direction rows, zones A and B), §5.4.2 and §5.4.4 (TXV control limits; the 0.5 °C upstream-subcooling test and its role in the method), §5.5.1 Figures 5.16-5.17 (fault level versus EER degradation), §5.5.2 Table 5.16 (undercharge diagnosis results); Breuker & Braun 1998b and Rossi & Braun 1997, reproduced as SP 1087 Table 5.1(a) — the fixed-orifice refrigerant-leakage row this rule’s pattern reproduces; the corroboration lineage HP-0001 draws on; Barandier 2023, Barandier & Mendes 2024 — undercharge prevalence on heat pumps (cited by HP-0001); Li & Braun 2007, HVAC&R Research 13(2) — the multiple-simultaneous-fault caveat on single-fault pattern charts; Sibling precedent: HP-0001 (commissioning-set placeholder parameters), RTU-0002 (fixed bands named as a simplification of a regressed baseline), HP-0002 and HP-0003 (defrost and compressor gating as host preconditions)
Operating statesmechanical cooling, compressor running, unit not in defrost — all three host-gated

Preconditions (host-enforced): The compressor must be running and the unit must not be in defrost. Both are host gates on comp_status and defrost_status: with the compressor off all four temperatures equalise and both differences collapse to zero, and a defrost cycle deliberately reverses the circuit, which scrambles every refrigerant-side temperature this rule reads (HP-0002/HP-0003 precedent). The compressor must also have held its current capacity for min_runtime_for_eval (15 min); superheat overshoots for minutes after a start or a capacity step while the expansion valve catches up. evap_sat_temp and cond_sat_temp are host-derived P-T lookups and the lookup MUST be configured for the refrigerant actually in the machine — a wrong refrigerant biases both differences at once and in opposite directions, which is this rule’s exact fault pattern. The suction and liquid probes must be in good contact with the line and insulated from ambient air; an uninsulated liquid-line probe reads high in a hot plant room and fabricates collapsed subcooling. Read yTxvSaturated as diagnostic context, NOT as an evaluability gate: this rule has no in-graph NO_EVAL test and false never means healthy (see Deviations).

Points: suction_temp, evap_sat_temp, cond_sat_temp, liquid_temp

Outputs:

  • yFault — True while suction superheat has stayed above superheat_high_band and liquid subcooling below subcooling_low_band, both continuously for at least alarm_delay
  • yTxvSaturated — Sub-condition flag (NOT an evaluability flag; false never means NO_EVAL) — true when liquid subcooling has fallen below subcooling_two_phase_floor, meaning the liquid line is no longer measurably subcooled and the expansion valve is at its control limit

Parameters:

NameDefaultUnitCXF pathDescription
superheat_high_band15.0°CshHigh.tSuction superheat above which the evaporator is judged starved. COMMISSIONING-SET PLACEHOLDER — the shipped 15.0 sits just above the no-fault compressor-suction superheats NIST SP 1087 reports for its test unit (10.2-13.7 °C, against an 11.1 °C compressor-map standard). Set it from the unit’s own charging chart plus a tolerance; a suction-line probe reads higher than an evaporator-exit probe on the same machine.
subcooling_low_band3.0°CscLow.tLiquid subcooling below which the condenser is judged short of liquid. COMMISSIONING-SET PLACEHOLDER on the same terms — published charging-chart targets for a TXV unit run roughly 8-11 °C at design, and this band is set well under the target so normal load swings do not reach it.
subcooling_two_phase_floor0.5°CtxvSat.tSubcooling below which the expansion-valve inlet is taken to be two-phase and the valve at its control limit. Drives yTxvSaturated only. 0.5 °C is SP 1087’s own single-phase/two-phase test (§5.4.2), used there to pick which fault-direction chart applies; unlike the bands above it is a physical boundary, not a per-unit tuning.
alarm_delay1800.0spersist.delayTimeContinuous divergence required before the alarm asserts (30 min). Long enough to outlast a thermostatic valve hunting after a load step, short enough that a real charge loss is reported within the hour

Description

A machine short of refrigerant runs short of liquid. The expansion valve opens further to keep the evaporator fed, and once it runs out of travel the evaporator starves while the condenser loses its liquid seal: superheat climbs and subcooling collapses at the same time. That divergence is the signature — capacity loss on its own says nothing about cause. NIST SP 1087 imposed graded charge faults on a TXV-equipped R410A heat pump in cooling and recorded exactly this pair once the valve saturated (§5.4.3, Table 5.2, zone B), matching the fixed-orifice charts of Breuker & Braun (1998b). This rule forms the two differences from four refrigerant-side temperatures and alarms when both sit past their commissioned bands for half an hour.

Detection Logic

suction_superheat = suction_temp  − evap_sat_temp
liquid_subcooling = cond_sat_temp − liquid_temp

yTxvSaturated = liquid_subcooling < subcooling_two_phase_floor
                (sub-condition flag; false does NOT mean NO_EVAL)

yFault        = suction_superheat > superheat_high_band
                AND liquid_subcooling < subcooling_low_band,
                sustained continuously for alarm_delay

Block graph (rule.cxf.jsonld):

HP-0004 block graph

The conjunction is the diagnosis, not a noise filter. High superheat with subcooling high is the liquid-line-restriction pattern (SP 1087 Table 5.2, zone B), where refrigerant backs up ahead of the restriction; low subcooling with superheat still normal is a valve that is compensating successfully. Only the two together indict the charge.

Both comparisons are strict, so a unit exactly on either band reads healthy, and both bands are per-unit commissioning values — the shipped defaults are placeholders, not thresholds anyone measured on the machine in front of you. persist requires 30 continuous minutes; delayOnInit = true holds that window across a controller restart rather than alarming on the first tick. yTxvSaturated reports whether the liquid line is still measurably subcooled. It does not gate the alarm, and the reason it must not is the card’s main deviation.

Possible Diagnoses

  1. Refrigerant leak — brazed joints, Schrader cores, service-valve packing and flare connections, in that order of prevalence; a charge top-up without a leak search buys months, not years
  2. The unit was charged short, at commissioning or after a repair that vented the circuit and was recharged by pressure rather than by weight
  3. Severe condenser airflow restriction — SP 1087’s zone-B chart moves superheat up and subcooling down for this fault too, and it separates only on condensing temperature, which rises rather than falls; this rule cannot see that (see Deviations)
  4. Instrumentation: a P-T derivation configured for the wrong refrigerant, or a liquid-line probe with poor contact or missing insulation. Both fabricate the pattern on a correctly charged machine

Energy Impact

EFFICIENCY_LOSS, MEDIUM confidence, PROXY_ESTIMATION. waste_kw = compressor_kw × d / (1 − d), the extra compressor runtime needed to deliver the same cooling at a degraded EER. NIST SP 1087 sizes d directly: every fault it tested except compressor leakage needed a fault level above 10% to cost 5% of EER, and a 20% charge shortfall cost 6.5-13% of EER, the largest hit in its Figure 5.17. Confidence is MEDIUM because the rule fires on a pattern, not a severity — it reports that the charge is low, not by how much, so d is a population number until the technician’s gauge set supplies a real one.

Emissions Impact

Scope 2, PROXY_EMISSIONS, MEDIUM confidence; typically 300-2,000 kg CO₂e/yr for a commercial packaged heat pump, all of it compressor electricity, so the avoided-emissions basis is the marginal operating emissions rate (MOER). A leaking circuit also vents refrigerant, and R410A carries a GWP near 2,000 — that release is a scope 1 emission this card does not estimate, because the leak rate is not observable from any point the rule reads. A site with refrigerant-tracking obligations should account for it separately.

Deviations

  • The 0.5 °C subcooling floor is a sub-condition flag, not an evaluability gate. SP 1087 uses it (§5.4.4) to choose which fault-direction chart applies, never to suppress evaluation, and the zone-B chart this rule encodes still lists falling subcooling as an undercharge symptom. Gating yFault on it would silence the rule exactly when the liquid line has flashed to two-phase — severe undercharge, not missing data — so the flag is named yTxvSaturated rather than y…Ok.
  • Fixed bands replace SP 1087’s regressed no-fault baseline. It compares each feature against a third-order polynomial regression on three variables (outdoor and indoor drybulb, indoor dew point); this library’s only regression primitive is a host-fitted line (HP-0001). Charging-chart-style nominal targets stand in — a real simplification, named as one on RTU-0002’s precedent, and the reason bands left at their defaults can alarm on a healthy machine forever.
  • Only the TXV-saturated row of the chart is encoded. In SP 1087’s zone A (valve still in control) undercharge moves no superheat at all — only subcooling, condensing temperature and the two air-side deltas — so a mild charge loss the valve is absorbing reads healthy here. Earlier investigators reported difficulty detecting undercharge reliably below about 40% charge loss (Breuker & Braun 1998b; Stylianou & Nikanpour 1996, via SP 1087 §2).
  • Two features, so severe condenser airflow restriction is not excluded. It shares the zone-B superheat-up/subcooling-down pair and separates on condensing temperature moving up rather than down — a direction test that needs a baseline for the current operating condition — the host-fitted-baseline convention that later shipped RTU-0007. The card names it as diagnosis 3 rather than pretending to rule it out.
  • Grounding is cooling-mode only. SP 1087 tested cooling exclusively, and in heating the coils swap roles, so the sensors sit on different heat exchangers even though the arithmetic is unchanged. operating_states says cooling; a heating-mode instance is an extrapolation from charge-diagnosis practice and needs its own commissioned bands.
  • Strict > and < at both bands. CDL Reals has no GreaterEqual or LessEqual, so a unit sitting exactly on a band reads healthy. The disagreement is measure-zero on real-valued signals; both sides of both bands, and of the two-phase floor, are pinned by vectors.
  • Superheat is measured at the compressor suction, not the evaporator exit. The point dictionary’s suction_temp is a suction-line probe, and SP 1087 notes suction superheat reads higher than evaporator-exit superheat on the same machine, which it attributes to heat transfer within the valve. Its 9 °C zone boundary is an evaporator-exit number fitted to one test rig and is deliberately not carried into this card as a parameter.
  • Compressor, defrost and steady-state gating stay host preconditions. The graph computes the fault given valid data, per SCHEMA.md; comp_status, defrost_status and time-since-capacity-change are not among its inputs. The 30-minute alarm_delay does not substitute — a post-start superheat overshoot starts the persistence timer rather than being excluded from it.
  • The pattern chart is a single-fault chart. SP 1087 imposed one fault at a time, and Li & Braun (2007) showed this whole family of charts misreads simultaneous faults, whose feature residuals superpose. Two faults at once can cancel this rule’s pattern or fake it; the diagnosis text is a ranking, not a verdict.
  • persist.delayOnInit = true (CDL default is false), the library’s standing choice. Severity 3 and category: EFFICIENCY_LOSS follow HP-0001 and RTU-0002, the library’s other charge-and-capacity cards; no reference card exists to inherit them from.

Notes

Do not read a cleared alarm as a repaired machine. The host gates this rule on the compressor running, so every stop drops yFault for the same reason a recharge does — the falling edge means “no longer diverging”, nothing more. When yTxvSaturated is true the technician should expect flash gas at the sight glass and should weigh the recovered charge rather than trusting subcooling to confirm the fix. HP-0001 sees the same fault as a COP shortfall without naming it, and HP-0005 is the overcharge branch, which is why the three carry each other in related. The heat-pump-faults playbook orders the on-site work; its step 1 already sends the technician to superheat and subcooling, which is the measurement this rule automates.

Test Vectors

14 scenarios, clock step 300 s over 7200 s.

ScenarioDescription
correctly_charged_unitNominal cooling operation: 11 °C of suction superheat against a 15 °C band and 9 °C of subcooling against a 3 °C band. Neither conjunct holds, and the liquid line is comfortably single-phase.
undercharge_with_liquid_still_subcooledThe signature: 20 °C superheat with subcooling collapsed to 1.5 °C. Both conjuncts hold from t=0, so delayOnInit puts the alarm one alarm_delay in. Subcooling is still above the two-phase floor, so yTxvSaturated stays false — the fault does not depend on it.
high_superheat_with_high_subcooling_is_not_underchargeThe discriminator the subcooling conjunct exists for: 20 °C superheat with subcooling raised to 12 °C is the liquid-line-restriction pattern, where refrigerant backs up ahead of the restriction. Superheat alone would call this a charge fault; the AND does not.
low_subcooling_alone_while_the_valve_compensatesSubcooling down to 1 °C with superheat held at a normal 11 °C — the expansion valve is still doing its job. This is the in-control-zone undercharge pattern, and this rule deliberately does not fire on it (see Deviations); the superheat conjunct blocks alone.
superheat_exactly_at_the_bandThreshold edge: superheat is exactly superheat_high_band (20.0 − 5.0 = 15.0, binary-exact) with subcooling already low. The comparison is a strict >, so a unit sitting precisely on the band reads healthy.
superheat_just_above_the_bandThreshold edge from the other side: 15.25 °C of superheat, a quarter of a degree over the band, and the alarm matures after alarm_delay.
subcooling_exactly_at_the_bandThreshold edge: subcooling is exactly subcooling_low_band (33.0 − 30.0 = 3.0, binary-exact) with superheat well over its band. The comparison is a strict <, so a unit precisely on the band reads healthy.
subcooling_just_below_the_bandThreshold edge from the other side: 2.75 °C of subcooling clears the band and the alarm matures.
subcooling_exactly_at_the_two_phase_floorEvaluability-flag edge: subcooling is exactly subcooling_two_phase_floor (30.5 − 30.0 = 0.5, binary-exact). The floor test is a strict <, so yTxvSaturated is false — and the fault asserts anyway, because the flag is a sub-condition, not a gate.
subcooling_below_the_two_phase_floorThe card’s design point, and the other side of the floor edge: 0.25 °C of subcooling means the liquid line is no longer measurably subcooled, the valve is at its limit, and this is a severe charge loss. yTxvSaturated goes true and yFault still asserts — a host that treated the flag as an evaluability gate would go silent on the worst case this rule exists for.
timer_starts_when_the_second_conjunct_arrivesSubcooling is low from t=0 but superheat only crosses its band at t=1200 s, as the charge loss deepens past the point where the valve can keep holding superheat. The persistence window runs from the conjunction, not from the first symptom, so the alarm lands at 3000 s.
transient_superheat_excursion_never_alarmsSuperheat spikes over its band for 1500 s — a load step, or a thermostatic valve hunting after a capacity change — and settles back 300 s short of alarm_delay. The timer resets on the recovery and nothing is reported.
alarm_clears_after_the_charge_is_restoredRecovery: the divergence alarms at 1800 s, a technician weighs in the missing charge at t = 3600 s, and both readings return to nominal. yFault drops on that same tick — TrueDelay only delays the rising edge.
idle_unit_reads_healthy_and_the_flag_reads_saturatedWhy the compressor-running precondition is host-enforced rather than optional: with the unit off, all four temperatures equalise, superheat and subcooling both collapse to zero, and the graph reports no fault while yTxvSaturated reads true. Neither answer means anything about the refrigerant charge.
vectors.json
{
  "schema": "cxf-library/vectors/v1",
  "clock": {
    "step_s": 300,
    "horizon_s": 7200
  },
  "scenarios": [
    {
      "name": "correctly_charged_unit",
      "description": "Nominal cooling operation: 11 \u00b0C of suction superheat against a 15 \u00b0C band and 9 \u00b0C of subcooling against a 3 \u00b0C band. Neither conjunct holds, and the liquid line is comfortably single-phase.",
      "inputs": {
        "suction_temp": 16.0,
        "evap_sat_temp": 5.0,
        "cond_sat_temp": 39.0,
        "liquid_temp": 30.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        },
        {
          "output": "yTxvSaturated",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        }
      ]
    },
    {
      "name": "undercharge_with_liquid_still_subcooled",
      "description": "The signature: 20 \u00b0C superheat with subcooling collapsed to 1.5 \u00b0C. Both conjuncts hold from t=0, so delayOnInit puts the alarm one alarm_delay in. Subcooling is still above the two-phase floor, so yTxvSaturated stays false \u2014 the fault does not depend on it.",
      "inputs": {
        "suction_temp": 25.0,
        "evap_sat_temp": 5.0,
        "cond_sat_temp": 31.5,
        "liquid_temp": 30.0
      },
      "expect": [
        {
          "output": "yTxvSaturated",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1500,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2100,
          "to_s": 7200,
          "equals": true
        }
      ]
    },
    {
      "name": "high_superheat_with_high_subcooling_is_not_undercharge",
      "description": "The discriminator the subcooling conjunct exists for: 20 \u00b0C superheat with subcooling raised to 12 \u00b0C is the liquid-line-restriction pattern, where refrigerant backs up ahead of the restriction. Superheat alone would call this a charge fault; the AND does not.",
      "inputs": {
        "suction_temp": 25.0,
        "evap_sat_temp": 5.0,
        "cond_sat_temp": 42.0,
        "liquid_temp": 30.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        },
        {
          "output": "yTxvSaturated",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        }
      ]
    },
    {
      "name": "low_subcooling_alone_while_the_valve_compensates",
      "description": "Subcooling down to 1 \u00b0C with superheat held at a normal 11 \u00b0C \u2014 the expansion valve is still doing its job. This is the in-control-zone undercharge pattern, and this rule deliberately does not fire on it (see Deviations); the superheat conjunct blocks alone.",
      "inputs": {
        "suction_temp": 16.0,
        "evap_sat_temp": 5.0,
        "cond_sat_temp": 31.0,
        "liquid_temp": 30.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        },
        {
          "output": "yTxvSaturated",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        }
      ]
    },
    {
      "name": "superheat_exactly_at_the_band",
      "description": "Threshold edge: superheat is exactly superheat_high_band (20.0 \u2212 5.0 = 15.0, binary-exact) with subcooling already low. The comparison is a strict `>`, so a unit sitting precisely on the band reads healthy.",
      "inputs": {
        "suction_temp": 20.0,
        "evap_sat_temp": 5.0,
        "cond_sat_temp": 31.0,
        "liquid_temp": 30.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        }
      ]
    },
    {
      "name": "superheat_just_above_the_band",
      "description": "Threshold edge from the other side: 15.25 \u00b0C of superheat, a quarter of a degree over the band, and the alarm matures after alarm_delay.",
      "inputs": {
        "suction_temp": 20.25,
        "evap_sat_temp": 5.0,
        "cond_sat_temp": 31.0,
        "liquid_temp": 30.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1500,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2100,
          "to_s": 7200,
          "equals": true
        }
      ]
    },
    {
      "name": "subcooling_exactly_at_the_band",
      "description": "Threshold edge: subcooling is exactly subcooling_low_band (33.0 \u2212 30.0 = 3.0, binary-exact) with superheat well over its band. The comparison is a strict `<`, so a unit precisely on the band reads healthy.",
      "inputs": {
        "suction_temp": 25.0,
        "evap_sat_temp": 5.0,
        "cond_sat_temp": 33.0,
        "liquid_temp": 30.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        }
      ]
    },
    {
      "name": "subcooling_just_below_the_band",
      "description": "Threshold edge from the other side: 2.75 \u00b0C of subcooling clears the band and the alarm matures.",
      "inputs": {
        "suction_temp": 25.0,
        "evap_sat_temp": 5.0,
        "cond_sat_temp": 32.75,
        "liquid_temp": 30.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1500,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2100,
          "to_s": 7200,
          "equals": true
        }
      ]
    },
    {
      "name": "subcooling_exactly_at_the_two_phase_floor",
      "description": "Evaluability-flag edge: subcooling is exactly subcooling_two_phase_floor (30.5 \u2212 30.0 = 0.5, binary-exact). The floor test is a strict `<`, so yTxvSaturated is false \u2014 and the fault asserts anyway, because the flag is a sub-condition, not a gate.",
      "inputs": {
        "suction_temp": 25.0,
        "evap_sat_temp": 5.0,
        "cond_sat_temp": 30.5,
        "liquid_temp": 30.0
      },
      "expect": [
        {
          "output": "yTxvSaturated",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1500,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2100,
          "to_s": 7200,
          "equals": true
        }
      ]
    },
    {
      "name": "subcooling_below_the_two_phase_floor",
      "description": "The card's design point, and the other side of the floor edge: 0.25 \u00b0C of subcooling means the liquid line is no longer measurably subcooled, the valve is at its limit, and this is a severe charge loss. yTxvSaturated goes true and yFault still asserts \u2014 a host that treated the flag as an evaluability gate would go silent on the worst case this rule exists for.",
      "inputs": {
        "suction_temp": 25.0,
        "evap_sat_temp": 5.0,
        "cond_sat_temp": 30.25,
        "liquid_temp": 30.0
      },
      "expect": [
        {
          "output": "yTxvSaturated",
          "from_s": 0,
          "to_s": 7200,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1500,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2100,
          "to_s": 7200,
          "equals": true
        }
      ]
    },
    {
      "name": "timer_starts_when_the_second_conjunct_arrives",
      "description": "Subcooling is low from t=0 but superheat only crosses its band at t=1200 s, as the charge loss deepens past the point where the valve can keep holding superheat. The persistence window runs from the conjunction, not from the first symptom, so the alarm lands at 3000 s.",
      "inputs": {
        "evap_sat_temp": 5.0,
        "cond_sat_temp": 31.0,
        "liquid_temp": 30.0,
        "suction_temp": [
          {
            "t": 0,
            "value": 16.0
          },
          {
            "t": 1200,
            "value": 25.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2700,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 3300,
          "to_s": 7200,
          "equals": true
        }
      ]
    },
    {
      "name": "transient_superheat_excursion_never_alarms",
      "description": "Superheat spikes over its band for 1500 s \u2014 a load step, or a thermostatic valve hunting after a capacity change \u2014 and settles back 300 s short of alarm_delay. The timer resets on the recovery and nothing is reported.",
      "inputs": {
        "evap_sat_temp": 5.0,
        "cond_sat_temp": 31.0,
        "liquid_temp": 30.0,
        "suction_temp": [
          {
            "t": 0,
            "value": 16.0
          },
          {
            "t": 900,
            "value": 25.0
          },
          {
            "t": 2400,
            "value": 16.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        }
      ]
    },
    {
      "name": "alarm_clears_after_the_charge_is_restored",
      "description": "Recovery: the divergence alarms at 1800 s, a technician weighs in the missing charge at t = 3600 s, and both readings return to nominal. yFault drops on that same tick \u2014 TrueDelay only delays the rising edge.",
      "inputs": {
        "evap_sat_temp": 5.0,
        "liquid_temp": 30.0,
        "suction_temp": [
          {
            "t": 0,
            "value": 25.0
          },
          {
            "t": 3600,
            "value": 16.0
          }
        ],
        "cond_sat_temp": [
          {
            "t": 0,
            "value": 31.0
          },
          {
            "t": 3600,
            "value": 39.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1500,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2100,
          "to_s": 3300,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 3900,
          "to_s": 7200,
          "equals": false
        }
      ]
    },
    {
      "name": "idle_unit_reads_healthy_and_the_flag_reads_saturated",
      "description": "Why the compressor-running precondition is host-enforced rather than optional: with the unit off, all four temperatures equalise, superheat and subcooling both collapse to zero, and the graph reports no fault while yTxvSaturated reads true. Neither answer means anything about the refrigerant charge.",
      "inputs": {
        "suction_temp": 22.0,
        "evap_sat_temp": 22.0,
        "cond_sat_temp": 22.0,
        "liquid_temp": 22.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        },
        {
          "output": "yTxvSaturated",
          "from_s": 0,
          "to_s": 7200,
          "equals": true
        }
      ]
    }
  ]
}

HP-0005 — Refrigerant overcharge — high liquid subcooling

Statusverified — engine e2ff2f8, cxf:fnv1a128:29e19fa80140e255eff5fc094242ff34, 2026-08-18
Severity3
Methodrule
Phase2
CategoryEFFICIENCY_LOSS
ConfidenceMEDIUM
EstimationPROXY_ESTIMATION
G36
Clusters
Suppresses
Suppressed by
RelatedHP-0001, HP-0004, RTU-0009, HP-0007
Playbooksheat-pump-faults
SourceNIST SP 1087, Kim, Yoon, Payne & Domanski, Cooling Mode Fault Detection and Diagnosis Method for a Residential Heat Pump (October 2008), Table 5.2 — for refrigerant overcharge on a TXV-equipped unit, subcooling, condensing temperature and discharge temperature all rise while superheat does not move; NIST SP 1087 §5.4.2 and Fig. 5.14 — the 0.5 °C TXV-upstream subcooling test that selects which fault chart applies; NIST SP 1087 Figs. 5.16-5.17 and Table 5.17 — overcharge is the least EER-sensitive fault tested, and was diagnosed correctly at fault levels its own EER-degradation detector never flagged; Library-authored: the HVAC FDD Reference v1.0 ch.11 specifies no refrigerant-side heat pump rule (HP-0001..052 only); name, severity 3 and method: rule are argued on this card; Sibling precedent: RTU-0002 (a fixed baseline shipped as a named simplification of a fitted model), HP-0001 (placeholder parameter with a silent-failure warning), HP-0002 and HP-0003 (defrost and mode gating kept host-side)
Operating statescompressor running in steady cooling — the source’s tested mode; heating is an authored extension needing its own instance, band and liquid-line sensor (see Deviations)

Preconditions (host-enforced): The host gates this rule end to end and there is no in-rule evaluability output: silence outside the gates is NO_EVAL by frontmatter, not a healthy charge. comp_status must have been true continuously for at least 10 min — subcooling on the source’s rig took roughly 5-8 min to settle after a start, and a pulling-down circuit reads whatever its transient says. defrost_status must be false, and the host must hold evaluation off for a recovery period after a cycle ends: a reversal drives both temperatures through excursions that have nothing to do with charge. cond_sat_temp is host-derived through a refrigerant P-T lookup, so the lookup must match the refrigerant actually in the machine — the wrong one offsets every subcooling reading by a near-constant the rule cannot distinguish from charge. liquid_temp must be sensed between the condenser outlet and the metering device for the mode being evaluated, with proper pipe contact and insulation. subcooling_high_band must be commissioned against this unit’s own target subcooling before any verdict means anything (see Deviations).

Points: cond_sat_temp, liquid_temp

Outputs:

  • yFault — True while liquid subcooling (cond_sat_temp − liquid_temp) has stayed above subcooling_high_band continuously for at least alarm_delay

Parameters:

NameDefaultUnitCXF pathDescription
subcooling_high_band12.0°CscHigh.tLiquid subcooling above which the circuit is holding more refrigerant than it should. PER-UNIT COMMISSIONING VALUE — the shipped 12.0 is roughly double a typical commissioned target of 5-6 °C and catches gross overcharge only; set it to the unit’s own charging-chart target plus about 3 °C to approach the source’s sensitivity
alarm_delay1800.0spersist.delayTimeContinuous excess subcooling required before the alarm asserts (30 min). Long enough to ride out a load step or a post-defrost recovery the host did not gate out; overcharge itself does not come and go

Description

Overcharge is refrigerant the circuit has no room for. The surplus backs up into the outlet end of the condenser, turning surface that should be condensing into extra subcooling area, and the liquid line leaves colder relative to its own condensing saturation temperature — subcooling rises, which is what this rule reads. NIST SP 1087 imposed 10%, 20% and 30% overcharge on a TXV-equipped heat pump in cooling and measured subcooling, condensing temperature and discharge temperature all rising together while superheat did not move at all, because the expansion valve went on holding it. The fault is cheap in energy and expensive in compressor life: 20% too much refrigerant cost that unit 1.1-3.4% of EER, so an efficiency rule such as HP-0001 will usually never see it.

Detection Logic

liquid_subcooling = cond_sat_temp − liquid_temp

yFault = liquid_subcooling > subcooling_high_band,
         sustained continuously for alarm_delay

Block graph (rule.cxf.jsonld):

HP-0005 block graph

One subtraction, one comparison, one timer. liquid_subcooling is computed in-graph rather than bound as a point: the point dictionary defines subcooling as exactly this difference, and both operands are already boundary inputs.

There is deliberately no superheat term. On a unit with a thermostatic expansion valve the valve holds superheat against the fault, and the source’s chart records superheat as unchanged for overcharge at every level it tested — a rule that required a superheat move would be unfirable on the equipment it targets. Nor is there an evaluability output: compressor state, mode and defrost are host preconditions, and the source’s own 0.5 °C TXV test is implied by any subcooling large enough to fire this rule (see Deviations).

The comparison is strict, so a unit sitting exactly on the band reads healthy. persist requires 30 continuous minutes and delayOnInit = true holds that window across a controller restart.

Possible Diagnoses

  1. Refrigerant overcharge from service — topped up by pressure rather than by weight, or charge added to a unit whose actual complaint was airflow
  2. Liquid-line restriction: a plugged filter-drier, a kinked line, or a clogged TXV inlet screen backs refrigerant up ahead of the restriction and raises subcooling identically. The source separates the two by condensing temperature falling and superheat rising, neither of which this rule reads
  3. Non-condensable gas from a short or skipped evacuation — raises head pressure and subcooling together; confirm off-cycle by comparing standstill pressure against ambient saturation, which is why the source excluded it from its online method
  4. A wrong reading rather than a wrong charge: cond_sat_temp derived with another refrigerant’s P-T relation, or a liquid-line sensor with poor pipe contact or missing insulation
  5. Flooded-condenser head-pressure control operating as designed at low ambient, which holds liquid in the condenser on purpose — check whether it is active before touching the charge

Energy Impact

EFFICIENCY_LOSS, MEDIUM confidence, PROXY_ESTIMATION. Overcharge was the fault EER tolerated best of the six the source imposed: 20% too much refrigerant cost 1.1-3.4% of EER across four operating conditions, and reaching a 5% hit took 32-42% overcharge — several times the charge error undercharge needs for the same damage. waste_kw ≈ eer_penalty × elec_power. PROXY and MEDIUM because the rule measures a temperature difference and borrows the penalty from published lab results. The case for fixing it is only partly the meter: excess charge raises head pressure and discharge temperature and pushes liquid toward the compressor, a reliability cost this rule cannot size.

Emissions Impact

Scope 2, PROXY_EMISSIONS, MEDIUM confidence; typically 50-400 kg CO₂e/yr for a commercial packaged heat pump — HP-0001’s range scaled down by this fault’s much smaller efficiency penalty. The waste is compressor electricity, so the avoided-emissions basis is the marginal operating emissions rate (MOER). The larger climate term is off that meter entirely: the excess charge must be recovered rather than vented, and the R-410A still in most of this equipment has a GWP above 2,000.

Deviations

  • A fixed nominal band replaces the source’s regressed reference model — the central simplification. The source predicts each feature from a third-order polynomial in outdoor drybulb, indoor drybulb and indoor dew point, then tests the residual against a noise band it measured at 0.574 °C for subcooling. No CDL block expresses that regression, so this card tests an absolute subcooling the way manufacturer charging charts publish one. RTU-0002 names the same class of substitution for its fixed split baselines.
  • subcooling_high_band ships as a commissioning placeholder and the default is deliberately insensitive. The source’s no-fault rig ran about 5 °C of subcooling and 30% overcharge raised it only 2-3 °C, so at the shipped 12.0 °C this rule catches gross overcharge and nothing subtle. That is the price of a runnable default on an unknown unit; the retune is target subcooling plus ~3 °C, and it fails silently in both directions (set low, every hot afternoon alarms; set high, nothing ever does).
  • No superheat conjunct, by evidence rather than convenience. The source’s fault chart marks superheat unchanged for overcharge in every tested case, because the TXV was never driven out of its control range by this fault. The companion undercharge rule (HP-0004) is where superheat carries information — it is the feature that separates the two charge faults, not one that confirms either alone.
  • No yTxvOk evaluability output, unlike the undercharge branch. The source uses subcooling above 0.5 °C at the valve inlet to decide which fault chart applies. Any subcooling large enough to trip this rule satisfies that test by construction, so the flag would be true whenever yFault is, and false only when the liquid line is effectively two-phase — a state in which “not overcharged” is a sound verdict, not NO_EVAL. Publishing it would invite hosts to discard a correct answer.
  • Discharge and condensing temperature are dropped from the pattern. The source’s overcharge signature is three simultaneous positive residuals (subcooling, condensing temperature, discharge temperature), but the other two are meaningful only against a conditions-regressed baseline: both track ambient and load directly, and no commissioning practice publishes a fixed nominal for either. Subcooling is the one feature of the three with a published target.
  • The rule stands alone; the source runs its chart only after an efficiency detector trips. In the source’s algorithm the rule chart is consulted only once EER falls 3% below the reference model. Not reproduced deliberately: its own results table shows 10-20% overcharge diagnosed correctly with EER degradation under 3% and the warning never firing, which is exactly the coverage this card adds over HP-0001.
  • A known confusion the rule cannot resolve. A severe liquid-line restriction raises subcooling too — it appears with subcooling up in the source’s saturated-valve chart — and so does non-condensable gas, steeply. This card names them in Possible Diagnoses rather than pretending to a differential diagnosis it has no points for; the playbook narrows it on site.
  • Heating-mode use is an authored extension. The source tested cooling only, and on a reversible unit the reversing valve swaps which coil is the condenser, so one liquid-line sensor is generally valid in one mode. Run one instance per mode, each with its own sensor binding and its own band. The physics of charge inventory carries over; this lab data does not.
  • Compressor, mode and defrost gating stay host preconditions, per the library’s stance that the graph computes the fault given valid data. Defrost matters more here than elsewhere: reversing the circuit scrambles both refrigerant temperatures for minutes, and every reading during and just after is noise to this rule (HP-0002 and HP-0003 precedent).
  • Strict > at the band. CDL Reals has no GreaterEqual, so a unit exactly on the band reads healthy where a technician would call it high — measure-zero on a real-valued signal, and both sides are pinned by vectors.
  • persist.delayOnInit = true (CDL default is false), the library’s standing choice: a unit already overcharged at controller start waits out the full 30 minutes rather than alarming on the first tick.
  • Single-fault provenance. The source’s chart, like every chart of this family, is fit from single-fault tests and misreads combinations. One boolean on one feature inherits no ranking error, but read the diagnosis list as a family rather than a verdict whenever a second fault is plausible.
  • Severity 3, category: EFFICIENCY_LOSS and clusters: [] are authored, not inherited — there is no reference card for this fault. method: rule describes this graph, not the source, whose own diagnosis is a probabilistic classifier over regressed residuals. Every scenario in vectors.json is library-authored and replayed against the pinned engine rev.

Notes

Read alongside HP-0001, which will usually be silent here by design, and HP-0004, the undercharge branch — the two firing together is a contradiction and means a sensor or a P-T lookup is wrong, not that the unit is both over- and undercharged. Confirm at the service ports before recovering anything; the heat-pump-faults playbook orders the charge work, and resolution is subcooling back inside the manufacturer’s charging-chart target with the recovered refrigerant weighed rather than guessed. Rule out the liquid-line restriction first: pulling charge out of a unit with a plugged drier makes the machine worse.

Test Vectors

10 scenarios, clock step 300 s over 9000 s.

ScenarioDescription
nominal_subcooling_healthyA correctly charged unit: 45 °C condensing saturation against a 39 °C liquid line is 6 K of subcooling, near the ~5 K the source measured on its no-fault rig and half the shipped band.
hot_day_same_subcooling_healthyBoth temperatures 10 K higher — a hot afternoon, or a condenser starved of air — and the same 6 K of subcooling. The rule reads the difference, not the level, so nothing here is a charge verdict. Pairs with overcharged_liquid_backs_up: wire the operands backwards and both flip.
overcharged_liquid_backs_upExcess refrigerant floods the lower condenser circuits: the liquid line leaves at 35 °C against a 50 °C condensing saturation, 15 K of subcooling. Both operands hold from t=0, so delayOnInit puts the alarm one alarm_delay in.
subcooling_exactly_at_the_bandThreshold edge: 47.0 − 35.0 is exactly subcooling_high_band (12.0 K), and both operands are binary-exact, so this is a true boundary pin. The comparison is strict, so a unit sitting precisely on the band reads healthy.
subcooling_just_above_the_bandThreshold edge from the other side: 47.2 − 35.0 is 12.2 K, two tenths past the band, and the alarm matures after alarm_delay.
two_phase_liquid_line_reads_healthyThe state the source’s 0.5 K TXV control-limit test names: 0.2 K of subcooling, a liquid line that is effectively two-phase. This card exposes no evaluability flag for it because the verdict is sound — a unit with no liquid seal is many things, none of them overcharged.
negative_subcooling_reads_healthySensor pathology: the liquid line reads 1.5 K hotter than the condensing saturation, which is physically impossible on a healthy circuit and usually means a P-T lookup running the wrong refrigerant or a swapped pair of sensors. The subtraction goes negative and the rule stays silent rather than saturating in either direction.
transient_high_subcooling_never_alarmsSubcooling jumps to 15 K for 1200 s — a load step, or a head-pressure control stroke that briefly holds liquid in the condenser — and recovers 600 s short of alarm_delay. The timer resets on the recovery and nothing is reported.
delay_runs_from_the_crossing_not_from_loadThe unit starts healthy at 6 K and crosses the band at t = 1200 s, holding 13 K thereafter. The alarm lands at 1200 + alarm_delay, not at alarm_delay — the persistence window is measured from the crossing.
alarm_clears_after_charge_recoveredRecovery: 15 K of subcooling alarms at 1800 s, the technician recovers the excess charge at t = 5400 s (playbook step 2), and the liquid line returns to 6 K of subcooling. yFault drops on that tick — TrueDelay only delays the rising edge.
vectors.json
{
  "schema": "cxf-library/vectors/v1",
  "clock": {
    "step_s": 300,
    "horizon_s": 9000
  },
  "scenarios": [
    {
      "name": "nominal_subcooling_healthy",
      "description": "A correctly charged unit: 45 \u00b0C condensing saturation against a 39 \u00b0C liquid line is 6 K of subcooling, near the ~5 K the source measured on its no-fault rig and half the shipped band.",
      "inputs": {
        "cond_sat_temp": 45.0,
        "liquid_temp": 39.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 9000,
          "equals": false
        }
      ]
    },
    {
      "name": "hot_day_same_subcooling_healthy",
      "description": "Both temperatures 10 K higher \u2014 a hot afternoon, or a condenser starved of air \u2014 and the same 6 K of subcooling. The rule reads the difference, not the level, so nothing here is a charge verdict. Pairs with overcharged_liquid_backs_up: wire the operands backwards and both flip.",
      "inputs": {
        "cond_sat_temp": 55.0,
        "liquid_temp": 49.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 9000,
          "equals": false
        }
      ]
    },
    {
      "name": "overcharged_liquid_backs_up",
      "description": "Excess refrigerant floods the lower condenser circuits: the liquid line leaves at 35 \u00b0C against a 50 \u00b0C condensing saturation, 15 K of subcooling. Both operands hold from t=0, so delayOnInit puts the alarm one alarm_delay in.",
      "inputs": {
        "cond_sat_temp": 50.0,
        "liquid_temp": 35.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1500,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2100,
          "to_s": 9000,
          "equals": true
        }
      ]
    },
    {
      "name": "subcooling_exactly_at_the_band",
      "description": "Threshold edge: 47.0 \u2212 35.0 is exactly subcooling_high_band (12.0 K), and both operands are binary-exact, so this is a true boundary pin. The comparison is strict, so a unit sitting precisely on the band reads healthy.",
      "inputs": {
        "cond_sat_temp": 47.0,
        "liquid_temp": 35.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 9000,
          "equals": false
        }
      ]
    },
    {
      "name": "subcooling_just_above_the_band",
      "description": "Threshold edge from the other side: 47.2 \u2212 35.0 is 12.2 K, two tenths past the band, and the alarm matures after alarm_delay.",
      "inputs": {
        "cond_sat_temp": 47.2,
        "liquid_temp": 35.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1500,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2100,
          "to_s": 9000,
          "equals": true
        }
      ]
    },
    {
      "name": "two_phase_liquid_line_reads_healthy",
      "description": "The state the source's 0.5 K TXV control-limit test names: 0.2 K of subcooling, a liquid line that is effectively two-phase. This card exposes no evaluability flag for it because the verdict is sound \u2014 a unit with no liquid seal is many things, none of them overcharged.",
      "inputs": {
        "cond_sat_temp": 45.0,
        "liquid_temp": 44.8
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 9000,
          "equals": false
        }
      ]
    },
    {
      "name": "negative_subcooling_reads_healthy",
      "description": "Sensor pathology: the liquid line reads 1.5 K hotter than the condensing saturation, which is physically impossible on a healthy circuit and usually means a P-T lookup running the wrong refrigerant or a swapped pair of sensors. The subtraction goes negative and the rule stays silent rather than saturating in either direction.",
      "inputs": {
        "cond_sat_temp": 45.0,
        "liquid_temp": 46.5
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 9000,
          "equals": false
        }
      ]
    },
    {
      "name": "transient_high_subcooling_never_alarms",
      "description": "Subcooling jumps to 15 K for 1200 s \u2014 a load step, or a head-pressure control stroke that briefly holds liquid in the condenser \u2014 and recovers 600 s short of alarm_delay. The timer resets on the recovery and nothing is reported.",
      "inputs": {
        "cond_sat_temp": 45.0,
        "liquid_temp": [
          {
            "t": 0,
            "value": 39.0
          },
          {
            "t": 600,
            "value": 30.0
          },
          {
            "t": 1800,
            "value": 39.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 9000,
          "equals": false
        }
      ]
    },
    {
      "name": "delay_runs_from_the_crossing_not_from_load",
      "description": "The unit starts healthy at 6 K and crosses the band at t = 1200 s, holding 13 K thereafter. The alarm lands at 1200 + alarm_delay, not at alarm_delay \u2014 the persistence window is measured from the crossing.",
      "inputs": {
        "cond_sat_temp": 45.0,
        "liquid_temp": [
          {
            "t": 0,
            "value": 39.0
          },
          {
            "t": 1200,
            "value": 32.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2700,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 3300,
          "to_s": 9000,
          "equals": true
        }
      ]
    },
    {
      "name": "alarm_clears_after_charge_recovered",
      "description": "Recovery: 15 K of subcooling alarms at 1800 s, the technician recovers the excess charge at t = 5400 s (playbook step 2), and the liquid line returns to 6 K of subcooling. yFault drops on that tick \u2014 TrueDelay only delays the rising edge.",
      "inputs": {
        "cond_sat_temp": 50.0,
        "liquid_temp": [
          {
            "t": 0,
            "value": 35.0
          },
          {
            "t": 5400,
            "value": 44.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1500,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2100,
          "to_s": 5100,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 5700,
          "to_s": 9000,
          "equals": false
        }
      ]
    }
  ]
}

HP-0006 — Reversing-valve internal bypass leakage

Statusverified — engine e2ff2f8, cxf:fnv1a128:04bdf03b92bee643d7e6186c505038a1, 2026-08-18
Severity3
Methodrule
Phase2
CategoryEFFICIENCY_LOSS
ConfidenceMEDIUM
EstimationPROXY_ESTIMATION
G36
Clusters
Suppresses
Suppressed by
RelatedHP-0003, HP-0004, HP-0001, HP-0007
Playbooksheat-pump-faults
SourceNIST SP 1087, Kim, Yoon, Payne & Domanski, Cooling Mode Fault Detection and Diagnosis Method for a Residential Heat Pump (October 2008), §5.1 Table 5.2 — zone-A fault-direction chart; NIST SP 1087 §5.4.2 (TXV control-limit test), §5.5.1 Figs 5.16-5.17 and §5.5.2 Tables 5.12/5.18 (EER sensitivity, misdiagnosis floor), §3.2.3 Table 3.3 (no-fault steady-state values); Li & Braun 2007, HVAC&R Research 13(2) — multiple-simultaneous-fault caveat
Operating statescooling, compressor running and settled — the source’s direction chart is cooling-mode only (see Deviations)

Preconditions (host-enforced): The compressor must be running and must have held its current capacity for at least 15 min: the source’s own steady-state detector needed 6-15 min after a start before its features settled, and a unit still pulling down shows deviations on physics rather than on fault. Defrost must be excluded, not merely tolerated — a defrost cycle reverses the circuit deliberately and scrambles every refrigerant-side temperature this rule reads, so the host gates on defrost_status. The four nominals are commissioning values, not library constants; until the host has written them from this unit’s own no-fault operation at the condition the instance runs in, the rule is comparing against the source rig’s numbers and means nothing (see Deviations). Nothing in the rule cross-checks its inputs: a drifted discharge sensor, or a P-T lookup configured for the wrong refrigerant, moves a conjunct on its own. TXV evaluability is signalled in-rule by yTxvOk; when it is false the verdict is NO_EVAL, not healthy.

Points: evap_sat_temp, cond_sat_temp, comp_discharge_temp, liquid_temp

Outputs:

  • yFault — True while all four refrigerant-side deviations — evaporating and discharge temperature above nominal, condensing temperature and subcooling below it — have held together for at least alarm_delay
  • yTxvOk — Evaluability signal — true when subcooling is above txv_control_min_subcool, the inlet condition under which the expansion valve is still compensating and the source’s direction chart applies; false means NO_EVAL and the host must ignore yFault

Parameters:

NameDefaultUnitCXF pathDescription
evap_sat_nominal10.0°CkEvapNom.kExpected evaporating saturation temperature for this unit at this operating condition. PER-UNIT COMMISSIONING VALUE; the shipped default is the source rig’s no-fault reading (see Deviations)
evap_sat_rise_min2.0KevapHigh.tRise above evap_sat_nominal that counts as the leakage signature. Adopted — the source classifies residuals against a fitted model, not a fixed band
disch_nominal66.0°CkDschNom.kExpected compressor discharge line temperature. PER-UNIT COMMISSIONING VALUE on the same terms as evap_sat_nominal
disch_rise_min5.0KdschHigh.tRise above disch_nominal that counts. Wider than the other bands because discharge temperature swings hardest with lift and load
cond_sat_nominal40.0°CkCondNom.kExpected condensing saturation temperature. PER-UNIT COMMISSIONING VALUE
cond_sat_fall_min2.0KcondLow.tFall below cond_sat_nominal that counts. This is the conjunct that separates leakage from condenser airflow restriction, which pushes condensing temperature the other way
subcool_nominal5.0KkScNom.kExpected liquid-line subcooling (cond_sat_temp − liquid_temp). PER-UNIT COMMISSIONING VALUE
subcool_fall_min1.5KscLow.tFall below subcool_nominal that counts. Must stay below subcool_nominal − txv_control_min_subcool or the fault band closes entirely (see Deviations)
txv_control_min_subcool0.5KtxvOk.tSubcooling below which the expansion valve inlet is two-phase, the valve has reached its opening limit and the source’s zone-A chart no longer describes the unit. Taken directly from NIST SP 1087 §5.4.2
alarm_delay1800.0spersist.delayTimeContinuous persistence of the full pattern required before the alarm asserts (30 min)

Description

A reversing valve fails two ways and HP-0003 only catches the first: a valve that never shifts, caught by discharge air that contradicts the commanded mode. This card catches the second — a valve that shifts correctly and then leaks internally, letting hot discharge gas bypass the slide straight back to the suction line. The unit still heats when told to heat, so the air-side test sees nothing and capacity quietly falls instead. NIST SP 1087 imposed graded leakage on an instrumented R410A residential heat pump and found it the most EER-sensitive of the six faults studied: 5.0% leakage cost 5.5% EER, while every other fault needed more than 10% fault level to lose the same 5%.

Detection Logic

subcooling = cond_sat_temp − liquid_temp

evap_rise  = evap_sat_temp       − evap_sat_nominal  > evap_sat_rise_min
disch_rise = comp_discharge_temp − disch_nominal     > disch_rise_min
cond_fall  = cond_sat_nominal    − cond_sat_temp     > cond_sat_fall_min
subc_fall  = subcool_nominal     − subcooling        > subcool_fall_min

yTxvOk = subcooling > txv_control_min_subcool   (false ⇒ host reports NO_EVAL)
yFault = evap_rise AND disch_rise AND cond_fall AND subc_fall AND yTxvOk,
         sustained continuously for alarm_delay

Block graph (rule.cxf.jsonld):

HP-0006 block graph

The conjunction is the diagnosis, not a robustness measure. Condenser airflow restriction also raises evaporating and discharge temperature and drops subcooling, but pushes condensing temperature up; undercharge drops condensing temperature and subcooling but leaves evaporating and discharge temperature alone. Drop either conjunct and the rule stops naming this fault.

yTxvOk is the source’s own control-limit test: above roughly 0.5 K of subcooling the valve inlet is single-phase liquid and the valve is still compensating, which is the regime its direction chart describes. Deploy knowing the consequence — the evaluable subcooling window is (txv_control_min_subcool, subcool_nominal − subcool_fall_min), so a leak severe enough to collapse subcooling past the floor reads NO_EVAL, not FAULT.

All five comparisons are strict. persist requires 30 continuous minutes, and delayOnInit = true holds that window across a controller restart.

Possible Diagnoses

  1. Reversing valve internal bypass leakage — an eroded or worn slide seal; the valve body is replaced rather than repaired, $500–$2,000 plus refrigerant recovery
  2. Compressor internal leakage — worn discharge valves or scroll flanks produce the same refrigerant-side pattern from a different component, and the source imposed both under one fault label. The service call separates them; this rule cannot
  3. Valve parked off-seat — a weak solenoid, a blocked pilot line, or low charge leaving too little differential to seat the slide, which then leaks by definition
  4. Stale nominals — an instance commissioned at one operating condition and evaluated at another. Check the commissioning record before condemning hardware
  5. Refrigerant-side instrumentation — a discharge sensor reading high, or a host P-T lookup configured for the wrong refrigerant, each move a conjunct with nothing in the rule to contradict them

Energy Impact

EFFICIENCY_LOSS, MEDIUM confidence, PROXY_ESTIMATION. NIST SP 1087 Table 5.12 measured EER degradation tracking leakage close to one-for-one across four test conditions: 5.0% leakage → 5.5% EER lost, 9.3% → 8.9%, 27.2% → 23.0%, 38.2% → 33.5%. The estimator is waste_kw = elec_power × eer_degradation_fraction, with the fraction taken from that relationship or from HP-0001’s fitted baseline on the same unit. PROXY because this rule reads four temperatures and no power at all. MEDIUM because the direction evidence is a controlled fault-imposition study but the magnitudes come from one lab unit in one mode.

Emissions Impact

Scope 2, PROXY_EMISSIONS, MEDIUM confidence; typically 200–1,500 kg CO₂e/yr for a commercial packaged heat pump, all of it compressor electricity, so the avoided-emissions basis is the marginal operating emissions rate (MOER). Note what is not here: an internally leaking valve loses nothing to atmosphere, so there is no scope 1 refrigerant component to add — the whole impact is the extra electricity, and it is worst at high lift, when the unit runs longest and the grid is dirtiest.

Deviations

  • Fixed nominal targets replace the source’s regressed reference model. NIST SP 1087 classifies each feature’s residual against a third-order polynomial fitted in three variables (indoor drybulb, outdoor drybulb, indoor dew point). The block set’s only regression primitive is a host-fitted line (HP-0001), so the four nominals become commissioning constants valid near the condition they were recorded at — the same named simplification RTU-0002 makes when it fixes its per-stage split baselines.
  • The shipped nominals are the source’s rig, not your unit. 10 °C evaporating, 66 °C discharge, 40 °C condensing and 5 K subcooling are the no-fault steady-state readings of NIST SP 1087 §3.2.3, taken on an R410A residential split at 26.7 °C indoor / 27.8 °C outdoor. They make the card runnable as delivered and nothing more.
  • The deviation bands are adopted, not transcribed. The source’s neutral-case thresholds run 0.3–1.0 K at 99% credibility, derived from lab instrumentation and a fitted model. Against a fixed nominal the operating-condition swing that model absorbed lands in the residual instead, so the shipped bands are several times wider. Narrow them only as far as a site’s own commissioning spread allows.
  • Superheat is not consumed, though the source lists it as a feature. Its chart marks superheat neutral for this fault and for every other TXV-in-control pattern, so a neutrality conjunct would discriminate nothing while adding suction_temp and two thresholds. Measurement point matters here: the source’s superheat is at the evaporator exit where the TXV holds it, whereas a superheat computed from suction_temp sits downstream of the bypass and would read high — which is a reason for charge rules to distrust suction-line superheat while this fault is live, not a reason to test it here.
  • The evaluability test is the source’s general control-limit criterion, not its rig-specific zone split. §5.4.2 grounds it physically — subcooling above ~0.5 K at the valve inlet means single-phase liquid and a valve still compensating — while the 9 °C superheat boundary the report also uses is a fitted artifact of that rig’s flow-coefficient curve. Only the general form belongs in a portable graph.
  • Not a suppression relationship with HP-0003. That card asks whether the valve switched at all, from discharge air; this one asks whether a switched valve holds its seal, from refrigerant temperatures. Neither failure implies the other and both can be absent at once, so the link is carried by related and suppresses/suppressed_by stay empty.
  • Cooling mode only. The source imposed every fault in cooling and publishes no heating-mode direction chart. Symmetry is physically plausible and unverified, and this library does not ship unverified direction charts; a heating-mode instance needs its own grounding and its own nominals.
  • A leak below roughly 5% of refrigerant flow is out of reach. The source’s own classifier misread 2.4–2.5% leaks as no-fault or as undercharge at EER losses of 1.3–3.4% (Table 5.18) — with lab instrumentation and a regressed baseline. A fixed-nominal rule is strictly weaker, so treat silence as “not this fault yet”, never as a clean valve.
  • Single-fault reasoning only. The direction chart is fitted from single-fault tests and its classifier assumes independence across features (Li & Braun 2007). Two simultaneous faults can cancel a conjunct or fabricate one; this card names one hypothesis, and the playbook’s charge check is what rules out the common companion.
  • Strict comparisons at all five limits. CDL Reals has no GreaterEqual, so a feature sitting exactly on its band edge reads healthy and subcooling exactly at txv_control_min_subcool reads NO_EVAL. The disagreement is measure-zero on real-valued signals and both sides of every limit are pinned by vectors.
  • persist.delayOnInit = true (CDL default is false), the library’s standing choice: a unit already showing the full pattern when the controller restarts waits out the 30 minutes rather than alarming on the first tick.
  • Operating state, the compressor-running gate and the defrost exclusion live in frontmatter for host enforcement rather than in the block graph, per the library’s design stance. Defrost is the sharp case: it reverses the circuit on purpose, which is this rule’s fault pattern by design.
  • The source publishes no deployable test vectors; every scenario in vectors.json is authored from its Table 5.2 direction chart and replayed against the pinned engine rev.

Notes

Check charge first: undercharge shares two of this rule’s four conjuncts, is far more common, and is cheap to rule out. The heat-pump-faults playbook orders that work. Expect HP-0001 on the same unit — a leak large enough for this rule to see costs 5% EER or more, a third of the way to that card’s alarm. Read yTxvOk before yFault: a severe leak can drive subcooling under the control-limit floor and silence the rule when it matters most. evap_sat_temp and cond_sat_temp are host-derived P-T conversions, and a lookup configured for the wrong refrigerant biases three of the four conjuncts at once.

Test Vectors

16 scenarios, clock step 60 s over 3600 s.

ScenarioDescription
no_fault_at_commissioned_nominalsEvery feature sits on its commissioned nominal: evaporating 10 °C, discharge 66 °C, condensing 40 °C, liquid line 35 °C for 5 K of subcooling. The expansion valve is comfortably in control.
internal_bypass_leakage_full_patternAll four deviations present at once: evaporating +3 K, discharge +8 K, condensing −3.5 K, subcooling 2.5 K. The alarm lands one alarm_delay after the first tick.
condenser_airflow_restriction_lookalikeThe source’s condenser-airflow pattern: evaporating and discharge up and subcooling down exactly as for leakage, but condensing temperature UP (43 °C) instead of down. condLow blocks alone and the rule stays quiet — this is the conjunct that separates the two faults.
undercharge_lookalikeThe source’s undercharge pattern with the valve still in control: condensing temperature and subcooling fall while evaporating and discharge stay on nominal. Both rise conjuncts block and the rule stays quiet — HP-0001 owns this one.
evap_rise_exactly_at_bandBoundary: evaporating temperature exactly evap_sat_rise_min above nominal (12.0 °C) with the other three conjuncts satisfied. GreaterThreshold is strict, so the band edge itself reads healthy.
evap_rise_just_past_bandBoundary from the other side: 12.1 °C clears the strict comparison and the alarm follows on schedule.
discharge_rise_exactly_at_bandBoundary: discharge exactly disch_rise_min above nominal (71.0 °C) with the other three conjuncts satisfied. dschHigh blocks alone.
discharge_rise_just_past_bandBoundary from the other side: 71.1 °C trips dschHigh and the pattern completes.
cond_fall_exactly_at_bandBoundary: condensing temperature exactly cond_sat_fall_min below nominal (38.0 °C), with the liquid line moved to hold subcooling at 2.5 K so only condLow sits at its edge.
cond_fall_just_past_bandBoundary from the other side: 37.9 °C condensing, liquid line moved with it to hold subcooling at 2.5 K.
subcool_fall_exactly_at_bandBoundary: subcooling exactly subcool_fall_min below nominal (3.5 K, from 36.5 − 33.0) with the other three conjuncts satisfied. scLow blocks alone.
subcool_fall_just_past_bandBoundary from the other side: subcooling 3.4 K completes the pattern.
txv_at_control_limit_is_no_evalSubcooling exactly at txv_control_min_subcool (0.5 K, from 36.5 − 36.0) with every other conjunct screaming leakage. The strict comparison puts yTxvOk false, so yFault is held down and the host must read NO_EVAL rather than health.
txv_just_inside_control_limitThe other side of the evaluability edge: subcooling 0.6 K clears txvOk, the pattern is judged, and the alarm asserts.
transient_shorter_than_alarm_delayThe full pattern appears for 1200 s — a load swing or a post-defrost recovery the host failed to gate — and then everything returns to nominal. Nothing reaches alarm_delay, so the rule never alarms.
alarm_clears_after_valve_replacementRecovery: the alarm asserts, the valve is replaced at t = 2400 s and every feature returns to nominal. TrueDelay passes falling edges immediately, so yFault clears on that tick.
vectors.json
{
  "schema": "cxf-library/vectors/v1",
  "clock": {
    "step_s": 60,
    "horizon_s": 3600
  },
  "scenarios": [
    {
      "name": "no_fault_at_commissioned_nominals",
      "description": "Every feature sits on its commissioned nominal: evaporating 10 \u00b0C, discharge 66 \u00b0C, condensing 40 \u00b0C, liquid line 35 \u00b0C for 5 K of subcooling. The expansion valve is comfortably in control.",
      "inputs": {
        "evap_sat_temp": 10.0,
        "comp_discharge_temp": 66.0,
        "cond_sat_temp": 40.0,
        "liquid_temp": 35.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        },
        {
          "output": "yTxvOk",
          "from_s": 0,
          "to_s": 3600,
          "equals": true
        }
      ]
    },
    {
      "name": "internal_bypass_leakage_full_pattern",
      "description": "All four deviations present at once: evaporating +3 K, discharge +8 K, condensing \u22123.5 K, subcooling 2.5 K. The alarm lands one alarm_delay after the first tick.",
      "inputs": {
        "evap_sat_temp": 13.0,
        "comp_discharge_temp": 74.0,
        "cond_sat_temp": 36.5,
        "liquid_temp": 34.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1740,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 1860,
          "to_s": 3600,
          "equals": true
        },
        {
          "output": "yTxvOk",
          "from_s": 0,
          "to_s": 3600,
          "equals": true
        }
      ]
    },
    {
      "name": "condenser_airflow_restriction_lookalike",
      "description": "The source's condenser-airflow pattern: evaporating and discharge up and subcooling down exactly as for leakage, but condensing temperature UP (43 \u00b0C) instead of down. condLow blocks alone and the rule stays quiet \u2014 this is the conjunct that separates the two faults.",
      "inputs": {
        "evap_sat_temp": 13.0,
        "comp_discharge_temp": 74.0,
        "cond_sat_temp": 43.0,
        "liquid_temp": 40.5
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        },
        {
          "output": "yTxvOk",
          "from_s": 0,
          "to_s": 3600,
          "equals": true
        }
      ]
    },
    {
      "name": "undercharge_lookalike",
      "description": "The source's undercharge pattern with the valve still in control: condensing temperature and subcooling fall while evaporating and discharge stay on nominal. Both rise conjuncts block and the rule stays quiet \u2014 HP-0001 owns this one.",
      "inputs": {
        "evap_sat_temp": 10.0,
        "comp_discharge_temp": 66.0,
        "cond_sat_temp": 36.5,
        "liquid_temp": 34.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        },
        {
          "output": "yTxvOk",
          "from_s": 0,
          "to_s": 3600,
          "equals": true
        }
      ]
    },
    {
      "name": "evap_rise_exactly_at_band",
      "description": "Boundary: evaporating temperature exactly evap_sat_rise_min above nominal (12.0 \u00b0C) with the other three conjuncts satisfied. GreaterThreshold is strict, so the band edge itself reads healthy.",
      "inputs": {
        "evap_sat_temp": 12.0,
        "comp_discharge_temp": 74.0,
        "cond_sat_temp": 36.5,
        "liquid_temp": 34.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "evap_rise_just_past_band",
      "description": "Boundary from the other side: 12.1 \u00b0C clears the strict comparison and the alarm follows on schedule.",
      "inputs": {
        "evap_sat_temp": 12.1,
        "comp_discharge_temp": 74.0,
        "cond_sat_temp": 36.5,
        "liquid_temp": 34.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1740,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 1860,
          "to_s": 3600,
          "equals": true
        }
      ]
    },
    {
      "name": "discharge_rise_exactly_at_band",
      "description": "Boundary: discharge exactly disch_rise_min above nominal (71.0 \u00b0C) with the other three conjuncts satisfied. dschHigh blocks alone.",
      "inputs": {
        "evap_sat_temp": 13.0,
        "comp_discharge_temp": 71.0,
        "cond_sat_temp": 36.5,
        "liquid_temp": 34.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "discharge_rise_just_past_band",
      "description": "Boundary from the other side: 71.1 \u00b0C trips dschHigh and the pattern completes.",
      "inputs": {
        "evap_sat_temp": 13.0,
        "comp_discharge_temp": 71.1,
        "cond_sat_temp": 36.5,
        "liquid_temp": 34.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1740,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 1860,
          "to_s": 3600,
          "equals": true
        }
      ]
    },
    {
      "name": "cond_fall_exactly_at_band",
      "description": "Boundary: condensing temperature exactly cond_sat_fall_min below nominal (38.0 \u00b0C), with the liquid line moved to hold subcooling at 2.5 K so only condLow sits at its edge.",
      "inputs": {
        "evap_sat_temp": 13.0,
        "comp_discharge_temp": 74.0,
        "cond_sat_temp": 38.0,
        "liquid_temp": 35.5
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "cond_fall_just_past_band",
      "description": "Boundary from the other side: 37.9 \u00b0C condensing, liquid line moved with it to hold subcooling at 2.5 K.",
      "inputs": {
        "evap_sat_temp": 13.0,
        "comp_discharge_temp": 74.0,
        "cond_sat_temp": 37.9,
        "liquid_temp": 35.4
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1740,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 1860,
          "to_s": 3600,
          "equals": true
        }
      ]
    },
    {
      "name": "subcool_fall_exactly_at_band",
      "description": "Boundary: subcooling exactly subcool_fall_min below nominal (3.5 K, from 36.5 \u2212 33.0) with the other three conjuncts satisfied. scLow blocks alone.",
      "inputs": {
        "evap_sat_temp": 13.0,
        "comp_discharge_temp": 74.0,
        "cond_sat_temp": 36.5,
        "liquid_temp": 33.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        },
        {
          "output": "yTxvOk",
          "from_s": 0,
          "to_s": 3600,
          "equals": true
        }
      ]
    },
    {
      "name": "subcool_fall_just_past_band",
      "description": "Boundary from the other side: subcooling 3.4 K completes the pattern.",
      "inputs": {
        "evap_sat_temp": 13.0,
        "comp_discharge_temp": 74.0,
        "cond_sat_temp": 36.5,
        "liquid_temp": 33.1
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1740,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 1860,
          "to_s": 3600,
          "equals": true
        }
      ]
    },
    {
      "name": "txv_at_control_limit_is_no_eval",
      "description": "Subcooling exactly at txv_control_min_subcool (0.5 K, from 36.5 \u2212 36.0) with every other conjunct screaming leakage. The strict comparison puts yTxvOk false, so yFault is held down and the host must read NO_EVAL rather than health.",
      "inputs": {
        "evap_sat_temp": 13.0,
        "comp_discharge_temp": 74.0,
        "cond_sat_temp": 36.5,
        "liquid_temp": 36.0
      },
      "expect": [
        {
          "output": "yTxvOk",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "txv_just_inside_control_limit",
      "description": "The other side of the evaluability edge: subcooling 0.6 K clears txvOk, the pattern is judged, and the alarm asserts.",
      "inputs": {
        "evap_sat_temp": 13.0,
        "comp_discharge_temp": 74.0,
        "cond_sat_temp": 36.5,
        "liquid_temp": 35.9
      },
      "expect": [
        {
          "output": "yTxvOk",
          "from_s": 0,
          "to_s": 3600,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1740,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 1860,
          "to_s": 3600,
          "equals": true
        }
      ]
    },
    {
      "name": "transient_shorter_than_alarm_delay",
      "description": "The full pattern appears for 1200 s \u2014 a load swing or a post-defrost recovery the host failed to gate \u2014 and then everything returns to nominal. Nothing reaches alarm_delay, so the rule never alarms.",
      "inputs": {
        "evap_sat_temp": [
          {
            "t": 0,
            "value": 13.0
          },
          {
            "t": 1200,
            "value": 10.0
          }
        ],
        "comp_discharge_temp": [
          {
            "t": 0,
            "value": 74.0
          },
          {
            "t": 1200,
            "value": 66.0
          }
        ],
        "cond_sat_temp": [
          {
            "t": 0,
            "value": 36.5
          },
          {
            "t": 1200,
            "value": 40.0
          }
        ],
        "liquid_temp": [
          {
            "t": 0,
            "value": 34.0
          },
          {
            "t": 1200,
            "value": 35.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "alarm_clears_after_valve_replacement",
      "description": "Recovery: the alarm asserts, the valve is replaced at t = 2400 s and every feature returns to nominal. TrueDelay passes falling edges immediately, so yFault clears on that tick.",
      "inputs": {
        "evap_sat_temp": [
          {
            "t": 0,
            "value": 13.0
          },
          {
            "t": 2400,
            "value": 10.0
          }
        ],
        "comp_discharge_temp": [
          {
            "t": 0,
            "value": 74.0
          },
          {
            "t": 2400,
            "value": 66.0
          }
        ],
        "cond_sat_temp": [
          {
            "t": 0,
            "value": 36.5
          },
          {
            "t": 2400,
            "value": 40.0
          }
        ],
        "liquid_temp": [
          {
            "t": 0,
            "value": 34.0
          },
          {
            "t": 2400,
            "value": 35.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1740,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 1860,
          "to_s": 2340,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 2460,
          "to_s": 3600,
          "equals": false
        }
      ]
    }
  ]
}

HP-0007 — Heat-pump compressor proof-of-operation failure

Statusverified — engine e2ff2f8, cxf:fnv1a128:3666ba36035e5c81277de17f12f77ae3, 2026-08-20
Severity2
Methodrule
Phase2
CategoryPROTECTIVE
ConfidenceMEDIUM
EstimationPROXY_ESTIMATION
G36
Clusters
Suppresses
Suppressed by
RelatedHP-0001, HP-0002, HP-0003, HP-0004, HP-0005, HP-0006, RTU-0001, HP-0008
Playbooksproof-of-operation, heat-pump-faults
SourceLibrary proof-of-operation precedents AHU-0039, TOWER-0004, PMP-0003, and HW-0009 — final command versus independent status, separate direction timers, and initialization-safe persistence; ASHRAE Guideline 36-2021 section 5.1.6 — equipment is proven when its digital status matches the state set by its digital command; semantic grounding only, not a source for these shipped timer values; Library-authored heat-pump compressor timing adaptation; no cited source publishes 300 s start and 120 s stop proof limits as portable values
Operating statesall states in which a final per-compressor command and independent proof for that same compressor or circuit are authoritative

Preconditions (host-enforced): Bind comp_cmd downstream of anti-short-cycle delay, safety lockouts, staging, demand response, and OEM permissives; an upstream heating/cooling demand is not a final compressor command. Bind comp_status to actual compressor electrical, inverter, pressure, or auxiliary proof, not demand or command echo. Instantiate per compressor/circuit where possible. An OR command/status pair proves only that some compressor runs and cannot detect a failed lag compressor while the lead remains on. Exclude defrost transitions, pump-down, crankcase or oil-management sequences, emergency heat, service, and manufacturer restart delays not represented in the final command. Inputs must be fresh and aligned; unmet obligations are NO_EVAL, not healthy.

Points: comp_cmd, comp_status

Outputs:

  • yFault — True while either final command/status mismatch has matured through its own proof timer
  • yFailToStart — Diagnostic direction flag; true after a final start command remains without independent run proof for start_proof_time. False never means NO_EVAL
  • yUnexpectedRun — Diagnostic direction flag; true after independent operation continues without a final run command for stop_proof_time. False never means NO_EVAL

Parameters:

NameDefaultUnitCXF pathDescription
start_proof_time300.0sstartProof.delayTimeADOPTED_TUNABLE final-command-to-proof allowance. Commission above normal device response, acceleration, proof pickup, and telemetry latency.
stop_proof_time120.0sstopProof.delayTimeADOPTED_TUNABLE command-off-to-proof-dropout allowance. Commission above normal deceleration, coast-down, proof dropout, and telemetry latency; intentional sequence operation must remain represented in the final command.

Description

This rule checks whether the heat-pump compressor did what its final Boolean command requested. Commanded on without independent proof is a fail-to-start; proven on without command is unexpected operation. The direction identifies the mismatch, not its cause, and neither diagnostic output is an evaluability gate.

Detection Logic

fail_to_start  = comp_cmd AND NOT comp_status
unexpected_run = NOT comp_cmd AND comp_status

yFailToStart   = fail_to_start sustained for start_proof_time
yUnexpectedRun = unexpected_run sustained for stop_proof_time
yFault         = yFailToStart OR yUnexpectedRun

HP-0007 block graph

Each direction has its own TrueDelay(delayOnInit=true). Agreement clears both lanes immediately. A direct mismatch reversal clears the old diagnostic and starts the other timer from zero; elapsed time never transfers between lanes.

Possible Diagnoses

  1. Compressor, contactor, inverter, capacitor, disconnect, or power failure.
  2. High/low-pressure, temperature, current, oil, or OEM safety lockout.
  3. Final command incorrectly bound upstream of anti-cycle or permissive logic.
  4. Bad compressor proof, command/status wiring, or multi-compressor aggregation.
  5. OEM defrost, pump-down, protection sequence, or local service control.

Energy Impact

The effect is direction-dependent. Unexpected operation can waste measured electrical energy during the mismatch. Fail-to-start is primarily availability, comfort, and diagnostic-coverage loss; these two booleans cannot price it.

Emissions Impact

Scope 2 is proxy-only for unexpected operation: multiply independently measured device kW by mismatch hours and an appropriate operating emissions factor. Do not claim avoided energy or emissions for fail-to-start without another model.

Deviations

  • Both timers are adopted commissioning values. No cited source establishes universal heat-pump compressor proof windows. Configure them independently around the actual sequence, proof device, sampling, and network latency.
  • The command is final and device-scoped. An upstream enable, demand, or fleet request can disagree with status while downstream logic works correctly.
  • Status is independent proof. Command echo makes the graph tautological; proof type determines whether electrical operation, rotation, or delivery was actually demonstrated.
  • No whole-rule suppression is encoded. Fail-to-start can invalidate another rule’s running premise, but unexpected operation may leave that rule physically meaningful; current metadata cannot suppress by direction.
  • delayOnInit=true is explicit on both lanes. Evaluator restart into an existing mismatch must serve the full configured proof time.
  • No empirical FPR or TPR is claimed. Current simulation telemetry cannot provide both an independent final command and field-like proof for this device.
  • The 300 s start allowance is an adopted commissioning placeholder for final-command-to-proof latency. It is not permission to bind upstream demand; ordinary anti-short-cycle timing belongs before comp_cmd.
  • Fleet OR aggregation has a documented blind spot: a lag compressor can fail while the lead keeps both OR signals true. Per-compressor instances are required whenever telemetry permits.
  • A brief compressor-off interval during defrost is raw fail-to-start if the final command stays true. The host excludes the transition unless the OEM final command already represents it.

Notes

Use yFailToStart to question the running premise of HP-0001 through HP-0006 and the compressor-status premise of related RTU-0001. yUnexpectedRun may leave those measurements meaningful, so no whole-rule suppression is encoded.

Test Vectors

27 scenarios, clock step 10 s over 2400 s.

ScenarioDescription
agreement_stoppedCommand and independent proof agree off; every direction remains clear.
agreement_runningCommand and independent proof agree on; every direction remains clear.
fail_to_start_at_initializationA standing commanded-on/unproved mismatch is clear one tick before 300 s, asserts exactly at 300 s, and remains true after the initialization-safe start timer.
unexpected_run_at_initializationA standing command-off/proven-on mismatch is clear one tick before 120 s, asserts exactly at 120 s, and remains true after the initialization-safe stop timer.
normal_delayed_start_and_stopStatus proves one tick inside both configured allowances, so ordinary start and stop latency never alarms.
start_mismatch_ends_one_tick_before_proofA start mismatch beginning at 300 s clears at 590 s, one 10 s evaluator step before maturity, and never alarms.
start_mismatch_ends_at_proof_boundaryThe start mismatch clears exactly 300 s after it begins. Agreement on the maturity tick wins and no alarm is asserted.
stop_mismatch_ends_one_tick_before_proofA stop mismatch beginning at 300 s clears at 410 s, one 10 s evaluator step before maturity, and never alarms.
stop_mismatch_ends_at_proof_boundaryThe stop mismatch clears exactly 120 s after it begins. Agreement on the maturity tick wins and no alarm is asserted.
mature_start_alarm_recoversA fail-to-start persists past its proof time, then clears immediately when independent proof arrives.
mature_stop_alarm_recoversAn unexpected run persists past its proof time, then clears immediately when proof drops.
pending_direction_reversal_starts_fresh_laneCommand and status swap while fail-to-start is pending. The old lane resets and the unexpected-run lane must serve its complete independent timer.
mature_direction_reversal_hands_offA mature fail-to-start reverses directly into unexpected-run. The old flag clears on the reversal tick, yFault has a real gap, and the opposite lane serves a fresh complete stop timer.
two_short_start_mismatches_do_not_accumulateTwo commanded-on/unproved episodes are separated by agreement; elapsed time cannot transfer across the reset.
two_short_stop_mismatches_do_not_accumulateTwo proven-on/uncommanded episodes are separated by agreement; elapsed time cannot transfer across the reset.
command_chatter_never_maturesThe final command chatters faster than either proof window while status is off; no continuous mismatch matures.
status_chatter_never_maturesIndependent proof chatters against a held-on command; every unproved interval clears before the start timer.
unexpected_run_chatter_never_maturesIndependent proof chatters on against a held-off command; every unexpected-run interval clears before the stop timer.
upstream_enable_raw_false_positiveAn upstream enable held true while downstream logic correctly withholds operation produces a raw fail-to-start alarm; this pins why only the final command is a valid binding.
command_echo_raw_blind_spotA status echo follows the command while the device is mechanically stopped. The pair agrees and the graph stays silent, pinning why proof must be independent.
manual_local_run_is_host_excludedA locally commanded device is proven on while the BAS final command is off. The raw unexpected-run alarm is correct; approved service or hand operation is host NO_EVAL.
normal_start_proof_latency_just_inside_timerA legitimate final compressor command precedes independent proof by 290 s, just inside the 300 s timer, and does not alarm.
downstream_anti_cycle_behavior_stays_in_agreementUpstream demand is intentionally absent from the graph: after the OEM anti-cycle interval, the final command and independent proof rise together and remain in agreement.
start_delay_just_beyond_timerProof arrives 310 s after command, one tick beyond the 300 s timer. Fail-to-start asserts exactly at 600 s and clears at 610 s.
defrost_transition_final_pair_agreesWhen the OEM final command represents a defrost compressor-off interval, command and proof drop together for 180 s and the proof rule stays in agreement.
brief_defrost_off_with_upstream_command_is_host_excludedIf an upstream request remains true while OEM defrost logic stops the compressor for 180 s, the raw pair is mis-scoped. It stays below 300 s here, but the host must gate it or bind the downstream final command.
multi_compressor_or_hides_lag_failureAn OR command and OR status both remain true because the lead compressor runs, hiding a failed lag-compressor request. The graph stays silent and documents the aggregation blind spot.
vectors.json
{
  "schema": "cxf-library/vectors/v1",
  "clock": {
    "step_s": 10,
    "horizon_s": 2400
  },
  "scenarios": [
    {
      "name": "agreement_stopped",
      "description": "Command and independent proof agree off; every direction remains clear.",
      "inputs": {
        "comp_cmd": false,
        "comp_status": false
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        }
      ]
    },
    {
      "name": "agreement_running",
      "description": "Command and independent proof agree on; every direction remains clear.",
      "inputs": {
        "comp_cmd": true,
        "comp_status": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        }
      ]
    },
    {
      "name": "fail_to_start_at_initialization",
      "description": "A standing commanded-on/unproved mismatch is clear one tick before 300 s, asserts exactly at 300 s, and remains true after the initialization-safe start timer.",
      "inputs": {
        "comp_cmd": true,
        "comp_status": false
      },
      "expect": [
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 290,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 300,
          "to_s": 2400,
          "equals": true
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 290,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 300,
          "to_s": 2400,
          "equals": true
        }
      ]
    },
    {
      "name": "unexpected_run_at_initialization",
      "description": "A standing command-off/proven-on mismatch is clear one tick before 120 s, asserts exactly at 120 s, and remains true after the initialization-safe stop timer.",
      "inputs": {
        "comp_cmd": false,
        "comp_status": true
      },
      "expect": [
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 110,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 120,
          "to_s": 2400,
          "equals": true
        },
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 110,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 120,
          "to_s": 2400,
          "equals": true
        }
      ]
    },
    {
      "name": "normal_delayed_start_and_stop",
      "description": "Status proves one tick inside both configured allowances, so ordinary start and stop latency never alarms.",
      "inputs": {
        "comp_cmd": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 300,
            "value": true
          },
          {
            "t": 1200,
            "value": false
          }
        ],
        "comp_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 590,
            "value": true
          },
          {
            "t": 1310,
            "value": false
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        }
      ]
    },
    {
      "name": "start_mismatch_ends_one_tick_before_proof",
      "description": "A start mismatch beginning at 300 s clears at 590 s, one 10 s evaluator step before maturity, and never alarms.",
      "inputs": {
        "comp_cmd": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 300,
            "value": true
          }
        ],
        "comp_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 590,
            "value": true
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        }
      ]
    },
    {
      "name": "start_mismatch_ends_at_proof_boundary",
      "description": "The start mismatch clears exactly 300 s after it begins. Agreement on the maturity tick wins and no alarm is asserted.",
      "inputs": {
        "comp_cmd": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 300,
            "value": true
          }
        ],
        "comp_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 600,
            "value": true
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        }
      ]
    },
    {
      "name": "stop_mismatch_ends_one_tick_before_proof",
      "description": "A stop mismatch beginning at 300 s clears at 410 s, one 10 s evaluator step before maturity, and never alarms.",
      "inputs": {
        "comp_cmd": [
          {
            "t": 0,
            "value": true
          },
          {
            "t": 300,
            "value": false
          }
        ],
        "comp_status": [
          {
            "t": 0,
            "value": true
          },
          {
            "t": 410,
            "value": false
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        }
      ]
    },
    {
      "name": "stop_mismatch_ends_at_proof_boundary",
      "description": "The stop mismatch clears exactly 120 s after it begins. Agreement on the maturity tick wins and no alarm is asserted.",
      "inputs": {
        "comp_cmd": [
          {
            "t": 0,
            "value": true
          },
          {
            "t": 300,
            "value": false
          }
        ],
        "comp_status": [
          {
            "t": 0,
            "value": true
          },
          {
            "t": 420,
            "value": false
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        }
      ]
    },
    {
      "name": "mature_start_alarm_recovers",
      "description": "A fail-to-start persists past its proof time, then clears immediately when independent proof arrives.",
      "inputs": {
        "comp_cmd": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 300,
            "value": true
          }
        ],
        "comp_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 700,
            "value": true
          }
        ]
      },
      "expect": [
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 590,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 600,
          "to_s": 690,
          "equals": true
        },
        {
          "output": "yFailToStart",
          "from_s": 700,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 590,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 600,
          "to_s": 690,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 700,
          "to_s": 2400,
          "equals": false
        }
      ]
    },
    {
      "name": "mature_stop_alarm_recovers",
      "description": "An unexpected run persists past its proof time, then clears immediately when proof drops.",
      "inputs": {
        "comp_cmd": [
          {
            "t": 0,
            "value": true
          },
          {
            "t": 300,
            "value": false
          }
        ],
        "comp_status": [
          {
            "t": 0,
            "value": true
          },
          {
            "t": 520,
            "value": false
          }
        ]
      },
      "expect": [
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 410,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 420,
          "to_s": 510,
          "equals": true
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 520,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 410,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 420,
          "to_s": 510,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 520,
          "to_s": 2400,
          "equals": false
        }
      ]
    },
    {
      "name": "pending_direction_reversal_starts_fresh_lane",
      "description": "Command and status swap while fail-to-start is pending. The old lane resets and the unexpected-run lane must serve its complete independent timer.",
      "inputs": {
        "comp_cmd": [
          {
            "t": 0,
            "value": true
          },
          {
            "t": 280,
            "value": false
          }
        ],
        "comp_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 280,
            "value": true
          }
        ]
      },
      "expect": [
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 390,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 400,
          "to_s": 2400,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 390,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 400,
          "to_s": 2400,
          "equals": true
        }
      ]
    },
    {
      "name": "mature_direction_reversal_hands_off",
      "description": "A mature fail-to-start reverses directly into unexpected-run. The old flag clears on the reversal tick, yFault has a real gap, and the opposite lane serves a fresh complete stop timer.",
      "inputs": {
        "comp_cmd": [
          {
            "t": 0,
            "value": true
          },
          {
            "t": 400,
            "value": false
          }
        ],
        "comp_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 400,
            "value": true
          }
        ]
      },
      "expect": [
        {
          "output": "yFailToStart",
          "from_s": 300,
          "to_s": 390,
          "equals": true
        },
        {
          "output": "yFailToStart",
          "from_s": 400,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 510,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 520,
          "to_s": 2400,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 300,
          "to_s": 390,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 400,
          "to_s": 510,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 520,
          "to_s": 2400,
          "equals": true
        }
      ]
    },
    {
      "name": "two_short_start_mismatches_do_not_accumulate",
      "description": "Two commanded-on/unproved episodes are separated by agreement; elapsed time cannot transfer across the reset.",
      "inputs": {
        "comp_cmd": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 300,
            "value": true
          },
          {
            "t": 580,
            "value": false
          },
          {
            "t": 900,
            "value": true
          },
          {
            "t": 1180,
            "value": false
          }
        ],
        "comp_status": false
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        }
      ]
    },
    {
      "name": "two_short_stop_mismatches_do_not_accumulate",
      "description": "Two proven-on/uncommanded episodes are separated by agreement; elapsed time cannot transfer across the reset.",
      "inputs": {
        "comp_cmd": false,
        "comp_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 300,
            "value": true
          },
          {
            "t": 400,
            "value": false
          },
          {
            "t": 900,
            "value": true
          },
          {
            "t": 1000,
            "value": false
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        }
      ]
    },
    {
      "name": "command_chatter_never_matures",
      "description": "The final command chatters faster than either proof window while status is off; no continuous mismatch matures.",
      "inputs": {
        "comp_cmd": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 300,
            "value": true
          },
          {
            "t": 400,
            "value": false
          },
          {
            "t": 600,
            "value": true
          },
          {
            "t": 700,
            "value": false
          },
          {
            "t": 900,
            "value": true
          },
          {
            "t": 1000,
            "value": false
          }
        ],
        "comp_status": false
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        }
      ]
    },
    {
      "name": "status_chatter_never_matures",
      "description": "Independent proof chatters against a held-on command; every unproved interval clears before the start timer.",
      "inputs": {
        "comp_cmd": true,
        "comp_status": [
          {
            "t": 0,
            "value": true
          },
          {
            "t": 300,
            "value": false
          },
          {
            "t": 580,
            "value": true
          },
          {
            "t": 600,
            "value": false
          },
          {
            "t": 880,
            "value": true
          },
          {
            "t": 900,
            "value": false
          },
          {
            "t": 1180,
            "value": true
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        }
      ]
    },
    {
      "name": "unexpected_run_chatter_never_matures",
      "description": "Independent proof chatters on against a held-off command; every unexpected-run interval clears before the stop timer.",
      "inputs": {
        "comp_cmd": false,
        "comp_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 300,
            "value": true
          },
          {
            "t": 400,
            "value": false
          },
          {
            "t": 600,
            "value": true
          },
          {
            "t": 700,
            "value": false
          },
          {
            "t": 900,
            "value": true
          },
          {
            "t": 1000,
            "value": false
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        }
      ]
    },
    {
      "name": "upstream_enable_raw_false_positive",
      "description": "An upstream enable held true while downstream logic correctly withholds operation produces a raw fail-to-start alarm; this pins why only the final command is a valid binding.",
      "inputs": {
        "comp_cmd": true,
        "comp_status": false
      },
      "expect": [
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 290,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 300,
          "to_s": 2400,
          "equals": true
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 290,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 300,
          "to_s": 2400,
          "equals": true
        }
      ]
    },
    {
      "name": "command_echo_raw_blind_spot",
      "description": "A status echo follows the command while the device is mechanically stopped. The pair agrees and the graph stays silent, pinning why proof must be independent.",
      "inputs": {
        "comp_cmd": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 300,
            "value": true
          }
        ],
        "comp_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 300,
            "value": true
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        }
      ]
    },
    {
      "name": "manual_local_run_is_host_excluded",
      "description": "A locally commanded device is proven on while the BAS final command is off. The raw unexpected-run alarm is correct; approved service or hand operation is host NO_EVAL.",
      "inputs": {
        "comp_cmd": false,
        "comp_status": true
      },
      "expect": [
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 110,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 120,
          "to_s": 2400,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 110,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 120,
          "to_s": 2400,
          "equals": true
        }
      ]
    },
    {
      "name": "normal_start_proof_latency_just_inside_timer",
      "description": "A legitimate final compressor command precedes independent proof by 290 s, just inside the 300 s timer, and does not alarm.",
      "inputs": {
        "comp_cmd": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 300,
            "value": true
          }
        ],
        "comp_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 590,
            "value": true
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        }
      ]
    },
    {
      "name": "downstream_anti_cycle_behavior_stays_in_agreement",
      "description": "Upstream demand is intentionally absent from the graph: after the OEM anti-cycle interval, the final command and independent proof rise together and remain in agreement.",
      "inputs": {
        "comp_cmd": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 590,
            "value": true
          }
        ],
        "comp_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 590,
            "value": true
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        }
      ]
    },
    {
      "name": "start_delay_just_beyond_timer",
      "description": "Proof arrives 310 s after command, one tick beyond the 300 s timer. Fail-to-start asserts exactly at 600 s and clears at 610 s.",
      "inputs": {
        "comp_cmd": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 300,
            "value": true
          }
        ],
        "comp_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 610,
            "value": true
          }
        ]
      },
      "expect": [
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 590,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 600,
          "to_s": 600,
          "equals": true
        },
        {
          "output": "yFailToStart",
          "from_s": 610,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 590,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 600,
          "to_s": 600,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 610,
          "to_s": 2400,
          "equals": false
        }
      ]
    },
    {
      "name": "defrost_transition_final_pair_agrees",
      "description": "When the OEM final command represents a defrost compressor-off interval, command and proof drop together for 180 s and the proof rule stays in agreement.",
      "inputs": {
        "comp_cmd": [
          {
            "t": 0,
            "value": true
          },
          {
            "t": 600,
            "value": false
          },
          {
            "t": 780,
            "value": true
          }
        ],
        "comp_status": [
          {
            "t": 0,
            "value": true
          },
          {
            "t": 600,
            "value": false
          },
          {
            "t": 780,
            "value": true
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        }
      ]
    },
    {
      "name": "brief_defrost_off_with_upstream_command_is_host_excluded",
      "description": "If an upstream request remains true while OEM defrost logic stops the compressor for 180 s, the raw pair is mis-scoped. It stays below 300 s here, but the host must gate it or bind the downstream final command.",
      "inputs": {
        "comp_cmd": true,
        "comp_status": [
          {
            "t": 0,
            "value": true
          },
          {
            "t": 600,
            "value": false
          },
          {
            "t": 780,
            "value": true
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        }
      ]
    },
    {
      "name": "multi_compressor_or_hides_lag_failure",
      "description": "An OR command and OR status both remain true because the lead compressor runs, hiding a failed lag-compressor request. The graph stays silent and documents the aggregation blind spot.",
      "inputs": {
        "comp_cmd": true,
        "comp_status": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        }
      ]
    }
  ]
}

HP-0008 — Auxiliary heat active above lockout with compressor running

Statusverified — engine e2ff2f8, cxf:fnv1a128:8917cda230eb3df1fff05a46442b7cf9, 2026-08-20
Severity2
Methodrule
Phase2
CategoryEXCESS_CONSUMPTION
ConfidenceMEDIUM
EstimationPROXY_ESTIMATION
G36
Clusters
Suppresses
Suppressed by
RelatedHP-0001, HP-0002, HP-0007
Playbooksheat-pump-faults
SourceWinkler and Ramaraj, Field Validation of Air-Source Heat Pumps for Cold Climates, NREL/TP-5500-84745 (2023), Table 5 and note b, p.15 — auxiliary lockout is the maximum OAT for auxiliary operation; observed site values vary; NREL HPXML Data Dictionary v4.0, BackupHeatingLockoutTemperature — backup is disabled above the configured temperature and dual-fuel uses BackupHeatingSwitchoverTemperature; Library-authored executable combination of heating mode, configured OAT lockout, concurrent compressor/auxiliary proof, defrost exclusion, and persistence
Operating statesnormal automatic heat-pump heating when the configured strategy prohibits auxiliary heat above its site-specific lockout while the compressor runs

Preconditions (host-enforced): aux_heat_status must independently prove active heat production by an explicitly classified auxiliary electric, gas, or other source; do not bind demand, command echo, availability, crankcase heat, base-pan heat, or defrost heat. The 5 degC placeholder is adoption-blocking: replace it with the installed balance/lockout strategy for climate, tariff, and equipment. mode_command code 1 must mean heating; comp_status must prove the same applicable compressor scope. OAT must be valid and representative. Exclude defrost, emergency heat, compressor failure/lockout, demand response, commissioning, and explicit high-capacity recovery. On dual-fuel equipment instantiate only when simultaneous compressor/fuel operation above the configured switchover is prohibited. Unmet obligations are NO_EVAL, not healthy.

Points: aux_heat_status, oat, mode_command, defrost_status, comp_status

Outputs:

  • yFault — True after heating mode, above-lockout OAT, concurrent auxiliary/compressor proof, and no defrost persist for sustained_duration
  • yHeatingMode — Diagnostic sub-condition flag; true when mode_command equals heating_mode_code. False never means NO_EVAL
  • yAboveLockout — Diagnostic sub-condition flag; true when OAT is strictly above aux_heat_lockout_oat. False never means NO_EVAL
  • yConcurrentHeat — Diagnostic sub-condition flag; true when auxiliary heat and compressor proofs are both active. False never means NO_EVAL

Parameters:

NameDefaultUnitCXF pathDescription
heating_mode_code11kHeat.kLIBRARY_PRECEDENT host-mapped heating-mode integer. Rebind when the site enumeration differs.
aux_heat_lockout_oat5.0°CaboveLockout.tNO_PORTABLE_DEFAULT and adoption-blocking placeholder. Replace with the installed unit’s commissioned auxiliary lockout or dual-fuel switchover strategy; equality is clear.
sustained_duration300.0spersist.delayTimeADOPTED_TUNABLE continuous signature duration intended to reject brief staging transitions; commission against the OEM sequence.

Description

This rule detects a proven auxiliary heat source operating concurrently with the heat-pump compressor above the configured outdoor lockout in heating mode, outside defrost. It targets prohibited concurrent operation, not emergency heat, required low-temperature supplementation, or balance-point optimization.

Detection Logic

heating_mode   = mode_command == heating_mode_code
above_lockout  = oat > aux_heat_lockout_oat
concurrent_heat = aux_heat_status AND comp_status
candidate = heating_mode AND above_lockout AND concurrent_heat
            AND NOT defrost_status

yHeatingMode = heating_mode
yAboveLockout = above_lockout
yConcurrentHeat = concurrent_heat
yFault = candidate sustained for sustained_duration

HP-0008 block graph

The OAT comparison is strict. Diagnostic outputs are immediate; TrueDelay(delayOnInit=true) applies only to the complete signature. Any false candidate subcondition resets timing and clears a mature alarm.

Possible Diagnoses

  1. Auxiliary lockout or dual-fuel switchover misconfigured or disabled
  2. Staging/thermostat sequence energizes backup heat too early
  3. OAT sensor bias, bad location, stale value, or wrong unit conversion
  4. Auxiliary proof bound to command, availability, or a non-space-heating load
  5. Compressor-capacity fault causing an authorized recovery mode not host-gated
  6. Defrost, emergency, demand-response, or commissioning state omitted from gating

Energy Impact

Unnecessary resistance or fuel backup can displace more efficient compressor heating. Estimate only from verified auxiliary stage input during fault hours, after proving that the installed sequence did not require the extra capacity.

Emissions Impact

Scope 2 applies to electric auxiliary heat and compressor power; Scope 1 can apply to fuel backup. Use measured or nameplate stage input and appropriate time-varying electricity or fuel emissions factors.

Deviations

  • 5 °C is not a portable default. It is an adoption-blocking placeholder; the applicable value is the installed balance/lockout or switchover strategy.
  • Confidence is MEDIUM rather than the brief’s proposed HIGH. Auxiliary role/proof, dual-fuel behavior, site setpoint, and emergency/recovery modes must be established before concurrent operation is avoidable.
  • Auxiliary semantics are provisional. Brick and 223 provide generic heating/status patterns but no exact auxiliary role; topology and independent proof must identify the real supplemental source.
  • Defrost is encoded; other exceptions are host gates. Emergency, failure, demand-response, and recovery state semantics are site-specific.
  • No automatic suppression is added. HP-0007 direction should lead diagnosis, but unexpected compressor proof can leave this concurrent-energy signature valid.
  • No empirical FPR or TPR is claimed. Current datasets do not expose aligned auxiliary-production proof, defrost state, and the configured site lockout.

Test Vectors

14 scenarios, clock step 60 s over 720 s.

ScenarioDescription
cooling_modeConcurrent heat in the cooling code is diagnostically visible but outside the heating-mode candidate.
heating_below_lockoutAuxiliary support below the configured lockout is permitted by this rule.
heating_above_lockout_without_auxiliaryCompressor heating above lockout is healthy when the auxiliary source is inactive.
auxiliary_above_lockout_compressor_offAuxiliary heat without compressor proof may be emergency operation and is not this concurrent-energy signature.
concurrent_heat_above_lockout_sustainedThe complete avoidable-energy signature matures only after persistence.
concurrent_heat_during_defrostDefrost explicitly removes the alarm candidate while leaving diagnostic subconditions visible.
exact_oat_lockout_is_clearThe exact configured OAT threshold is clear under strict greater-than semantics.
unsupported_mode_codeAn unmapped integer mode cannot accidentally satisfy the heating-mode gate.
auxiliary_stage_ends_one_step_before_persistenceAn auxiliary stage ending one evaluator step before 300 s never matures.
mature_fault_recovers_when_auxiliary_clearsA mature alarm clears immediately when the auxiliary source stops producing heat.
two_short_auxiliary_episodes_resetTwo short stages separated by a reset do not accumulate timer age.
initialization_violation_serves_full_delaydelayOnInit prevents an immediate alarm when evaluation starts inside the complete signature.
defrost_interval_resets_candidateA defrost interval between two auxiliary stages resets persistence even though the other subconditions remain true.
emergency_or_recovery_raw_caseThe raw graph alarms on sustained concurrent recovery/emergency heat; the host must exclude an authorized mode.
vectors.json
{
  "schema": "cxf-library/vectors/v1",
  "clock": {
    "step_s": 60,
    "horizon_s": 720
  },
  "scenarios": [
    {
      "name": "cooling_mode",
      "description": "Concurrent heat in the cooling code is diagnostically visible but outside the heating-mode candidate.",
      "inputs": {
        "aux_heat_status": true,
        "oat": 6.0,
        "mode_command": 2,
        "defrost_status": false,
        "comp_status": true
      },
      "expect": [
        {
          "output": "yHeatingMode",
          "from_s": 0,
          "to_s": 720,
          "equals": false
        },
        {
          "output": "yAboveLockout",
          "from_s": 0,
          "to_s": 720,
          "equals": true
        },
        {
          "output": "yConcurrentHeat",
          "from_s": 0,
          "to_s": 720,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 720,
          "equals": false
        }
      ]
    },
    {
      "name": "heating_below_lockout",
      "description": "Auxiliary support below the configured lockout is permitted by this rule.",
      "inputs": {
        "aux_heat_status": true,
        "oat": 4.9,
        "mode_command": 1,
        "defrost_status": false,
        "comp_status": true
      },
      "expect": [
        {
          "output": "yHeatingMode",
          "from_s": 0,
          "to_s": 720,
          "equals": true
        },
        {
          "output": "yAboveLockout",
          "from_s": 0,
          "to_s": 720,
          "equals": false
        },
        {
          "output": "yConcurrentHeat",
          "from_s": 0,
          "to_s": 720,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 720,
          "equals": false
        }
      ]
    },
    {
      "name": "heating_above_lockout_without_auxiliary",
      "description": "Compressor heating above lockout is healthy when the auxiliary source is inactive.",
      "inputs": {
        "aux_heat_status": false,
        "oat": 6.0,
        "mode_command": 1,
        "defrost_status": false,
        "comp_status": true
      },
      "expect": [
        {
          "output": "yHeatingMode",
          "from_s": 0,
          "to_s": 720,
          "equals": true
        },
        {
          "output": "yAboveLockout",
          "from_s": 0,
          "to_s": 720,
          "equals": true
        },
        {
          "output": "yConcurrentHeat",
          "from_s": 0,
          "to_s": 720,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 720,
          "equals": false
        }
      ]
    },
    {
      "name": "auxiliary_above_lockout_compressor_off",
      "description": "Auxiliary heat without compressor proof may be emergency operation and is not this concurrent-energy signature.",
      "inputs": {
        "aux_heat_status": true,
        "oat": 6.0,
        "mode_command": 1,
        "defrost_status": false,
        "comp_status": false
      },
      "expect": [
        {
          "output": "yHeatingMode",
          "from_s": 0,
          "to_s": 720,
          "equals": true
        },
        {
          "output": "yAboveLockout",
          "from_s": 0,
          "to_s": 720,
          "equals": true
        },
        {
          "output": "yConcurrentHeat",
          "from_s": 0,
          "to_s": 720,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 720,
          "equals": false
        }
      ]
    },
    {
      "name": "concurrent_heat_above_lockout_sustained",
      "description": "The complete avoidable-energy signature matures only after persistence.",
      "inputs": {
        "aux_heat_status": true,
        "oat": 6.0,
        "mode_command": 1,
        "defrost_status": false,
        "comp_status": true
      },
      "expect": [
        {
          "output": "yHeatingMode",
          "from_s": 0,
          "to_s": 720,
          "equals": true
        },
        {
          "output": "yAboveLockout",
          "from_s": 0,
          "to_s": 720,
          "equals": true
        },
        {
          "output": "yConcurrentHeat",
          "from_s": 0,
          "to_s": 720,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 240,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 360,
          "to_s": 720,
          "equals": true
        }
      ]
    },
    {
      "name": "concurrent_heat_during_defrost",
      "description": "Defrost explicitly removes the alarm candidate while leaving diagnostic subconditions visible.",
      "inputs": {
        "aux_heat_status": true,
        "oat": 6.0,
        "mode_command": 1,
        "defrost_status": true,
        "comp_status": true
      },
      "expect": [
        {
          "output": "yHeatingMode",
          "from_s": 0,
          "to_s": 720,
          "equals": true
        },
        {
          "output": "yAboveLockout",
          "from_s": 0,
          "to_s": 720,
          "equals": true
        },
        {
          "output": "yConcurrentHeat",
          "from_s": 0,
          "to_s": 720,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 720,
          "equals": false
        }
      ]
    },
    {
      "name": "exact_oat_lockout_is_clear",
      "description": "The exact configured OAT threshold is clear under strict greater-than semantics.",
      "inputs": {
        "aux_heat_status": true,
        "oat": 5.0,
        "mode_command": 1,
        "defrost_status": false,
        "comp_status": true
      },
      "expect": [
        {
          "output": "yHeatingMode",
          "from_s": 0,
          "to_s": 720,
          "equals": true
        },
        {
          "output": "yAboveLockout",
          "from_s": 0,
          "to_s": 720,
          "equals": false
        },
        {
          "output": "yConcurrentHeat",
          "from_s": 0,
          "to_s": 720,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 720,
          "equals": false
        }
      ]
    },
    {
      "name": "unsupported_mode_code",
      "description": "An unmapped integer mode cannot accidentally satisfy the heating-mode gate.",
      "inputs": {
        "aux_heat_status": true,
        "oat": 6.0,
        "mode_command": 0,
        "defrost_status": false,
        "comp_status": true
      },
      "expect": [
        {
          "output": "yHeatingMode",
          "from_s": 0,
          "to_s": 720,
          "equals": false
        },
        {
          "output": "yAboveLockout",
          "from_s": 0,
          "to_s": 720,
          "equals": true
        },
        {
          "output": "yConcurrentHeat",
          "from_s": 0,
          "to_s": 720,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 720,
          "equals": false
        }
      ]
    },
    {
      "name": "auxiliary_stage_ends_one_step_before_persistence",
      "description": "An auxiliary stage ending one evaluator step before 300 s never matures.",
      "inputs": {
        "aux_heat_status": [
          {
            "t": 0,
            "value": true
          },
          {
            "t": 240,
            "value": false
          }
        ],
        "oat": 6.0,
        "mode_command": 1,
        "defrost_status": false,
        "comp_status": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 720,
          "equals": false
        },
        {
          "output": "yConcurrentHeat",
          "from_s": 0,
          "to_s": 180,
          "equals": true
        },
        {
          "output": "yConcurrentHeat",
          "from_s": 240,
          "to_s": 720,
          "equals": false
        }
      ]
    },
    {
      "name": "mature_fault_recovers_when_auxiliary_clears",
      "description": "A mature alarm clears immediately when the auxiliary source stops producing heat.",
      "inputs": {
        "aux_heat_status": [
          {
            "t": 0,
            "value": true
          },
          {
            "t": 420,
            "value": false
          }
        ],
        "oat": 6.0,
        "mode_command": 1,
        "defrost_status": false,
        "comp_status": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 240,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 360,
          "to_s": 360,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 420,
          "to_s": 720,
          "equals": false
        },
        {
          "output": "yConcurrentHeat",
          "from_s": 0,
          "to_s": 360,
          "equals": true
        },
        {
          "output": "yConcurrentHeat",
          "from_s": 420,
          "to_s": 720,
          "equals": false
        }
      ]
    },
    {
      "name": "two_short_auxiliary_episodes_reset",
      "description": "Two short stages separated by a reset do not accumulate timer age.",
      "inputs": {
        "aux_heat_status": [
          {
            "t": 0,
            "value": true
          },
          {
            "t": 180,
            "value": false
          },
          {
            "t": 300,
            "value": true
          },
          {
            "t": 480,
            "value": false
          }
        ],
        "oat": 6.0,
        "mode_command": 1,
        "defrost_status": false,
        "comp_status": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 720,
          "equals": false
        }
      ]
    },
    {
      "name": "initialization_violation_serves_full_delay",
      "description": "delayOnInit prevents an immediate alarm when evaluation starts inside the complete signature.",
      "inputs": {
        "aux_heat_status": true,
        "oat": 6.0,
        "mode_command": 1,
        "defrost_status": false,
        "comp_status": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 240,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 360,
          "to_s": 720,
          "equals": true
        },
        {
          "output": "yHeatingMode",
          "from_s": 0,
          "to_s": 720,
          "equals": true
        },
        {
          "output": "yAboveLockout",
          "from_s": 0,
          "to_s": 720,
          "equals": true
        },
        {
          "output": "yConcurrentHeat",
          "from_s": 0,
          "to_s": 720,
          "equals": true
        }
      ]
    },
    {
      "name": "defrost_interval_resets_candidate",
      "description": "A defrost interval between two auxiliary stages resets persistence even though the other subconditions remain true.",
      "inputs": {
        "aux_heat_status": true,
        "oat": 6.0,
        "mode_command": 1,
        "defrost_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 180,
            "value": true
          },
          {
            "t": 300,
            "value": false
          },
          {
            "t": 480,
            "value": true
          }
        ],
        "comp_status": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 720,
          "equals": false
        }
      ]
    },
    {
      "name": "emergency_or_recovery_raw_case",
      "description": "The raw graph alarms on sustained concurrent recovery/emergency heat; the host must exclude an authorized mode.",
      "inputs": {
        "aux_heat_status": true,
        "oat": 6.0,
        "mode_command": 1,
        "defrost_status": false,
        "comp_status": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 240,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 360,
          "to_s": 720,
          "equals": true
        },
        {
          "output": "yHeatingMode",
          "from_s": 0,
          "to_s": 720,
          "equals": true
        },
        {
          "output": "yAboveLockout",
          "from_s": 0,
          "to_s": 720,
          "equals": true
        },
        {
          "output": "yConcurrentHeat",
          "from_s": 0,
          "to_s": 720,
          "equals": true
        }
      ]
    }
  ]
}

HW Fault Rules

Hot water / boiler plant fault detection rules (HW-*). Core source grounding: HVAC FDD Reference v1.0 ch.14 (pdf pages 124–127) — the three most impactful boiler plant problems: short-cycling, combustion efficiency degradation, and the missing OAT lockout the chapter calls one of the simplest, highest-ROI fixes in the whole catalog (>25% prevalence, 100% of plant energy wasted while active).

Point dictionary: points/hw.points.json. Supplementary deep-read sources per the project’s internal research triage (licensed sources; not distributed) (G36 heating-plant AFDD document, FEMP O&M guide, PNNL-13890) — paraphrase-and-cite only. HW-0010..0012 extend the family with regulation and staging checks grounded in NIST regulation concepts, the LBNL simulated boiler plant inventory, and verified library graph precedents. Their numeric settings are explicitly classified; HW-0012’s load/stage thresholds are adoption-blocking placeholders rather than portable defaults.

Index

IDNameSevMethodStatus
HW-0001Boiler short-cycling2ruleverified
HW-0002Boiler efficiency degradation3statisticalverified
HW-0003Boiler/HW pump on above OAT lockout3ruleverified
HW-0004HW loop low delta-T3ruleverified
HW-0005HW loop DP too high (pump speed vs mild OAT)3ruleverified
HW-0006HW loop DP reset not functioning3statisticalverified
HW-0007HW supply temperature too high at low load3ruleverified
HW-0008HWS temperature reset not functioning3statisticalverified
HW-0009Boiler proof-of-operation failure2ruleverified
HW-0010Hot-water supply temperature tracking failure3ruleverified
HW-0011Hot-water temperature-control hunting3ruleverified
HW-0012Excess boiler stages at low plant load3ruleverified

Severity and method for FC-050–052 per the reference’s ch.14 cards. FC-053–057 are library-authored rules grounded in PNNL-27338’s hot-water distribution measure-identification algorithms (§4; adapted via an internal paraphrased deep-read digest, not distributed) — the reference’s ch.14 specifies only three rules, so these five extend the family under the same numbering with explicit non-reference sourcing; severities are library-assigned by analogy to the AHU/CHW siblings.

Relationships

  • HW-0001 is the boiler member of the short-cycling family (AHU-0004, RTU-0001, FCU-0001, HP-0002’s frequency branch) and inherits its edge-counter idiom and Nyquist band discipline.
  • HW-0002 follows the HP-0001 host-fitted baseline pattern (efficiency vs firing rate, single regressor).
  • HW-0003 is the heating-plant cousin of AHU-0018’s after-hours operation — equipment running when conditions say it cannot be useful; Scope 1 emissions make it unusually consequential per kWh.
  • HW-0010 mirrors CHW-0007’s strict two-sided hydronic tracking form, but gates on firing plus established circulation and compares a coherent plant header with its final active target.
  • HW-0011 adapts VFD-0004’s dual mean-crossing/MAD regulation topology to capacity-normalized boiler firing and HWS error. Its counts require a fixed 60 s exercised tick and a complete 1800 s warm-up.
  • HW-0012 uses a native integer firing-unit count and a host-derived 0..1 useful-load fraction. yLoadOk is an evaluability output; fleet membership, capacity basis, and the allowed staging map are commissioning obligations.
  • No Boiler Control Instability cluster is created. Cycling, hunting, tracking, and over-staging can co-occur, but they do not share a reliable first trigger or one repair that clears all members.

HW-0001 — Boiler short-cycling

Statusverified — engine e2ff2f8, cxf:fnv1a128:504010d1861bbe0d8a10b7f90528e0b1, 2026-08-17
Severity2
Methodrule
Phase2
CategoryPROTECTIVE
ConfidenceMEDIUM
EstimationQUALITATIVE_ONLY
G36
Clusters
Suppresses
Suppressed by
RelatedHW-0002, HW-0003, HW-0011, HW-0012, RTU-0001, AHU-0004
Playbookshot-water-plant-faults
SourceHVAC FDD Reference v1.0 §14 (ch. ‘Hot Water Plants’, pdf pp. 124-125), HW-0001; Shohet et al. 2020; Meng et al. 2021
Operating statesheating season / HW plant enabled

Preconditions (host-enforced): The host must report NO_EVAL for the first count_window (1 h) after engine start. While the moving average’s window fills, its divisor is elapsed time rather than the window, so the count reports an extrapolated pace instead of a completed-hour tally, and alarm_delay (15 min) is far too short to cover the hour on its own — warmup_pace_asserts_on_two_starts shows a verdict reached at 1200 s on the strength of two starts. The plant must be enabled and in heating season: a boiler idle because nothing is calling for heat produces zero starts, and reporting that as healthy cycling is the opposite of information. boiler_status must be the burner’s FIRING (flame) status, not the boiler’s enable status — an enable that stays true across an entire morning hides every burner cycle inside it, which is the point dictionary’s warning on this point. Bind it per boiler: on a multi-boiler plant the OR of the statuses never falls while any boiler is firing, so every lag-boiler start is invisible and each boiler’s own cycling is undercounted. The host tick interval must sit inside the band the count arithmetic and the edge counter jointly allow — 57.15 s ≤ dt < 360 s — with count_scale retuned to match (see Deviations). When any gate is unmet the verdict is NO_EVAL, not healthy.

Points: boiler_status

Outputs:

  • yFault — True while the number of boiler starts in the trailing count_window has stayed above max_starts_per_hour for at least alarm_delay

Parameters:

NameDefaultUnitCXF pathDescription
max_starts_per_hour4.01/hcntHigh.tStarts per hour above which the cycling counts as short-cycling rather than load-following. The reference’s ceiling, equivalent to a 15-minute minimum interval between firings
count_window3600.0srate.deltaTrailing window the starts are counted over (1 h). It also fixes the units of max_starts_per_hour; a host that shortens it must retune count_scale with it and read the ceiling as starts per window rather than per hour
count_scale12.01count.kRescales the moving average of the one-tick pulse train back into a start count: k = count_window / host tick interval in seconds. The default 12.0 is 3600/300, correct only at a 300 s tick; a host on a different tick MUST retune this or every count is wrong by the ratio of the two intervals
alarm_delay900.0spersist.delayTimeContinuous fault persistence required before the alarm asserts (15 min)

Description

Every boiler start pays twice: a pre-purge that pushes the previous cycle’s heat up the stack, a burner run through the part of its range where the fuel/air ratio is worst, and a post-purge that does it again. The vessel takes a thermal step with each one — fire side at flame temperature in seconds, water side lagging — and the differential works the tube sheet, refractory and welds. The reference puts the efficiency penalty at 3–5% and the avoided damage at $10K–$100K, which is the argument for severity 2 on a fault whose energy number is modest. None of the causes is visible from the water temperature, which is why this shows up as a start count rather than a comfort complaint; the rule reports that the burner starts more often than the plant should need, and the service call decides why.

Detection Logic

start  = rising edge of boiler_status                      one tick wide
count  = MovingAverage(start, count_window) × count_scale   starts in the trailing hour
yFault = count > max_starts_per_hour, sustained continuously for alarm_delay

Block graph (rule.cxf.jsonld):

HW-0001 block graph

Reals.MovingAverage is a continuous-time integral mean, so a one-tick pulse of height 1.0 encloses exactly one tick interval of area: n starts inside the window give rate = n · dt / count_window, and count_scale = count_window / dt recovers n exactly (four starts an hour at a 300 s tick is precisely 4.0 in IEEE-754). count_scale must be retuned to the host tick — left at 12.0 on a 60 s tick the count reads a fifth of the true cadence and the rule never fires, and a mis-set scale gives a plausible number rather than an error. The legal tick band is 57.15 s ≤ dt < 360 s (Deviations); 300 s is recommended and is the only tick these vectors exercise.

cntHigh is strict, so four starts an hour reads clear and five alarms; persist then requires 15 minutes above the ceiling and carries delayOnInit = true. The count is rolling, not tumbling: a crossing survives count_window minus the span of the starts that caused it, so the same five starts in one hour alarm or not depending on how tightly they are packed, and the alarm outlives the cycling by up to a full hour. During the first count_window the average divides by elapsed time rather than by the window and reports an extrapolated pace that alarm_delay is too short to cover — the host NO_EVAL precondition for that hour is load-bearing.

Possible Diagnoses

Transcribed from the reference’s HW-0001 card:

  1. Boiler oversized for the current load — the fix is staging, a lead boiler with more turndown, or a buffer tank
  2. Aquastat differential too small, so the burner satisfies and relights a minute later. Check this first: it is free and remote
  3. Staging logic cycling between boilers, which no single boiler’s own controls would cause — the reason this rule is bound per boiler
  4. Short-circuiting in the piping — primary/secondary imbalance returning supply water to the boiler inlet, which reads as load satisfaction
  5. Control valve hunting downstream, modulating plant load faster than the boiler can follow (AHU-0004 is the AHU-side view of the same instability)

Energy Impact

PROTECTIVE, MEDIUM confidence, QUALITATIVE_ONLY. The rule sees one boolean and cannot price a start, so there is no waste term computable from its inputs. Size the opportunity host-side: cycling hours × rated fuel input × the 3–5% efficiency penalty (Energy Impact Reference §4.4), with the $10K–$100K of avoided boiler damage as the larger and more probabilistic term. Confidence is MEDIUM because the efficiency figure depends on cycle length, return water temperature and how far the vessel gets from steady state, none of which this rule measures. Heating-dominant: the plant only runs in the heating season.

Emissions Impact

Scope 1, QUALITATIVE_EMISSIONS, MEDIUM confidence. This is combustion at the building, so the 3–5% loss is fuel burned on site rather than purchased electricity whatever the grid is doing. The reference records the range as “protective; indirect via boiler degradation” and sets the avoided-emissions basis to N/A, which this card keeps — the larger term is the embodied carbon of a pressure vessel replaced years early.

Deviations

  • min_run_time is a tunable, not a graph parameter. The reference lists it at 10 min beside max_starts_per_hour and then prints one line of logic, never saying how a per-cycle duration enters the verdict. Recorded as a transcription gap rather than filled in with invented logic; the starts-per-hour ceiling subsumes the protective intent, and RTU-0001 documents the identical gap.
  • The rolling count is built from a moving average, because the block set has no windowed counter. Integers.OnCounter counts monotonically from a reset, so a trailing hour would need a host-driven reset — a tumbling window whose verdict depends on where the boundary falls. AHU-0004 established the idiom and RTU-0001 carried it to a compressor.
  • count_scale = count_window / dt couples the rule to the host tick, and the failure is silent. A host ticking every 60 s must set 60.0; too low and the rule never fires, too high and it alarms on a healthy boiler. It belongs on the deployment checklist beside the point binding.
  • The legal tick band is 57.15 s ≤ dt < 360 s. The floor is Reals.MovingAverage’s fixed 64-checkpoint ring: dt ≥ count_window / 63. The ceiling is the edge counter — a rising edge needs a false sample between two true ones, and observing the first faulted cadence (5/h) needs 5 ≤ count_window / (2 · dt), i.e. dt ≤ 360 s; 360 s itself is excluded because at one half-period per tick detection depends on sampling phase. RTU-0001’s looser dt < 450 s only makes the threshold exceedable at all, and misses the ordinary 5/h boiler.
  • Nyquist is necessary, not sufficient: the constraint is the shorter of the two intervals. The honest binding rule is dt ≤ min(shortest firing, shortest off period), which for a boiler with a 10-minute minimum run and a tight aquastat is the off period. At the 300 s tick a boiler that restarts within 300 s of shutting down has starts swallowed and the count reads low. The failure direction is silence — right for a protective rule, worth knowing when a technician insists the boiler is cycling.
  • The first count_window reads as a pace, not a count, and alarm_delay does not cover it. While t < count_window the average divides by elapsed time, so two starts in the first ten minutes report an extrapolated rate and persist matures on it (engine-pinned: yFault at 1200 s, clearing at 1800 s as the divisor grows). AHU-0004 is immune because its alarm_delay equals its count_window; this rule, like RTU-0001, is not, which is why the first-hour NO_EVAL precondition is load-bearing.
  • The startup edge pulse is spurious and inert. Logical.Edge compares u against pre_u_start on the first tick, so a boiler already firing when the rule loads registers a start at t = 0. It never reaches the count: dt is zero on that tick, so the pulse encloses no area. pre_u_start is left at the CDL default per SCHEMA.md’s “set only non-default values”.
  • Strict > on a discrete count, as the reference writes it: four starts an hour is clear and five alarms. The boundary is unambiguous rather than measure-zero, because the recovered count is exact in steady state — 12.0 × (4 × 300/3600) evaluates to precisely 4.0.
  • The counting window is half-open. rate compares the accumulated integral now against its value one count_window ago, so a start exactly that old has just left the window. It errs toward silence, and it is what makes the alarm outlive the cycling: the lag is count_window minus the span of the last five starts, a full hour when the boiler stops after a single burst.
  • TrueDelay asserts at exactly T + delayTime, so a count that falls back on the same tick the timer matures reports nothing. The realized test is “above the ceiling for strictly more than alarm_delay”, read at tick resolution; five starts spread over 45 minutes rather than 40 is the whole difference between an alarm and silence.
  • persist.delayOnInit = true (CDL default false), the library’s standing choice: a plant already cycling above the ceiling when the controller restarts waits out the full 15 minutes rather than alarming on the first tick.
  • Operating state is declared, not gated. “Heating season / HW plant enabled” is the reference’s own operating-state line; there is no plant-enable point in the equation to gate on, and inventing one would move the rule’s boundary.
  • clusters: []. clusters/clusters.json defines no cluster containing a hot water plant rule, and this card does not edit the cluster set. CLU-07 (Unnecessary Plant Operation) is where HW-0003 would belong; this fault is not part of that syndrome.
  • No test vectors are transcribed, because the reference publishes none. Every scenario in vectors.json is authored from the equation, and each assertion edge was derived by replaying the graph at the pinned engine rev rather than by closed-form arithmetic — the moving average’s warm-up and decay trajectories do not match hand-computed sample statistics.
  • Severity 2, phase 2, method: rule and the tunable defaults are the reference’s chapter 14 card. g36: null — this is a research-derived rule (Shohet et al. 2020; Meng et al. 2021), not a G36 clause.

Notes

Bind boiler_status to the burner’s flame status, and bind it per boiler: an enable that stays true all morning reports zero starts, and an OR across a multi-boiler plant never falls while any boiler fires, so every lag-boiler start is invisible.

Check diagnosis 2 remotely before anyone drives out — pull supply water temperature alongside boiler_status, and a burner shutting down within a degree or two of setpoint and relighting immediately is the aquastat differential, free to widen. HW-0002 reads the same boiler from the efficiency side: a plant tripping both may have one problem, while HW-0002 alone with a steady fire is a combustion or heat-transfer finding.

Test Vectors

11 scenarios, clock step 300 s over 14400 s.

ScenarioDescription
boiler_off_all_dayThe boiler never fires. No rising edges, no count, no verdict — the baseline case that shows the counter reads zero rather than drifting.
steady_firing_never_countsThe boiler is firing continuously from the moment the rule loads and never stops. Logical.Edge registers one rising edge at t = 0 against its pre_u_start default, and that pulse encloses no area because the moving average’s first tick has dt = 0, so the count stays at zero. A boiler that holds fire for four hours is the opposite of short-cycling and reads as such.
three_starts_per_hourHealthy load-following: a 5-minute fire every 20 minutes, three starts an hour against a ceiling of four. The cadence begins at t = 3600 s so the reading is a completed-hour count rather than the warm-up extrapolation.
four_starts_per_hour_is_exactly_the_thresholdThreshold edge from below: a 5-minute fire every 15 minutes puts exactly four starts in every trailing hour. The count evaluates to precisely 4.0 in IEEE-754 (12.0 x 1200/3600), so the strict > keeps it clear — this is a real boundary pin, not a near miss.
five_starts_per_hour_is_the_first_faulted_cadenceThreshold edge from above, and the finest cadence a 300 s tick can resolve above four: a repeating 600/600/600/900/900 s spacing puts exactly five rising edges in every trailing hour. The count clears 4 at t = 6300 s (the fifth start) and the alarm follows one alarm_delay later at 7200 s.
six_starts_per_hour_is_the_fastest_resolvable_cadenceA boiler cycling on a 10-minute period — five minutes of fire, five minutes off — which at the 300 s tick these vectors run is the Nyquist ceiling: one start per two ticks, 6/h. The count crosses 4 at t = 6000 s and the alarm lands at 6900 s.
five_starts_in_forty_minutes_alarms_for_one_tickDelay edge from above: five starts packed into 2400 s hold the count above the ceiling from t = 6000 s until the first of them ages out of the trailing hour at 7200 s — 1200 s, one tick longer than alarm_delay. The alarm asserts at exactly 6900 s and falls again at 7200 s.
five_starts_in_forty_five_minutes_release_on_the_maturity_tickDelay edge from below, and the same five starts: spreading them over 2700 s holds the count above the ceiling for exactly alarm_delay (t = 6300 s to 7200 s). persist matures at 7200 s, the tick the fifth start’s window has already dropped the first, so the input is false when the timer comes due and nothing is ever reported. Three hundred seconds of burst spacing is the whole difference between this scenario and the one above.
five_starts_in_fifty_five_minutes_never_alarmsThe rolling counter’s blind spot, stated as a vector: five starts spread across 3300 s reach the same five-per-hour count, but only for the 300 s between the fifth start and the first one ageing out. Same starts in the same hour as the two scenarios above, no alarm — what the rule reports is cycling sustained above the ceiling, not every excursion through it.
cycling_stops_alarm_clearsRecovery: the boiler cycles at 6/h from t = 3600 s and the aquastat differential is widened after the twelfth start (t = 10200 s). The alarm holds from 6900 s until 11100 s and clears at 11400 s, the tick the trailing hour first holds four starts rather than five. The clear lags the last start by 1200 s here, and by up to a full count_window when the cycling stops after a shorter burst — the counter has to forget.
warmup_pace_asserts_on_two_startsThe warm-up artifact, and why the host NO_EVAL precondition is not optional. Two starts inside the first fifteen minutes (t = 300 s and 900 s) are read against elapsed time rather than against the window, so the count reports the extrapolated pace — well above four — and persist matures at 1200 s on the strength of two starts. The verdict decays with the growing divisor and clears at 1800 s. A single start from cold does not do this, and neither does a healthy 2/h or 3/h cadence; two starts inside ten minutes does.
vectors.json
{
  "schema": "cxf-library/vectors/v1",
  "clock": {
    "step_s": 300,
    "horizon_s": 14400
  },
  "scenarios": [
    {
      "name": "boiler_off_all_day",
      "description": "The boiler never fires. No rising edges, no count, no verdict \u2014 the baseline case that shows the counter reads zero rather than drifting.",
      "inputs": {
        "boiler_status": false
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 14400,
          "equals": false
        }
      ]
    },
    {
      "name": "steady_firing_never_counts",
      "description": "The boiler is firing continuously from the moment the rule loads and never stops. `Logical.Edge` registers one rising edge at t = 0 against its `pre_u_start` default, and that pulse encloses no area because the moving average's first tick has dt = 0, so the count stays at zero. A boiler that holds fire for four hours is the opposite of short-cycling and reads as such.",
      "inputs": {
        "boiler_status": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 14400,
          "equals": false
        }
      ]
    },
    {
      "name": "three_starts_per_hour",
      "description": "Healthy load-following: a 5-minute fire every 20 minutes, three starts an hour against a ceiling of four. The cadence begins at t = 3600 s so the reading is a completed-hour count rather than the warm-up extrapolation.",
      "inputs": {
        "boiler_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 3600,
            "value": true
          },
          {
            "t": 3900,
            "value": false
          },
          {
            "t": 4800,
            "value": true
          },
          {
            "t": 5100,
            "value": false
          },
          {
            "t": 6000,
            "value": true
          },
          {
            "t": 6300,
            "value": false
          },
          {
            "t": 7200,
            "value": true
          },
          {
            "t": 7500,
            "value": false
          },
          {
            "t": 8400,
            "value": true
          },
          {
            "t": 8700,
            "value": false
          },
          {
            "t": 9600,
            "value": true
          },
          {
            "t": 9900,
            "value": false
          },
          {
            "t": 10800,
            "value": true
          },
          {
            "t": 11100,
            "value": false
          },
          {
            "t": 12000,
            "value": true
          },
          {
            "t": 12300,
            "value": false
          },
          {
            "t": 13200,
            "value": true
          },
          {
            "t": 13500,
            "value": false
          },
          {
            "t": 14400,
            "value": true
          },
          {
            "t": 14700,
            "value": false
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 14400,
          "equals": false
        }
      ]
    },
    {
      "name": "four_starts_per_hour_is_exactly_the_threshold",
      "description": "Threshold edge from below: a 5-minute fire every 15 minutes puts exactly four starts in every trailing hour. The count evaluates to precisely 4.0 in IEEE-754 (12.0 x 1200/3600), so the strict `>` keeps it clear \u2014 this is a real boundary pin, not a near miss.",
      "inputs": {
        "boiler_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 3600,
            "value": true
          },
          {
            "t": 3900,
            "value": false
          },
          {
            "t": 4500,
            "value": true
          },
          {
            "t": 4800,
            "value": false
          },
          {
            "t": 5400,
            "value": true
          },
          {
            "t": 5700,
            "value": false
          },
          {
            "t": 6300,
            "value": true
          },
          {
            "t": 6600,
            "value": false
          },
          {
            "t": 7200,
            "value": true
          },
          {
            "t": 7500,
            "value": false
          },
          {
            "t": 8100,
            "value": true
          },
          {
            "t": 8400,
            "value": false
          },
          {
            "t": 9000,
            "value": true
          },
          {
            "t": 9300,
            "value": false
          },
          {
            "t": 9900,
            "value": true
          },
          {
            "t": 10200,
            "value": false
          },
          {
            "t": 10800,
            "value": true
          },
          {
            "t": 11100,
            "value": false
          },
          {
            "t": 11700,
            "value": true
          },
          {
            "t": 12000,
            "value": false
          },
          {
            "t": 12600,
            "value": true
          },
          {
            "t": 12900,
            "value": false
          },
          {
            "t": 13500,
            "value": true
          },
          {
            "t": 13800,
            "value": false
          },
          {
            "t": 14400,
            "value": true
          },
          {
            "t": 14700,
            "value": false
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 14400,
          "equals": false
        }
      ]
    },
    {
      "name": "five_starts_per_hour_is_the_first_faulted_cadence",
      "description": "Threshold edge from above, and the finest cadence a 300 s tick can resolve above four: a repeating 600/600/600/900/900 s spacing puts exactly five rising edges in every trailing hour. The count clears 4 at t = 6300 s (the fifth start) and the alarm follows one alarm_delay later at 7200 s.",
      "inputs": {
        "boiler_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 3600,
            "value": true
          },
          {
            "t": 3900,
            "value": false
          },
          {
            "t": 4200,
            "value": true
          },
          {
            "t": 4500,
            "value": false
          },
          {
            "t": 4800,
            "value": true
          },
          {
            "t": 5100,
            "value": false
          },
          {
            "t": 5400,
            "value": true
          },
          {
            "t": 5700,
            "value": false
          },
          {
            "t": 6300,
            "value": true
          },
          {
            "t": 6600,
            "value": false
          },
          {
            "t": 7200,
            "value": true
          },
          {
            "t": 7500,
            "value": false
          },
          {
            "t": 7800,
            "value": true
          },
          {
            "t": 8100,
            "value": false
          },
          {
            "t": 8400,
            "value": true
          },
          {
            "t": 8700,
            "value": false
          },
          {
            "t": 9000,
            "value": true
          },
          {
            "t": 9300,
            "value": false
          },
          {
            "t": 9900,
            "value": true
          },
          {
            "t": 10200,
            "value": false
          },
          {
            "t": 10800,
            "value": true
          },
          {
            "t": 11100,
            "value": false
          },
          {
            "t": 11400,
            "value": true
          },
          {
            "t": 11700,
            "value": false
          },
          {
            "t": 12000,
            "value": true
          },
          {
            "t": 12300,
            "value": false
          },
          {
            "t": 12600,
            "value": true
          },
          {
            "t": 12900,
            "value": false
          },
          {
            "t": 13500,
            "value": true
          },
          {
            "t": 13800,
            "value": false
          },
          {
            "t": 14400,
            "value": true
          },
          {
            "t": 14700,
            "value": false
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 6900,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 7500,
          "to_s": 14400,
          "equals": true
        }
      ]
    },
    {
      "name": "six_starts_per_hour_is_the_fastest_resolvable_cadence",
      "description": "A boiler cycling on a 10-minute period \u2014 five minutes of fire, five minutes off \u2014 which at the 300 s tick these vectors run is the Nyquist ceiling: one start per two ticks, 6/h. The count crosses 4 at t = 6000 s and the alarm lands at 6900 s.",
      "inputs": {
        "boiler_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 3600,
            "value": true
          },
          {
            "t": 3900,
            "value": false
          },
          {
            "t": 4200,
            "value": true
          },
          {
            "t": 4500,
            "value": false
          },
          {
            "t": 4800,
            "value": true
          },
          {
            "t": 5100,
            "value": false
          },
          {
            "t": 5400,
            "value": true
          },
          {
            "t": 5700,
            "value": false
          },
          {
            "t": 6000,
            "value": true
          },
          {
            "t": 6300,
            "value": false
          },
          {
            "t": 6600,
            "value": true
          },
          {
            "t": 6900,
            "value": false
          },
          {
            "t": 7200,
            "value": true
          },
          {
            "t": 7500,
            "value": false
          },
          {
            "t": 7800,
            "value": true
          },
          {
            "t": 8100,
            "value": false
          },
          {
            "t": 8400,
            "value": true
          },
          {
            "t": 8700,
            "value": false
          },
          {
            "t": 9000,
            "value": true
          },
          {
            "t": 9300,
            "value": false
          },
          {
            "t": 9600,
            "value": true
          },
          {
            "t": 9900,
            "value": false
          },
          {
            "t": 10200,
            "value": true
          },
          {
            "t": 10500,
            "value": false
          },
          {
            "t": 10800,
            "value": true
          },
          {
            "t": 11100,
            "value": false
          },
          {
            "t": 11400,
            "value": true
          },
          {
            "t": 11700,
            "value": false
          },
          {
            "t": 12000,
            "value": true
          },
          {
            "t": 12300,
            "value": false
          },
          {
            "t": 12600,
            "value": true
          },
          {
            "t": 12900,
            "value": false
          },
          {
            "t": 13200,
            "value": true
          },
          {
            "t": 13500,
            "value": false
          },
          {
            "t": 13800,
            "value": true
          },
          {
            "t": 14100,
            "value": false
          },
          {
            "t": 14400,
            "value": true
          },
          {
            "t": 14700,
            "value": false
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 6600,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 7200,
          "to_s": 14400,
          "equals": true
        }
      ]
    },
    {
      "name": "five_starts_in_forty_minutes_alarms_for_one_tick",
      "description": "Delay edge from above: five starts packed into 2400 s hold the count above the ceiling from t = 6000 s until the first of them ages out of the trailing hour at 7200 s \u2014 1200 s, one tick longer than alarm_delay. The alarm asserts at exactly 6900 s and falls again at 7200 s.",
      "inputs": {
        "boiler_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 3600,
            "value": true
          },
          {
            "t": 3900,
            "value": false
          },
          {
            "t": 4200,
            "value": true
          },
          {
            "t": 4500,
            "value": false
          },
          {
            "t": 4800,
            "value": true
          },
          {
            "t": 5100,
            "value": false
          },
          {
            "t": 5400,
            "value": true
          },
          {
            "t": 5700,
            "value": false
          },
          {
            "t": 6000,
            "value": true
          },
          {
            "t": 6300,
            "value": false
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 6600,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 6900,
          "to_s": 6900,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 7200,
          "to_s": 14400,
          "equals": false
        }
      ]
    },
    {
      "name": "five_starts_in_forty_five_minutes_release_on_the_maturity_tick",
      "description": "Delay edge from below, and the same five starts: spreading them over 2700 s holds the count above the ceiling for exactly alarm_delay (t = 6300 s to 7200 s). `persist` matures at 7200 s, the tick the fifth start's window has already dropped the first, so the input is false when the timer comes due and nothing is ever reported. Three hundred seconds of burst spacing is the whole difference between this scenario and the one above.",
      "inputs": {
        "boiler_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 3600,
            "value": true
          },
          {
            "t": 3900,
            "value": false
          },
          {
            "t": 4200,
            "value": true
          },
          {
            "t": 4500,
            "value": false
          },
          {
            "t": 4800,
            "value": true
          },
          {
            "t": 5100,
            "value": false
          },
          {
            "t": 5700,
            "value": true
          },
          {
            "t": 6000,
            "value": false
          },
          {
            "t": 6300,
            "value": true
          },
          {
            "t": 6600,
            "value": false
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 14400,
          "equals": false
        }
      ]
    },
    {
      "name": "five_starts_in_fifty_five_minutes_never_alarms",
      "description": "The rolling counter's blind spot, stated as a vector: five starts spread across 3300 s reach the same five-per-hour count, but only for the 300 s between the fifth start and the first one ageing out. Same starts in the same hour as the two scenarios above, no alarm \u2014 what the rule reports is cycling sustained above the ceiling, not every excursion through it.",
      "inputs": {
        "boiler_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 3600,
            "value": true
          },
          {
            "t": 3900,
            "value": false
          },
          {
            "t": 4500,
            "value": true
          },
          {
            "t": 4800,
            "value": false
          },
          {
            "t": 5400,
            "value": true
          },
          {
            "t": 5700,
            "value": false
          },
          {
            "t": 6300,
            "value": true
          },
          {
            "t": 6600,
            "value": false
          },
          {
            "t": 6900,
            "value": true
          },
          {
            "t": 7200,
            "value": false
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 14400,
          "equals": false
        }
      ]
    },
    {
      "name": "cycling_stops_alarm_clears",
      "description": "Recovery: the boiler cycles at 6/h from t = 3600 s and the aquastat differential is widened after the twelfth start (t = 10200 s). The alarm holds from 6900 s until 11100 s and clears at 11400 s, the tick the trailing hour first holds four starts rather than five. The clear lags the last start by 1200 s here, and by up to a full count_window when the cycling stops after a shorter burst \u2014 the counter has to forget.",
      "inputs": {
        "boiler_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 3600,
            "value": true
          },
          {
            "t": 3900,
            "value": false
          },
          {
            "t": 4200,
            "value": true
          },
          {
            "t": 4500,
            "value": false
          },
          {
            "t": 4800,
            "value": true
          },
          {
            "t": 5100,
            "value": false
          },
          {
            "t": 5400,
            "value": true
          },
          {
            "t": 5700,
            "value": false
          },
          {
            "t": 6000,
            "value": true
          },
          {
            "t": 6300,
            "value": false
          },
          {
            "t": 6600,
            "value": true
          },
          {
            "t": 6900,
            "value": false
          },
          {
            "t": 7200,
            "value": true
          },
          {
            "t": 7500,
            "value": false
          },
          {
            "t": 7800,
            "value": true
          },
          {
            "t": 8100,
            "value": false
          },
          {
            "t": 8400,
            "value": true
          },
          {
            "t": 8700,
            "value": false
          },
          {
            "t": 9000,
            "value": true
          },
          {
            "t": 9300,
            "value": false
          },
          {
            "t": 9600,
            "value": true
          },
          {
            "t": 9900,
            "value": false
          },
          {
            "t": 10200,
            "value": true
          },
          {
            "t": 10500,
            "value": false
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 6600,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 7200,
          "to_s": 10800,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 11400,
          "to_s": 14400,
          "equals": false
        }
      ]
    },
    {
      "name": "warmup_pace_asserts_on_two_starts",
      "description": "The warm-up artifact, and why the host NO_EVAL precondition is not optional. Two starts inside the first fifteen minutes (t = 300 s and 900 s) are read against elapsed time rather than against the window, so the count reports the extrapolated pace \u2014 well above four \u2014 and `persist` matures at 1200 s on the strength of two starts. The verdict decays with the growing divisor and clears at 1800 s. A single start from cold does not do this, and neither does a healthy 2/h or 3/h cadence; two starts inside ten minutes does.",
      "inputs": {
        "boiler_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 300,
            "value": true
          },
          {
            "t": 600,
            "value": false
          },
          {
            "t": 900,
            "value": true
          },
          {
            "t": 1200,
            "value": false
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 900,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 1200,
          "to_s": 1500,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 1800,
          "to_s": 14400,
          "equals": false
        }
      ]
    }
  ]
}

HW-0002 — Boiler efficiency degradation

Statusverified — engine e2ff2f8, cxf:fnv1a128:2b1b4b0297999b62251fc5113d6cacf8, 2026-08-17
Severity3
Methodstatistical
Phase2
CategoryEFFICIENCY_LOSS
ConfidenceHIGH
EstimationBASELINE_COMPARISON
G36
Clusters
Suppresses
Suppressed by
RelatedHW-0001, HW-0003, HW-0010, HW-0012, HP-0001
Playbookshot-water-plant-faults
SourceHVAC FDD Reference v1.0 §14 (ch. ‘Hot Water Plants’, pdf pp. 125-126), HW-0002; Meng et al. 2021; Shohet et al. 2020; PNNL-13890 (O&M best practices)
Operating statesboiler firing and settled at its current fire — one rule instance per boiler, each carrying that boiler’s fitted line

Preconditions (host-enforced): fuel_power and thermal_power must be computed on the same heating-value convention the baseline was fitted on. The point dictionary’s warning on fuel_power is the one that bites hardest here: fuel input derived from flow × HHV and fuel input derived from flow × LHV differ by roughly 10% for natural gas, so an efficiency computed one way against a line fitted the other way is wrong by about five times this rule’s whole threshold. Pick one convention per site, record it, and refit if it ever changes. The host owns the baseline: it runs the learning_period_days (14 d) regression of efficiency against boiler_firing_rate for THIS boiler and writes the result into eff_baseline_slope and eff_baseline_intercept with set_param. Until it has, the rule is comparing against shipped placeholders and means nothing (see Deviations). boiler_firing_rate must also lie inside the range the line was fitted over — the graph extrapolates forever and knows nothing about where the fit stops being physical. thermal_power is almost always a host-computed virtual point (HW flow × ΔT × cp); its provenance is part of the baseline’s validity, not separate from it, and a flow meter that reads 5% high moves measured efficiency by five points on its own. The boiler must be firing and settled: the minutes after a light-off are spent heating the vessel rather than the water, and they read as degraded on physics. Evaluability is signalled in-rule by yFuelOk; when it is false the verdict is NO_EVAL, not healthy.

Points: thermal_power, fuel_power, boiler_firing_rate

Outputs:

  • yFault — True while the measured efficiency has stayed more than efficiency_threshold below the fitted baseline for the current firing rate, continuously for at least alarm_delay
  • yFuelOk — Evaluability signal — true when fuel_power is above fuel_power_min, the floor below which the efficiency quotient is meaningless. False means NO_EVAL and the host must ignore yFault

Parameters:

NameDefaultUnitCXF pathDescription
eff_baseline_slope0.00061/%fireSlope.kSlope of the host-fitted efficiency-vs-firing-rate regression, in efficiency fraction per percent of fire. PER-BOILER SITE CONFIGURATION — the reference supplies a learned model, not a number, and the shipped 0.0006 is a placeholder for a conventional non-condensing boiler. Inherently signed: a condensing boiler’s fit is negative, because its efficiency is highest at low fire
eff_baseline_intercept0.781expected.pIntercept of the same regression — the expected efficiency extrapolated to 0% fire, which is not an operating point but is what a straight line needs. PER-BOILER SITE CONFIGURATION on the same terms as the slope; the pair is only meaningful together
efficiency_threshold0.051effLow.tShortfall below the fitted line that counts as degradation, in efficiency FRACTION — 0.05 is the reference’s 5 efficiency points. This is an absolute difference, not a relative one: writing 5 into it (percent) silences the rule permanently
fuel_power_min5.0kWfuelOk.tFuel input below which the efficiency quotient is not evaluated. Guards the division — at zero fuel the quotient is NaN, and at a standing pilot it reads as total degradation. PER-BOILER SITE CONFIGURATION: set it above the pilot and purge flow and below the smallest genuine firing input
alarm_delay3600.0spersist.delayTimeContinuous shortfall required before the alarm asserts (60 min). ADOPTED, not transcribed — the reference’s tunables line for this card truncates before reaching it (see Deviations)

Description

A boiler’s efficiency is not a constant and is not supposed to be. The same burner returns more of its fuel as useful heat at full fire than at minimum fire, where jacket and standby losses are spread across a smaller output — or less, if the boiler condenses, because a cooler flue and a wetter heat exchanger are what low fire produces. Which way the curve runs is a fact about the machine, so no fixed efficiency threshold would either stay quiet all winter or ever alarm. What there is, per boiler, is a line: the host fits efficiency against firing rate over fourteen days and writes the slope and intercept in as parameters, and the graph asks whether today’s fire is within five efficiency points of what that line predicts. Five points is a lot of gas, and none of the causes announce themselves — scale, soot and a rich burner all leave the boiler making its setpoint on more fuel.

Detection Logic

measured_eff = thermal_power / fuel_power
expected_eff = eff_baseline_slope × boiler_firing_rate + eff_baseline_intercept

yFuelOk = fuel_power > fuel_power_min            (false ⇒ host reports NO_EVAL)
yFault  = (expected_eff − measured_eff) > efficiency_threshold AND yFuelOk,
          sustained continuously for alarm_delay

Block graph (rule.cxf.jsonld):

HW-0002 block graph

fireSlope and expected are the fitted line and the whole statistical content of the rule at runtime; shortfall and effLow write the reference’s equation out unchanged, as an absolute difference in efficiency points rather than a ratio. At the shipped placeholders the line predicts 0.792 at 20% fire, 0.810 at 50% and 0.840 at full fire, with the alarm five points under each — identical meter readings are healthy at low fire and faulted at high fire, decided entirely by boiler_firing_rate.

eff is the only division in the rule and its denominator goes to zero every time the burner stops. With both meters at zero the quotient is NaN and every comparison against it is false; with a standing pilot and no useful output it is a clean, believable 0.0, which is the more dangerous of the two. fuelOk drives both the boundary output yFuelOk and the second input of gate, so a boiler below the floor holds yFault down and the host reads the silence as “not evaluated” rather than “healthy”. persist requires 60 continuous minutes of shortfall — long enough to ride out a light-off, a firing-rate step or a return-temperature swing — and carries delayOnInit = true.

Possible Diagnoses

Transcribed from the reference’s HW-0002 card:

  1. Fouled heat exchanger or water-side scale — a millimetre costs several efficiency points and develops slowly enough for a fitted line to catch it
  2. Burner misalignment or fouling — soot does the same from the fire side, and a sooted burner is usually a badly adjusted one
  3. Incorrect fuel/air ratio — excess air carries heat up the stack, too little leaves fuel unburned; only a combustion analyser separates them
  4. Flue gas recirculation problem — an FGR damper out of position moves the NOx/efficiency trade the burner was commissioned on
  5. Refractory degradation — cracked refractory lets heat into the jacket instead of the water, usually found only when someone opens the front

Energy Impact

EFFICIENCY_LOSS, HIGH confidence, BASELINE_COMPARISON. The estimator is the quotient the rule already computes: waste_kw = fuel_power × (1 − measured_eff / expected_eff) — a boiler burning 1000 kW at 0.74 against an 0.81 baseline wastes about 86 kW of gas. The reference’s range is 5–15% of fuel; PNNL-13890’s case study puts $730/yr on a 300-hp boiler. HIGH confidence holds because both terms are metered rather than inferred, with two caveats that do not change it: the baseline is the boiler’s own recent behaviour, so degradation present when the line was fitted is invisible, and thermal_power is usually derived, so its accuracy is the flow meter’s. Heating-dominant by construction.

Emissions Impact

Scope 1, PROXY_EMISSIONS, HIGH confidence; the reference’s typical range is 1,000–10,000 kg CO₂e/yr against a static 0.181 kg CO₂e/kWh natural gas factor. This is combustion at the building, so there is no grid to hedge against: the avoided-emissions basis is the static Scope 1 factor and the saving is the same whatever hour the boiler runs, which makes the emissions arithmetic the energy arithmetic times a constant.

Deviations

  • The comparison is an absolute difference in efficiency points, as the reference writes it, and it diverges from the sibling cards on purpose: CHW-0001 tests a relative loss and HP-0001 multiplies its ratio through, both to avoid dividing by a fitted line, and neither move is needed here. The consequence at deployment: an absolute threshold is a larger relative tolerance the lower the baseline sits — five points is 6.1% of an 0.82 baseline and 5.6% of an 0.90 one — so this rule is the more forgiving of the two, and most forgiving where there is least efficiency to spare.
  • efficiency_threshold carries a fraction, not a percentage, and the failure mode is silence. The reference prints “5%”; the graph compares two dimensionless quotients, so the parameter is 0.05. Writing 5 asks for a 500-point shortfall and the rule goes quiet forever with no error. HP-0001’s cop_ratio_threshold has the mirror-image trap.
  • eff_baseline_slope and eff_baseline_intercept ship as documented placeholders. The reference specifies a model, not numbers (baseline_model.predict(boiler_firing_rate), fitted over learning_period_days), and this library’s split puts the fitting in the host and the fitted line in the graph as set_param targets. The shipped 0.0006 /% and 0.78 describe a conventional non-condensing boiler and exist so the document is runnable as delivered. They are not site values, and a wrong pair fails silently in both directions — fitted five points high, every hour alarms; fitted low, nothing ever does. Precedent: HP-0001’s COP line, VAV-0001’s ventilation_requirement.
  • The slope may be negative — the documented exception to the library’s no-negative-parameters convention. A regression slope is inherently signed and here the sign is a fact about the boiler type: conventional efficiency rises with fire, condensing falls, and one rule instance must accept either without rewiring. HP-0001 carries the identical exception.
  • alarm_delay is adopted, not transcribed, because the source line truncates. The reference’s tunables line ends at “efficiency_threshold = 5%, learning_period_days = 14,” and whatever followed did not survive the extract. The 60 minutes comes from the two nearest authorities — CHW-0001’s AlarmDelay = 60 min on the same fitted-baseline shape, and HP-0001 — and is the shortest delay that reliably outlasts a light-off transient on a large boiler.
  • fuel_power_min and yFuelOk are adopted, not transcribed. The reference names no fuel floor and no evaluability gate, but the graph divides by a live signal, and per SCHEMA.md a test computable from the rule’s own inputs belongs in the graph as a boundary output rather than as prose. yFuelOk is not an echo of fuel_power — it is the comparison the division needs. The 5.0 kW default sits above a standing pilot on a small commercial boiler and is arbitrary on a 3 MW firetube, where minimum fire alone is hundreds of kW.
  • The heating-value convention is a precondition the rule cannot check. HHV and LHV differ by about 10% for natural gas, so an efficiency computed one way against a line fitted the other is off by roughly eight efficiency points — more than the whole threshold — and a healthy boiler reads as permanently degraded. Nothing in three signals reveals which convention produced them, so it lives in preconditions. It is the single most likely way to deploy this rule wrongly.
  • The nominal boundary case is a FAULT, and that is arithmetic rather than a choice. A five-point shortfall cannot be represented exactly at these magnitudes: 0.05 is an odd multiple of 2⁻⁵⁶ while every double in [0.5, 1) is a multiple of 2⁻⁵³, so no difference of two efficiencies in that range can equal the threshold. 0.760 against 0.810 evaluates to 0.050000000000000044 and trips the strict >; one ulp lower clears. That is as tightly as the boundary can be bracketed, and the card says so rather than implying a precision it does not have.
  • The fitted line is extrapolated without limit. Nothing in the graph knows the firing-rate range the regression covered, so a far enough fire drifts the expected efficiency into values the fit never supported. The block set has no domain guard, so it is a frontmatter precondition; a host that wants it enforced can clamp boiler_firing_rate with Reals.Limiter upstream. HP-0001 documents the same open end.
  • One regressor, which is the reference’s choice and a real blind spot. For a condensing boiler the dominant variable is return water temperature — the same burner at the same fire condenses at 40 °C return and does not at 60 °C, several efficiency points apart — so a plant whose return temperature tracks the weather shows scatter this line cannot explain. The point dictionary carries no HW return temperature and the reference’s Required Points list has three entries; a second regressor would be a different card. Condensing sites should widen efficiency_threshold or restrict the evaluated operating states.
  • learning_period_days (14 d) stays a host precondition. It gates a fitting run that happens offline, outside any tick, and nothing in the block graph could observe it.
  • method: statistical describes the baseline’s provenance, not the runtime. The graph performs one division, one multiply-add, one subtraction and two comparisons; the classification is honest because the coefficients come from a regression. HP-0001 and RTU-0002 carry the same note.
  • persist.delayOnInit = true (CDL default false), the library’s standing choice: a boiler already below its line at controller restart waits out the full hour rather than alarming on the first tick.
  • clusters: []. clusters/clusters.json defines no cluster containing a hot water plant rule, and this card does not edit the cluster set. CLU-06 is the chilled-water analogue this fault would head on the heating side if such a cluster existed.
  • No test vectors are transcribed, because the reference publishes none. All fifteen scenarios in vectors.json are authored from the equation and replayed against the pinned engine rev.
  • Severity 3, phase 2, method: statistical, confidence: HIGH and the 5% threshold are the reference’s chapter 14 card. g36: null — research-derived (Meng et al. 2021; Shohet et al. 2020), not a G36 clause.

Notes

Read yFuelOk before yFault. The two outputs are what separate a repair from a burner that simply stopped: a host treating the falling edge of yFault as a fix will close this fault every night the boiler shuts down.

Whatever schedules the learning run must refuse to re-fit while this fault is active. The line comes from the boiler’s own history, so re-fitting after degradation has developed bakes it in as the new normal and the rule reports healthy on a machine everyone agrees is wasting gas.

Work the diagnoses in the order a combustion analyser can see them: stack temperature and oxygen at high and low fire separate the fuel/air ratio (3) and FGR (4) from the heat-transfer causes (1, 2, 5) in about twenty minutes. HW-0001 reads the same boiler from the cycling side and is worth checking first — a plant tripping both may have one problem.

Test Vectors

15 scenarios, clock step 300 s over 10800 s.

ScenarioDescription
on_the_baseline_lineA boiler sitting exactly on its fitted line: 50% fire, 1000 kW of gas in, 810 kW of heat out, measured efficiency 0.810 against an expected 0.810. The shortfall is exactly zero and nothing is reported.
two_points_below_the_line_is_not_a_faultOrdinary drift: 0.790 measured against 0.810 expected, a two-point shortfall against a five-point threshold. Boilers wander this much between cleanings and the reference does not call it.
degraded_at_mid_fireThe fault the card is for: 0.740 measured against 0.810 expected, a seven-point shortfall held continuously. The alarm asserts at t = 3600 s.
nominal_five_point_shortfall_faultsThreshold edge, and the surprise: 0.760 measured against 0.810 expected is nominally exactly the five-point threshold, but 0.81 - 0.76 evaluates to 0.050000000000000044 in IEEE-754 and the strict > fires. The nominal boundary case is a FAULT, not a pass — see Deviations.
one_ulp_of_efficiency_clears_the_thresholdThreshold edge from the other side, one unit in the last place away: thermal power of 760.0000000000001 kW puts the measured efficiency on the next double above 0.76, the shortfall at 0.04999999999999993, and the verdict back to clear. The boundary is bracketed as tightly as binary arithmetic allows.
same_efficiency_is_healthy_at_low_fireThe regressor earning its place: 0.780 measured at 20% fire, where the line expects 0.792. A 1.2-point shortfall, clear.
same_efficiency_is_a_fault_at_high_fireIdentical meter readings to the scenario above, opposite verdict: at 100% fire the line expects 0.840, so the same 0.780 is a six-point shortfall and alarms at 3600 s. Nothing but boiler_firing_rate changed.
fuel_power_exactly_at_the_floorEvaluability edge from below: fuel input sitting exactly on fuel_power_min. The strict > reads that as not evaluable, yFuelOk is false, and yFault stays down even though the efficiency shortfall behind the gate is 8.6 points. False here means NO_EVAL, not healthy.
fuel_power_just_above_the_floorEvaluability edge from above: a tenth of a kilowatt more fuel and the same 8.6-point shortfall is evaluated and alarmed at 3600 s.
boiler_off_divide_by_zeroThe boiler is off: no fuel, no heat, and the efficiency quotient is 0/0 = NaN. Every comparison against NaN is false, so the rule is silent — but the gate is what makes that silence legible, and yFuelOk says NO_EVAL rather than leaving the host to infer it.
transient_dip_never_alarmsA half-hour excursion — a cold-start cycle, a load step the burner has not caught up with — that reaches a seven-point shortfall and recovers at t = 2700 s. The hour of persistence swallows it.
alarm_clears_after_burner_serviceRecovery: the boiler runs seven points down until the burner is cleaned and the fuel/air ratio reset at t = 7200 s. The alarm holds from 3600 s and falls on the tick the efficiency returns — no fall delay in this rule.
fuel_meter_dropout_forces_no_evalThe scenario a host must not read as a repair. The same degraded boiler shuts down at t = 7200 s, both meters go to zero, and yFault falls exactly as it does in alarm_clears_after_burner_service. Only yFuelOk separates them: it stays true through a real recovery and goes false here.
degradation_released_on_the_alarm_tickDelay edge from below: the shortfall is present from t = 0 and disappears at exactly 3600 s, the tick persist would mature on. The input is already clear when the timer comes due, so nothing is reported — a full hour of seven-point degradation this rule declines to call.
degradation_released_one_tick_laterDelay edge from above: the same shortfall held 300 s longer asserts at exactly 3600 s and clears at 3900 s. One tick of input is one tick of alarm.
vectors.json
{
  "schema": "cxf-library/vectors/v1",
  "clock": {
    "step_s": 300,
    "horizon_s": 10800
  },
  "scenarios": [
    {
      "name": "on_the_baseline_line",
      "description": "A boiler sitting exactly on its fitted line: 50% fire, 1000 kW of gas in, 810 kW of heat out, measured efficiency 0.810 against an expected 0.810. The shortfall is exactly zero and nothing is reported.",
      "inputs": {
        "boiler_firing_rate": 50.0,
        "fuel_power": 1000.0,
        "thermal_power": 810.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 10800,
          "equals": false
        },
        {
          "output": "yFuelOk",
          "from_s": 0,
          "to_s": 10800,
          "equals": true
        }
      ]
    },
    {
      "name": "two_points_below_the_line_is_not_a_fault",
      "description": "Ordinary drift: 0.790 measured against 0.810 expected, a two-point shortfall against a five-point threshold. Boilers wander this much between cleanings and the reference does not call it.",
      "inputs": {
        "boiler_firing_rate": 50.0,
        "fuel_power": 1000.0,
        "thermal_power": 790.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 10800,
          "equals": false
        }
      ]
    },
    {
      "name": "degraded_at_mid_fire",
      "description": "The fault the card is for: 0.740 measured against 0.810 expected, a seven-point shortfall held continuously. The alarm asserts at t = 3600 s.",
      "inputs": {
        "boiler_firing_rate": 50.0,
        "fuel_power": 1000.0,
        "thermal_power": 740.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3300,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 3900,
          "to_s": 10800,
          "equals": true
        }
      ]
    },
    {
      "name": "nominal_five_point_shortfall_faults",
      "description": "Threshold edge, and the surprise: 0.760 measured against 0.810 expected is nominally exactly the five-point threshold, but 0.81 - 0.76 evaluates to 0.050000000000000044 in IEEE-754 and the strict `>` fires. The nominal boundary case is a FAULT, not a pass \u2014 see Deviations.",
      "inputs": {
        "boiler_firing_rate": 50.0,
        "fuel_power": 1000.0,
        "thermal_power": 760.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3300,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 3900,
          "to_s": 10800,
          "equals": true
        }
      ]
    },
    {
      "name": "one_ulp_of_efficiency_clears_the_threshold",
      "description": "Threshold edge from the other side, one unit in the last place away: thermal power of 760.0000000000001 kW puts the measured efficiency on the next double above 0.76, the shortfall at 0.04999999999999993, and the verdict back to clear. The boundary is bracketed as tightly as binary arithmetic allows.",
      "inputs": {
        "boiler_firing_rate": 50.0,
        "fuel_power": 1000.0,
        "thermal_power": 760.0000000000001
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 10800,
          "equals": false
        }
      ]
    },
    {
      "name": "same_efficiency_is_healthy_at_low_fire",
      "description": "The regressor earning its place: 0.780 measured at 20% fire, where the line expects 0.792. A 1.2-point shortfall, clear.",
      "inputs": {
        "boiler_firing_rate": 20.0,
        "fuel_power": 1000.0,
        "thermal_power": 780.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 10800,
          "equals": false
        }
      ]
    },
    {
      "name": "same_efficiency_is_a_fault_at_high_fire",
      "description": "Identical meter readings to the scenario above, opposite verdict: at 100% fire the line expects 0.840, so the same 0.780 is a six-point shortfall and alarms at 3600 s. Nothing but boiler_firing_rate changed.",
      "inputs": {
        "boiler_firing_rate": 100.0,
        "fuel_power": 1000.0,
        "thermal_power": 780.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3300,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 3900,
          "to_s": 10800,
          "equals": true
        }
      ]
    },
    {
      "name": "fuel_power_exactly_at_the_floor",
      "description": "Evaluability edge from below: fuel input sitting exactly on fuel_power_min. The strict `>` reads that as not evaluable, yFuelOk is false, and yFault stays down even though the efficiency shortfall behind the gate is 8.6 points. False here means NO_EVAL, not healthy.",
      "inputs": {
        "boiler_firing_rate": 10.0,
        "fuel_power": 5.0,
        "thermal_power": 3.5
      },
      "expect": [
        {
          "output": "yFuelOk",
          "from_s": 0,
          "to_s": 10800,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 10800,
          "equals": false
        }
      ]
    },
    {
      "name": "fuel_power_just_above_the_floor",
      "description": "Evaluability edge from above: a tenth of a kilowatt more fuel and the same 8.6-point shortfall is evaluated and alarmed at 3600 s.",
      "inputs": {
        "boiler_firing_rate": 10.0,
        "fuel_power": 5.1,
        "thermal_power": 3.57
      },
      "expect": [
        {
          "output": "yFuelOk",
          "from_s": 0,
          "to_s": 10800,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3300,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 3900,
          "to_s": 10800,
          "equals": true
        }
      ]
    },
    {
      "name": "boiler_off_divide_by_zero",
      "description": "The boiler is off: no fuel, no heat, and the efficiency quotient is 0/0 = NaN. Every comparison against NaN is false, so the rule is silent \u2014 but the gate is what makes that silence legible, and yFuelOk says NO_EVAL rather than leaving the host to infer it.",
      "inputs": {
        "boiler_firing_rate": 0.0,
        "fuel_power": 0.0,
        "thermal_power": 0.0
      },
      "expect": [
        {
          "output": "yFuelOk",
          "from_s": 0,
          "to_s": 10800,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 10800,
          "equals": false
        }
      ]
    },
    {
      "name": "transient_dip_never_alarms",
      "description": "A half-hour excursion \u2014 a cold-start cycle, a load step the burner has not caught up with \u2014 that reaches a seven-point shortfall and recovers at t = 2700 s. The hour of persistence swallows it.",
      "inputs": {
        "boiler_firing_rate": 50.0,
        "fuel_power": 1000.0,
        "thermal_power": [
          {
            "t": 0,
            "value": 810.0
          },
          {
            "t": 900,
            "value": 740.0
          },
          {
            "t": 2700,
            "value": 810.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 10800,
          "equals": false
        }
      ]
    },
    {
      "name": "alarm_clears_after_burner_service",
      "description": "Recovery: the boiler runs seven points down until the burner is cleaned and the fuel/air ratio reset at t = 7200 s. The alarm holds from 3600 s and falls on the tick the efficiency returns \u2014 no fall delay in this rule.",
      "inputs": {
        "boiler_firing_rate": 50.0,
        "fuel_power": 1000.0,
        "thermal_power": [
          {
            "t": 0,
            "value": 740.0
          },
          {
            "t": 7200,
            "value": 810.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3300,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 3900,
          "to_s": 6900,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 7200,
          "to_s": 10800,
          "equals": false
        }
      ]
    },
    {
      "name": "fuel_meter_dropout_forces_no_eval",
      "description": "The scenario a host must not read as a repair. The same degraded boiler shuts down at t = 7200 s, both meters go to zero, and yFault falls exactly as it does in alarm_clears_after_burner_service. Only yFuelOk separates them: it stays true through a real recovery and goes false here.",
      "inputs": {
        "boiler_firing_rate": [
          {
            "t": 0,
            "value": 50.0
          },
          {
            "t": 7200,
            "value": 0.0
          }
        ],
        "fuel_power": [
          {
            "t": 0,
            "value": 1000.0
          },
          {
            "t": 7200,
            "value": 0.0
          }
        ],
        "thermal_power": [
          {
            "t": 0,
            "value": 740.0
          },
          {
            "t": 7200,
            "value": 0.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3300,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 3900,
          "to_s": 6900,
          "equals": true
        },
        {
          "output": "yFuelOk",
          "from_s": 0,
          "to_s": 6900,
          "equals": true
        },
        {
          "output": "yFuelOk",
          "from_s": 7200,
          "to_s": 10800,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 7200,
          "to_s": 10800,
          "equals": false
        }
      ]
    },
    {
      "name": "degradation_released_on_the_alarm_tick",
      "description": "Delay edge from below: the shortfall is present from t = 0 and disappears at exactly 3600 s, the tick persist would mature on. The input is already clear when the timer comes due, so nothing is reported \u2014 a full hour of seven-point degradation this rule declines to call.",
      "inputs": {
        "boiler_firing_rate": 50.0,
        "fuel_power": 1000.0,
        "thermal_power": [
          {
            "t": 0,
            "value": 740.0
          },
          {
            "t": 3600,
            "value": 810.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 10800,
          "equals": false
        }
      ]
    },
    {
      "name": "degradation_released_one_tick_later",
      "description": "Delay edge from above: the same shortfall held 300 s longer asserts at exactly 3600 s and clears at 3900 s. One tick of input is one tick of alarm.",
      "inputs": {
        "boiler_firing_rate": 50.0,
        "fuel_power": 1000.0,
        "thermal_power": [
          {
            "t": 0,
            "value": 740.0
          },
          {
            "t": 3900,
            "value": 810.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3300,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 3600,
          "to_s": 3600,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 3900,
          "to_s": 10800,
          "equals": false
        }
      ]
    }
  ]
}

HW-0003 — Boiler or HW pump operating above OAT lockout temperature

Statusverified — engine e2ff2f8, cxf:fnv1a128:d6dd066552fff48e4a1b0ebab1cff9e6, 2026-08-17
Severity3
Methodrule
Phase2
CategoryCRITICAL_WASTE
ConfidenceHIGH
EstimationDIRECT_MEASUREMENT
G36
Clusters
Suppresses
Suppressed by
RelatedHW-0001, HW-0002, AHU-0018
Playbookshot-water-plant-faults
SourceHVAC FDD Reference v1.0 §14 (ch. ‘Hot Water Plants’, pdf pp. 126-127), HW-0003; PNNL RetuningOpps H01; >25% prevalence (PNNL 151-building study)
Operating statesall

Preconditions (host-enforced): oat must be a trustworthy outdoor reading. The reference’s own diagnosis 5 is an OAT sensor reading low, and this rule cannot tell that from a correctly-programmed lockout — a sensor in afternoon sun or above a warm roof fails the other way and hides the fault instead. Cross-check against a nearby weather station or a second outdoor sensor before dispatching on a fleet of these. The plant must not be serving a domestic hot water load: diagnosis 4 is DHW demand legitimately keeping a boiler firing in July, and nothing in three points distinguishes a boiler making 60 °C service water from a boiler heating an empty building. Sites with combined heating/DHW plants must exclude this rule, gate it on the DHW valve position host-side, or accept that the summer months are noise. boiler_status and hw_pump_status must belong to the same plant as oat; on a multi-boiler plant either may legitimately be bound to the OR across boilers, which is the one place in this library where that aggregation is correct (contrast HW-0001, where it destroys the measurement). Both status points should be proof of operation — a current switch or a flow proof — rather than the command echoed back from a relay.

Points: boiler_status, hw_pump_status, oat

Outputs:

  • yFault — True while the boiler or the HW pump has been running with the outdoor air temperature above heating_plant_lockout_temp, continuously for lockout_check_duration

Parameters:

NameDefaultUnitCXF pathDescription
heating_plant_lockout_temp16.0°CmildOat.tOutdoor air temperature above which the heating plant should be locked out. 16 °C (61 °F) is the reference’s default and a common code and retro-commissioning value; sites with high-mass buildings or 24-hour perimeter loads sometimes set it higher, and that is a finding rather than a reason to retune
lockout_check_duration3600.0ssustained.delayTimeHow long the plant must run above the lockout temperature before the alarm asserts (60 min). This is the rule’s only timer — the reference states no separate AlarmDelay for this card, so the duration is the persistence (see Deviations)

Description

The reference calls the outdoor air lockout one of the simplest and highest-return fixes in its catalogue, and the arithmetic is why: above about 16 °C a commercial building does not need its heating plant, so every therm the boiler burns and every kilowatt the pump draws is waste in full — not a percentage of waste, the whole thing. By the PNNL 151-building study it is present in more than a quarter of buildings: lockouts left out of a sequence, set to a temperature nobody revisited, overridden one cold April morning and never released. The pump half matters as much as the boiler half and is easier to miss — a circulator running all summer costs its full draw, keeps the piping warm and pushes heat into ceilings the cooling plant then removes, which is why the equation is a disjunction rather than a boiler test with a pump footnote.

Detection Logic

yFault = (boiler_status OR hw_pump_status)
     AND oat > heating_plant_lockout_temp
     sustained continuously for lockout_check_duration

Block graph (rule.cxf.jsonld):

HW-0003 block graph

plantOn is evaluated tick by tick rather than per point, so a lead pump handing over to a boiler keeps the condition continuously true across the handover; a rule written as two per-point tests would miss that hour. mildOat is strict, so exactly 16.0 °C is not above the lockout.

sustained is the whole timing story: one Logical.TrueDelay at 60 minutes, which is simultaneously the reference’s lockout_check_duration and the only persistence this card has (see Deviations). An hour rides out a morning warm-up finishing as the sun comes up, a boiler completing the cycle it was in when the outdoor temperature crossed, and a flow proof chattering on a pump changeover. Persistence is continuous, not accumulated — a dip back under the lockout discards the elapsed time rather than pausing it — and delayOnInit = true, so a plant already running above its lockout at restart waits out the full hour. The alarm falls the instant the plant stops, with no release delay.

Possible Diagnoses

Transcribed from the reference’s HW-0003 card:

  1. Boiler lockout sequence never programmed — the most common cause and a $0 remote fix
  2. Lockout setpoint set too high — a plant locked out at 21 °C looks programmed and behaves almost as badly; this survives a casual review of the sequence
  3. Lockout overridden by an operator, usually a shoulder-season cold snap with no expiry on the override
  4. Domestic hot water demand keeping the boiler on — not a fault, and invisible to this rule; a host precondition rather than a diagnosis (see Deviations)
  5. OAT sensor reading incorrectly low — also not a plant fault, also invisible here, and free to rule out against a weather station first

Energy Impact

CRITICAL_WASTE, HIGH confidence, DIRECT_MEASUREMENT. While the fault is active the entire plant draw is waste — waste_kw = boiler_current_kw + hw_pump_kw, the reference’s formula — because there is no heating load for any of it to serve. No baseline, no efficiency, no proxy. Climate sensitivity is “both”: the cost peaks in the swing seasons, when outdoor temperatures spend weeks above the lockout, and in a cooling-dominant climate the shed pipe heat also loads the chillers. Confidence is HIGH because both halves are directly measured, subject to the sensor caveat that is diagnosis 5.

Emissions Impact

Scope 1 + 2, DIRECT_EMISSIONS, HIGH confidence; the reference’s typical range is 2,000–15,000 kg CO₂e/yr for a plant running above its lockout. The split is worth keeping: the boiler’s fuel is Scope 1 on a static combustion factor, the pump’s electricity is Scope 2 on the marginal operating emissions rate, and the two respond to different levers. A site that has decarbonised its electricity still owns the whole Scope 1 half.

Deviations

  • There is one timer, and it is the reference’s lockout_check_duration. The tunables line ends at “lockout_check_duration = 60 min,” and no AlarmDelay appears for this card anywhere in the chapter. Rather than adopt one and stack two hours of delay on a fault that is fully decided in one, the rule reads the duration as the persistence, which is what “sustained for lockout_check_duration” says in the equation. A host wanting a separate alarm persistence must add it downstream.
  • Diagnoses 4 and 5 are host preconditions, not detections. A boiler firing in July for a DHW load and one firing because the lockout was never programmed produce identical values on all three points, as do a correctly locked-out plant and one whose OAT sensor reads 8 °C low. The DHW case generates false positives at scale — combined heating/DHW plants are common in older buildings — and the OAT case generates silent misses, which is worse and has no in-rule remedy. Both are recorded in preconditions so a host can gate or cross-check.
  • Strict > at the lockout temperature, as the reference writes it; CDL Reals has no GreaterEqual in any case. A plant running at exactly 16.0 °C reads clear. The disagreement is measure-zero and errs toward silence, but a BAS that quantises outdoor temperature to whole degrees will sit on the boundary often, and should set the parameter between two quantisation levels.
  • The disjunction is plant-level, and the OR across boilers is correct here. This rule asks whether anything is running, so an OR of individual boiler statuses preserves the question exactly — the opposite of HW-0001, which counts transitions of a specific burner and is destroyed by the same aggregation. Both cards state the constraint.
  • TrueDelay asserts at exactly T + delayTime, so the realized test is “above the lockout for strictly more than lockout_check_duration” at tick resolution: a plant that stops on the maturity tick is never reported, and one that runs a single tick longer asserts for that tick and clears.
  • operating_states: all, deliberately. Every other rule in the hot water chapter is gated to the heating season; this one detects a plant behaving as though it were the heating season when it is not, so gating it that way would delete it. The graph needs no operating state — two proofs of operation and a temperature.
  • clusters: []. clusters/clusters.json defines no cluster containing a hot water plant rule. CLU-07 (Unnecessary Plant Operation) is the syndrome this fault belongs to on the heating side and this card is a candidate member; adding it is the cluster owner’s edit.
  • The energy formula’s inputs are not this rule’s inputs. waste_kw = boiler_current_kw + hw_pump_kw needs a fuel measurement and a pump power measurement, and the hot water point dictionary carries neither as a plant-power point. The host supplies them — fuel_power from HW-0002’s point set where metered, pump draw from the pump or drive family — and the formula is otherwise transcribed unchanged.
  • No test vectors are transcribed, because the reference publishes none. All twelve scenarios in vectors.json are authored from the equation and replayed against the pinned engine rev.
  • Severity 3, phase 2, method: rule, category: CRITICAL_WASTE and both tunable defaults are the reference’s chapter 14 card. g36: null — a PNNL retro-commissioning finding (RetuningOpps H01), not a G36 clause.

Notes

Settle the DHW question before deployment, not after. On a combined heating/DHW plant this rule alarms every summer day, and the right response is not to widen the parameter — 16 °C is correct — but to exclude the rule, gate it host-side on the heating loop’s isolation valve or the DHW valve position, or bind boiler_status to a boiler that does not serve DHW where the plant has a dedicated one. Widening the lockout converts a false positive into a real fault.

Check the outdoor air sensor before dispatching anyone: it is diagnosis 5, it is free, and the same sensor feeds whatever reset schedules the building has, so finding it resolves more than this fault. AHU-0018 is the same finding at another scale; where both fire, fix the plant first, and expect one cause — a commissioning phase that got cut.

Test Vectors

12 scenarios, clock step 300 s over 9000 s.

ScenarioDescription
cold_day_plant_runningJanuary: 5 degC outdoors, boiler firing, pump circulating. This is the hot water plant doing its job and the rule has nothing to say about it.
warm_day_plant_offThe other healthy case: 25 degC outdoors with the plant properly locked out. Both status points are false, so the disjunction is false and the outdoor temperature never matters.
boiler_firing_above_lockoutThe fault as the reference writes it: 20 degC outdoors and the boiler firing, held for the full lockout_check_duration. The alarm asserts at t = 3600 s.
pump_circulating_above_lockoutThe second branch of the disjunction, and the one sites forget: the boiler is off but the HW pump is still circulating at 20 degC. Pump energy, distribution losses through warm pipe, and no heating load to justify either. Same alarm at 3600 s.
oat_exactly_at_the_lockout_temperatureThreshold edge from below: the outdoor air sits exactly on heating_plant_lockout_temp with the whole plant running. Reals.GreaterThreshold is strict, so 16.0 degC is not above 16.0 degC and nothing is reported.
oat_one_tenth_above_the_lockout_temperatureThreshold edge from above: a tenth of a degree warmer, same plant, and the alarm arrives at 3600 s. A measure-zero disagreement on a real-valued signal, pinned from both sides.
lead_pump_hands_over_to_the_boilerThe disjunction is continuous, not per-point: the pump runs alone until t = 1800 s, then the boiler picks up and the pump stops. Neither point is true for a whole hour but the plant is, so the timer never restarts and the alarm still lands at 3600 s. A rule written per point would miss this.
oat_rises_through_the_lockoutA spring morning: the plant runs from midnight and the outdoor temperature crosses 16 degC at t = 1800 s. The clock starts at the crossing, not at the start of the run, and the alarm lands at 5400 s.
oat_dips_below_lockout_and_restarts_the_clockPersistence is continuous, so a 600 s dip back under the lockout at t = 1800 s discards the elapsed time rather than pausing it: the alarm lands at 6000 s, one full duration after the second crossing.
plant_stops_on_the_maturity_tickDelay edge from below: the plant runs above lockout from t = 0 and both status points drop at exactly 3600 s, the tick the timer matures on. The input is false when the timer comes due, so a full hour of unnecessary operation is never reported.
plant_stops_one_tick_laterDelay edge from above: the same run held 300 s longer asserts at exactly 3600 s and clears at 3900 s.
alarm_clears_when_the_plant_shuts_downRecovery: the lockout is programmed and the plant stops at t = 5400 s. The alarm falls on that tick — this rule has no fall delay — which is the operator’s confirmation that the fix took.
vectors.json
{
  "schema": "cxf-library/vectors/v1",
  "clock": {
    "step_s": 300,
    "horizon_s": 9000
  },
  "scenarios": [
    {
      "name": "cold_day_plant_running",
      "description": "January: 5 degC outdoors, boiler firing, pump circulating. This is the hot water plant doing its job and the rule has nothing to say about it.",
      "inputs": {
        "oat": 5.0,
        "boiler_status": true,
        "hw_pump_status": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 9000,
          "equals": false
        }
      ]
    },
    {
      "name": "warm_day_plant_off",
      "description": "The other healthy case: 25 degC outdoors with the plant properly locked out. Both status points are false, so the disjunction is false and the outdoor temperature never matters.",
      "inputs": {
        "oat": 25.0,
        "boiler_status": false,
        "hw_pump_status": false
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 9000,
          "equals": false
        }
      ]
    },
    {
      "name": "boiler_firing_above_lockout",
      "description": "The fault as the reference writes it: 20 degC outdoors and the boiler firing, held for the full lockout_check_duration. The alarm asserts at t = 3600 s.",
      "inputs": {
        "oat": 20.0,
        "boiler_status": true,
        "hw_pump_status": false
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3300,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 3900,
          "to_s": 9000,
          "equals": true
        }
      ]
    },
    {
      "name": "pump_circulating_above_lockout",
      "description": "The second branch of the disjunction, and the one sites forget: the boiler is off but the HW pump is still circulating at 20 degC. Pump energy, distribution losses through warm pipe, and no heating load to justify either. Same alarm at 3600 s.",
      "inputs": {
        "oat": 20.0,
        "boiler_status": false,
        "hw_pump_status": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3300,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 3900,
          "to_s": 9000,
          "equals": true
        }
      ]
    },
    {
      "name": "oat_exactly_at_the_lockout_temperature",
      "description": "Threshold edge from below: the outdoor air sits exactly on heating_plant_lockout_temp with the whole plant running. `Reals.GreaterThreshold` is strict, so 16.0 degC is not above 16.0 degC and nothing is reported.",
      "inputs": {
        "oat": 16.0,
        "boiler_status": true,
        "hw_pump_status": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 9000,
          "equals": false
        }
      ]
    },
    {
      "name": "oat_one_tenth_above_the_lockout_temperature",
      "description": "Threshold edge from above: a tenth of a degree warmer, same plant, and the alarm arrives at 3600 s. A measure-zero disagreement on a real-valued signal, pinned from both sides.",
      "inputs": {
        "oat": 16.1,
        "boiler_status": true,
        "hw_pump_status": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3300,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 3900,
          "to_s": 9000,
          "equals": true
        }
      ]
    },
    {
      "name": "lead_pump_hands_over_to_the_boiler",
      "description": "The disjunction is continuous, not per-point: the pump runs alone until t = 1800 s, then the boiler picks up and the pump stops. Neither point is true for a whole hour but the plant is, so the timer never restarts and the alarm still lands at 3600 s. A rule written per point would miss this.",
      "inputs": {
        "oat": 20.0,
        "boiler_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 1800,
            "value": true
          }
        ],
        "hw_pump_status": [
          {
            "t": 0,
            "value": true
          },
          {
            "t": 1800,
            "value": false
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3300,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 3900,
          "to_s": 9000,
          "equals": true
        }
      ]
    },
    {
      "name": "oat_rises_through_the_lockout",
      "description": "A spring morning: the plant runs from midnight and the outdoor temperature crosses 16 degC at t = 1800 s. The clock starts at the crossing, not at the start of the run, and the alarm lands at 5400 s.",
      "inputs": {
        "boiler_status": true,
        "hw_pump_status": false,
        "oat": [
          {
            "t": 0,
            "value": 12.0
          },
          {
            "t": 1800,
            "value": 20.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5100,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 5700,
          "to_s": 9000,
          "equals": true
        }
      ]
    },
    {
      "name": "oat_dips_below_lockout_and_restarts_the_clock",
      "description": "Persistence is continuous, so a 600 s dip back under the lockout at t = 1800 s discards the elapsed time rather than pausing it: the alarm lands at 6000 s, one full duration after the second crossing.",
      "inputs": {
        "boiler_status": true,
        "hw_pump_status": true,
        "oat": [
          {
            "t": 0,
            "value": 20.0
          },
          {
            "t": 1800,
            "value": 15.0
          },
          {
            "t": 2400,
            "value": 20.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5700,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 6300,
          "to_s": 9000,
          "equals": true
        }
      ]
    },
    {
      "name": "plant_stops_on_the_maturity_tick",
      "description": "Delay edge from below: the plant runs above lockout from t = 0 and both status points drop at exactly 3600 s, the tick the timer matures on. The input is false when the timer comes due, so a full hour of unnecessary operation is never reported.",
      "inputs": {
        "oat": 20.0,
        "boiler_status": [
          {
            "t": 0,
            "value": true
          },
          {
            "t": 3600,
            "value": false
          }
        ],
        "hw_pump_status": false
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 9000,
          "equals": false
        }
      ]
    },
    {
      "name": "plant_stops_one_tick_later",
      "description": "Delay edge from above: the same run held 300 s longer asserts at exactly 3600 s and clears at 3900 s.",
      "inputs": {
        "oat": 20.0,
        "boiler_status": [
          {
            "t": 0,
            "value": true
          },
          {
            "t": 3900,
            "value": false
          }
        ],
        "hw_pump_status": false
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3300,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 3600,
          "to_s": 3600,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 3900,
          "to_s": 9000,
          "equals": false
        }
      ]
    },
    {
      "name": "alarm_clears_when_the_plant_shuts_down",
      "description": "Recovery: the lockout is programmed and the plant stops at t = 5400 s. The alarm falls on that tick \u2014 this rule has no fall delay \u2014 which is the operator's confirmation that the fix took.",
      "inputs": {
        "oat": 20.0,
        "boiler_status": [
          {
            "t": 0,
            "value": true
          },
          {
            "t": 5400,
            "value": false
          }
        ],
        "hw_pump_status": [
          {
            "t": 0,
            "value": true
          },
          {
            "t": 5400,
            "value": false
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3300,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 3900,
          "to_s": 5100,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 5400,
          "to_s": 9000,
          "equals": false
        }
      ]
    }
  ]
}

HW-0004 — HW loop low delta-T

Statusverified — engine e2ff2f8, cxf:fnv1a128:022b2acd6415b5ce7aaba663fa8c3c49, 2026-08-17
Severity3
Methodrule
Phase2
CategoryEXCESS_CONSUMPTION
ConfidenceMEDIUM
EstimationPROXY_ESTIMATION
G36
Clusters
Suppresses
Suppressed by
RelatedHW-0002, HW-0003, HW-0005, HW-0010, CHW-0004, AHU-0015, VAV-0003
Playbookslow-delta-t, hot-water-plant-faults
SourcePNNL-27338 §4.6 (low hot-water loop delta-T, pp. 4.19-4.21) — algorithm, 20 °F design / 10 °F trip pair, and its own note that the test carries no load gate; PNNL-27338 §4.4 (pp. 4.12-4.13) — the 35% pump-speed line this report uses to mean a lightly loaded HW loop, adopted here as the evaluability floor; PNNL-27338 (Katipamula et al. 2018) — adapted via an internal paraphrased deep-read digest, not distributed (paraphrased algorithm digest; candidate 8); Sibling precedent: CHW-0004 (graph shape, two-parameter trip line, yLoadOk evaluability output), VFD-0002 (assembled limit); Library extension: the HVAC FDD Reference v1.0 ch.14 specifies only HW-0001..052 — see faults/hw/README.md
Operating statesHeating plant producing — boilers firing or enabled and holding a supply setpoint — with the distribution pumps circulating above min_pump_speed_for_eval. The rule’s yLoadOk covers the pump half of that state; the producing half is the host’s to enforce, and the rule is wrong without it (see Deviations).

Preconditions (host-enforced): hws_temp and hwr_temp must describe the same hydraulic loop at the same moment, and must be the loop the coils are on. On a primary/secondary plant the boiler’s own leaving and entering temperatures see primary flow and read a healthy delta-T while the secondary loop that actually serves the coils short-circuits through the decoupler — which is the fault. Bind the secondary supply and return headers on a decoupled plant and the boiler connections on a variable-primary one, and bind hw_pump_vfd_speed from the pumps that move that same water. Both temperatures must be in °C (the rule converts nothing), and design_delta_t must be this loop’s design value read off the plant’s drawings, not the shipped 11.0 K, before any verdict means anything. Sensor placement and calibration decide the finding: a supply sensor reading low or a return sensor reading high biases delta-T toward the alarm, nothing in the rule can tell a swapped sensor pair from a genuine collapse, and a 0.5 K offset is 9% of a 5.5 K trip line. The plant must actually be making heat: a loop circulating with the boilers off equalises supply and return and alarms permanently (pinned by loop_circulating_with_no_heat_input), so gate host-side on the boiler or plant-enable status — HW-0003 is the rule that owns that condition. hw_pump_vfd_speed must be a speed feedback from a variable-flow distribution system: a constant-speed pump reads full speed always and the load gate protects nothing, and a drive pinned at a minimum speed above 35% does the same. Evaluability is signalled in-rule by yLoadOk; when it is false the verdict is NO_EVAL, not a healthy loop.

Points: hws_temp, hwr_temp, hw_pump_vfd_speed

Outputs:

  • yFault — True while the hot water delta-T has stayed below design_delta_t × low_dt_fraction with the distribution pumps above min_pump_speed_for_eval, continuously for at least alarm_delay
  • yLoadOk — Evaluability signal — true when hw_pump_vfd_speed is above min_pump_speed_for_eval, the speed below which so little water is moving that delta-T says nothing about the distribution system. False means NO_EVAL and the host must ignore yFault

Parameters:

NameDefaultUnitCXF pathDescription
design_delta_t11.0°CdesignDt.kDesign hot water delta-T. PER-LOOP SITE CONFIGURATION — read it off the plant’s design documents. The shipped 11.0 K is a placeholder near the 20 °F (11.1 K) design PNNL-27338 §4.6 assumes; radiant and low-temperature hydronic loops are commonly designed nearer 5-8 K, and condensing-boiler retrofits are often pushed to 15 K or more to hold the return temperature down, and none of those are served by the shipped value.
low_dt_fraction0.51lowDtLimit.kFraction of design delta-T below which the loop is faulted. 0.5 reproduces PNNL-27338 §4.6’s own pair, whose 10 °F trip is exactly half its 20 °F design. Kept as its own parameter rather than folded into the trip line so a site can retune the tolerance and the design value independently — see Deviations.
min_pump_speed_for_eval35.0%loadOk.tDistribution pump speed below which delta-T is not evaluated. ADOPTED — §4.6 gates on nothing at all; 35% is the speed §4.4 treats as a lightly loaded HW loop, and it sits below the 45% HW-0005 calls working hard.
alarm_delay3600.0spersist.delayTimeContinuous low delta-T at flow required before the alarm asserts (60 min). ADOPTED from CHW-0004 — PNNL-27338 specifies a 15-60 min averaging window per §1.2, not an alarm persistence.

Description

A heating loop is sized on a temperature difference. Design the coils for 11 K between supply and return and the pumps move enough water to carry the peak load; let that difference fall to 4 K and the same load needs nearly three times the flow, so the pumps speed up and a plant carrying the building on one boiler starts a second. On a heating loop low delta-T means the return comes back too hot, and a condensing boiler needs return water below roughly 55 °C to condense at all — a loop whose return has crept up has not only doubled its pumping, it has moved the boiler out of the regime the plant was bought for. Nothing looks broken: zones hold setpoint and the boiler makes supply temperature. It is measured at the loop because that is where a bypassed three-way valve here and an oversized control valve there add up. This rule is a library extension — the reference’s ch.14 stops at HW-0003 — built from PNNL-27338 §4.6 and CHW-0004’s parameter shapes.

Detection Logic

delta_t   = hws_temp − hwr_temp                     (supply minus return: the heating sign)
low_limit = design_delta_t × low_dt_fraction        (11.0 × 0.5 = 5.5 K)

yLoadOk = hw_pump_vfd_speed > min_pump_speed_for_eval   (false ⇒ host reports NO_EVAL)
yFault  = delta_t < low_limit AND yLoadOk,
          sustained continuously for alarm_delay

Block graph (rule.cxf.jsonld):

HW-0004 block graph

deltaT is CHW-0004’s block with the inputs the other way round — supply on u1, return on u2 — and that ordering is the whole heating-side inversion. It is the line to check first: a graph that subtracts in the cooling direction reports a permanent fault on a healthy loop and looks exactly like a rule that works. designDt and lowDtLimit assemble the trip line in the graph rather than shipping a pre-multiplied 5.5, so both numbers stay independent set_param targets; they are retuned for different reasons.

lowDt is strict, so a loop sitting exactly on the trip line reads healthy, and the boundary is bit-exact: 11.0 halves to exactly 5.5, which a pair of realistic temperatures can reach (65.5 − 60.0). loadOk is the evaluability story — PNNL-27338 §4.6 gates on nothing, and without a gate every night setback and mild afternoon on a reset loop reads as a fault. Pump speed is the only load-shaped signal the HW dictionary carries; Deviations records what that substitution costs. persist requires 60 continuous minutes and carries delayOnInit = true; low delta-T is a loop condition, not an event.

Possible Diagnoses

Library-authored — PNNL-27338 §4.6 specifies a threshold test, not causes, so this is the heating-side reading of CHW-0004’s list plus the hot-loop cases:

  1. Three-way valves bypassing at coils, unit heaters and cabinet heaters — the classic cause, worst when the building is warm and the bypass port widest
  2. Oversized two-way control valves — no authority left below 20% open, so the valve sits nearly shut and still overflows its coil
  3. Zone valves failing open, leaking by, or left in hand
  4. Loop bypasses and balancing valves left open from an unfinished commissioning, or reverse flow through a primary/secondary decoupler
  5. Coils that cannot transfer their duty — water-side fouling, or air trapped at an unvented high point, which has no chilled-water analog
  6. Distribution pressure above what the loop needs, forcing flow through valves already throttling. HW-0005 detects that directly and is the one to fix first
  7. A supply temperature reset pushed too far down — cooler water makes every valve open further for the same duty; the fix is the reset schedule
  8. A loop oversized for the building it ended up serving — the case with no repair, where the delta-T is telling the truth

Causes 1 through 5 are local defects this loop-level rule aggregates: forty heating coils can reach the trip line with four misbehaving and thirty-six fine.

Energy Impact

EXCESS_CONSUMPTION, MEDIUM confidence, PROXY_ESTIMATION. The pump term is CHW-0004’s estimator on heating values — excess_pump_kw ≈ hw_pump_kw × (design_dt − actual_dt) / design_dt — so a loop at 5 K on an 11 K design spends more than half its pump energy moving water that comes back too hot to be worth having moved. PNNL-27338 publishes no savings range for §4.6, so the 5-15% in savings_range is the chilled-water figure and is the weaker half of the claim; the stronger half is the boiler, where a return above the condensing threshold forfeits 5-10 efficiency points, or on non-condensing plant costs the staging and cycling HW-0001 measures. Confidence is MEDIUM because the finding is loop-level and the repair is not.

Emissions Impact

Scope 1 + 2, PROXY_EMISSIONS, MEDIUM confidence. The split matters more than the total: pump energy is purchased electricity on a marginal operating emissions rate, while the condensing-mode and staging penalties are fuel burned on site on a static factor. A site that has decarbonised its electricity still owns the whole Scope 1 half, and on a condensing plant that half is the larger one. No published emissions range exists for this fault — the sibling CHW rule’s figure is a cooling-side pump-and-staging number and does not transfer — so the estimate is left to the host’s own fuel and electricity factors.

Deviations

  • This rule is a library extension, not a transcription. The reference’s ch.14 specifies HW-0001, 051 and 052 and stops; faults/hw/README.md frames FC-053 through 057 as library-authored rules grounded in PNNL-27338 §4. The name, severity 3 and method: rule are that index’s; the graph, parameters, diagnoses and energy claim are authored here, with every number the report does not fix marked ADOPTED in params.
  • The delta-T is inverted relative to CHW-0004, and the inversion is the whole port. Same block and trip line, opposite operand order, because on a heating loop the supply is the hot side. Clinically it means the return is too hot, which is why this card carries a condensing-boiler argument its sibling has no reason to make; for deployment it means getting it wrong fails silently, with every healthy loop reporting a permanent fault.
  • design_delta_t = 11.0 K is ADOPTED, and the 0.1 K between it and PNNL-27338 is deliberate. §4.6 writes a 20 °F (11.1 K) design with the trip at half. The parameter is a per-loop site value that must be read off the drawings anyway, so a tenth of a kelvin of transcription fidelity buys nothing — and 11.0 halves to exactly 5.5, whose trailing mantissa zeros let a realistic temperature pair land on the line and make the strict comparison testable rather than merely asserted. At 11.1 the line falls at 5.55, which no difference of two doubles in the 32-128 binade reaches. The shipped line sits 0.06 K below PNNL’s, an order of magnitude inside any sensor’s error.
  • low_dt_fraction = 0.5 is PNNL-27338’s ratio, factored into its own parameter. §4.6 subtracts against a fixed 10 °F; this rule reconstructs the same line as design × fraction so a site with a 20 K condensing-retrofit design can retune one without recomputing the other. Three blocks instead of one buys that. Precedent: CHW-0004 and VFD-0002’s assembled speed floor.
  • The load gate is entirely adopted — §4.6 has none, and its own prose names low demand as a confound without encoding a fix. Shipping that literally would alarm through every night setback and mild shoulder-season afternoon on loops whose delta-T is small for the correct reason. The gate is the CHW sibling’s design imported wholesale and is the largest departure from the cited algorithm.
  • hw_pump_vfd_speed substitutes for CHW-0004’s chiller_load, and the substitution is not free. The HW dictionary has no load analog — boiler_firing_rate describes what the boiler is doing, not how much water is moving — and PNNL-27338 itself uses pump speed as its HW load heuristic in §4.2 and §4.4. The costs: the proxy is partly endogenous (low delta-T raises flow, which raises pump speed), so the gate excludes quiet loops rather than lightly loaded ones; a constant-speed pump or a drive with a minimum above 35% never falls below the floor, so the gate protects nothing; and a drive that latches its last command reads true on a dead loop. All three are preconditions text, because none is separable inside the rule.
  • min_pump_speed_for_eval = 35% is ADOPTED from §4.4, not ported from the CHW sibling, whose 40% is a chiller load and does not convert. 35% is the number PNNL-27338 uses for a hot water loop with little demand, and it sits below the 45% §4.2 calls working hard, so this rule and HW-0005 read the speed axis consistently: a loop between 35% and 45% is evaluable here and uninteresting there.
  • Strict < at the trip line and strict > at the load floor. CDL Reals has no LessEqual or GreaterEqual in any case, and PNNL’s own arithmetic is strict. A loop at exactly 5.5 K reads healthy and a pump at exactly 35.0% reads NO_EVAL; both disagreements are measure-zero and both err toward silence.
  • yLoadOk is an evaluability output, not an echo of an input. It is a boundary input compared against a parameter, which is what SCHEMA.md asks such an output to be; it adds no logic and changes no verdict, and it is the only thing that lets a host tell “delta-T is fine” from “the loop was too quiet to ask”. Same stance as CHW-0004’s yLoadOk and HP-0001’s yPowerOk.
  • Persistence stands in for PNNL’s window average. Every AIRCx algorithm averages a 15-60 minute window and compares the average (§1.2); this rule consumes instantaneous points and requires the condition continuously, so a delta-T alternating above and below the line every 20 minutes never alarms even though a loop spending half its day low is a genuine finding. A steady syndrome — a bypassing or oversized valve — reads the same either way.
  • There is no boiler-on conjunct, and that is a real blind spot. A loop circulating with no heat input equalises, so delta-T goes to zero and the rule alarms at full confidence on a plant with no distribution defect. Adding boiler_status was rejected: it rebuilds HW-0003’s plant-level disjunction inside a distribution rule, silences the rule during the off-cycles of a plant that is firing normally, and trades a documented false positive for an undocumented false negative. The honest placement is operating_states plus a host gate.
  • Nothing guards against an inverted delta-T either. A swapped sensor pair, or a pair mounted on the wrong side of a decoupler, gives a negative delta-T that is below any positive trip line and alarms permanently. A limiter or a second comparison against zero could suppress it, but a genuinely negative delta-T is also what reverse flow through a decoupler looks like, so the guard would hide a real hydraulic fault to hide a wiring one. Commissioning check: swap the leads and watch the sign, once, before trusting the rule.
  • The rule is blind to which coil is responsible, and to how many. That is the design rather than a simplification — the individual defects are usually too small to detect one at a time, which is why the syndrome is measured in the return header. Air-side companions AHU-0015, FCU-0005 and VAV-0003 are related, none is wired.
  • alarm_delay = 3600 s is adopted from CHW-0004. PNNL-27338 specifies a data window and a minimum sample count, not an alarm persistence. An hour matches the sibling and HW-0003, and a heating loop’s thermal mass makes anything shorter noise.
  • persist.delayOnInit = true (CDL default false), the library’s standing choice: a loop already below the line at controller restart waits out the full hour rather than alarming on the first tick.
  • playbooks cites two, and neither Applies-To row names this card yet. low-delta-t is written around the chilled-water plant but already claims PNNL-27338 covers both CHW and HW, and its Step 1 arithmetic transfers once the subtraction is read in the heating direction; hot-water-plant-faults names low loop delta-T in its energy row. Both Applies-To rows are the index owner’s edit, the same sequencing CHW-0004 recorded.
  • clusters: []. clusters/clusters.json has no hot water cluster; CLU-06 is chilled water by name and membership. A hot-water plant syndrome (this rule, HW-0005, 055 and 056 all describe one plant giving away pump and fuel energy) is a reasonable future cluster and the cluster owner’s edit.
  • suppresses and suppressed_by are both empty. HW-0005 is the closest candidate — distribution pressure above what the loop needs is diagnosis 6 — but it is a cause of low delta-T rather than a reason to disbelieve it, and both findings stay true and separately actionable. Suppression edges must also be declared on both cards, and HW-0005 ships the matching empty pair.
  • No published test vectors exist for this algorithm. PNNL-27338 §4.6 specifies thresholds, not cases, so every scenario in vectors.json is authored from the equation and replayed against the pinned engine rev.
  • Operating states and preconditions are declared in frontmatter for host enforcement rather than encoded in the block graph, per the library’s design stance.

Notes

Read yLoadOk before yFault. A loop that is off, or coasting through a mild afternoon at 25% pump speed, holds yLoadOk false for hours, and every yFault = false underneath means “not evaluated” rather than “delta-T is fine”.

Check whether the plant is condensing before deciding what the finding is worth: there the return temperature is an input to the efficiency curve and the fuel penalty dominates the pump penalty, while on a non-condensing plant the same alarm is a pumping and staging finding that can be scheduled rather than chased.

Trend delta-T against outdoor air for a week before sending anyone. A delta-T that degrades as the weather warms points at bypasses and valves with no authority left at low load; one that is flat and low across the range points at oversized valves, an over-aggressive reset, or an oversized loop. Vent the high points, then check the largest coils — the syndrome is a sum and the big air handlers dominate it. Where HW-0005 also fires, treat the pressure finding as the trigger and re-check delta-T after the DP setpoint comes down.

Test Vectors

15 scenarios, clock step 60 s over 7200 s.

ScenarioDescription
normal_delta_tA heating loop doing its job: 70 degC supply, 58 degC return, pumps at 60%. The 12 K delta-T is above design, the rule is evaluated and silent.
low_delta_tThe fault: 70 degC supply, 66 degC return, pumps at 60%. A 4 K delta-T against a 5.5 K trip line, and the alarm lands at exactly alarm_delay because delayOnInit holds the condition from the first tick. Return water this warm also costs a condensing boiler its condensing mode.
quiet_loop_is_not_evaluatedNO_EVAL: a 1 K delta-T with the pumps at 20%. Almost no water is moving, so almost no heat is being picked up and the delta-T says nothing about the distribution system. yLoadOk = false is what the host reads instead of a verdict.
delta_t_exactly_at_the_thresholdBoundary, bit-exact: 65.5 - 60.0 is exactly 5.5, and 11.0 x 0.5 is the same double (both operands and the product have trailing zero mantissa bits, and halving is exact). Reals.Less is strict, so a loop sitting exactly on the trip line reads healthy.
delta_t_just_below_the_thresholdBoundary from below: 65.49 - 60.0 clears the strict comparison by 10 mK and alarms on the normal schedule.
delta_t_just_above_the_thresholdBoundary from above: 65.51 - 60.0 is above the trip line and never alarms, however marginal the loop’s performance is.
pump_speed_exactly_at_the_evaluability_floorBoundary on the other conjunct: pump speed exactly at min_pump_speed_for_eval (35%) with a collapsed 4 K delta-T. Reals.GreaterThreshold is strict, so exactly-at-the-floor is NO_EVAL and the rule stays silent.
pump_speed_just_below_the_evaluability_floor34.9% speed with the same collapsed delta-T: still NO_EVAL.
pump_speed_just_above_the_evaluability_floor35.1% speed with the same collapsed delta-T: evaluable, and the alarm lands at exactly alarm_delay. Two tenths of a percent of drive speed is the whole difference between this scenario and the one before last.
delta_t_collapses_mid_runA loop running at a healthy 12 K delta-T loses it at t = 1800 s - a zone valve failing open, or a bypass opening as the building unloads. The alarm lands at exactly 5400 s: the mid-run rising edge carries the same T + delayTime arithmetic as the init case.
delta_t_recovers_after_alarmRecovery: the alarm asserts at 3600 s and the return temperature drops back at t = 5400 s. TrueDelay passes the falling edge with no delay, so yFault clears on that tick.
pumps_slow_after_alarmThe evaluability release: an alarming loop drops to 20% pump speed at t = 5400 s with the low delta-T unchanged. yFault and yLoadOk fall on the same tick, and only the pair tells the host that the loop went quiet rather than that the delta-T recovered.
intermittent_low_delta_never_alarmsDelta-T alternating between 4 K and 12 K every 1200 s - a hunting mixing valve, or zones opening and closing against each other. No single episode reaches alarm_delay, so nothing fires. Persistence is not averaging: a loop spending half its day at 4 K is a real finding this rule cannot make.
supply_and_return_sensors_swappedBlind spot, pinned: the two temperature sensors are wired backwards (supply reads 60 degC, return 70 degC), so delta-T reads -10 K. Nothing in the graph knows a negative heating delta-T is impossible, and the rule reports the strongest possible low-delta-T fault on a loop that may be running perfectly.
loop_circulating_with_no_heat_inputThe second blind spot, pinned: pumps at 70% around a loop the boilers are not heating, so supply and return equalise at 45 degC and delta-T reads 0.5 K. The rule alarms, and the finding is wrong - there is no distribution defect, there is no heat. This is the case operating_states excludes and HW-0003 detects.
vectors.json
{
  "schema": "cxf-library/vectors/v1",
  "clock": {
    "step_s": 60,
    "horizon_s": 7200
  },
  "scenarios": [
    {
      "name": "normal_delta_t",
      "description": "A heating loop doing its job: 70 degC supply, 58 degC return, pumps at 60%. The 12 K delta-T is above design, the rule is evaluated and silent.",
      "inputs": {
        "hws_temp": 70.0,
        "hwr_temp": 58.0,
        "hw_pump_vfd_speed": 60.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        },
        {
          "output": "yLoadOk",
          "from_s": 0,
          "to_s": 7200,
          "equals": true
        }
      ]
    },
    {
      "name": "low_delta_t",
      "description": "The fault: 70 degC supply, 66 degC return, pumps at 60%. A 4 K delta-T against a 5.5 K trip line, and the alarm lands at exactly alarm_delay because delayOnInit holds the condition from the first tick. Return water this warm also costs a condensing boiler its condensing mode.",
      "inputs": {
        "hws_temp": 70.0,
        "hwr_temp": 66.0,
        "hw_pump_vfd_speed": 60.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3540,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 3600,
          "to_s": 7200,
          "equals": true
        },
        {
          "output": "yLoadOk",
          "from_s": 0,
          "to_s": 7200,
          "equals": true
        }
      ]
    },
    {
      "name": "quiet_loop_is_not_evaluated",
      "description": "NO_EVAL: a 1 K delta-T with the pumps at 20%. Almost no water is moving, so almost no heat is being picked up and the delta-T says nothing about the distribution system. yLoadOk = false is what the host reads instead of a verdict.",
      "inputs": {
        "hws_temp": 70.0,
        "hwr_temp": 69.0,
        "hw_pump_vfd_speed": 20.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        },
        {
          "output": "yLoadOk",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        }
      ]
    },
    {
      "name": "delta_t_exactly_at_the_threshold",
      "description": "Boundary, bit-exact: 65.5 - 60.0 is exactly 5.5, and 11.0 x 0.5 is the same double (both operands and the product have trailing zero mantissa bits, and halving is exact). Reals.Less is strict, so a loop sitting exactly on the trip line reads healthy.",
      "inputs": {
        "hws_temp": 65.5,
        "hwr_temp": 60.0,
        "hw_pump_vfd_speed": 60.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        },
        {
          "output": "yLoadOk",
          "from_s": 0,
          "to_s": 7200,
          "equals": true
        }
      ]
    },
    {
      "name": "delta_t_just_below_the_threshold",
      "description": "Boundary from below: 65.49 - 60.0 clears the strict comparison by 10 mK and alarms on the normal schedule.",
      "inputs": {
        "hws_temp": 65.49,
        "hwr_temp": 60.0,
        "hw_pump_vfd_speed": 60.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3540,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 3600,
          "to_s": 7200,
          "equals": true
        },
        {
          "output": "yLoadOk",
          "from_s": 0,
          "to_s": 7200,
          "equals": true
        }
      ]
    },
    {
      "name": "delta_t_just_above_the_threshold",
      "description": "Boundary from above: 65.51 - 60.0 is above the trip line and never alarms, however marginal the loop's performance is.",
      "inputs": {
        "hws_temp": 65.51,
        "hwr_temp": 60.0,
        "hw_pump_vfd_speed": 60.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        },
        {
          "output": "yLoadOk",
          "from_s": 0,
          "to_s": 7200,
          "equals": true
        }
      ]
    },
    {
      "name": "pump_speed_exactly_at_the_evaluability_floor",
      "description": "Boundary on the other conjunct: pump speed exactly at min_pump_speed_for_eval (35%) with a collapsed 4 K delta-T. Reals.GreaterThreshold is strict, so exactly-at-the-floor is NO_EVAL and the rule stays silent.",
      "inputs": {
        "hws_temp": 70.0,
        "hwr_temp": 66.0,
        "hw_pump_vfd_speed": 35.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        },
        {
          "output": "yLoadOk",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        }
      ]
    },
    {
      "name": "pump_speed_just_below_the_evaluability_floor",
      "description": "34.9% speed with the same collapsed delta-T: still NO_EVAL.",
      "inputs": {
        "hws_temp": 70.0,
        "hwr_temp": 66.0,
        "hw_pump_vfd_speed": 34.9
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        },
        {
          "output": "yLoadOk",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        }
      ]
    },
    {
      "name": "pump_speed_just_above_the_evaluability_floor",
      "description": "35.1% speed with the same collapsed delta-T: evaluable, and the alarm lands at exactly alarm_delay. Two tenths of a percent of drive speed is the whole difference between this scenario and the one before last.",
      "inputs": {
        "hws_temp": 70.0,
        "hwr_temp": 66.0,
        "hw_pump_vfd_speed": 35.1
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3540,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 3600,
          "to_s": 7200,
          "equals": true
        },
        {
          "output": "yLoadOk",
          "from_s": 0,
          "to_s": 7200,
          "equals": true
        }
      ]
    },
    {
      "name": "delta_t_collapses_mid_run",
      "description": "A loop running at a healthy 12 K delta-T loses it at t = 1800 s - a zone valve failing open, or a bypass opening as the building unloads. The alarm lands at exactly 5400 s: the mid-run rising edge carries the same T + delayTime arithmetic as the init case.",
      "inputs": {
        "hws_temp": 70.0,
        "hwr_temp": [
          {
            "t": 0,
            "value": 58.0
          },
          {
            "t": 1800,
            "value": 66.0
          }
        ],
        "hw_pump_vfd_speed": 60.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5340,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 5400,
          "to_s": 7200,
          "equals": true
        },
        {
          "output": "yLoadOk",
          "from_s": 0,
          "to_s": 7200,
          "equals": true
        }
      ]
    },
    {
      "name": "delta_t_recovers_after_alarm",
      "description": "Recovery: the alarm asserts at 3600 s and the return temperature drops back at t = 5400 s. TrueDelay passes the falling edge with no delay, so yFault clears on that tick.",
      "inputs": {
        "hws_temp": 70.0,
        "hwr_temp": [
          {
            "t": 0,
            "value": 66.0
          },
          {
            "t": 5400,
            "value": 58.0
          }
        ],
        "hw_pump_vfd_speed": 60.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3540,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 3600,
          "to_s": 5340,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 5400,
          "to_s": 7200,
          "equals": false
        },
        {
          "output": "yLoadOk",
          "from_s": 0,
          "to_s": 7200,
          "equals": true
        }
      ]
    },
    {
      "name": "pumps_slow_after_alarm",
      "description": "The evaluability release: an alarming loop drops to 20% pump speed at t = 5400 s with the low delta-T unchanged. yFault and yLoadOk fall on the same tick, and only the pair tells the host that the loop went quiet rather than that the delta-T recovered.",
      "inputs": {
        "hws_temp": 70.0,
        "hwr_temp": 66.0,
        "hw_pump_vfd_speed": [
          {
            "t": 0,
            "value": 60.0
          },
          {
            "t": 5400,
            "value": 20.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3540,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 3600,
          "to_s": 5340,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 5400,
          "to_s": 7200,
          "equals": false
        },
        {
          "output": "yLoadOk",
          "from_s": 0,
          "to_s": 5340,
          "equals": true
        },
        {
          "output": "yLoadOk",
          "from_s": 5400,
          "to_s": 7200,
          "equals": false
        }
      ]
    },
    {
      "name": "intermittent_low_delta_never_alarms",
      "description": "Delta-T alternating between 4 K and 12 K every 1200 s - a hunting mixing valve, or zones opening and closing against each other. No single episode reaches alarm_delay, so nothing fires. Persistence is not averaging: a loop spending half its day at 4 K is a real finding this rule cannot make.",
      "inputs": {
        "hws_temp": 70.0,
        "hwr_temp": [
          {
            "t": 0,
            "value": 66.0
          },
          {
            "t": 1200,
            "value": 58.0
          },
          {
            "t": 2400,
            "value": 66.0
          },
          {
            "t": 3600,
            "value": 58.0
          },
          {
            "t": 4800,
            "value": 66.0
          },
          {
            "t": 6000,
            "value": 58.0
          }
        ],
        "hw_pump_vfd_speed": 60.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        },
        {
          "output": "yLoadOk",
          "from_s": 0,
          "to_s": 7200,
          "equals": true
        }
      ]
    },
    {
      "name": "supply_and_return_sensors_swapped",
      "description": "Blind spot, pinned: the two temperature sensors are wired backwards (supply reads 60 degC, return 70 degC), so delta-T reads -10 K. Nothing in the graph knows a negative heating delta-T is impossible, and the rule reports the strongest possible low-delta-T fault on a loop that may be running perfectly.",
      "inputs": {
        "hws_temp": 60.0,
        "hwr_temp": 70.0,
        "hw_pump_vfd_speed": 60.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3540,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 3600,
          "to_s": 7200,
          "equals": true
        },
        {
          "output": "yLoadOk",
          "from_s": 0,
          "to_s": 7200,
          "equals": true
        }
      ]
    },
    {
      "name": "loop_circulating_with_no_heat_input",
      "description": "The second blind spot, pinned: pumps at 70% around a loop the boilers are not heating, so supply and return equalise at 45 degC and delta-T reads 0.5 K. The rule alarms, and the finding is wrong - there is no distribution defect, there is no heat. This is the case operating_states excludes and HW-0003 detects.",
      "inputs": {
        "hws_temp": 45.0,
        "hwr_temp": 44.5,
        "hw_pump_vfd_speed": 70.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3540,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 3600,
          "to_s": 7200,
          "equals": true
        },
        {
          "output": "yLoadOk",
          "from_s": 0,
          "to_s": 7200,
          "equals": true
        }
      ]
    }
  ]
}

HW-0005 — HW loop DP too high (pump speed vs mild OAT)

Statusverified — engine e2ff2f8, cxf:fnv1a128:6d35dff9c968ba4897bf8966d5555c16, 2026-08-17
Severity3
Methodrule
Phase2
CategoryEXCESS_CONSUMPTION
ConfidenceMEDIUM
EstimationPROXY_ESTIMATION
G36
Clusters
Suppresses
Suppressed by
RelatedHW-0004, HW-0006, HW-0003, CHW-0003, PMP-0002
Playbookshot-water-plant-faults
SourcePNNL-27338 §4.2 (high hot-water loop differential pressure, pp. 4.7-4.8) — both thresholds: pump VFD above 45% with outdoor air above 60 °F; PNNL-27338 (Katipamula et al. 2018) — adapted via an internal paraphrased deep-read digest, not distributed (paraphrased algorithm digest; candidate 4); Sibling precedent: HW-0003 (mild-OAT comparator plus TrueDelay), CHW-0004 (evaluability output shape), CHW-0003 (the DP-reset finding this rule does not duplicate); Library extension: the HVAC FDD Reference v1.0 ch.14 specifies only HW-0001..052 — see faults/hw/README.md
Operating statesHeating distribution enabled with variable-speed pumps under differential-pressure control, in weather above mild_weather_oat — the rule’s own yMildWeather is that second half

Preconditions (host-enforced): hw_pump_vfd_speed must be a speed feedback from the distribution pumps that serve the heating loop, and it must fall to zero when the pump stops. A drive that latches its last commanded speed while stopped, or a point bound to the speed command rather than the feedback, fabricates this fault on an idle plant; bind the feedback, or gate host-side on hw_pump_status. On a multi-pump loop bind the lead drive or a host-computed maximum across the running drives — an average across a lead/standby pair halves the reading and hides the fault. The loop must be variable-flow under DP control for the inference to hold at all: a constant-speed pump reads full speed forever and alarms every mild day, and a three-way-valve loop has no DP control to mis-set. oat must be a trustworthy outdoor reading on the same site; a sensor in afternoon sun reads high and manufactures evaluability, one shaded or over-damped reads low and hides the fault for weeks. The plant must not be circulating for a domestic hot water or process load: a DHW recirculation pump running through July is not a DP setpoint fault, and nothing in two points tells them apart — exclude the rule, bind a pump that does not serve DHW, or accept summer noise, the same decision HW-0003 forces. Evaluability is signalled in-rule by yMildWeather; when it is false the verdict is NO_EVAL, not a healthy loop.

Points: hw_pump_vfd_speed, oat

Outputs:

  • yFault — True while the HW distribution pumps have run above high_pump_speed with outdoor air above mild_weather_oat, continuously for at least alarm_delay
  • yMildWeather — Evaluability signal — true when oat is above mild_weather_oat, the weather above which a hard-working distribution pump has no load to justify it. False means NO_EVAL and the host must ignore yFault

Parameters:

NameDefaultUnitCXF pathDescription
high_pump_speed45.0%pumpHigh.tDistribution pump speed above which the pumps are working harder than mild weather can justify. PNNL-27338 §4.2’s threshold verbatim (its avg_pump_vfd > 45%).
mild_weather_oat15.6°CmildOat.tOutdoor air temperature above which the heating load is light enough that pump speed becomes evidence about the DP setpoint. PNNL-27338 §4.2’s 60 °F, converted and rounded to a tenth of a kelvin. Distinct from HW-0003’s heating_plant_lockout_temp despite the similar value — different rule, different source, retune separately (see Deviations).
alarm_delay3600.0ssustained.delayTimeContinuous hard pumping in mild weather required before the alarm asserts (60 min). ADOPTED — PNNL-27338 specifies a 15-60 min averaging window (§1.2), not an alarm persistence; 60 min matches HW-0003 and HW-0004.

Description

A variable-speed heating loop tells you what its pressure setpoint costs by how fast the pumps have to run. On a mild day the heating valves are mostly closed and a pump holding a properly reset differential pressure should be near its minimum; find it still turning at 80% and the loop is being pressurised to a number nobody chose for that day — almost always the design-day setpoint the balancer left behind. The evidence is the pump, not the pressure: the setpoint is being tracked perfectly in nearly every building carrying this fault, which is exactly why the waste is invisible on a DP trend. Pump power follows the cube of speed, so the economics are unusually good — the repair is a reset schedule and a lower base setpoint, both remote. This rule is a library extension; the reference’s ch.14 stops at HW-0003 and both thresholds come from PNNL-27338 §4.2.

Detection Logic

yMildWeather = oat > mild_weather_oat                    (false ⇒ host reports NO_EVAL)
yFault = hw_pump_vfd_speed > high_pump_speed AND yMildWeather,
         sustained continuously for alarm_delay

Block graph (rule.cxf.jsonld):

HW-0005 block graph

Four blocks, and both comparisons are PNNL-27338 §4.2’s own numbers. pumpHigh and mildOat are strict, so a drive sitting exactly at 45.0% reads clear and outdoor air exactly at 15.6 °C is NO_EVAL rather than a fault.

mildOat.y feeds both the conjunction and the yMildWeather boundary output, and that second consumer is the whole NO_EVAL story: on a cold day a pump at 80% is doing what it was bought to do, so yFault = false there means the question was not asked. It also separates a repair from the weather turning — when the alarm and its evaluability drop on the same tick, nothing was fixed.

sustained requires 60 continuous minutes and carries delayOnInit = true. Continuous means continuous: a dip below the speed threshold discards the elapsed time rather than pausing it, so the clock restarts from the second crossing. TrueDelay asserts at exactly T + delayTime, making the realized test “above both thresholds for strictly more than alarm_delay” at tick resolution.

Possible Diagnoses

Library-authored — PNNL-27338 §4.2 specifies a threshold test, not causes:

  1. No DP reset schedule at all — the usual finding and the reason this rule exists. HW-0006 tests the setpoint trend directly and is the confirming rule
  2. A reset that exists but resets from the wrong thing, or across too narrow a range — outdoor-air-based reset on an internally-driven load, or a 10 kPa span on a loop that could give up 60
  3. The base setpoint itself too high — a commissioned schedule whose whole range sits above what the loop needs looks healthy on a setpoint trend and still fails this test, which is the case HW-0006 cannot see
  4. Balancing valves throttled hard at the far end of the loop, so the pumps overcome a restriction a rebalance would remove
  5. The DP sensor in the wrong place — at the pump discharge rather than near the hydraulically most remote coil, forcing the loop to carry a distribution loss no coil ever sees
  6. Manual override or hand mode on the drive, which reads identically to a setpoint that is too high
  7. Oversized pumps against a load the building never reached — the case with no repair beyond a trim or a lower setpoint, where the speed is telling the truth

Energy Impact

EXCESS_CONSUMPTION, MEDIUM confidence, PROXY_ESTIMATION. The waste is pump electricity and the arithmetic is the affinity law: pump_waste_kw ≈ hw_pump_kw × [1 − (1 − speed_reduction/100)³], CHW-0003’s estimator on the heating loop’s drives — a loop that could run at 55% instead of 80% is giving away roughly two thirds of its pump power. §4.2 publishes no savings range, so savings_range carries the DP-reset figure this library uses on the chilled water side (0.5-2% of site energy, PNNL-25985 EEM-10/11); treat it as an order of magnitude. Confidence is MEDIUM because the evidence is one step removed — the rule does not measure the setpoint, and diagnoses 4 through 7 fail the test honestly.

Emissions Impact

Scope 2, PROXY_EMISSIONS, MEDIUM confidence. All of the direct waste is purchased electricity for the distribution pumps, so the scope does not vary by site the way a boiler fault’s does and the marginal operating emissions rate is the right factor. There is a second-order Scope 1 term this card does not quantify: over-pressurising the loop costs valve authority and therefore delta-T, and that penalty belongs to HW-0004, which measures it directly.

Deviations

  • This rule is a library extension, not a transcription. The reference’s ch.14 specifies HW-0001, 051 and 052 and stops; faults/hw/README.md frames FC-053 through 057 as library-authored rules grounded in PNNL-27338 §4. The name, severity 3 and method: rule are that index’s, both thresholds are §4.2’s, and the graph is HW-0003’s shape; the rest is authored here.
  • hw_dp and hw_dp_sp are deliberately not bound. A DP tracking comparison measures whether the pressure controller is working, and in almost every building carrying this fault it is working perfectly. A DP threshold would need a per-loop number with no published basis — 60 kPa is generous on one loop and starvation on another, and §4.2 does not test pressure either. Pump speed already normalises for the loop, because it is what the plant must do to hold whatever setpoint it has. Binding the pressure points as context would also make them binding obligations for signals the graph never reads, so points stays at two.
  • yMildWeather is an evaluability output, and HW-0003’s identical comparison deliberately is not. There the mild weather is the fault claim, so a cold day is a healthy verdict; here it is what makes a pump-speed reading interpretable at all, so yFault = false below the floor must not be read as a clean loop. Exposing the conjunct adds no logic and changes no verdict, and it is a comparison against a parameter rather than an echo of an input, which is what SCHEMA.md asks such an output to be. Same shape as CHW-0004’s yLoadOk.
  • 15.6 °C and 16.0 °C are two different parameters and must stay that way. This rule’s mild_weather_oat is §4.2’s 60 °F converted and rounded to a tenth; HW-0003’s heating_plant_lockout_temp is the reference’s ch.14 number. One is the temperature above which a heating plant should be off, the other the temperature above which pump speed becomes evidence — a host that consolidates them retunes two rules with one edit.
  • Strict > on both comparisons. CDL Reals has no GreaterEqual, and §4.2’s own arithmetic is strict. Both disagreements are measure-zero and both err toward silence, but a BAS that quantises drive speed to whole percent or outdoor air to whole degrees will sit on a boundary often, and should set the parameters between two quantisation levels.
  • Persistence stands in for PNNL’s window average. §4.2 tests avg_pump_vfd and avg_OAT over a 15-60 minute window (§1.2); this rule consumes instantaneous points and requires the condition continuously, so a 10-minute dip to 40% restarts the hour where an average would have carried through it. The trade — never alarming on a transient, but talkable-out-of a finding by a loop that oscillates around the threshold — is worth it on a fault whose whole character is that it sits still for months.
  • No run-status conjunct. The rule reads speed alone and relies on a stopped pump reporting 0%. Adding hw_pump_status would guard against drives that latch their last commanded speed while stopped, at the cost of a third point and a second binding obligation for what is a wiring question rather than a plant one. It is preconditions text instead, alongside the multi-pump binding rule (lead drive or host-computed maximum, never an average across a lead/standby pair).
  • The domestic-hot-water confound is HW-0003’s, and is not detectable here either. A plant circulating in July for a service water or process load runs its pumps in mild weather for a legitimate reason and produces identical values on both points. Exclude the rule, bind a pump that does not serve DHW, or accept summer noise — never raise mild_weather_oat, which converts a false positive into a silent miss across the whole shoulder season.
  • suppresses and suppressed_by are both empty, and the HW-0006 pairing is why that is worth saying. The tempting edge is “HW-0006 suppresses HW-0005”, and it is wrong in both directions: a commissioned reset whose entire range sits too high fails this test and passes HW-0006’s (diagnosis 3), while a flat setpoint low enough to keep the drives under 45% fails HW-0006’s and passes this one. Where both fire they are cause and consequence, and suppressing the consequence would delete the energy claim that justifies writing the reset schedule. related, not suppression.
  • playbooks: [hot-water-plant-faults], not missing-reset. The hot water playbook’s Step 3 ends on this finding — high HW differential pressure setpoints, reset from the most-open valve — and its energy row names the same PNNL-27338 measures. missing-reset is the natural home for HW-0006, whose verification step is a setpoint trend; this rule’s is a pump-speed trend against outdoor air. The playbook’s Applies-To row is the index owner’s edit.
  • clusters: []. clusters/clusters.json has no hot water cluster, and CLU-02 (Missing Reset Strategy) is triggered by AHU-0023 with AHU and CHW members; this rule is a plausible future member and adding it is the cluster owner’s edit.
  • alarm_delay = 3600 s is adopted. PNNL-27338 specifies a data window and a minimum sample count, not an alarm persistence; an hour matches HW-0003 and HW-0004, and this fault moves on a scale of months.
  • sustained.delayOnInit = true (CDL default false), the library’s standing choice: a plant already pumping hard in mild weather at controller restart waits out the full hour rather than alarming on the first tick.
  • No published test vectors exist for this algorithm. §4.2 specifies thresholds, not cases, so every scenario in vectors.json is authored from the equation and replayed against the pinned engine rev.
  • Operating states and preconditions are declared in frontmatter for host enforcement rather than encoded in the block graph, per the library’s design stance.

Notes

Read yMildWeather before yFault. Through most of a heating season this rule is not evaluating anything, which is the intended behaviour: it has one useful window, the mild hours.

Verify with a scatter plot before dispatching anyone. Plot pump speed against outdoor air for a fortnight of the shoulder season: a loop with a working reset draws a slope, a loop with this fault draws a horizontal band, and the height of that band is roughly what the setpoint is costing. If the DP setpoint trend is flat the finding is HW-0006’s as well and the schedule is the repair; if it moves and the pumps still do not slow down, the base setpoint is too high.

Then find the DP sensor — diagnosis 5 survives every remote fix, because no reset schedule written against a sensor at the pump discharge can give back a distribution loss no coil ever sees. Re-check the loop’s delta-T after the setpoint comes down; this fault and HW-0004 travel together.

Test Vectors

16 scenarios, clock step 60 s over 7200 s.

ScenarioDescription
mild_day_pumps_loafingThe healthy mild-weather case: 20 degC outdoors and the distribution pumps at 30%. The loop is asking for little and the drives have backed off, which is what a working DP reset looks like.
cold_day_pumps_working_hardNO_EVAL: 0 degC outdoors with the pumps at 80%. The plant is meant to work hard on a design day, so pump speed carries no information about the DP setpoint and yMildWeather = false tells the host not to read yFault as a clean bill of health.
mild_day_pumps_working_hardThe fault as PNNL-27338 sec.4.2 writes it: 20 degC outdoors, pumps at 80%. Light load, hard-working pumps, and the alarm lands at exactly alarm_delay because delayOnInit holds the condition from the first tick.
pump_speed_exactly_at_the_thresholdThreshold edge from below: the drives sit exactly on high_pump_speed (45%) in mild weather. Reals.GreaterThreshold is strict, so 45.0% is not above 45.0% and nothing is reported.
pump_speed_just_below_the_threshold44.9% in the same mild weather: still clear, by a tenth of a percent of drive speed.
pump_speed_just_above_the_threshold45.1% in the same mild weather: the alarm arrives at 3600 s. Both sides of the pump-speed boundary are pinned so the strictness cannot change silently.
oat_exactly_at_the_mild_weather_floorThreshold edge on the evaluability conjunct: outdoor air exactly at mild_weather_oat (15.6 degC) with the pumps at 80%. Strict comparison, so exactly-at-the-floor is NO_EVAL rather than a fault.
oat_just_below_the_mild_weather_floor15.5 degC with the same hard-working pumps: still NO_EVAL.
oat_just_above_the_mild_weather_floor15.7 degC with the same hard-working pumps: evaluable, and the alarm lands at exactly alarm_delay. A tenth of a degree of outdoor air is the whole difference between this scenario and the one before last.
plant_off_in_mild_weatherThe other healthy mild-weather case: the distribution is off and the drive reports 0%. The rule needs no run-status conjunct as long as the speed feedback falls to zero with the pump - a drive that latches its last commanded speed while stopped fabricates this fault, which is why preconditions asks for feedback rather than command.
morning_warms_through_the_floorA spring morning: the pumps run at 80% from midnight and outdoor air crosses 15.6 degC at t = 1800 s. The clock starts at the crossing, not at the start of the run, and the alarm lands at 5400 s.
pumps_slow_after_alarmThe repair, seen from the trend: the DP setpoint is reset down at t = 5400 s and the drives fall to 30%. yFault clears on that tick - TrueDelay passes the falling edge with no delay - while yMildWeather stays true, so the host can tell a fix from a weather change.
evening_cooldown_releases_evaluabilityThe weather change, for contrast: an alarming plant sees outdoor air fall to 10 degC at t = 5400 s with the pumps still at 80%. yFault and yMildWeather drop on the same tick and nothing was repaired.
pump_speed_dips_and_restarts_the_clockPersistence is continuous, not accumulated: a 600 s dip to 40% at t = 1800 s discards the elapsed time rather than pausing it, so the alarm lands at 6000 s - one full alarm_delay after the second crossing. PNNL-27338’s window average would have carried the dip through instead (see Deviations).
pumps_slow_on_the_maturity_tickDelay edge from below: the condition holds from t = 0 and the drives fall to 30% at exactly 3600 s, the tick the timer matures on. The input is false when the timer comes due, so a full hour of hard pumping in mild weather is never reported.
pumps_slow_one_tick_laterDelay edge from above: the same run held 60 s longer asserts at exactly 3600 s and clears at 3660 s. The pair makes the T + delayTime boundary observable rather than assumed.
vectors.json
{
  "schema": "cxf-library/vectors/v1",
  "clock": {
    "step_s": 60,
    "horizon_s": 7200
  },
  "scenarios": [
    {
      "name": "mild_day_pumps_loafing",
      "description": "The healthy mild-weather case: 20 degC outdoors and the distribution pumps at 30%. The loop is asking for little and the drives have backed off, which is what a working DP reset looks like.",
      "inputs": {
        "hw_pump_vfd_speed": 30.0,
        "oat": 20.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        },
        {
          "output": "yMildWeather",
          "from_s": 0,
          "to_s": 7200,
          "equals": true
        }
      ]
    },
    {
      "name": "cold_day_pumps_working_hard",
      "description": "NO_EVAL: 0 degC outdoors with the pumps at 80%. The plant is meant to work hard on a design day, so pump speed carries no information about the DP setpoint and yMildWeather = false tells the host not to read yFault as a clean bill of health.",
      "inputs": {
        "hw_pump_vfd_speed": 80.0,
        "oat": 0.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        },
        {
          "output": "yMildWeather",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        }
      ]
    },
    {
      "name": "mild_day_pumps_working_hard",
      "description": "The fault as PNNL-27338 sec.4.2 writes it: 20 degC outdoors, pumps at 80%. Light load, hard-working pumps, and the alarm lands at exactly alarm_delay because delayOnInit holds the condition from the first tick.",
      "inputs": {
        "hw_pump_vfd_speed": 80.0,
        "oat": 20.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3540,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 3600,
          "to_s": 7200,
          "equals": true
        },
        {
          "output": "yMildWeather",
          "from_s": 0,
          "to_s": 7200,
          "equals": true
        }
      ]
    },
    {
      "name": "pump_speed_exactly_at_the_threshold",
      "description": "Threshold edge from below: the drives sit exactly on high_pump_speed (45%) in mild weather. Reals.GreaterThreshold is strict, so 45.0% is not above 45.0% and nothing is reported.",
      "inputs": {
        "hw_pump_vfd_speed": 45.0,
        "oat": 20.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        },
        {
          "output": "yMildWeather",
          "from_s": 0,
          "to_s": 7200,
          "equals": true
        }
      ]
    },
    {
      "name": "pump_speed_just_below_the_threshold",
      "description": "44.9% in the same mild weather: still clear, by a tenth of a percent of drive speed.",
      "inputs": {
        "hw_pump_vfd_speed": 44.9,
        "oat": 20.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        },
        {
          "output": "yMildWeather",
          "from_s": 0,
          "to_s": 7200,
          "equals": true
        }
      ]
    },
    {
      "name": "pump_speed_just_above_the_threshold",
      "description": "45.1% in the same mild weather: the alarm arrives at 3600 s. Both sides of the pump-speed boundary are pinned so the strictness cannot change silently.",
      "inputs": {
        "hw_pump_vfd_speed": 45.1,
        "oat": 20.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3540,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 3600,
          "to_s": 7200,
          "equals": true
        },
        {
          "output": "yMildWeather",
          "from_s": 0,
          "to_s": 7200,
          "equals": true
        }
      ]
    },
    {
      "name": "oat_exactly_at_the_mild_weather_floor",
      "description": "Threshold edge on the evaluability conjunct: outdoor air exactly at mild_weather_oat (15.6 degC) with the pumps at 80%. Strict comparison, so exactly-at-the-floor is NO_EVAL rather than a fault.",
      "inputs": {
        "hw_pump_vfd_speed": 80.0,
        "oat": 15.6
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        },
        {
          "output": "yMildWeather",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        }
      ]
    },
    {
      "name": "oat_just_below_the_mild_weather_floor",
      "description": "15.5 degC with the same hard-working pumps: still NO_EVAL.",
      "inputs": {
        "hw_pump_vfd_speed": 80.0,
        "oat": 15.5
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        },
        {
          "output": "yMildWeather",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        }
      ]
    },
    {
      "name": "oat_just_above_the_mild_weather_floor",
      "description": "15.7 degC with the same hard-working pumps: evaluable, and the alarm lands at exactly alarm_delay. A tenth of a degree of outdoor air is the whole difference between this scenario and the one before last.",
      "inputs": {
        "hw_pump_vfd_speed": 80.0,
        "oat": 15.7
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3540,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 3600,
          "to_s": 7200,
          "equals": true
        },
        {
          "output": "yMildWeather",
          "from_s": 0,
          "to_s": 7200,
          "equals": true
        }
      ]
    },
    {
      "name": "plant_off_in_mild_weather",
      "description": "The other healthy mild-weather case: the distribution is off and the drive reports 0%. The rule needs no run-status conjunct as long as the speed feedback falls to zero with the pump - a drive that latches its last commanded speed while stopped fabricates this fault, which is why preconditions asks for feedback rather than command.",
      "inputs": {
        "hw_pump_vfd_speed": 0.0,
        "oat": 25.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        },
        {
          "output": "yMildWeather",
          "from_s": 0,
          "to_s": 7200,
          "equals": true
        }
      ]
    },
    {
      "name": "morning_warms_through_the_floor",
      "description": "A spring morning: the pumps run at 80% from midnight and outdoor air crosses 15.6 degC at t = 1800 s. The clock starts at the crossing, not at the start of the run, and the alarm lands at 5400 s.",
      "inputs": {
        "hw_pump_vfd_speed": 80.0,
        "oat": [
          {
            "t": 0,
            "value": 12.0
          },
          {
            "t": 1800,
            "value": 20.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5340,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 5400,
          "to_s": 7200,
          "equals": true
        },
        {
          "output": "yMildWeather",
          "from_s": 0,
          "to_s": 1740,
          "equals": false
        },
        {
          "output": "yMildWeather",
          "from_s": 1800,
          "to_s": 7200,
          "equals": true
        }
      ]
    },
    {
      "name": "pumps_slow_after_alarm",
      "description": "The repair, seen from the trend: the DP setpoint is reset down at t = 5400 s and the drives fall to 30%. yFault clears on that tick - TrueDelay passes the falling edge with no delay - while yMildWeather stays true, so the host can tell a fix from a weather change.",
      "inputs": {
        "hw_pump_vfd_speed": [
          {
            "t": 0,
            "value": 80.0
          },
          {
            "t": 5400,
            "value": 30.0
          }
        ],
        "oat": 20.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3540,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 3600,
          "to_s": 5340,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 5400,
          "to_s": 7200,
          "equals": false
        },
        {
          "output": "yMildWeather",
          "from_s": 0,
          "to_s": 7200,
          "equals": true
        }
      ]
    },
    {
      "name": "evening_cooldown_releases_evaluability",
      "description": "The weather change, for contrast: an alarming plant sees outdoor air fall to 10 degC at t = 5400 s with the pumps still at 80%. yFault and yMildWeather drop on the same tick and nothing was repaired.",
      "inputs": {
        "hw_pump_vfd_speed": 80.0,
        "oat": [
          {
            "t": 0,
            "value": 20.0
          },
          {
            "t": 5400,
            "value": 10.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3540,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 3600,
          "to_s": 5340,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 5400,
          "to_s": 7200,
          "equals": false
        },
        {
          "output": "yMildWeather",
          "from_s": 0,
          "to_s": 5340,
          "equals": true
        },
        {
          "output": "yMildWeather",
          "from_s": 5400,
          "to_s": 7200,
          "equals": false
        }
      ]
    },
    {
      "name": "pump_speed_dips_and_restarts_the_clock",
      "description": "Persistence is continuous, not accumulated: a 600 s dip to 40% at t = 1800 s discards the elapsed time rather than pausing it, so the alarm lands at 6000 s - one full alarm_delay after the second crossing. PNNL-27338's window average would have carried the dip through instead (see Deviations).",
      "inputs": {
        "hw_pump_vfd_speed": [
          {
            "t": 0,
            "value": 80.0
          },
          {
            "t": 1800,
            "value": 40.0
          },
          {
            "t": 2400,
            "value": 80.0
          }
        ],
        "oat": 20.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5940,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 6000,
          "to_s": 7200,
          "equals": true
        },
        {
          "output": "yMildWeather",
          "from_s": 0,
          "to_s": 7200,
          "equals": true
        }
      ]
    },
    {
      "name": "pumps_slow_on_the_maturity_tick",
      "description": "Delay edge from below: the condition holds from t = 0 and the drives fall to 30% at exactly 3600 s, the tick the timer matures on. The input is false when the timer comes due, so a full hour of hard pumping in mild weather is never reported.",
      "inputs": {
        "hw_pump_vfd_speed": [
          {
            "t": 0,
            "value": 80.0
          },
          {
            "t": 3600,
            "value": 30.0
          }
        ],
        "oat": 20.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        },
        {
          "output": "yMildWeather",
          "from_s": 0,
          "to_s": 7200,
          "equals": true
        }
      ]
    },
    {
      "name": "pumps_slow_one_tick_later",
      "description": "Delay edge from above: the same run held 60 s longer asserts at exactly 3600 s and clears at 3660 s. The pair makes the T + delayTime boundary observable rather than assumed.",
      "inputs": {
        "hw_pump_vfd_speed": [
          {
            "t": 0,
            "value": 80.0
          },
          {
            "t": 3660,
            "value": 30.0
          }
        ],
        "oat": 20.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3540,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 3600,
          "to_s": 3600,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 3660,
          "to_s": 7200,
          "equals": false
        },
        {
          "output": "yMildWeather",
          "from_s": 0,
          "to_s": 7200,
          "equals": true
        }
      ]
    }
  ]
}

HW-0006 — HW loop DP reset not functioning

Statusverified — engine e2ff2f8, cxf:fnv1a128:754dbe3f355e9ba37c4c95e68c10df7b, 2026-08-17
Severity3
Methodstatistical
Phase2
CategoryEXCESS_CONSUMPTION
ConfidenceHIGH
EstimationPROXY_ESTIMATION
G36
Clusters
Suppresses
Suppressed by
RelatedHW-0008, CHW-0003, AHU-0024
Playbooksmissing-reset, hot-water-plant-faults
SourcePNNL-27338 §4.3.2 (no DP reset; daily MAX−MIN of the loop DP setpoint against a 2.5 psi range), pp. 4.10-4.11; PNNL-27338 §4.2.2 (high loop DP), pp. 4.7-4.8 — establishes pump VFD speed as the HW loop’s load proxy; PNNL-27338 (Katipamula et al. 2018) — adapted via an internal paraphrased deep-read digest, not distributed (rule candidate 5); Sibling-rule precedent: CHW-0003 (window, alarm delay, half-range tolerance), CHW-0002 (sampler+dwell activity conjunct and its NO_EVAL output), AHU-0024; Library extension: HVAC FDD Reference v1.0 ch.14 specifies HW-0001..052 only — index framing in faults/hw/README.md
Operating statesHeating season, HW distribution running on variable-speed pumps for the bulk of the evaluation window

Preconditions (host-enforced): hw_dp_sp must be the setpoint the pumps actually control to — the active value in the pump controller, not a design figure in a schedule table — and hw_pump_vfd_speed must be the speed of a pump on that same loop. On a lead/lag set bind the lead pump: a lag pump’s speed is pinned by staging logic rather than by the building, and it reads flat while the loop swings. On primary/secondary plants bind the secondary (distribution) pumps; a constant-speed primary has no reset to fail. A loop whose pumps have no drive at all must not be bound: there is no DP reset to detect and the speed input has nothing to say. The plant must be running for most of the window — a loop shut down for the summer holds both signals still, and only yPumpSpeedVaried stands between that and a false alarm; evaluability is signalled in-rule by that output, and when it is false the verdict is NO_EVAL, not healthy. Finally, confirm the setpoint point is actually written: a BAS that trends the reset output only while the reset is enabled shows a flat last-known value that no pump is following.

Points: hw_dp_sp, hw_pump_vfd_speed

Outputs:

  • yFault — True while the HW loop differential-pressure setpoint has stayed flat over the evaluation window despite sufficient pump-speed variation, for at least alarm_delay
  • yPumpSpeedVaried — Evaluability signal — true when HW pump speed has varied enough within the evaluation window for a flat setpoint to mean anything; false means NO_EVAL and the host must ignore yFault

Parameters:

NameDefaultUnitCXF pathDescription
evaluation_window259200.0sspRef.samplePeriod, pumpRef.samplePeriod, spFlatHeld.delayTime, pumpFlatHeld.delayTimeWindow over which setpoint flatness and pump-speed variation are assessed (3 days); drives both baseline sample periods and both dwell timers
sp_flat_tolerance8.625kPaspFlat.tMax deviation of hw_dp_sp from its sampled baseline to count as flat — half of PNNL-27338 §4.3.2’s 2.5 psi (17.24 kPa) minimum expected setpoint range
pump_variation_tolerance10.0%pumpFlat.tMax deviation of hw_pump_vfd_speed from its sampled baseline to still count as flat (half an adopted 20-point minimum speed range; PNNL-27338 states no activity gate for this check — see Deviations)
alarm_delay86400.0spersist.delayTimeFault persistence before alarm (24 h)

Description

The hot water loop holds one differential-pressure setpoint all winter while the pumps modulate underneath it. Whatever the design-day pressure was is what the loop gets in November and in March, and the coil valves throttle away the surplus. Pump power follows the cube of pressure, so a fifth off the setpoint is roughly half the pump energy, and what buys it is a sequence, not a pump.

This rule is a library extension — the reference’s ch.14 specifies three hot water rules and no reset checks. The detection is grounded in PNNL-27338 §4.3.2, which flags a loop whose DP setpoint moves less than 2.5 psi across a day; the window, the activity conjunct and every block in the graph are this library’s. The pump-speed conjunct is what makes a flat setpoint mean something: a loop that is off, or whose pumps sit at a fixed speed, holds its setpoint flat for reasons that have nothing to do with a missing reset.

Detection Logic

baseline(x)      = x sampled and held every evaluation_window (3 days)
sp_flat          = |hw_dp_sp − baseline(hw_dp_sp)| < sp_flat_tolerance,
                   continuously for evaluation_window
pump_flat        = |hw_pump_vfd_speed − baseline(hw_pump_vfd_speed)|
                   < pump_variation_tolerance,
                   continuously for evaluation_window

yPumpSpeedVaried = NOT pump_flat     (false ⇒ host reports NO_EVAL)
yFault           = sp_flat AND yPumpSpeedVaried, sustained for alarm_delay

Block graph (rule.cxf.jsonld):

HW-0006 block graph

This is CHW-0002’s detector with the heating loop’s points bound to it. Two symmetric chains compare each signal against a Discrete.Sampler hold refreshed once per window; the sampler emits the live input on its first tick, so there is no startup artifact. spFlatHeld asserts only after the setpoint has stayed within tolerance continuously for a full window, and any reset activity of 8.625 kPa or more restarts it. pumpFlatHeld does the same for speed, and its negation is yPumpSpeedVaried — “not varied” means a full window of continuous flatness, so the signal is optimistically true during the first window after startup, which is harmless because yFault needs that same window.

Both dwell timers fire on the same tick when the loop is flat in both signals, so the fault conjunction is false by construction on that tick and there is no boundary race. persist (24 h) filters the remainder, and every TrueDelay carries delayOnInit = true. Worst-case time to alarm from cold start is evaluation_window + alarm_delay — 4 days; a loop going flat mid-run alarms 4 days after the setpoint settles. Comparisons are strict, so a signal sitting exactly on a tolerance falls on the not-flat side.

Possible Diagnoses

The reference algorithm publishes a detection test and no diagnosis list; these are this library’s, in the order a technician should work them.

  1. Loop DP reset never programmed — the common case, and a desk fix
  2. Reset programmed but disabled, or the setpoint overridden to a fixed value and the override never released
  3. Valve-position or zone requests never reach the pump controller, so a correctly written reset has nothing to respond to
  4. DP sensor mounted at the pump discharge rather than at the hydraulically most remote coil — a pipe-fitting job, and it makes a correct reset impossible to commission
  5. Reset running but writing to a different point than the one trended — a monitoring artifact and the cheapest thing on the list to rule out

Energy Impact

EXCESS_CONSUMPTION, HIGH confidence, PROXY_ESTIMATION. The savings figure is the hot water playbook’s: HW supply-temperature reset and DP reset together are worth 1–3% of site energy, with no published split. The mechanism on this half is the cubic pump law — pump_waste_kw = hw_pump_kw × [1 − (1 − DP_reduction/100)³], CHW-0003’s formula — and it is heating-dominant because that is where the loop runs for months. Confidence is HIGH for the detection, not for the dollar figure: a flat setpoint on a modulating loop is about as direct an inference as this library makes, while the saving depends on surplus pressure these two points cannot measure.

Emissions Impact

Scope 2, PROXY_EMISSIONS, HIGH confidence. The waste is pump electricity, so it prices at the marginal operating emissions rate and shrinks as the grid does — unlike HW-0008’s fuel-side twin, which a site owns however clean its electricity gets. Typical range 300–3,000 kg CO₂e/yr by analogy with CHW-0003’s chilled water case; no HW-specific figure is published.

Deviations

  • This rule extends the reference rather than transcribing it. Ch.14 covers HW-0001, 051 and 052 only, so there is no reference card behind this one — no published description, operating states, diagnosis list, tunables line or test vectors. The detection is paraphrased from PNNL-27338 §4.3.2 (Katipamula et al. 2018); name, severity and method come from faults/hw/README.md.
  • Daily MAX−MIN → rolling sampled baseline plus dwell. PNNL’s reset checks run once a day at midnight over the prior day’s array; this engine has no windowed min/max block and no batch clock, a question the library settled at AHU-0023/AHU-0024 and again at CHW-0002/CHW-0003. Detection is equivalent for a setpoint that moves and returns, and slightly conservative for monotonic drift inside one window, where a range test would still call the day flat.
  • 2.5 psi becomes an 8.625 kPa half-range. The point dictionary carries hw_dp_sp in kPa and rules do no unit conversion, so it happens here once: 2.5 psi = 17.24 kPa, half-range 8.62, shipped as 8.625 (a 17.25 kPa range). The half-range is the library’s convention, because a signal swinging ±t about a baseline spans 2t. The 0.08% difference from an exact conversion is far below any BAS’s setpoint resolution, and 8.625 is exactly representable in binary, which is what lets the boundary be pinned to the bit.
  • The 3-day window is adopted from CHW-0003, not from PNNL. PNNL’s window is one calendar day, which works there because the daily batch discards its evidence at every midnight; a rolling dwell does not, and a one-day dwell plus a 24 h alarm delay would fire on any quiet weekend. Three days spans a weekend plus a working day. evaluation_window drives all four timing parameters together and hosts must set them as a group.
  • Pump-speed range is the activity conjunct, and it is not CHW-0003’s valve test. The hot water dictionary has no valve aggregate to bind, so the honest analog is the load proxy PNNL’s own HW algorithms use (§4.2.2 and §4.4.2 both test avg_pump_vfd). What it buys is weaker: pump-speed range answers “is this loop alive and modulating”, not “is any coil starving”. A loop pinned at its DP limit by a starving coil usually runs its pumps flat out, which collapses the speed range and correctly yields NO_EVAL — but a loop that still modulates below full speed against a maxed-out setpoint reads as a missing reset here. HW-0005 (high loop DP) separates those two on the same loop.
  • The 20-point minimum speed range is adopted, and argued rather than cited. §4.3.2 has no activity gate at all, so there is no figure to transcribe; pump_variation_tolerance = 10 % (half-range) follows CHW-0002’s adopted min_load_range in shape and intent — a low bar a live loop clears easily, not a discriminating threshold. A variable-speed loop that never swings 20 points across three days is riding its minimum-speed floor, running against a fixed bypass, or reporting a dead feedback, and NO_EVAL is right in all three. Lowering it makes the rule fire more often, not less.
  • NO_EVAL is surfaced as yPumpSpeedVaried, where CHW-0003 ships no evaluability output at all. That rule’s valve test is a conjunct of the fault condition — a starving coil makes the flat setpoint legitimate, not unmeasurable. The pump-speed test here is an evaluability question, which is CHW-0002’s yLoadVaried semantics exactly, and it earns its place as an output rather than an echo by being a stateful window test over the signal’s own baseline. Boolean logic has no tri-state, so the host must read it first: false means NO_EVAL, never healthy.
  • Reals.MovingAverage rejected, and the tick band that follows. The engine implements it with a fixed 64-checkpoint ring, so a three-day window would need dt ≥ 4,114 s before it stops silently dropping its oldest samples. No BAS ticks that slowly; AHU-0023 found this and every reset rule since has inherited it. The sampler-and-dwell chain has no lower bound on tick period, and its upper bound is the one to watch — a reset excursion shorter than one tick is invisible to the flatness test, so trend at 5–15 min.
  • Strict comparisons on both tolerances. Reals.LessThreshold is u < t, so a setpoint deviating exactly 8.625 kPa clears the flatness dwell and a pump speed deviating exactly 10 points counts as varied. Equality is measure-zero in continuous data and perfectly reachable in a BAS that scales setpoints to fixed increments, so both boundaries are pinned from both sides.
  • alarm_delay = 24 h implemented as TrueDelay on the fault conjunction; the evaluation window itself is enforced by the two flatness dwells. delayOnInit = true on every TrueDelay (startup conservatism per AHU-0016), so a rule loaded onto an already-faulted loop still waits the full window plus delay. 24 h is the value every reset rule in this library carries; PNNL’s daily batch has no analog, its evaluation being one shot per midnight.
  • Two playbooks, where the house habit is one. missing-reset owns the reset family’s verification step and its Applies-To already reaches past the AHU rules to the CHW pair; hot-water-plant-faults owns the remedy in its step 3 (reset the loop DP from the most-open valve position). Neither covers the fault alone and both indexes belong to other writers, so this card lists both rather than stretching one.
  • Blind spots. The rule reads the setpoint, never the pressure: a loop whose setpoint resets correctly while the pumps fail to track it is a different fault. Diagnoses 1–3 produce one signature and cannot be separated here. Diagnosis 4 is worse than invisible — it produces a plausible flat setpoint this rule reports as a missing reset, which is why the playbook’s first step is to find the sensor. A loop cycling on and off around a fixed speed can present a varying speed and a legitimately flat setpoint. And a reset written backwards is a working reset as far as a range test is concerned.

Notes

Fix path is the missing-reset playbook for the verification step and hot-water-plant-faults for the remedy: plot hw_dp_sp against hw_pump_vfd_speed over the window, confirm the DP sensor sits at the hydraulically most remote coil rather than at the pump, then program the reset from valve position or zone requests.

clusters is deliberately empty. CLU-02 (“Missing Reset Strategy”) is an AHU-scoped cluster triggered by AHU-0023, and membership is clusters/clusters.json’s to declare — the same call CHW-0002 and CHW-0003 made one system upstream. A plant failing both HW-0006 and HW-0008 has one root cause, which is that nobody commissioned the hot water resets, and it should be dispatched as one visit.

Test Vectors

9 scenarios, clock step 300 s over 432000 s.

ScenarioDescription
reset_workingDP setpoint swings 50 kPa every 6 h while the pumps modulate 35-75%; the flatness dwell never completes and nothing alarms
fixed_setpointDP setpoint pinned at 150 kPa while pump speed swings 35-75%; fault asserts at exactly evaluation_window (3 d) + alarm_delay (24 h) = 345600 s, pinned from both sides
flat_pump_speed_no_evalNO_EVAL: setpoint flat but the pumps hold one speed all week; yPumpSpeedVaried drops at exactly evaluation_window and yFault never fires
sp_at_flat_toleranceSetpoint deviates from its baseline by exactly sp_flat_tolerance (8.625 kPa): strict < puts that on the not-flat side, so no fault
sp_within_flat_toleranceSetpoint jitter of 8.5 kPa stays inside sp_flat_tolerance, so it still counts as flat; fault at 345600 s
pump_speed_at_variation_tolerancePump speed deviates from its baseline by exactly pump_variation_tolerance (10%): strict < makes that varied, so the rule stays evaluable and the flat setpoint alarms
pump_speed_within_variation_tolerancePump speed moves only 9.5 points about its baseline, inside pump_variation_tolerance: NO_EVAL at exactly evaluation_window, and the simultaneous setpoint-flat edge cannot race it into a fault
sp_goes_flat_mid_runReset stops working 12 h into the run: the flatness dwell starts at 43200 s, so the alarm lands at exactly 43200 + 259200 + 86400 = 388800 s
reset_resumesSetpoint flat for 2 days, then reset activity resumes before the window completes; the dwell restarts and nothing alarms
vectors.json
{
  "schema": "cxf-library/vectors/v1",
  "clock": {
    "step_s": 300,
    "horizon_s": 432000
  },
  "scenarios": [
    {
      "name": "reset_working",
      "description": "DP setpoint swings 50 kPa every 6 h while the pumps modulate 35-75%; the flatness dwell never completes and nothing alarms",
      "inputs": {
        "hw_dp_sp": [
          {
            "t": 0,
            "value": 150.0
          },
          {
            "t": 21600,
            "value": 100.0
          },
          {
            "t": 43200,
            "value": 150.0
          },
          {
            "t": 64800,
            "value": 100.0
          },
          {
            "t": 86400,
            "value": 150.0
          },
          {
            "t": 108000,
            "value": 100.0
          },
          {
            "t": 129600,
            "value": 150.0
          },
          {
            "t": 151200,
            "value": 100.0
          },
          {
            "t": 172800,
            "value": 150.0
          },
          {
            "t": 194400,
            "value": 100.0
          },
          {
            "t": 216000,
            "value": 150.0
          },
          {
            "t": 237600,
            "value": 100.0
          },
          {
            "t": 259200,
            "value": 150.0
          },
          {
            "t": 280800,
            "value": 100.0
          },
          {
            "t": 302400,
            "value": 150.0
          },
          {
            "t": 324000,
            "value": 100.0
          },
          {
            "t": 345600,
            "value": 150.0
          },
          {
            "t": 367200,
            "value": 100.0
          },
          {
            "t": 388800,
            "value": 150.0
          },
          {
            "t": 410400,
            "value": 100.0
          },
          {
            "t": 432000,
            "value": 150.0
          }
        ],
        "hw_pump_vfd_speed": [
          {
            "t": 0,
            "value": 35.0
          },
          {
            "t": 21600,
            "value": 75.0
          },
          {
            "t": 43200,
            "value": 35.0
          },
          {
            "t": 64800,
            "value": 75.0
          },
          {
            "t": 86400,
            "value": 35.0
          },
          {
            "t": 108000,
            "value": 75.0
          },
          {
            "t": 129600,
            "value": 35.0
          },
          {
            "t": 151200,
            "value": 75.0
          },
          {
            "t": 172800,
            "value": 35.0
          },
          {
            "t": 194400,
            "value": 75.0
          },
          {
            "t": 216000,
            "value": 35.0
          },
          {
            "t": 237600,
            "value": 75.0
          },
          {
            "t": 259200,
            "value": 35.0
          },
          {
            "t": 280800,
            "value": 75.0
          },
          {
            "t": 302400,
            "value": 35.0
          },
          {
            "t": 324000,
            "value": 75.0
          },
          {
            "t": 345600,
            "value": 35.0
          },
          {
            "t": 367200,
            "value": 75.0
          },
          {
            "t": 388800,
            "value": 35.0
          },
          {
            "t": 410400,
            "value": 75.0
          },
          {
            "t": 432000,
            "value": 35.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 432000,
          "equals": false
        },
        {
          "output": "yPumpSpeedVaried",
          "from_s": 0,
          "to_s": 432000,
          "equals": true
        }
      ]
    },
    {
      "name": "fixed_setpoint",
      "description": "DP setpoint pinned at 150 kPa while pump speed swings 35-75%; fault asserts at exactly evaluation_window (3 d) + alarm_delay (24 h) = 345600 s, pinned from both sides",
      "inputs": {
        "hw_dp_sp": 150.0,
        "hw_pump_vfd_speed": [
          {
            "t": 0,
            "value": 35.0
          },
          {
            "t": 21600,
            "value": 75.0
          },
          {
            "t": 43200,
            "value": 35.0
          },
          {
            "t": 64800,
            "value": 75.0
          },
          {
            "t": 86400,
            "value": 35.0
          },
          {
            "t": 108000,
            "value": 75.0
          },
          {
            "t": 129600,
            "value": 35.0
          },
          {
            "t": 151200,
            "value": 75.0
          },
          {
            "t": 172800,
            "value": 35.0
          },
          {
            "t": 194400,
            "value": 75.0
          },
          {
            "t": 216000,
            "value": 35.0
          },
          {
            "t": 237600,
            "value": 75.0
          },
          {
            "t": 259200,
            "value": 35.0
          },
          {
            "t": 280800,
            "value": 75.0
          },
          {
            "t": 302400,
            "value": 35.0
          },
          {
            "t": 324000,
            "value": 75.0
          },
          {
            "t": 345600,
            "value": 35.0
          },
          {
            "t": 367200,
            "value": 75.0
          },
          {
            "t": 388800,
            "value": 35.0
          },
          {
            "t": 410400,
            "value": 75.0
          },
          {
            "t": 432000,
            "value": 35.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 345300,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 345600,
          "to_s": 432000,
          "equals": true
        },
        {
          "output": "yPumpSpeedVaried",
          "from_s": 0,
          "to_s": 432000,
          "equals": true
        }
      ]
    },
    {
      "name": "flat_pump_speed_no_eval",
      "description": "NO_EVAL: setpoint flat but the pumps hold one speed all week; yPumpSpeedVaried drops at exactly evaluation_window and yFault never fires",
      "inputs": {
        "hw_dp_sp": 150.0,
        "hw_pump_vfd_speed": 60.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 432000,
          "equals": false
        },
        {
          "output": "yPumpSpeedVaried",
          "from_s": 0,
          "to_s": 258900,
          "equals": true
        },
        {
          "output": "yPumpSpeedVaried",
          "from_s": 259200,
          "to_s": 432000,
          "equals": false
        }
      ]
    },
    {
      "name": "sp_at_flat_tolerance",
      "description": "Setpoint deviates from its baseline by exactly sp_flat_tolerance (8.625 kPa): strict < puts that on the not-flat side, so no fault",
      "inputs": {
        "hw_dp_sp": [
          {
            "t": 0,
            "value": 150.0
          },
          {
            "t": 21600,
            "value": 158.625
          },
          {
            "t": 43200,
            "value": 150.0
          },
          {
            "t": 64800,
            "value": 158.625
          },
          {
            "t": 86400,
            "value": 150.0
          },
          {
            "t": 108000,
            "value": 158.625
          },
          {
            "t": 129600,
            "value": 150.0
          },
          {
            "t": 151200,
            "value": 158.625
          },
          {
            "t": 172800,
            "value": 150.0
          },
          {
            "t": 194400,
            "value": 158.625
          },
          {
            "t": 216000,
            "value": 150.0
          },
          {
            "t": 237600,
            "value": 158.625
          },
          {
            "t": 259200,
            "value": 150.0
          },
          {
            "t": 280800,
            "value": 158.625
          },
          {
            "t": 302400,
            "value": 150.0
          },
          {
            "t": 324000,
            "value": 158.625
          },
          {
            "t": 345600,
            "value": 150.0
          },
          {
            "t": 367200,
            "value": 158.625
          },
          {
            "t": 388800,
            "value": 150.0
          },
          {
            "t": 410400,
            "value": 158.625
          },
          {
            "t": 432000,
            "value": 150.0
          }
        ],
        "hw_pump_vfd_speed": [
          {
            "t": 0,
            "value": 35.0
          },
          {
            "t": 21600,
            "value": 75.0
          },
          {
            "t": 43200,
            "value": 35.0
          },
          {
            "t": 64800,
            "value": 75.0
          },
          {
            "t": 86400,
            "value": 35.0
          },
          {
            "t": 108000,
            "value": 75.0
          },
          {
            "t": 129600,
            "value": 35.0
          },
          {
            "t": 151200,
            "value": 75.0
          },
          {
            "t": 172800,
            "value": 35.0
          },
          {
            "t": 194400,
            "value": 75.0
          },
          {
            "t": 216000,
            "value": 35.0
          },
          {
            "t": 237600,
            "value": 75.0
          },
          {
            "t": 259200,
            "value": 35.0
          },
          {
            "t": 280800,
            "value": 75.0
          },
          {
            "t": 302400,
            "value": 35.0
          },
          {
            "t": 324000,
            "value": 75.0
          },
          {
            "t": 345600,
            "value": 35.0
          },
          {
            "t": 367200,
            "value": 75.0
          },
          {
            "t": 388800,
            "value": 35.0
          },
          {
            "t": 410400,
            "value": 75.0
          },
          {
            "t": 432000,
            "value": 35.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 432000,
          "equals": false
        },
        {
          "output": "yPumpSpeedVaried",
          "from_s": 0,
          "to_s": 432000,
          "equals": true
        }
      ]
    },
    {
      "name": "sp_within_flat_tolerance",
      "description": "Setpoint jitter of 8.5 kPa stays inside sp_flat_tolerance, so it still counts as flat; fault at 345600 s",
      "inputs": {
        "hw_dp_sp": [
          {
            "t": 0,
            "value": 150.0
          },
          {
            "t": 21600,
            "value": 158.5
          },
          {
            "t": 43200,
            "value": 150.0
          },
          {
            "t": 64800,
            "value": 158.5
          },
          {
            "t": 86400,
            "value": 150.0
          },
          {
            "t": 108000,
            "value": 158.5
          },
          {
            "t": 129600,
            "value": 150.0
          },
          {
            "t": 151200,
            "value": 158.5
          },
          {
            "t": 172800,
            "value": 150.0
          },
          {
            "t": 194400,
            "value": 158.5
          },
          {
            "t": 216000,
            "value": 150.0
          },
          {
            "t": 237600,
            "value": 158.5
          },
          {
            "t": 259200,
            "value": 150.0
          },
          {
            "t": 280800,
            "value": 158.5
          },
          {
            "t": 302400,
            "value": 150.0
          },
          {
            "t": 324000,
            "value": 158.5
          },
          {
            "t": 345600,
            "value": 150.0
          },
          {
            "t": 367200,
            "value": 158.5
          },
          {
            "t": 388800,
            "value": 150.0
          },
          {
            "t": 410400,
            "value": 158.5
          },
          {
            "t": 432000,
            "value": 150.0
          }
        ],
        "hw_pump_vfd_speed": [
          {
            "t": 0,
            "value": 35.0
          },
          {
            "t": 21600,
            "value": 75.0
          },
          {
            "t": 43200,
            "value": 35.0
          },
          {
            "t": 64800,
            "value": 75.0
          },
          {
            "t": 86400,
            "value": 35.0
          },
          {
            "t": 108000,
            "value": 75.0
          },
          {
            "t": 129600,
            "value": 35.0
          },
          {
            "t": 151200,
            "value": 75.0
          },
          {
            "t": 172800,
            "value": 35.0
          },
          {
            "t": 194400,
            "value": 75.0
          },
          {
            "t": 216000,
            "value": 35.0
          },
          {
            "t": 237600,
            "value": 75.0
          },
          {
            "t": 259200,
            "value": 35.0
          },
          {
            "t": 280800,
            "value": 75.0
          },
          {
            "t": 302400,
            "value": 35.0
          },
          {
            "t": 324000,
            "value": 75.0
          },
          {
            "t": 345600,
            "value": 35.0
          },
          {
            "t": 367200,
            "value": 75.0
          },
          {
            "t": 388800,
            "value": 35.0
          },
          {
            "t": 410400,
            "value": 75.0
          },
          {
            "t": 432000,
            "value": 35.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 345300,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 345600,
          "to_s": 432000,
          "equals": true
        }
      ]
    },
    {
      "name": "pump_speed_at_variation_tolerance",
      "description": "Pump speed deviates from its baseline by exactly pump_variation_tolerance (10%): strict < makes that varied, so the rule stays evaluable and the flat setpoint alarms",
      "inputs": {
        "hw_dp_sp": 150.0,
        "hw_pump_vfd_speed": [
          {
            "t": 0,
            "value": 50.0
          },
          {
            "t": 21600,
            "value": 60.0
          },
          {
            "t": 43200,
            "value": 50.0
          },
          {
            "t": 64800,
            "value": 60.0
          },
          {
            "t": 86400,
            "value": 50.0
          },
          {
            "t": 108000,
            "value": 60.0
          },
          {
            "t": 129600,
            "value": 50.0
          },
          {
            "t": 151200,
            "value": 60.0
          },
          {
            "t": 172800,
            "value": 50.0
          },
          {
            "t": 194400,
            "value": 60.0
          },
          {
            "t": 216000,
            "value": 50.0
          },
          {
            "t": 237600,
            "value": 60.0
          },
          {
            "t": 259200,
            "value": 50.0
          },
          {
            "t": 280800,
            "value": 60.0
          },
          {
            "t": 302400,
            "value": 50.0
          },
          {
            "t": 324000,
            "value": 60.0
          },
          {
            "t": 345600,
            "value": 50.0
          },
          {
            "t": 367200,
            "value": 60.0
          },
          {
            "t": 388800,
            "value": 50.0
          },
          {
            "t": 410400,
            "value": 60.0
          },
          {
            "t": 432000,
            "value": 50.0
          }
        ]
      },
      "expect": [
        {
          "output": "yPumpSpeedVaried",
          "from_s": 0,
          "to_s": 432000,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 345300,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 345600,
          "to_s": 432000,
          "equals": true
        }
      ]
    },
    {
      "name": "pump_speed_within_variation_tolerance",
      "description": "Pump speed moves only 9.5 points about its baseline, inside pump_variation_tolerance: NO_EVAL at exactly evaluation_window, and the simultaneous setpoint-flat edge cannot race it into a fault",
      "inputs": {
        "hw_dp_sp": 150.0,
        "hw_pump_vfd_speed": [
          {
            "t": 0,
            "value": 50.0
          },
          {
            "t": 21600,
            "value": 59.5
          },
          {
            "t": 43200,
            "value": 50.0
          },
          {
            "t": 64800,
            "value": 59.5
          },
          {
            "t": 86400,
            "value": 50.0
          },
          {
            "t": 108000,
            "value": 59.5
          },
          {
            "t": 129600,
            "value": 50.0
          },
          {
            "t": 151200,
            "value": 59.5
          },
          {
            "t": 172800,
            "value": 50.0
          },
          {
            "t": 194400,
            "value": 59.5
          },
          {
            "t": 216000,
            "value": 50.0
          },
          {
            "t": 237600,
            "value": 59.5
          },
          {
            "t": 259200,
            "value": 50.0
          },
          {
            "t": 280800,
            "value": 59.5
          },
          {
            "t": 302400,
            "value": 50.0
          },
          {
            "t": 324000,
            "value": 59.5
          },
          {
            "t": 345600,
            "value": 50.0
          },
          {
            "t": 367200,
            "value": 59.5
          },
          {
            "t": 388800,
            "value": 50.0
          },
          {
            "t": 410400,
            "value": 59.5
          },
          {
            "t": 432000,
            "value": 50.0
          }
        ]
      },
      "expect": [
        {
          "output": "yPumpSpeedVaried",
          "from_s": 0,
          "to_s": 258900,
          "equals": true
        },
        {
          "output": "yPumpSpeedVaried",
          "from_s": 259200,
          "to_s": 432000,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 432000,
          "equals": false
        }
      ]
    },
    {
      "name": "sp_goes_flat_mid_run",
      "description": "Reset stops working 12 h into the run: the flatness dwell starts at 43200 s, so the alarm lands at exactly 43200 + 259200 + 86400 = 388800 s",
      "inputs": {
        "hw_dp_sp": [
          {
            "t": 0,
            "value": 150.0
          },
          {
            "t": 21600,
            "value": 100.0
          },
          {
            "t": 43200,
            "value": 150.0
          }
        ],
        "hw_pump_vfd_speed": [
          {
            "t": 0,
            "value": 35.0
          },
          {
            "t": 21600,
            "value": 75.0
          },
          {
            "t": 43200,
            "value": 35.0
          },
          {
            "t": 64800,
            "value": 75.0
          },
          {
            "t": 86400,
            "value": 35.0
          },
          {
            "t": 108000,
            "value": 75.0
          },
          {
            "t": 129600,
            "value": 35.0
          },
          {
            "t": 151200,
            "value": 75.0
          },
          {
            "t": 172800,
            "value": 35.0
          },
          {
            "t": 194400,
            "value": 75.0
          },
          {
            "t": 216000,
            "value": 35.0
          },
          {
            "t": 237600,
            "value": 75.0
          },
          {
            "t": 259200,
            "value": 35.0
          },
          {
            "t": 280800,
            "value": 75.0
          },
          {
            "t": 302400,
            "value": 35.0
          },
          {
            "t": 324000,
            "value": 75.0
          },
          {
            "t": 345600,
            "value": 35.0
          },
          {
            "t": 367200,
            "value": 75.0
          },
          {
            "t": 388800,
            "value": 35.0
          },
          {
            "t": 410400,
            "value": 75.0
          },
          {
            "t": 432000,
            "value": 35.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 388500,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 388800,
          "to_s": 432000,
          "equals": true
        },
        {
          "output": "yPumpSpeedVaried",
          "from_s": 0,
          "to_s": 432000,
          "equals": true
        }
      ]
    },
    {
      "name": "reset_resumes",
      "description": "Setpoint flat for 2 days, then reset activity resumes before the window completes; the dwell restarts and nothing alarms",
      "inputs": {
        "hw_dp_sp": [
          {
            "t": 0,
            "value": 150.0
          },
          {
            "t": 172800,
            "value": 100.0
          },
          {
            "t": 194400,
            "value": 150.0
          },
          {
            "t": 216000,
            "value": 100.0
          },
          {
            "t": 237600,
            "value": 150.0
          },
          {
            "t": 259200,
            "value": 100.0
          },
          {
            "t": 280800,
            "value": 150.0
          },
          {
            "t": 302400,
            "value": 100.0
          },
          {
            "t": 324000,
            "value": 150.0
          },
          {
            "t": 345600,
            "value": 100.0
          },
          {
            "t": 367200,
            "value": 150.0
          },
          {
            "t": 388800,
            "value": 100.0
          },
          {
            "t": 410400,
            "value": 150.0
          },
          {
            "t": 432000,
            "value": 100.0
          }
        ],
        "hw_pump_vfd_speed": [
          {
            "t": 0,
            "value": 35.0
          },
          {
            "t": 21600,
            "value": 75.0
          },
          {
            "t": 43200,
            "value": 35.0
          },
          {
            "t": 64800,
            "value": 75.0
          },
          {
            "t": 86400,
            "value": 35.0
          },
          {
            "t": 108000,
            "value": 75.0
          },
          {
            "t": 129600,
            "value": 35.0
          },
          {
            "t": 151200,
            "value": 75.0
          },
          {
            "t": 172800,
            "value": 35.0
          },
          {
            "t": 194400,
            "value": 75.0
          },
          {
            "t": 216000,
            "value": 35.0
          },
          {
            "t": 237600,
            "value": 75.0
          },
          {
            "t": 259200,
            "value": 35.0
          },
          {
            "t": 280800,
            "value": 75.0
          },
          {
            "t": 302400,
            "value": 35.0
          },
          {
            "t": 324000,
            "value": 75.0
          },
          {
            "t": 345600,
            "value": 35.0
          },
          {
            "t": 367200,
            "value": 75.0
          },
          {
            "t": 388800,
            "value": 35.0
          },
          {
            "t": 410400,
            "value": 75.0
          },
          {
            "t": 432000,
            "value": 35.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 432000,
          "equals": false
        }
      ]
    }
  ]
}

HW-0007 — HW supply temperature too high at low load

Statusverified — engine e2ff2f8, cxf:fnv1a128:47d67bb2538aa6a4e4d0fcfb95b2726e, 2026-08-17
Severity3
Methodrule
Phase2
CategoryEXCESS_CONSUMPTION
ConfidenceMEDIUM
EstimationPROXY_ESTIMATION
G36
Clusters
Suppresses
Suppressed by
RelatedHW-0008, HW-0004, HW-0005, HW-0006, HW-0003, HW-0010
Playbookshot-water-plant-faults
SourcePNNL-27338 §4.4.2 (pp. 4.12-4.13), high HW supply temperature: pump VFD speed below 35% together with supply water above 130 °F over the averaging window; PNNL-27338 §4.5.2 (pp. 4.16-4.17) — the missing HWS reset this fault is usually the water-side symptom of; HW-0008 is its detector; PNNL-27338 §1.2, §2.1 — the data_window / no_required_data / max_dx_time gating this library expresses as preconditions and evaluability outputs; PNNL-27338 (Katipamula et al. 2018) — adapted via an internal paraphrased deep-read digest, not distributed (HW candidate #6); Library-authored extension: faults/hw/README.md index (name, severity, method); the HVAC FDD Reference v1.0 ch.14 specifies only HW-0001..052 and does not contain this rule; Sibling-rule precedent: CHW-0004 (evaluability floor + hour of persistence), HW-0003 (threshold + TrueDelay on a hot water plant), VFD-0001 (min_cmd_for_eval / yCmdOk)
Operating statesHeating plant enabled with the distribution loop circulating above min_pump_speed_for_eval — the rule’s own yLoopOk is that state

Preconditions (host-enforced): hws_temp and hw_pump_vfd_speed must describe the same loop. On a primary/secondary or injection-mixed plant that is the precondition most often violated: the boiler’s own leaving-water temperature is high by design and says nothing about what the building gets, so bind hws_temp on the system side of the decoupler or mixing valve, and bind the speed from the distribution pump that serves it rather than a boiler primary circulator. On a lead/lag distribution pair, bind the lead pump’s speed or a host-computed plant speed; two pumps at 30% each are not one pump at 30%, and the graph cannot tell them apart. The loop must be variable-speed: a constant-volume distribution pump has no speed signal worth reading and this rule does not apply to it. Before first deployment, check high_hws_temp_threshold against the plant’s design supply temperature and the low end of whatever reset schedule exists — 54.4 °C is PNNL’s number for a typical hydronic system and it is below the design water temperature of an old cast-iron radiator plant, which will read faulted at every mild hour until someone decides whether the emitters can actually accept cooler water. Sites with combined heating/DHW plants must exclude the rule or gate it host-side, exactly as HW-0003 requires: a boiler holding 60 °C for service water is not a heating fault. Evaluability is signalled in-rule by yLoopOk: when it is false the verdict is NO_EVAL, not a plant making appropriate water.

Points: hw_pump_vfd_speed, hws_temp

Outputs:

  • yFault — True while the HW distribution pump has stayed below low_load_speed_threshold with supply water above high_hws_temp_threshold and the loop circulating, continuously for alarm_delay
  • yLoopOk — Evaluability signal — true when hw_pump_vfd_speed is above min_pump_speed_for_eval, the speed below which the loop is not moving enough water for its supply temperature to describe how the plant is serving the building. False means NO_EVAL and the host must ignore yFault

Parameters:

NameDefaultUnitCXF pathDescription
low_load_speed_threshold35.0%lowSpeed.tPump speed below which the loop counts as lightly loaded (PNNL-27338 §4.4.2). Pump speed is the load proxy: on a DP-controlled loop the drive slows as coil valves close, so a low speed means the building is drawing little of what the plant is making
high_hws_temp_threshold54.4°ChotSupply.tSupply water temperature above which the plant is making full-temperature water (PNNL-27338 §4.4.2’s 130 °F). PER-PLANT SITE CONFIGURATION — check it against the design supply temperature and the bottom of the reset schedule before trusting a verdict (see Deviations)
min_pump_speed_for_eval10.0%circulating.tPump speed at or below which the loop is not judged to be circulating and nothing is evaluated. ADOPTED — PNNL specifies no plant-running gate, and without one a stopped pump reads as the lightest possible load (see Deviations)
alarm_delay3600.0spersist.delayTimeContinuous low-load-with-hot-water required before the alarm asserts (60 min). ADOPTED — PNNL publishes no alarm persistence for this measure, only a 15-60 min averaging window; 60 min is the top of that range and the library’s standing plant-rule persistence (HW-0003, CHW-0004)

Description

A hot water plant that cannot lower its supply temperature spends the heating season paying design-day prices for mild-day heat. The boiler makes water hot enough for the coldest hour of the year and on a 10 °C afternoon the building takes almost none of it; what is left is standing loss plus a boiler at the least efficient end of its curve — on a condensing boiler, never condensing at all, because the return comes back as hot as the supply went out.

The rule reads that off two signals: pump speed says how much of the plant’s output the building is drawing, because a DP-controlled drive slows as the coil valves close, and supply temperature says what the plant is making anyway. Light draw with full-temperature water is nearly always the water-side symptom of a reset that is missing, disabled or bottomed out too high, which is why HW-0008 sits next to this card. Library-authored: ch.14 has no such rule, and the logic and both published thresholds come from PNNL-27338 §4.4.2.

Detection Logic

yLoopOk = hw_pump_vfd_speed > min_pump_speed_for_eval   (false ⇒ host reports NO_EVAL)

yFault  = yLoopOk
      AND hw_pump_vfd_speed < low_load_speed_threshold
      AND hws_temp > high_hws_temp_threshold,
          sustained continuously for alarm_delay

Block graph (rule.cxf.jsonld):

HW-0007 block graph

lowSpeed and hotSupply are PNNL’s two conditions, both strict and both reading their thresholds as parameters. lightLoad and overTemp chain Logical.And rather than using a MultiAnd, which no card in this library does.

circulating is the third comparison and the one PNNL does not specify. The low-speed side of a low-speed test is exactly where a stopped pump lives: a plant on overnight setback reads 0% with 65 °C water standing in the boiler and satisfies both published conditions perfectly. The same comparison is exposed as yLoopOk, so a host can tell “the loop is circulating and the water is appropriate” from “nobody is pumping and the rule has no opinion” — two readings that both come out as yFault = false.

persist requires 60 continuous minutes and carries delayOnInit = true. Persistence is continuous, not accumulated: a pickup that takes the pumps to 40% for ten minutes discards the elapsed time rather than pausing it. The alarm falls on the tick the condition ends, with no release delay, and TrueDelay asserts at exactly T + delayTime, so the realized test is “held for strictly more than alarm_delay” at tick resolution.

Possible Diagnoses

Authored for this card — PNNL-27338 publishes detection thresholds and no diagnosis list, and the reference has no HW-0007 card to transcribe.

  1. No HW supply temperature reset programmed — the common case and a remote fix. HW-0008 detects the same failure directly, at the setpoint
  2. Reset programmed but its low end is too high. A schedule bottoming out at 65 °C looks correct in the sequence and behaves nearly as badly in the mild hours; this is the diagnosis that survives a casual review of the controls
  3. Reset disabled or overridden during a cold snap and never released
  4. Boiler minimum-temperature protection setting the floor. Where that limit is what stops the reset going lower the finding is real but the fix is a blending valve, a buffer arrangement or a condensing retrofit
  5. Boiler-local control ignoring the BAS — the aquastat runs its own high limit and the reset never reaches the fire. Cross-check hws_temp against hws_temp_sp; this is the one case where HW-0008 stays quiet while this rule fires
  6. Emitters that genuinely need the temperature. An old cast-iron system sized for 82 °C water may be operating as designed — a plant-design finding, and the reason the threshold is site configuration rather than law

Energy Impact

EXCESS_CONSUMPTION, MEDIUM confidence, PROXY_ESTIMATION. The hot water playbook puts HW supply-temperature reset and DP reset together at 1–3% of site energy from PNNL-27338’s measure set, with no published split and no estimator that converts a flagged hour to kilowatts. The mechanism is definite: standing loss scales with how much hotter the water is than its surroundings, and the boiler-side term is small on a non-condensing machine and large on a condensing one. Confidence is MEDIUM because of the threshold rather than the measurement — the rule does not know the plant’s design supply temperature, and 54.4 °C at low load is a finding on modern coils and Tuesday on a 1950s radiator system. Heating-dominant, weighted to the mild hours where most heating hours live.

Emissions Impact

Scope 1, PROXY_EMISSIONS, MEDIUM confidence. The waste is fuel, not electricity: the pump is doing useful work at whatever speed the loop needs, and what is thrown away is the extra fuel burned to hold water hotter than the building asked for, plus what leaks out of the pipe. A decarbonised grid does not touch it, and the condensing case is worth separating in a report — a plant holding its return above the dew point pays a combustion penalty on every therm. No factor is applied here; the host multiplies whatever fuel figure it can bound (see runtime_estimation) by a static combustion factor, as HW-0003 does.

Deviations

  • This rule is a library extension, not a transcription. Ch.14 specifies HW-0001, 051 and 052 and publishes no card, tunables table or vectors for anything resembling this one. The detection logic and both published thresholds are paraphrased from PNNL-27338 §4.4.2 (Katipamula et al. 2018); name, severity: 3 and method: rule are faults/hw/README.md’s.
  • 130 °F ships as 54.4 °C, 0.044 K below the exact conversion. Shipping 54.444… would put a fifteen-digit literal in the graph to express a number the source stated to three significant figures. The shipped value is very slightly more sensitive than PNNL’s, by an amount an order of magnitude finer than any hot water sensor a building owns, and the parameter is per-plant configuration in any case.
  • min_pump_speed_for_eval is ADOPTED, and it is the largest departure from the source. PNNL’s algorithm is two conditions with no plant-running gate, which read literally is unsound on the low side: a stopped pump reads 0%, the lightest possible load, while boiler water stays hot for hours — every overnight setback would report the fault. PNNL gates every algorithm on data sufficiency (§1.2/§2.1), the same class of concern SCHEMA.md routes to preconditions and evaluability outputs, but nothing in §4.4.2 excludes a stopped pump. Closed here as a conjunct that is also exposed as yLoopOk; precedent is CHW-0004’s yLoadOk and VFD-0001’s yCmdOk.
  • The floor ships at 10%, well below the fault threshold, on purpose. Its only job is to exclude a pump that is stopped or barely turning; the band between it and low_load_speed_threshold is where the fault lives, and a pump at 15% is genuinely circulating and genuinely lightly loaded. VFD-0001 sets its analogous floor at 20% because it asks a harder question of the signal. Note the direction before retuning: raising this number narrows the fault band, and raising it above 35% deletes the rule.
  • alarm_delay is ADOPTED at 60 minutes. PNNL publishes no fault persistence for this measure, only a 15–60 minute rolling average. Sixty is the top of that range and the library’s standing plant-rule persistence (HW-0003, CHW-0004), and it rides out the transient that would otherwise dominate: morning warm-up, when the loop is hot and the pumps have not ramped because the coil valves are still opening.
  • Continuous persistence replaces PNNL’s window average. The reference averages both signals across the window; this rule requires the conjunction on every tick for the full hour. For a steady condition — what a missing reset produces — the two agree; they differ on intermittency, and this one is stricter, so a loop alternating 20 minutes hot-and-loafing with 20 minutes busy never alarms though its window average might trip. CHW-0004 made the same trade. Reals.MovingAverage is not the alternative: its fixed 64-checkpoint ring needs a tick of at least window/63 before it silently drops samples.
  • Pump speed alone is the load proxy; no OAT conjunct. PNNL’s neighbouring high-DP measure (§4.2.2, this library’s HW-0005) crosses pump speed with mild outdoor air and §4.4.2 deliberately does not — the pump speed is the load measurement, and a lightly loaded building on a cold, internally-driven afternoon is as valid a finding as one in April. An OAT gate would narrow the rule to the swing seasons and make two hot water cards answer nearly the same question. HW-0003 needs warm weather because it asks whether the plant should run at all; this one needs light load because it asks what temperature the running plant should make.
  • Strict comparisons on all three thresholds. CDL Reals has no LessEqual or GreaterEqual in any case, so a pump at exactly 35.0%, water at exactly 54.4 °C and a pump at exactly 10.0% all fall on the no-fault, no-eval side. Equality is measure-zero in continuous data and perfectly reachable in a BAS that scales drive feedback to whole percent, so each boundary carries an on-the-line vector plus one a tenth of a unit either side. Both the vector and the parameter spell 54.4, so the temperature boundary is decided by the strictness rather than by rounding.
  • persist.delayOnInit = true (CDL default false), the library’s standing choice: a plant already loafing with hot water at controller restart waits out the full hour rather than alarming on the first tick.
  • No suppression contract with HW-0008, deliberately. The two rules see one failure from opposite ends, and the tempting move is to suppress this one. The findings are separable in both directions — a reset that moves correctly but bottoms out at 65 °C fires this rule and not HW-0008, and a flat setpoint parked at 50 °C fires HW-0008 and never trips 54.4 °C here — and suppression is a two-card contract. They are related and belong in one visit.
  • related spans the family and is asserted here first. faults/hw/README.md’s Relationships section documents HW-0001/HW-0002/HW-0003 only and is another writer’s. One entry needs its sign stated: HW-0004 (low loop delta-T) can point the opposite way, since hotter supply water at a given return temperature raises delta-T, so a plant showing both has two causes rather than one.
  • clusters: []. CLU-02 (“Missing Reset Strategy”) is the syndrome this fault belongs to and already carries the air-side consequence rule AHU-0031, this card’s structural analog one system over. But the cluster is AHU-scoped today, membership is clusters/clusters.json’s to declare, and the natural HW-side trigger is HW-0008 rather than this rule.
  • playbooks: [hot-water-plant-faults]. Its Applies-To row names only HW-0001/HW-0002/HW-0003 — the index owner’s line to extend — but Step 1 already carries this fault’s remedy in full, including the OAT-based reset schedule, because it was written from the same PNNL measure set. missing-reset is the near miss: its procedure is SAT and DSP plots throughout, and the card that should claim it on the hot water side is HW-0008.
  • Blind spots, in the order they will bite. A constant-volume loop has no speed signal and the rule does not apply — bind nothing rather than binding a run status scaled to 100. A loop whose DP setpoint never resets holds its pumps fast at low load, which suppresses this rule silently; that miss is HW-0005/HW-0006’s finding. The rule sees water, not setpoints, so it cannot separate “the reset never ran” from “the boiler ignored it” — diagnosis 5, which needs hws_temp_sp. And nothing guards against a supply sensor reading high: a 3 K offset moves this decision by more than half the distance between a well-reset plant and a flagged one.
  • No published test vectors. PNNL-27338 publishes none for its hot water measures and the reference has no card for this fault, so every scenario in vectors.json is authored from the equation and replayed against the pinned engine rev.

Notes

Read yLoopOk before yFault. A plant on setback, a summer weekend, or a loop whose pumps have stopped all hold it false, and every yFault = false underneath means “not evaluated” rather than “the water is at the right temperature”.

Trend hws_temp against hws_temp_sp and outdoor air for a week before dispatching anyone. A supply temperature tracking a flat setpoint is diagnosis 1 or 2 and the fix is written from a desk; a setpoint that resets while the water does not follow is diagnosis 5, at the boiler’s own controller; a floor no schedule explains is diagnosis 4, a plant-design conversation. Only then ask whether the emitters can accept cooler water — diagnosis 6, the one case where the right outcome is to retune high_hws_temp_threshold and close the finding. Where both fire, HW-0008 is the one to fix: it names the cause and this rule measures the consequence.

Test Vectors

19 scenarios, clock step 300 s over 9000 s.

ScenarioDescription
heating_season_high_loadA January afternoon: the loop is drawing everything the plant can make, pumps at 70% and supply water at 71 degC. Full-temperature water at full flow is the plant doing its job.
mild_day_supply_temperature_reset_downThe healthy version of the fault’s own weather: pumps loafing at 22% because the building barely needs heat, and a working reset has taken the supply water down to 49 degC to match. Low load is not the finding; low load with hot water is.
low_load_with_full_temperature_waterThe fault as PNNL-27338 4.4.2 states it: pumps at 20% and supply water at 60 degC, held for the full alarm_delay. The boiler is making design-day water for a loop that is barely drawing, and the alarm asserts at t = 3600 s.
pump_speed_exactly_at_the_low_load_thresholdSpeed boundary, on the line: 35.0% with 60 degC water. Reals.LessThreshold is strict, so 35.0 is not below 35.0 and the loop does not count as lightly loaded.
pump_speed_one_tenth_below_the_low_load_thresholdSpeed boundary, from below: 34.9% is lightly loaded, so the same 60 degC supply water alarms at 3600 s.
pump_speed_one_tenth_above_the_low_load_thresholdSpeed boundary, from above: 35.1% and nothing is reported. With the on-the-line case, this pins the threshold from all three sides.
hws_temp_exactly_at_the_high_supply_thresholdTemperature boundary, on the line: supply water at exactly 54.4 degC with the pumps at 20%. Reals.GreaterThreshold is strict, so the plant reads clear. Both the vector and the parameter spell 54.4, so the two are the same double and the comparison is decided by strictness rather than by rounding.
hws_temp_one_tenth_above_the_high_supply_thresholdTemperature boundary, from above: a tenth of a degree warmer at 54.5 degC, same light load, and the alarm arrives at 3600 s.
hws_temp_one_tenth_below_the_high_supply_thresholdTemperature boundary, from below: 54.3 degC reads clear, which with the two vectors above pins this threshold from all three sides.
pump_speed_exactly_at_the_evaluability_floorEvaluability boundary, on the line: 10.0% is not above 10.0%, so the loop is not judged to be circulating. yLoopOk is false for the whole run and the 60 degC supply water is never assessed.
pump_speed_one_tenth_below_the_evaluability_floorEvaluability boundary, from below: 9.9% with hot water. Same verdict and same reason — a drive at its lowest command is not evidence that the loop is moving water past the coils.
pump_speed_one_tenth_above_the_evaluability_floorEvaluability boundary, from above: 10.1% is circulating, so the same hot water is now a finding and alarms at 3600 s. The floor is pinned from all three sides.
pump_off_with_hot_standing_waterThe false positive the evaluability floor exists to prevent: an overnight setback with the pumps stopped and 65 degC water still standing in the boiler. Speed reads 0%, which is below 35% and would otherwise satisfy the reference’s two-condition test for as long as the water stays hot. yLoopOk is false, so the host reads NO_EVAL rather than a fault.
supply_temperature_climbs_mid_runA reset schedule losing its way: the loop sits at 20% all morning with 50 degC water, and at t = 1800 s the supply temperature climbs to 60 degC. The clock starts at the crossing, not at the start of the run, so the alarm lands at 5400 s.
pump_speed_rises_and_restarts_the_clockPersistence is continuous, not accumulated. A morning pickup takes the pumps to 40% for 600 s at t = 1800 s and the elapsed time is discarded rather than paused: the alarm lands at 6000 s, one full alarm_delay after the loop settles back to 20%.
condition_ends_on_the_maturity_tickDelay edge from below: the condition holds from t = 0 and the supply temperature drops to 50 degC at exactly 3600 s, the tick the timer matures on. The input is false when the timer comes due, so a full hour of overheated water is never reported.
condition_ends_one_tick_laterDelay edge from above: the same run held 300 s longer asserts at exactly 3600 s and clears at 3900 s. With the vector above, both sides of the delay edge are pinned.
supply_temperature_resets_down_after_the_alarmRecovery, and the operator’s confirmation that a reset schedule took: the supply setpoint comes down and the water follows to 48 degC at t = 5400 s. yFault falls on that tick with no release delay, and yLoopOk stays true throughout — the loop was circulating the whole time, so the clearing verdict is a real one.
pump_stops_after_the_alarmThe other way yFault falls, and the reason yLoopOk is a boundary output: the water stays at 60 degC but the pumps stop at t = 5400 s. yFault clears exactly as it does when the temperature resets down, and only yLoopOk says which of the two happened — here, nothing was fixed and the loop simply stopped being evaluable.
vectors.json
{
  "schema": "cxf-library/vectors/v1",
  "clock": {
    "step_s": 300,
    "horizon_s": 9000
  },
  "scenarios": [
    {
      "name": "heating_season_high_load",
      "description": "A January afternoon: the loop is drawing everything the plant can make, pumps at 70% and supply water at 71 degC. Full-temperature water at full flow is the plant doing its job.",
      "inputs": {
        "hw_pump_vfd_speed": 70.0,
        "hws_temp": 71.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 9000,
          "equals": false
        },
        {
          "output": "yLoopOk",
          "from_s": 0,
          "to_s": 9000,
          "equals": true
        }
      ]
    },
    {
      "name": "mild_day_supply_temperature_reset_down",
      "description": "The healthy version of the fault's own weather: pumps loafing at 22% because the building barely needs heat, and a working reset has taken the supply water down to 49 degC to match. Low load is not the finding; low load with hot water is.",
      "inputs": {
        "hw_pump_vfd_speed": 22.0,
        "hws_temp": 49.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 9000,
          "equals": false
        },
        {
          "output": "yLoopOk",
          "from_s": 0,
          "to_s": 9000,
          "equals": true
        }
      ]
    },
    {
      "name": "low_load_with_full_temperature_water",
      "description": "The fault as PNNL-27338 4.4.2 states it: pumps at 20% and supply water at 60 degC, held for the full alarm_delay. The boiler is making design-day water for a loop that is barely drawing, and the alarm asserts at t = 3600 s.",
      "inputs": {
        "hw_pump_vfd_speed": 20.0,
        "hws_temp": 60.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3300,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 3900,
          "to_s": 9000,
          "equals": true
        },
        {
          "output": "yLoopOk",
          "from_s": 0,
          "to_s": 9000,
          "equals": true
        }
      ]
    },
    {
      "name": "pump_speed_exactly_at_the_low_load_threshold",
      "description": "Speed boundary, on the line: 35.0% with 60 degC water. `Reals.LessThreshold` is strict, so 35.0 is not below 35.0 and the loop does not count as lightly loaded.",
      "inputs": {
        "hw_pump_vfd_speed": 35.0,
        "hws_temp": 60.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 9000,
          "equals": false
        },
        {
          "output": "yLoopOk",
          "from_s": 0,
          "to_s": 9000,
          "equals": true
        }
      ]
    },
    {
      "name": "pump_speed_one_tenth_below_the_low_load_threshold",
      "description": "Speed boundary, from below: 34.9% is lightly loaded, so the same 60 degC supply water alarms at 3600 s.",
      "inputs": {
        "hw_pump_vfd_speed": 34.9,
        "hws_temp": 60.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3300,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 3900,
          "to_s": 9000,
          "equals": true
        }
      ]
    },
    {
      "name": "pump_speed_one_tenth_above_the_low_load_threshold",
      "description": "Speed boundary, from above: 35.1% and nothing is reported. With the on-the-line case, this pins the threshold from all three sides.",
      "inputs": {
        "hw_pump_vfd_speed": 35.1,
        "hws_temp": 60.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 9000,
          "equals": false
        }
      ]
    },
    {
      "name": "hws_temp_exactly_at_the_high_supply_threshold",
      "description": "Temperature boundary, on the line: supply water at exactly 54.4 degC with the pumps at 20%. `Reals.GreaterThreshold` is strict, so the plant reads clear. Both the vector and the parameter spell 54.4, so the two are the same double and the comparison is decided by strictness rather than by rounding.",
      "inputs": {
        "hw_pump_vfd_speed": 20.0,
        "hws_temp": 54.4
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 9000,
          "equals": false
        }
      ]
    },
    {
      "name": "hws_temp_one_tenth_above_the_high_supply_threshold",
      "description": "Temperature boundary, from above: a tenth of a degree warmer at 54.5 degC, same light load, and the alarm arrives at 3600 s.",
      "inputs": {
        "hw_pump_vfd_speed": 20.0,
        "hws_temp": 54.5
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3300,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 3900,
          "to_s": 9000,
          "equals": true
        }
      ]
    },
    {
      "name": "hws_temp_one_tenth_below_the_high_supply_threshold",
      "description": "Temperature boundary, from below: 54.3 degC reads clear, which with the two vectors above pins this threshold from all three sides.",
      "inputs": {
        "hw_pump_vfd_speed": 20.0,
        "hws_temp": 54.3
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 9000,
          "equals": false
        }
      ]
    },
    {
      "name": "pump_speed_exactly_at_the_evaluability_floor",
      "description": "Evaluability boundary, on the line: 10.0% is not above 10.0%, so the loop is not judged to be circulating. yLoopOk is false for the whole run and the 60 degC supply water is never assessed.",
      "inputs": {
        "hw_pump_vfd_speed": 10.0,
        "hws_temp": 60.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 9000,
          "equals": false
        },
        {
          "output": "yLoopOk",
          "from_s": 0,
          "to_s": 9000,
          "equals": false
        }
      ]
    },
    {
      "name": "pump_speed_one_tenth_below_the_evaluability_floor",
      "description": "Evaluability boundary, from below: 9.9% with hot water. Same verdict and same reason \u2014 a drive at its lowest command is not evidence that the loop is moving water past the coils.",
      "inputs": {
        "hw_pump_vfd_speed": 9.9,
        "hws_temp": 60.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 9000,
          "equals": false
        },
        {
          "output": "yLoopOk",
          "from_s": 0,
          "to_s": 9000,
          "equals": false
        }
      ]
    },
    {
      "name": "pump_speed_one_tenth_above_the_evaluability_floor",
      "description": "Evaluability boundary, from above: 10.1% is circulating, so the same hot water is now a finding and alarms at 3600 s. The floor is pinned from all three sides.",
      "inputs": {
        "hw_pump_vfd_speed": 10.1,
        "hws_temp": 60.0
      },
      "expect": [
        {
          "output": "yLoopOk",
          "from_s": 0,
          "to_s": 9000,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3300,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 3900,
          "to_s": 9000,
          "equals": true
        }
      ]
    },
    {
      "name": "pump_off_with_hot_standing_water",
      "description": "The false positive the evaluability floor exists to prevent: an overnight setback with the pumps stopped and 65 degC water still standing in the boiler. Speed reads 0%, which is below 35% and would otherwise satisfy the reference's two-condition test for as long as the water stays hot. yLoopOk is false, so the host reads NO_EVAL rather than a fault.",
      "inputs": {
        "hw_pump_vfd_speed": 0.0,
        "hws_temp": 65.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 9000,
          "equals": false
        },
        {
          "output": "yLoopOk",
          "from_s": 0,
          "to_s": 9000,
          "equals": false
        }
      ]
    },
    {
      "name": "supply_temperature_climbs_mid_run",
      "description": "A reset schedule losing its way: the loop sits at 20% all morning with 50 degC water, and at t = 1800 s the supply temperature climbs to 60 degC. The clock starts at the crossing, not at the start of the run, so the alarm lands at 5400 s.",
      "inputs": {
        "hw_pump_vfd_speed": 20.0,
        "hws_temp": [
          {
            "t": 0,
            "value": 50.0
          },
          {
            "t": 1800,
            "value": 60.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5100,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 5700,
          "to_s": 9000,
          "equals": true
        }
      ]
    },
    {
      "name": "pump_speed_rises_and_restarts_the_clock",
      "description": "Persistence is continuous, not accumulated. A morning pickup takes the pumps to 40% for 600 s at t = 1800 s and the elapsed time is discarded rather than paused: the alarm lands at 6000 s, one full alarm_delay after the loop settles back to 20%.",
      "inputs": {
        "hws_temp": 60.0,
        "hw_pump_vfd_speed": [
          {
            "t": 0,
            "value": 20.0
          },
          {
            "t": 1800,
            "value": 40.0
          },
          {
            "t": 2400,
            "value": 20.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5700,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 6300,
          "to_s": 9000,
          "equals": true
        }
      ]
    },
    {
      "name": "condition_ends_on_the_maturity_tick",
      "description": "Delay edge from below: the condition holds from t = 0 and the supply temperature drops to 50 degC at exactly 3600 s, the tick the timer matures on. The input is false when the timer comes due, so a full hour of overheated water is never reported.",
      "inputs": {
        "hw_pump_vfd_speed": 20.0,
        "hws_temp": [
          {
            "t": 0,
            "value": 60.0
          },
          {
            "t": 3600,
            "value": 50.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 9000,
          "equals": false
        }
      ]
    },
    {
      "name": "condition_ends_one_tick_later",
      "description": "Delay edge from above: the same run held 300 s longer asserts at exactly 3600 s and clears at 3900 s. With the vector above, both sides of the delay edge are pinned.",
      "inputs": {
        "hw_pump_vfd_speed": 20.0,
        "hws_temp": [
          {
            "t": 0,
            "value": 60.0
          },
          {
            "t": 3900,
            "value": 50.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3300,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 3600,
          "to_s": 3600,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 3900,
          "to_s": 9000,
          "equals": false
        }
      ]
    },
    {
      "name": "supply_temperature_resets_down_after_the_alarm",
      "description": "Recovery, and the operator's confirmation that a reset schedule took: the supply setpoint comes down and the water follows to 48 degC at t = 5400 s. yFault falls on that tick with no release delay, and yLoopOk stays true throughout \u2014 the loop was circulating the whole time, so the clearing verdict is a real one.",
      "inputs": {
        "hw_pump_vfd_speed": 20.0,
        "hws_temp": [
          {
            "t": 0,
            "value": 60.0
          },
          {
            "t": 5400,
            "value": 48.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3300,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 3900,
          "to_s": 5100,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 5400,
          "to_s": 9000,
          "equals": false
        },
        {
          "output": "yLoopOk",
          "from_s": 0,
          "to_s": 9000,
          "equals": true
        }
      ]
    },
    {
      "name": "pump_stops_after_the_alarm",
      "description": "The other way yFault falls, and the reason yLoopOk is a boundary output: the water stays at 60 degC but the pumps stop at t = 5400 s. yFault clears exactly as it does when the temperature resets down, and only yLoopOk says which of the two happened \u2014 here, nothing was fixed and the loop simply stopped being evaluable.",
      "inputs": {
        "hws_temp": 60.0,
        "hw_pump_vfd_speed": [
          {
            "t": 0,
            "value": 20.0
          },
          {
            "t": 5400,
            "value": 0.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3300,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 3900,
          "to_s": 5100,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 5400,
          "to_s": 9000,
          "equals": false
        },
        {
          "output": "yLoopOk",
          "from_s": 0,
          "to_s": 5100,
          "equals": true
        },
        {
          "output": "yLoopOk",
          "from_s": 5400,
          "to_s": 9000,
          "equals": false
        }
      ]
    }
  ]
}

HW-0008 — HWS temperature reset not functioning

Statusverified — engine e2ff2f8, cxf:fnv1a128:d14378530edda50c195f5757ef979370, 2026-08-17
Severity3
Methodstatistical
Phase2
CategoryEXCESS_CONSUMPTION
ConfidenceHIGH
EstimationPROXY_ESTIMATION
G36
Clusters
Suppresses
Suppressed by
RelatedHW-0006, HW-0010, CHW-0002, AHU-0023
Playbooksmissing-reset, hot-water-plant-faults
SourcePNNL-27338 §4.5.2 (no HW supply-temperature reset; daily MAX−MIN of the HWS setpoint against a 10 °F range), pp. 4.16-4.17; PNNL-27338 §4.4.2 (high HW supply temperature), pp. 4.12-4.13 — the fault this reset exists to prevent; PNNL-27338 (Katipamula et al. 2018) — adapted via an internal paraphrased deep-read digest, not distributed (rule candidate 7); Sibling-rule precedent: AHU-0023 (window, OAT-range evaluability, half-range tolerance), CHW-0002 (plant-side binding of the same detector); Library extension: HVAC FDD Reference v1.0 ch.14 specifies HW-0001..052 only — index framing in faults/hw/README.md
Operating statesHeating season — boiler plant enabled and serving the loop for the bulk of the evaluation window

Preconditions (host-enforced): hws_temp_sp must be the active setpoint the boilers track — the plant controller’s live value, not a design figure in a schedule table or a boiler’s local panel setting that the BAS overrides. On a multi-boiler plant bind the plant-level setpoint; an individual boiler’s leaving-water setpoint is set by staging logic and reads flat while the plant resets. oat must be a trustworthy outdoor reading: the whole evaluability test rests on it, and the same sensor that HW-0003’s diagnosis 5 warns about (in the sun, above a warm roof, or dead at its last value) feeds this rule too. A plant that must hold a fixed supply temperature for a domestic hot water load has a legitimate reason to keep the setpoint flat — Legionella control sets a floor no reset may cross — and combined heating/DHW plants must either be excluded, be bound to a heating-only boiler where the plant has one, or be gated host-side on the DHW load. The plant must be running for most of the window; a boiler off for the season holds the setpoint still for reasons this rule cannot see. Evaluability is signalled in-rule by yOatVaried: when it is false the verdict is NO_EVAL, not healthy.

Points: hws_temp_sp, oat

Outputs:

  • yFault — True while the hot water supply temperature setpoint has stayed flat over the evaluation window despite sufficient outdoor-air variation, for at least alarm_delay
  • yOatVaried — Evaluability signal — true when OAT has varied enough within the evaluation window for a flat setpoint to mean anything; false means NO_EVAL and the host must ignore yFault

Parameters:

NameDefaultUnitCXF pathDescription
evaluation_window604800.0sspRef.samplePeriod, oatRef.samplePeriod, spFlatHeld.delayTime, oatFlatHeld.delayTimeWindow over which setpoint flatness and OAT variation are assessed (7 days); drives both baseline sample periods and both dwell timers
sp_flat_tolerance2.75°CspFlat.tMax deviation of hws_temp_sp from its sampled baseline to count as flat — half of PNNL-27338 §4.5.2’s 10 °F (5.6 °C) minimum expected setpoint range
oat_variation_tolerance4.0°CoatFlat.tMax deviation of oat from its sampled baseline to still count as flat (half AHU-0023’s 8 °C minimum OAT range; PNNL-27338 states no weather gate for this check — see Deviations)
alarm_delay86400.0spersist.delayTimeFault persistence before alarm (24 h)

Description

The hot water supply setpoint sits at its design value from October to April while the weather moves through thirty degrees underneath it. A working reset schedules supply temperature against outdoor air, and the savings arrive by two routes: every metre of distribution pipe sheds less heat, and a condensing boiler that sees return water below roughly 55 °C picks up something like ten points of combustion efficiency it cannot reach at design temperature.

This rule is a library extension — the reference’s ch.14 specifies three hot water rules and no reset checks. The detection is grounded in PNNL-27338 §4.5.2, which flags a plant whose supply setpoint moves less than 10 °F across a day; the window, the weather gate and every block in the graph are this library’s. The weather gate is what makes a flat setpoint evidence: a week where the weather did not move is a week where a correct reset had nothing to do, and a frozen OAT sensor — one of the things that causes a flat setpoint — flattens the driver and takes the rule out of service rather than letting it report a fault it cannot substantiate.

Detection Logic

baseline(x)  = x sampled and held every evaluation_window (7 days)
sp_flat      = |hws_temp_sp − baseline(hws_temp_sp)| < sp_flat_tolerance,
               continuously for evaluation_window
oat_flat     = |oat − baseline(oat)| < oat_variation_tolerance,
               continuously for evaluation_window

yOatVaried   = NOT oat_flat        (false ⇒ host reports NO_EVAL)
yFault       = sp_flat AND yOatVaried, sustained for alarm_delay

Block graph (rule.cxf.jsonld):

HW-0008 block graph

This is AHU-0023’s detector with the boiler plant’s points bound to it. Two symmetric chains compare each signal against a weekly Discrete.Sampler baseline, which emits the live input on its first tick, so there is no startup artifact. spFlatHeld asserts only after the setpoint has stayed within tolerance continuously for a full window, and any reset activity of 2.75 °C or more restarts it. oatFlatHeld does the same for outdoor air, and its negation is yOatVaried — “not varied” means a full window of continuous flatness, so the signal is optimistically true during the first window after startup, which is harmless because yFault needs that same window.

Both dwell timers fire on the same tick when plant and weather are flat together, so the fault conjunction is false by construction on that tick and there is no boundary race. persist (24 h) filters the remainder, and every TrueDelay carries delayOnInit = true. Worst-case time to alarm from cold start is evaluation_window + alarm_delay — 8 days. Comparisons are strict, so a signal sitting exactly on a tolerance falls on the not-flat side.

Possible Diagnoses

The reference algorithm publishes a detection test and no diagnosis list; these are this library’s, in the order a technician should work them.

  1. HWS reset never programmed — the common case, and a desk fix
  2. Reset programmed but disabled, or the setpoint overridden to a fixed value after a cold-weather complaint and never released
  3. Reset schedule configured with endpoints so close together that the plant never leaves its design temperature — programmed, reviewed, and useless
  4. Boilers running to their own local panel setpoints and ignoring the BAS value, in which case the trended setpoint may even reset while the water does not
  5. Domestic hot water load holding the plant at a fixed temperature. Not a fault; a precondition this rule cannot check (see Deviations)

Energy Impact

EXCESS_CONSUMPTION, HIGH confidence, PROXY_ESTIMATION. The savings figure is the hot water playbook’s: HW supply-temperature reset and DP reset together are worth 1–3% of site energy, with no published split. The share belonging to this rule depends on the boiler — on a non-condensing plant the recovery is distribution and standby loss, worth a few percent of plant fuel, while on a condensing plant a reset that drops return water under about 55 °C for the mild half of the season is worth several times that. Heating-dominant, and the strongest case for the fix is in mild climates and shoulder seasons.

Emissions Impact

Scope 1, PROXY_EMISSIONS, HIGH confidence; typical 500–5,000 kg CO₂e/yr for a plant carrying an uncommissioned setpoint through a heating season. The scope is what separates this rule from its DP twin: combustion at the site on a static fuel factor, so a site that has decarbonised its electricity still owns every kilogram. Plants heated electrically or by heat pump move the accounting to Scope 2 and the marginal operating emissions rate; the fault is the same and the host owns the classification.

Deviations

  • This rule extends the reference rather than transcribing it. Ch.14 covers HW-0001, 051 and 052 only, so there is no reference card behind this one — no published description, operating states, diagnosis list, tunables line or test vectors. The detection is paraphrased from PNNL-27338 §4.5.2 (Katipamula et al. 2018); name, severity and method come from faults/hw/README.md.
  • Daily MAX−MIN → rolling sampled baseline plus dwell. PNNL’s reset checks run once a day at midnight over the prior day’s array; this engine has no windowed min/max block and no batch clock, a question the library settled at AHU-0023 and has reused since. Tolerances are half the reference range — the half-range convention, because a signal swinging ±t about a baseline spans 2t. Detection is equivalent for a setpoint that moves and returns, and slightly conservative for monotonic drift inside one window, where a range test would still call it flat.
  • 10 °F becomes a 2.75 °C half-range. The point dictionary carries hws_temp_sp in °C, so the conversion happens here once: a 10 °F interval is 5.56 °C, half-range 2.78, shipped as 2.75 (a 5.5 °C range). The rounding is 1% tight — a hair less willing to call the setpoint flat, so it errs toward silence — and 2.75 is exactly representable in binary, which lets the boundary be pinned to the bit rather than to within a rounding error.
  • The 7-day window is adopted from AHU-0023, not from PNNL. PNNL’s window is one calendar day, which works there because the daily batch discards its evidence at every midnight; a rolling dwell does not, and a one-day dwell would alarm on the first stable weekend of the season. Seven days is also what makes the weather gate meaningful, since a front takes days to move through. evaluation_window drives all four timing parameters together and hosts must set them as a group.
  • The OAT gate is a library addition; §4.5.2 has none. PNNL’s test is a bare range test on the setpoint trend with no driver condition, so a plant holding its setpoint through a genuinely stable week is a fault by that algorithm and NO_EVAL by this one. The conjunct can only make the rule quieter — it never creates a finding — and it buys the degradation property above: a dead OAT sensor suppresses the rule rather than having the flat setpoint it caused reported as a controls fault. AHU-0023 made the same trade.
  • The 8 °C minimum OAT range is adopted from AHU-0023 (shipped as oat_variation_tolerance = 4 °C, half-range), because PNNL has no gate and so supplies no figure. It is a low bar any heating season clears — 8 °C of movement across a week is unremarkable weather anywhere a boiler runs — and it is the value a host tuning both rules expects to find the same on each. Lowering it makes the rule evaluable more often and therefore noisier.
  • The DHW case is a precondition, not a detection. A plant holding 60 °C for domestic hot water has a legitimate flat setpoint, and nothing in two points distinguishes it from a plant nobody programmed. Same limitation HW-0003 records, same answer: exclude the rule, bind a heating-only boiler where the plant has one, or gate host-side on the DHW load. Widening sp_flat_tolerance would convert a false positive into a blind spot.
  • NO_EVAL is surfaced as yOatVaried. Boolean logic has no tri-state, so evaluability is a second boundary output the host consults before interpreting yFault — false means NO_EVAL, never healthy. Same name and inverted-flat semantics as AHU-0023, deliberately, so a host binding both reads one convention, and it earns its place as an output rather than an echo by being a stateful window test over the signal’s own sampled baseline.
  • Reals.MovingAverage rejected, and the tick band that follows. The engine implements it with a fixed 64-checkpoint ring, so a seven-day window would need dt ≥ 9,600 s before it stops silently dropping its oldest samples. No BAS ticks that slowly; AHU-0023 found this and every reset rule since has inherited it. The sampler-and-dwell chain has no lower bound on tick period, and its upper bound is the one to watch — a reset excursion shorter than one tick is invisible to the flatness test, so trend at 5–15 min.
  • Strict comparisons on both tolerances. Reals.LessThreshold is u < t, so a setpoint deviating exactly 2.75 °C clears the flatness dwell and an outdoor temperature deviating exactly 4 °C counts as varied. Equality is measure-zero in continuous data and perfectly reachable in a BAS that quantises setpoints to whole degrees, so both boundaries are pinned from both sides.
  • alarm_delay = 24 h implemented as TrueDelay on the fault conjunction; the evaluation window itself is enforced by the two flatness dwells. delayOnInit = true on every TrueDelay (startup conservatism per AHU-0016), so a rule loaded onto an already-faulted plant still waits the full window plus delay. PNNL has no analog to the delay, its evaluation being one shot per midnight.
  • Two playbooks, matching HW-0006. missing-reset owns the reset family’s verification step — plot the setpoint against its driver over the window — and hot-water-plant-faults carries the remedy in its step 1 (program an OAT-based reset schedule). Neither covers the fault alone and both indexes belong to other writers, so this card lists both rather than stretching one.
  • Blind spots. The rule reads the setpoint, never the water: a plant whose setpoint schedules beautifully while the boilers make design-temperature water anyway is diagnosis 4 and invisible here — pair with HW-0002 and HW-0007 to see it. Diagnoses 1–3 are one signature. A reset driven by something other than weather (a valve-request trim-and-respond sequence, which some hot water plants run) is legitimately flat in stable weather and legitimately active when the weather is still, so on those plants the rule stays correct but reports NO_EVAL more often than it needs to. And a plant simply off for the window holds both signals flat; yOatVaried will usually catch it, but the host’s operating-state gate is what should.

Notes

Fix path is the missing-reset playbook for the verification step and hot-water-plant-faults for the remedy, whose step 1 gives the schedule shape with a worked example range. Plot hws_temp_sp against oat over the window first: a flat line against moving weather is this fault, and a line that moves but never leaves a 2 °C band is diagnosis 3, which looks programmed in a screenshot and behaves almost as badly.

clusters is deliberately empty. CLU-02 (“Missing Reset Strategy”) is an AHU-scoped cluster triggered by AHU-0023, and membership is clusters/clusters.json’s to declare — the same call the CHW pair made. A plant failing both HW-0006 and HW-0008 has one root cause, which is that nobody commissioned the hot water resets, and it should be dispatched as one visit.

Test Vectors

9 scenarios, clock step 300 s over 777600 s.

ScenarioDescription
reset_workingHWS setpoint swings 10 degC with the weather; the flatness dwell restarts every 12 h and nothing alarms
fixed_setpointHWS setpoint pinned at 70 degC while OAT swings 2-14 degC; fault asserts at exactly evaluation_window (7 d) + alarm_delay (24 h) = 691200 s, pinned from both sides
flat_oat_no_evalNO_EVAL: setpoint flat but the weather never moves; yOatVaried drops at exactly evaluation_window and yFault never fires
sp_at_flat_toleranceSetpoint deviates from its baseline by exactly sp_flat_tolerance (2.75 degC): strict < puts that on the not-flat side, so no fault
sp_within_flat_toleranceSetpoint jitter of 2.5 degC stays inside sp_flat_tolerance, so it still counts as flat; fault at 691200 s
oat_at_variation_toleranceOAT deviates from its baseline by exactly oat_variation_tolerance (4 degC): strict < makes that varied, so the rule stays evaluable and the flat setpoint alarms
oat_within_variation_toleranceOAT moves only 3.9 degC about its baseline, inside oat_variation_tolerance: NO_EVAL at exactly evaluation_window, and the simultaneous setpoint-flat edge cannot race it into a fault
sp_goes_flat_mid_runReset stops working 12 h into the run: the dwell starts at 43200 s, so the alarm lands at exactly 43200 + 604800 + 86400 = 734400 s
reset_resumesSetpoint flat for 4 days, then reset activity resumes before the window completes; the dwell restarts and nothing alarms
vectors.json
{
  "schema": "cxf-library/vectors/v1",
  "clock": {
    "step_s": 300,
    "horizon_s": 777600
  },
  "scenarios": [
    {
      "name": "reset_working",
      "description": "HWS setpoint swings 10 degC with the weather; the flatness dwell restarts every 12 h and nothing alarms",
      "inputs": {
        "hws_temp_sp": [
          {
            "t": 0,
            "value": 70.0
          },
          {
            "t": 43200,
            "value": 60.0
          },
          {
            "t": 86400,
            "value": 70.0
          },
          {
            "t": 129600,
            "value": 60.0
          },
          {
            "t": 172800,
            "value": 70.0
          },
          {
            "t": 216000,
            "value": 60.0
          },
          {
            "t": 259200,
            "value": 70.0
          },
          {
            "t": 302400,
            "value": 60.0
          },
          {
            "t": 345600,
            "value": 70.0
          },
          {
            "t": 388800,
            "value": 60.0
          },
          {
            "t": 432000,
            "value": 70.0
          },
          {
            "t": 475200,
            "value": 60.0
          },
          {
            "t": 518400,
            "value": 70.0
          },
          {
            "t": 561600,
            "value": 60.0
          },
          {
            "t": 604800,
            "value": 70.0
          },
          {
            "t": 648000,
            "value": 60.0
          },
          {
            "t": 691200,
            "value": 70.0
          },
          {
            "t": 734400,
            "value": 60.0
          },
          {
            "t": 777600,
            "value": 70.0
          }
        ],
        "oat": [
          {
            "t": 0,
            "value": 2.0
          },
          {
            "t": 43200,
            "value": 14.0
          },
          {
            "t": 86400,
            "value": 2.0
          },
          {
            "t": 129600,
            "value": 14.0
          },
          {
            "t": 172800,
            "value": 2.0
          },
          {
            "t": 216000,
            "value": 14.0
          },
          {
            "t": 259200,
            "value": 2.0
          },
          {
            "t": 302400,
            "value": 14.0
          },
          {
            "t": 345600,
            "value": 2.0
          },
          {
            "t": 388800,
            "value": 14.0
          },
          {
            "t": 432000,
            "value": 2.0
          },
          {
            "t": 475200,
            "value": 14.0
          },
          {
            "t": 518400,
            "value": 2.0
          },
          {
            "t": 561600,
            "value": 14.0
          },
          {
            "t": 604800,
            "value": 2.0
          },
          {
            "t": 648000,
            "value": 14.0
          },
          {
            "t": 691200,
            "value": 2.0
          },
          {
            "t": 734400,
            "value": 14.0
          },
          {
            "t": 777600,
            "value": 2.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 777600,
          "equals": false
        },
        {
          "output": "yOatVaried",
          "from_s": 0,
          "to_s": 777600,
          "equals": true
        }
      ]
    },
    {
      "name": "fixed_setpoint",
      "description": "HWS setpoint pinned at 70 degC while OAT swings 2-14 degC; fault asserts at exactly evaluation_window (7 d) + alarm_delay (24 h) = 691200 s, pinned from both sides",
      "inputs": {
        "hws_temp_sp": 70.0,
        "oat": [
          {
            "t": 0,
            "value": 2.0
          },
          {
            "t": 43200,
            "value": 14.0
          },
          {
            "t": 86400,
            "value": 2.0
          },
          {
            "t": 129600,
            "value": 14.0
          },
          {
            "t": 172800,
            "value": 2.0
          },
          {
            "t": 216000,
            "value": 14.0
          },
          {
            "t": 259200,
            "value": 2.0
          },
          {
            "t": 302400,
            "value": 14.0
          },
          {
            "t": 345600,
            "value": 2.0
          },
          {
            "t": 388800,
            "value": 14.0
          },
          {
            "t": 432000,
            "value": 2.0
          },
          {
            "t": 475200,
            "value": 14.0
          },
          {
            "t": 518400,
            "value": 2.0
          },
          {
            "t": 561600,
            "value": 14.0
          },
          {
            "t": 604800,
            "value": 2.0
          },
          {
            "t": 648000,
            "value": 14.0
          },
          {
            "t": 691200,
            "value": 2.0
          },
          {
            "t": 734400,
            "value": 14.0
          },
          {
            "t": 777600,
            "value": 2.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 690900,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 691200,
          "to_s": 777600,
          "equals": true
        },
        {
          "output": "yOatVaried",
          "from_s": 0,
          "to_s": 777600,
          "equals": true
        }
      ]
    },
    {
      "name": "flat_oat_no_eval",
      "description": "NO_EVAL: setpoint flat but the weather never moves; yOatVaried drops at exactly evaluation_window and yFault never fires",
      "inputs": {
        "hws_temp_sp": 70.0,
        "oat": 5.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 777600,
          "equals": false
        },
        {
          "output": "yOatVaried",
          "from_s": 0,
          "to_s": 604500,
          "equals": true
        },
        {
          "output": "yOatVaried",
          "from_s": 604800,
          "to_s": 777600,
          "equals": false
        }
      ]
    },
    {
      "name": "sp_at_flat_tolerance",
      "description": "Setpoint deviates from its baseline by exactly sp_flat_tolerance (2.75 degC): strict < puts that on the not-flat side, so no fault",
      "inputs": {
        "hws_temp_sp": [
          {
            "t": 0,
            "value": 70.0
          },
          {
            "t": 43200,
            "value": 72.75
          },
          {
            "t": 86400,
            "value": 70.0
          },
          {
            "t": 129600,
            "value": 72.75
          },
          {
            "t": 172800,
            "value": 70.0
          },
          {
            "t": 216000,
            "value": 72.75
          },
          {
            "t": 259200,
            "value": 70.0
          },
          {
            "t": 302400,
            "value": 72.75
          },
          {
            "t": 345600,
            "value": 70.0
          },
          {
            "t": 388800,
            "value": 72.75
          },
          {
            "t": 432000,
            "value": 70.0
          },
          {
            "t": 475200,
            "value": 72.75
          },
          {
            "t": 518400,
            "value": 70.0
          },
          {
            "t": 561600,
            "value": 72.75
          },
          {
            "t": 604800,
            "value": 70.0
          },
          {
            "t": 648000,
            "value": 72.75
          },
          {
            "t": 691200,
            "value": 70.0
          },
          {
            "t": 734400,
            "value": 72.75
          },
          {
            "t": 777600,
            "value": 70.0
          }
        ],
        "oat": [
          {
            "t": 0,
            "value": 2.0
          },
          {
            "t": 43200,
            "value": 14.0
          },
          {
            "t": 86400,
            "value": 2.0
          },
          {
            "t": 129600,
            "value": 14.0
          },
          {
            "t": 172800,
            "value": 2.0
          },
          {
            "t": 216000,
            "value": 14.0
          },
          {
            "t": 259200,
            "value": 2.0
          },
          {
            "t": 302400,
            "value": 14.0
          },
          {
            "t": 345600,
            "value": 2.0
          },
          {
            "t": 388800,
            "value": 14.0
          },
          {
            "t": 432000,
            "value": 2.0
          },
          {
            "t": 475200,
            "value": 14.0
          },
          {
            "t": 518400,
            "value": 2.0
          },
          {
            "t": 561600,
            "value": 14.0
          },
          {
            "t": 604800,
            "value": 2.0
          },
          {
            "t": 648000,
            "value": 14.0
          },
          {
            "t": 691200,
            "value": 2.0
          },
          {
            "t": 734400,
            "value": 14.0
          },
          {
            "t": 777600,
            "value": 2.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 777600,
          "equals": false
        },
        {
          "output": "yOatVaried",
          "from_s": 0,
          "to_s": 777600,
          "equals": true
        }
      ]
    },
    {
      "name": "sp_within_flat_tolerance",
      "description": "Setpoint jitter of 2.5 degC stays inside sp_flat_tolerance, so it still counts as flat; fault at 691200 s",
      "inputs": {
        "hws_temp_sp": [
          {
            "t": 0,
            "value": 70.0
          },
          {
            "t": 43200,
            "value": 72.5
          },
          {
            "t": 86400,
            "value": 70.0
          },
          {
            "t": 129600,
            "value": 72.5
          },
          {
            "t": 172800,
            "value": 70.0
          },
          {
            "t": 216000,
            "value": 72.5
          },
          {
            "t": 259200,
            "value": 70.0
          },
          {
            "t": 302400,
            "value": 72.5
          },
          {
            "t": 345600,
            "value": 70.0
          },
          {
            "t": 388800,
            "value": 72.5
          },
          {
            "t": 432000,
            "value": 70.0
          },
          {
            "t": 475200,
            "value": 72.5
          },
          {
            "t": 518400,
            "value": 70.0
          },
          {
            "t": 561600,
            "value": 72.5
          },
          {
            "t": 604800,
            "value": 70.0
          },
          {
            "t": 648000,
            "value": 72.5
          },
          {
            "t": 691200,
            "value": 70.0
          },
          {
            "t": 734400,
            "value": 72.5
          },
          {
            "t": 777600,
            "value": 70.0
          }
        ],
        "oat": [
          {
            "t": 0,
            "value": 2.0
          },
          {
            "t": 43200,
            "value": 14.0
          },
          {
            "t": 86400,
            "value": 2.0
          },
          {
            "t": 129600,
            "value": 14.0
          },
          {
            "t": 172800,
            "value": 2.0
          },
          {
            "t": 216000,
            "value": 14.0
          },
          {
            "t": 259200,
            "value": 2.0
          },
          {
            "t": 302400,
            "value": 14.0
          },
          {
            "t": 345600,
            "value": 2.0
          },
          {
            "t": 388800,
            "value": 14.0
          },
          {
            "t": 432000,
            "value": 2.0
          },
          {
            "t": 475200,
            "value": 14.0
          },
          {
            "t": 518400,
            "value": 2.0
          },
          {
            "t": 561600,
            "value": 14.0
          },
          {
            "t": 604800,
            "value": 2.0
          },
          {
            "t": 648000,
            "value": 14.0
          },
          {
            "t": 691200,
            "value": 2.0
          },
          {
            "t": 734400,
            "value": 14.0
          },
          {
            "t": 777600,
            "value": 2.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 690900,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 691200,
          "to_s": 777600,
          "equals": true
        }
      ]
    },
    {
      "name": "oat_at_variation_tolerance",
      "description": "OAT deviates from its baseline by exactly oat_variation_tolerance (4 degC): strict < makes that varied, so the rule stays evaluable and the flat setpoint alarms",
      "inputs": {
        "hws_temp_sp": 70.0,
        "oat": [
          {
            "t": 0,
            "value": 5.0
          },
          {
            "t": 43200,
            "value": 9.0
          },
          {
            "t": 86400,
            "value": 5.0
          },
          {
            "t": 129600,
            "value": 9.0
          },
          {
            "t": 172800,
            "value": 5.0
          },
          {
            "t": 216000,
            "value": 9.0
          },
          {
            "t": 259200,
            "value": 5.0
          },
          {
            "t": 302400,
            "value": 9.0
          },
          {
            "t": 345600,
            "value": 5.0
          },
          {
            "t": 388800,
            "value": 9.0
          },
          {
            "t": 432000,
            "value": 5.0
          },
          {
            "t": 475200,
            "value": 9.0
          },
          {
            "t": 518400,
            "value": 5.0
          },
          {
            "t": 561600,
            "value": 9.0
          },
          {
            "t": 604800,
            "value": 5.0
          },
          {
            "t": 648000,
            "value": 9.0
          },
          {
            "t": 691200,
            "value": 5.0
          },
          {
            "t": 734400,
            "value": 9.0
          },
          {
            "t": 777600,
            "value": 5.0
          }
        ]
      },
      "expect": [
        {
          "output": "yOatVaried",
          "from_s": 0,
          "to_s": 777600,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 690900,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 691200,
          "to_s": 777600,
          "equals": true
        }
      ]
    },
    {
      "name": "oat_within_variation_tolerance",
      "description": "OAT moves only 3.9 degC about its baseline, inside oat_variation_tolerance: NO_EVAL at exactly evaluation_window, and the simultaneous setpoint-flat edge cannot race it into a fault",
      "inputs": {
        "hws_temp_sp": 70.0,
        "oat": [
          {
            "t": 0,
            "value": 5.0
          },
          {
            "t": 43200,
            "value": 8.9
          },
          {
            "t": 86400,
            "value": 5.0
          },
          {
            "t": 129600,
            "value": 8.9
          },
          {
            "t": 172800,
            "value": 5.0
          },
          {
            "t": 216000,
            "value": 8.9
          },
          {
            "t": 259200,
            "value": 5.0
          },
          {
            "t": 302400,
            "value": 8.9
          },
          {
            "t": 345600,
            "value": 5.0
          },
          {
            "t": 388800,
            "value": 8.9
          },
          {
            "t": 432000,
            "value": 5.0
          },
          {
            "t": 475200,
            "value": 8.9
          },
          {
            "t": 518400,
            "value": 5.0
          },
          {
            "t": 561600,
            "value": 8.9
          },
          {
            "t": 604800,
            "value": 5.0
          },
          {
            "t": 648000,
            "value": 8.9
          },
          {
            "t": 691200,
            "value": 5.0
          },
          {
            "t": 734400,
            "value": 8.9
          },
          {
            "t": 777600,
            "value": 5.0
          }
        ]
      },
      "expect": [
        {
          "output": "yOatVaried",
          "from_s": 0,
          "to_s": 604500,
          "equals": true
        },
        {
          "output": "yOatVaried",
          "from_s": 604800,
          "to_s": 777600,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 777600,
          "equals": false
        }
      ]
    },
    {
      "name": "sp_goes_flat_mid_run",
      "description": "Reset stops working 12 h into the run: the dwell starts at 43200 s, so the alarm lands at exactly 43200 + 604800 + 86400 = 734400 s",
      "inputs": {
        "hws_temp_sp": [
          {
            "t": 0,
            "value": 70.0
          },
          {
            "t": 21600,
            "value": 60.0
          },
          {
            "t": 43200,
            "value": 70.0
          }
        ],
        "oat": [
          {
            "t": 0,
            "value": 2.0
          },
          {
            "t": 43200,
            "value": 14.0
          },
          {
            "t": 86400,
            "value": 2.0
          },
          {
            "t": 129600,
            "value": 14.0
          },
          {
            "t": 172800,
            "value": 2.0
          },
          {
            "t": 216000,
            "value": 14.0
          },
          {
            "t": 259200,
            "value": 2.0
          },
          {
            "t": 302400,
            "value": 14.0
          },
          {
            "t": 345600,
            "value": 2.0
          },
          {
            "t": 388800,
            "value": 14.0
          },
          {
            "t": 432000,
            "value": 2.0
          },
          {
            "t": 475200,
            "value": 14.0
          },
          {
            "t": 518400,
            "value": 2.0
          },
          {
            "t": 561600,
            "value": 14.0
          },
          {
            "t": 604800,
            "value": 2.0
          },
          {
            "t": 648000,
            "value": 14.0
          },
          {
            "t": 691200,
            "value": 2.0
          },
          {
            "t": 734400,
            "value": 14.0
          },
          {
            "t": 777600,
            "value": 2.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 734100,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 734400,
          "to_s": 777600,
          "equals": true
        },
        {
          "output": "yOatVaried",
          "from_s": 0,
          "to_s": 777600,
          "equals": true
        }
      ]
    },
    {
      "name": "reset_resumes",
      "description": "Setpoint flat for 4 days, then reset activity resumes before the window completes; the dwell restarts and nothing alarms",
      "inputs": {
        "hws_temp_sp": [
          {
            "t": 0,
            "value": 70.0
          },
          {
            "t": 345600,
            "value": 60.0
          },
          {
            "t": 388800,
            "value": 70.0
          },
          {
            "t": 432000,
            "value": 60.0
          },
          {
            "t": 475200,
            "value": 70.0
          },
          {
            "t": 518400,
            "value": 60.0
          },
          {
            "t": 561600,
            "value": 70.0
          },
          {
            "t": 604800,
            "value": 60.0
          },
          {
            "t": 648000,
            "value": 70.0
          },
          {
            "t": 691200,
            "value": 60.0
          },
          {
            "t": 734400,
            "value": 70.0
          },
          {
            "t": 777600,
            "value": 60.0
          }
        ],
        "oat": [
          {
            "t": 0,
            "value": 2.0
          },
          {
            "t": 43200,
            "value": 14.0
          },
          {
            "t": 86400,
            "value": 2.0
          },
          {
            "t": 129600,
            "value": 14.0
          },
          {
            "t": 172800,
            "value": 2.0
          },
          {
            "t": 216000,
            "value": 14.0
          },
          {
            "t": 259200,
            "value": 2.0
          },
          {
            "t": 302400,
            "value": 14.0
          },
          {
            "t": 345600,
            "value": 2.0
          },
          {
            "t": 388800,
            "value": 14.0
          },
          {
            "t": 432000,
            "value": 2.0
          },
          {
            "t": 475200,
            "value": 14.0
          },
          {
            "t": 518400,
            "value": 2.0
          },
          {
            "t": 561600,
            "value": 14.0
          },
          {
            "t": 604800,
            "value": 2.0
          },
          {
            "t": 648000,
            "value": 14.0
          },
          {
            "t": 691200,
            "value": 2.0
          },
          {
            "t": 734400,
            "value": 14.0
          },
          {
            "t": 777600,
            "value": 2.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 777600,
          "equals": false
        }
      ]
    }
  ]
}

HW-0009 — Boiler proof-of-operation failure

Statusverified — engine e2ff2f8, cxf:fnv1a128:22ca393fffc23948414362e2dd3ab69c, 2026-08-18
Severity2
Methodrule
Phase2
CategoryPROTECTIVE
ConfidenceMEDIUM
EstimationQUALITATIVE_ONLY
G36
Clusters
Suppresses
Suppressed by
RelatedHW-0001, HW-0002, HW-0010, PMP-0003
Playbookshot-water-plant-faults
SourceASHRAE Guideline 36-2021 §5.1.6 — the definition of proven: the equipment’s DI status point matches the state its DO command point was set to. This rule is that comparison, held in both directions; ASHRAE Guideline 36-2021 §5.21.10.5 — the hot water plant’s pump command/status alarm: commanded on with status off is Level 2 after 15 s, commanded off with status on is Level 4 after 60 s. Two directions, two windows, two severities — the shape this family instantiates per equipment; ASHRAE Guideline 36-2021 §5.1.15.5.b.1 — faulted equipment. Fans and pumps are faulted by status not matching command; a chiller by status still off five minutes after the start command, and only at the first start, ‘because status will come and go if [it] cycles on low load’; a BOILER by its own safety-shutdown alarm contact and by leaving-water temperature, never by a status proof. Both halves of that clause shape this card (see Deviations); ASHRAE Guideline 36-2021 §5.21.3 — boiler staging: wait five minutes for a newly enabled boiler to prove it is operating correctly. The published number behind start_proof_time; HVAC FDD Reference v1.0 ch.14 specifies no boiler proof-of-operation rule — its three cards are HW-0001/HW-0002/HW-0003. Name, severity 2 and category PROTECTIVE are argued here by analogy to PMP-0001, the reference’s own command-versus-proof card; points/hw.points.json boiler_cmd (the plant-level enable the BAS writes, not a lead/lag sequencer’s internal stage command) and boiler_status (the FIRING status, per HW-0001’s contract); Burner-management sequence timing — prepurge, pilot trial for ignition, main-flame establishing — comes from the burner control’s listing and the boiler safety codes governing the vessel (in North America, ASME CSD-1 for smaller automatically fired boilers and NFPA 85 for larger combustion systems). No clause of either is cited here: the intervals are per-burner and published in the manufacturer’s sequence of operation
Operating statesall — the rule watches the enable itself, so there is no plant state in which it has nothing to say. The one state it must not be evaluated in is an enabled boiler that has stopped firing because it is satisfied (see preconditions).

Preconditions (host-enforced): Four bindings and one gate. (1) boiler_cmd must be the enable the BAS actually writes to THIS boiler, per the dictionary note: a lead/lag sequencer’s internal stage command changes state with no DO behind it, and a plant enable broadcast to several boilers makes every lag boiler read as firing without a command. (2) boiler_status must be the burner’s FIRING (flame) status, HW-0001’s contract. A status echoed back from the enable relay makes the two conjuncts one conjunct and the rule can never fire — PMP-0001’s warning, and it bites harder here because the enable and the flame are separated by a whole burner-management sequence. (3) One instance per boiler, both points from the same boiler; the OR across a multi-boiler plant destroys the measurement exactly as it does in HW-0001. (4) start_proof_time must clear this burner’s published light-off sequence including any listed recycle attempt (see Deviations). THE GATE: an enabled boiler that is satisfied stops firing while its enable stays true — its own operating control cycles the burner underneath the BAS. Every satisfied interval longer than start_proof_time then reads as a failure to start. The host must suspend yFailToStart while the boiler is enabled and satisfied (leaving water at or above its own setpoint, or the plant staged with no call), or bind an enable that already means should be firing now. G36 makes the same carve-out for chillers by evaluating status only at the first start; a two-point graph cannot see a first start, so the gate is host-side. Delivery quality is also host-side: a status held at its last value through a comms outage reads as a fault in whichever direction the stale value points, and the rule cannot tell that from the real thing.

Points: boiler_cmd, boiler_status

Outputs:

  • yFault — True while either direction has been asserting — the boiler has failed to prove firing, or is firing unbidden. The two directions are mutually exclusive, so yFault names one of them and the flags say which
  • yFailToStart — Sub-condition flag — enabled with no proven flame, continuously for start_proof_time. Not an evaluability output: false never means NO_EVAL
  • yUnexpectedRun — Sub-condition flag — proven firing with no enable, continuously for stop_proof_time. Same kind as yFailToStart

Parameters:

NameDefaultUnitCXF pathDescription
start_proof_time300.0sstartProof.delayTimeHow long an enabled boiler may go without proving flame before the rule calls it a failure to start (5 min). G36 gives a newly enabled boiler five minutes to prove correct operation during a stage change (§5.21.3) and gives a chiller the same five minutes from its start command; a boiler needs every second of it, because the path from enable to flame crosses prepurge, pilot trial and main-flame establishing. COMMISSIONING VALUE against the burner’s published sequence: a long prepurge or a listed recycle attempt pushes it toward 600 s
stop_proof_time120.0sstopProof.delayTimeHow long a boiler may keep firing after its enable drops before the rule calls it an unexpected run (2 min). Twice G36’s 60 s for a hot water pump (§5.21.10.5), because a burner’s controlled shutdown and post-purge can hold a derived status point true after the fuel valve has closed, and this is the direction where a false alarm sends someone to a boiler that is merely finishing

Description

An enable is a request; firing is an outcome, and on a boiler the distance between them is a whole burner-management sequence — prepurge, pilot trial for ignition, main-flame establishing, each with its own proving switch. When the sequence does not complete, the burner controller locks out and stays locked out while the BAS keeps writing an enable into a boiler that has already decided not to run. From the safety system’s point of view nothing is broken; a lockout is the burner control doing its job. What is broken is availability, and in January a locked-out lead boiler is an emergency even though every component behaved correctly. The mirror case — firing with no enable, from a switch left in HAND or a welded contactor — burns fuel nobody asked for.

Detection Logic

yFailToStart   = (boiler_cmd  AND NOT boiler_status)  sustained for start_proof_time
yUnexpectedRun = (boiler_status AND NOT boiler_cmd)   sustained for stop_proof_time

yFault         = yFailToStart OR yUnexpectedRun

Block graph (rule.cxf.jsonld):

HW-0009 block graph

Seven blocks: a negation, an And and a delay per direction, and one Or. The two And gates cannot be true on the same tick, and each delay drops its output the moment its And does, so the flags are mutually exclusive by construction — a mismatch that changes direction takes the second window from zero (hand_switch_flip_never_asserts_both_directions).

Both flags are diagnostic. Neither is an evaluability output and there is no NO_EVAL condition inside this graph: the rule is evaluable whenever both points are delivered, and whether they are is the host’s delivery-quality job. Silence means command and status agree, which is the healthy answer in both states.

delayOnInit = true on both delays. A restart serves the full window before either direction can assert, which does real work here: a plant coming back up is exactly when a boiler is mid-light-off, and the CDL default would report the sequence this rule was built to wait for.

Possible Diagnoses

Read the flags first; they split the list in two.

yFailToStart — enabled, not firing:

  1. The burner management system has locked out — flame failure, low-water cutoff, high limit, low gas pressure, a combustion-air proving switch. The controller holds a code until someone resets it, and that code names the fault this rule can only point at.
  2. No fuel to burn. A manual gas cock closed after service, a tripped safety shutoff valve, a regulator or gas-train pressure switch out of range. Common after any work on the fuel train.
  3. The burner cannot spin. Combustion-air blower overload tripped, drive faulted, disconnect open, coupling or belt gone.
  4. Nothing is wrong. The boiler is enabled and satisfied, its burner off by its own operating control — the precondition gate, and the first thing to rule out on a plant that has not implemented it.
  5. The status point failed low — a current switch set above the burner’s actual draw, a flame-relay contact, or a point bound to the wrong boiler.

yUnexpectedRun — firing, not enabled:

  1. Local control has the machine — Hand/Off/Auto in HAND at the burner panel, or the boiler firing on its own aquastat with the BAS out of the loop. The most common cause, usually left over from a service call.
  2. The command never reaches the boiler, or never leaves. Welded contactor or relay, an output wired to the wrong terminal, an enable inverted at the interposing relay.
  3. The status point is stuck true — a latched current switch, or a point bound to a boiler that really is running.

Energy Impact

PROTECTIVE, MEDIUM confidence, QUALITATIVE_ONLY. The directions have opposite economics. A boiler that will not fire wastes no fuel; it costs unmet heat and whatever carries the load instead, neither of which this library prices. A boiler firing unbidden wastes its whole fuel input, directly measurable wherever the host binds fuel_power. MEDIUM because two booleans disagreeing is unambiguous evidence of something while the diagnosis stays wide open, and because the status point is uncorroborated — diagnoses 5 and 8 are that point failing.

Emissions Impact

Scope 1, QUALITATIVE_EMISSIONS. Fuel burned at the boiler is a direct emission, so the unexpected-run direction abates Scope 1 at the plant rather than Scope 2 at the meter — HW-0003’s basis, and the reason a heating-plant waste term is worth more per kWh than the same number on a fan. The fail-to-start direction carries no emissions term of its own; whatever picks up the load carries it, and electric backup heat usually carries it badly.

Deviations

  • Composed from Logical.Not + Logical.And + Logical.TrueDelay per direction plus one Logical.Or, rather than CDL.Logical.Proof. Proof is exported at the pinned rev and loads (a probe graph exported a content id), but four of its published behaviors, each reproduced on that probe, break this template: it starts checking as soon as either the feedbackDelay + debounce timer lapses or the measurement has been stable for debounce, whichever is first, so a status stably false all night gets no proof window at all (yLocFal asserted on the same tick the command rose); one window serves both directions, so 300 s to start and 120 s to stop cannot both be expressed; both outputs latch true together on an unstable measurement, a third meaning the mutual-exclusivity contract cannot carry, and hold until a stable-equality edge; and its internal delay-on-init is fixed, alarming from the first tick. None of that is a defect — it is the block designed for fans and pumps, whose status follows the command in seconds.
  • start_proof_time 300 s. G36 gives a newly enabled boiler five minutes to prove correct operation at a stage change (§5.21.3) and a chiller five minutes from its start command (§5.1.15.5.b.1.ii); a pump gets 15 s, and the spread is the burner sequence. Still a commissioning value on the HP-0001 convention: prepurge, trial for ignition, flame establishing and any listed recycle attempt live in the burner manufacturer’s sequence of operation, and no portable number covers both a fire-tube with a 90-second purge and a condensing boiler that lights in twenty.
  • stop_proof_time 120 s, deliberately not G36’s 60 s. The pump number is for a device that stops when its starter opens; a burner runs a controlled shutdown and post-purge that a derived status point can stay true through. Double the pump window buys that margin in the direction where a false alarm costs the most credibility and the least energy — G36 rates it Level 4 against Level 2 for the other.
  • One severity for two directions. G36 splits them (Level 2 commanded-on, Level 4 commanded-off); one card carries one severity, set by the worse direction — a lead boiler that will not fire. Hosts wanting the split read the flags, which is what they are for.
  • This is an availability rule and must never be read as a safety layer. A failed proof usually means the burner management system locked out, which is the safety system working. G36 makes the same architectural choice: a boiler is declared faulted by its own safety-shutdown alarm — network or hardwired contact — and by leaving-water temperature, never by a status proof (§5.1.15.5.b.1.iii). Where that contact reaches the BAS it is the better signal and it names the cause; this card is the coverage rule for the many plants that never bring it in, and it sits downstream of every interlock rather than beside them.
  • The satisfied-boiler gate is host-side and the rule is wrong without it. G36 solves the same problem for chillers by evaluating status only at the first start, on the stated grounds that status comes and goes when equipment cycles on low load. Reproducing that needs edge and latch state the family template does not carry, and it would put an operating-state decision inside a graph the design stance keeps status-blind. The precondition names it instead, where the host can meet it with the setpoint it already has.
  • No evaluability output; both boolean flags are direction flags. No in-rule condition makes “no mismatch” anything other than an answer. What can make the verdict meaningless is a stale or missing point, and that is delivery quality — the host’s job under the design stance, not a boolean this graph could compute from the two points it is judging.
  • delayOnInit = true on both delays (CDL default false), the library’s standing choice, load-bearing here: an engine restart during a plant restart lands mid-light-off, and the default would report the burner-management sequence as a failure to complete it.
  • suppresses: []. A boiler that will not fire leaves the loop cold, but HW-0004 and HW-0007 already require the plant to be making heat as a host precondition, so an edge here would duplicate a gate those cards own. HW-0001 is related in both directions rather than suppressed: a lockout-retry cycle produces real starts and HW-0001 should count them (see Notes).
  • g36: null despite four G36 citations. The field carries the clause a transcribed fault condition came from, and this rule transcribes none: G36 defines proven (§5.1.6) and specifies this alarm for pumps (§5.21.10.5), but its hot water plant AFDD routine (§5.21.11) has no command-versus-status fault at all. The citations live in source.
  • playbooks: [hot-water-plant-faults], with a gap. It has no step for a boiler that will not prove; the nearest content is Step 2.5, low water flow tripping a safety, filed under short-cycling, and its Applies-To row does not name this rule. Both edits belong to the playbook’s owner; the Notes carry the field procedure meanwhile, on the RTU-0009 precedent.
  • Name, severity 2, category: PROTECTIVE and method: rule are authored, mirrored from PMP-0001 and VFD-0001, the library’s other command-versus-proof cards. No published test vectors exist for this rule; every scenario in vectors.json is authored from the equation and replayed against the pinned engine rev.

Notes

Read the burner controller before the trend. A locked-out boiler annunciates why, and that code is worth more than everything in this card — the rule’s contribution is noticing at 3 a.m. on a Sunday instead of when the building opens cold. Run it beside HW-0001: a burner that locks out, retries, proves flame for a minute and locks out again shows up as short-cycling first and as a flickering yFailToStart here (flame_pulse_clears_and_restarts_the_proof pins that shape), and both firing together mean the same visit. PMP-0003 is the pump instance of the same template; its start window is 15 s against this card’s 300, which is the burner sequence measured in parameters.

Test Vectors

8 scenarios, clock step 30 s over 1800 s.

ScenarioDescription
fires_as_commanded_stays_silentEnabled and firing. Command and status agree, so neither direction ever accumulates.
off_as_commanded_stays_silentBoiler disabled and dark — the other agreeing state. Silence here is the rule reporting a healthy plant, not an absent verdict.
fail_to_start_asserts_at_start_proof_timeEnabled and never proves firing — a burner-management lockout. yFailToStart asserts at exactly start_proof_time (300 s) and delayOnInit = true serves that window from the first tick.
unexpected_run_asserts_at_stop_proof_timeFiring with no enable — burner in HAND, a welded contactor, or a status stuck true. yUnexpectedRun asserts at exactly stop_proof_time (120 s), the shorter of the two windows.
normal_light_off_inside_the_window_stays_silentThe case start_proof_time exists for: enabled at t = 0, flame proven at 240 s after prepurge, pilot and flame establishing. A healthy burner-management cycle is not a fault.
enable_dropped_mid_window_restarts_the_proofA command flip inside the proof window: the plant stages the boiler off at 180 s and back on at 240 s while it never fires. The timer restarts from the second rising edge, so the alarm lands at 540 s (240 + 300), not at 300 s.
flame_pulse_clears_and_restarts_the_proofLockout-retry: the boiler stays enabled, holds a proven flame from 600 to 660 s, then drops out again. Each proof clears the alarm and restarts the window, so a boiler cycling on its own safety retries flickers here and is counted properly by HW-0001.
hand_switch_flip_never_asserts_both_directionsThe mismatch changes direction at 1200 s — the BAS drops the enable as someone puts the burner in HAND. yFailToStart falls on the same tick the command falls and yUnexpectedRun starts its own window from zero, so the two flags are never true together; yFault goes quiet for the 120 s between them.
vectors.json
{
  "schema": "cxf-library/vectors/v1",
  "clock": {
    "step_s": 30,
    "horizon_s": 1800
  },
  "scenarios": [
    {
      "name": "fires_as_commanded_stays_silent",
      "description": "Enabled and firing. Command and status agree, so neither direction ever accumulates.",
      "inputs": {
        "boiler_cmd": true,
        "boiler_status": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        }
      ]
    },
    {
      "name": "off_as_commanded_stays_silent",
      "description": "Boiler disabled and dark \u2014 the other agreeing state. Silence here is the rule reporting a healthy plant, not an absent verdict.",
      "inputs": {
        "boiler_cmd": false,
        "boiler_status": false
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        }
      ]
    },
    {
      "name": "fail_to_start_asserts_at_start_proof_time",
      "description": "Enabled and never proves firing \u2014 a burner-management lockout. yFailToStart asserts at exactly start_proof_time (300 s) and delayOnInit = true serves that window from the first tick.",
      "inputs": {
        "boiler_cmd": true,
        "boiler_status": false
      },
      "expect": [
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 270,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 300,
          "to_s": 1800,
          "equals": true
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 270,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 300,
          "to_s": 1800,
          "equals": true
        }
      ]
    },
    {
      "name": "unexpected_run_asserts_at_stop_proof_time",
      "description": "Firing with no enable \u2014 burner in HAND, a welded contactor, or a status stuck true. yUnexpectedRun asserts at exactly stop_proof_time (120 s), the shorter of the two windows.",
      "inputs": {
        "boiler_cmd": false,
        "boiler_status": true
      },
      "expect": [
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 90,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 120,
          "to_s": 1800,
          "equals": true
        },
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 90,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 120,
          "to_s": 1800,
          "equals": true
        }
      ]
    },
    {
      "name": "normal_light_off_inside_the_window_stays_silent",
      "description": "The case start_proof_time exists for: enabled at t = 0, flame proven at 240 s after prepurge, pilot and flame establishing. A healthy burner-management cycle is not a fault.",
      "inputs": {
        "boiler_cmd": true,
        "boiler_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 240,
            "value": true
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        }
      ]
    },
    {
      "name": "enable_dropped_mid_window_restarts_the_proof",
      "description": "A command flip inside the proof window: the plant stages the boiler off at 180 s and back on at 240 s while it never fires. The timer restarts from the second rising edge, so the alarm lands at 540 s (240 + 300), not at 300 s.",
      "inputs": {
        "boiler_cmd": [
          {
            "t": 0,
            "value": true
          },
          {
            "t": 180,
            "value": false
          },
          {
            "t": 240,
            "value": true
          }
        ],
        "boiler_status": false
      },
      "expect": [
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 510,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 540,
          "to_s": 1800,
          "equals": true
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 510,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 540,
          "to_s": 1800,
          "equals": true
        }
      ]
    },
    {
      "name": "flame_pulse_clears_and_restarts_the_proof",
      "description": "Lockout-retry: the boiler stays enabled, holds a proven flame from 600 to 660 s, then drops out again. Each proof clears the alarm and restarts the window, so a boiler cycling on its own safety retries flickers here and is counted properly by HW-0001.",
      "inputs": {
        "boiler_cmd": true,
        "boiler_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 600,
            "value": true
          },
          {
            "t": 690,
            "value": false
          }
        ]
      },
      "expect": [
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 270,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 300,
          "to_s": 570,
          "equals": true
        },
        {
          "output": "yFailToStart",
          "from_s": 600,
          "to_s": 960,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 990,
          "to_s": 1800,
          "equals": true
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        }
      ]
    },
    {
      "name": "hand_switch_flip_never_asserts_both_directions",
      "description": "The mismatch changes direction at 1200 s \u2014 the BAS drops the enable as someone puts the burner in HAND. yFailToStart falls on the same tick the command falls and yUnexpectedRun starts its own window from zero, so the two flags are never true together; yFault goes quiet for the 120 s between them.",
      "inputs": {
        "boiler_cmd": [
          {
            "t": 0,
            "value": true
          },
          {
            "t": 1200,
            "value": false
          }
        ],
        "boiler_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 1200,
            "value": true
          }
        ]
      },
      "expect": [
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 270,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 300,
          "to_s": 1170,
          "equals": true
        },
        {
          "output": "yFailToStart",
          "from_s": 1200,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 1290,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 1320,
          "to_s": 1800,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 300,
          "to_s": 1170,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 1200,
          "to_s": 1290,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 1320,
          "to_s": 1800,
          "equals": true
        }
      ]
    }
  ]
}

HW-0010 — Hot-water supply temperature tracking failure

Statusverified — engine e2ff2f8, cxf:fnv1a128:d680c1142ee5617cde72f0dbcc028046, 2026-08-20
Severity3
Methodrule
Phase2
CategoryCOMFORT_ENERGY
ConfidenceMEDIUM
EstimationQUALITATIVE_ONLY
G36
Clusters
Suppresses
Suppressed by
RelatedHW-0002, HW-0004, HW-0007, HW-0008, HW-0009, HW-0011, FPB-0006
Playbookshot-water-plant-faults
SourceNIST, Automatically Detecting Faulty Regulation in HVAC Controls (2013), pp. 412 and 416-419 — regulated-variable allowance bands, transient exclusions, and field-tuned alarm parameters; LBNL Simulated Boiler Plant dataset inventory, PDF pp.4-8 — hot-water loop supply temperature/setpoint/status channels and separate sensor-bias, fouling, and poor-PI fault cases; the dataset is a future replay target, not a completed validation claim; Library precedent CHW-0007 — verified strict mirrored hydronic tracking-error topology and continuous 900 s persistence
Operating statesnormal automatic hot-water operation after startup, with at least one boiler proven firing and distribution circulation established

Preconditions (host-enforced): hws_temp and hws_temp_sp must describe the same controlled outlet or common header and final active target. A common header plus an OR of firing statuses is valid only when all points belong to the same configured plant; it is not an individual-boiler outlet comparison. Exclude warm-up and setback recovery, setpoint/reset ramps, stage or pump changes, minimum-flow transitions, tuning tests, and intentional demand, high-limit, fuel, flame-safeguard, freeze, emissions, or other capacity limits until the plant has settled. boiler_status must represent firing rather than enable, and hw_pump_status must establish distribution circulation. Temperature, status, and setpoint signals must be fresh, aligned, calibrated, and in the declared units. When any obligation is unmet the verdict is NO_EVAL, not healthy.

Points: hws_temp, hws_temp_sp, boiler_status, hw_pump_status

Outputs:

  • yFault — True after the active plant remains more than tracking_error above or below its final HWS target continuously for sustained_duration
  • yTooCold — Immediate direction flag; true while the active plant is more than tracking_error below setpoint
  • yTooHot — Immediate direction flag; true while the active plant is more than tracking_error above setpoint

Parameters:

NameDefaultUnitCXF pathDescription
tracking_error2.0KtooHot.t, tooCold.tADOPTED_TUNABLE symmetric settled tracking allowance. Commission above combined measurement error, setpoint resolution, and the controller’s normal deadband; exact equality is clear.
sustained_duration900.0spersist.delayTimeLIBRARY_PRECEDENT from CHW-0007’s identical hydronic tracking form. Confirm it exceeds ordinary plant response after every excluded transition.

Description

This rule reports a firing, circulating hot-water plant that cannot hold the same target its supply-temperature point is meant to control. The direction is diagnostic, not causal: cold water may reflect capacity, flow, fouling, staging, or an intentional limit the host failed to exclude; hot water may reflect overshoot, aggressive staging, a bad target, sensor bias, or the wrong header.

Detection Logic

error       = hws_temp - hws_temp_sp
too_hot     = error > tracking_error
too_cold    = -error > tracking_error
plant_active = boiler_status AND hw_pump_status

yTooHot  = plant_active AND too_hot
yTooCold = plant_active AND too_cold
yFault   = TrueDelay(yTooHot OR yTooCold, sustained_duration)

Block graph (rule.cxf.jsonld):

HW-0010 block graph

Both comparisons are strict, so exactly +/-2 K is clear. The single delay is after their OR: a directly sampled hot-to-cold jump without an in-band tick preserves persistence, while an in-band, boiler-off, or pump-off tick resets it. delayOnInit=true requires the complete interval after evaluator startup.

Possible Diagnoses

  1. Boiler capacity, fuel input, heat exchanger, or minimum-flow limitation.
  2. Poor temperature-loop tuning, excessive integral action, or plant delay.
  3. Stage command, firing proof, distribution pump, or control-valve problem.
  4. Active setpoint not reaching the local boiler or mixing controller.
  5. Temperature sensor bias, poor placement, stale delivery, or wrong header.
  6. A real demand, reset, safety, emissions, or high-limit condition omitted from the host gate.

Energy Impact

The finding is qualitative. Sustained over-temperature can increase pipe loss and keep a condensing plant above its efficient return-temperature region; under-temperature can increase terminal/pump effort or shift load to other heat. The graph has no fuel or delivered-load model, so it does not invent savings.

Emissions Impact

Any scope-1 effect follows the change in boiler fuel use and cannot be inferred from error direction alone. Quantification requires measured fuel or a validated load-and-efficiency model; this rule reports no generic emissions reduction.

Deviations

  • This is a library-authored application of NIST regulation concepts and the CHW-0007 graph, not a source-transcribed boiler rule.
  • The shipped 2 K allowance is adopted and tunable; neither NIST nor LBNL publishes it as a portable boiler threshold.
  • The LBNL dataset is cited for point/fault coverage only. No LBNL replay was available locally for this slice, so no LBNL-derived TPR or FPR claim is recorded; the frontmatter separately records limited healthy EnergyPlus FPR evidence.
  • Confidence is MEDIUM rather than the brief’s proposed HIGH because common- header topology, sensor bias, and intentional plant limits remain material confounders even with the stated host gates.
  • Direction flags include the active-plant gate. When the plant stops, they and yFault clear immediately; the host must report NO_EVAL outside the stated operating state.

Notes

Read HW-0009 when command and proof disagree, HW-0008 when the target itself is not resetting, and HW-0002/HW-0004 when temperature tracking coexists with efficiency or low-delta-T evidence. These rules may co-occur and do not suppress one another.

Test Vectors

19 scenarios, clock step 60 s over 3600 s.

ScenarioDescription
inactive_plant_with_errorA stopped plant is outside the rule’s operating premise even with a large temperature error.
active_on_setpointA firing boiler and running pump holding the active target are healthy.
too_cold_at_initializationA standing cold-side violation exposes direction immediately: yFault is clear one tick before 900 s, asserts exactly at 900 s, and remains true after the initialization-safe delay.
too_hot_at_initializationThe mirror hot-side violation uses the same continuous persistence.
hot_error_exact_boundary_clearExactly +2 K is clear under the strict comparator.
hot_error_just_beyond_boundaryOne hundredth kelvin above the hot boundary follows the full delay.
cold_error_exact_boundary_clearExactly -2 K is clear under the mirrored strict comparator.
cold_error_just_beyond_boundaryOne hundredth kelvin past the cold boundary follows the full delay.
boiler_only_is_inactiveBoiler firing without established distribution-pump proof gates off both directions.
pump_only_is_inactivePump circulation without a firing heat source is not tracking evidence for this rule.
violation_ends_one_tick_before_persistenceThe cold error returns to the band one evaluator step before 900 s and never matures.
mature_alarm_recoversA mature hot-side alarm and its diagnostic clear immediately after temperature recovers.
two_short_episodes_do_not_accumulateTwo cold episodes separated by an in-band interval each start a fresh timer.
pump_proof_drop_resets_timerLoss and restoration of pump proof gives the continuing temperature error a fresh 900-second clock.
boiler_proof_drop_resets_timerA firing-status interruption independently resets persistence.
direct_direction_handoff_preserves_timerA sampled hot-to-cold handoff never re-enters the band, so the shared out-of-band timer continues while diagnostics swap.
setpoint_step_settles_before_delayA reset step creates raw cold error, but temperature catches the new target before persistence.
temperature_threshold_chatter_resets_delayAlternating just inside and outside the hot boundary never accumulates continuous persistence.
common_header_binding_is_host_excludedThe graph alarms on a coherent raw tuple even if this header is not the firing boiler’s controlled target; topology proof is a mandatory host gate.
vectors.json
{
  "schema": "cxf-library/vectors/v1",
  "clock": {
    "step_s": 60,
    "horizon_s": 3600
  },
  "scenarios": [
    {
      "name": "inactive_plant_with_error",
      "description": "A stopped plant is outside the rule's operating premise even with a large temperature error.",
      "inputs": {
        "hws_temp": 50.0,
        "hws_temp_sp": 60.0,
        "boiler_status": false,
        "hw_pump_status": false
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        },
        {
          "output": "yTooCold",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        },
        {
          "output": "yTooHot",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "active_on_setpoint",
      "description": "A firing boiler and running pump holding the active target are healthy.",
      "inputs": {
        "hws_temp": 60.0,
        "hws_temp_sp": 60.0,
        "boiler_status": true,
        "hw_pump_status": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        },
        {
          "output": "yTooCold",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        },
        {
          "output": "yTooHot",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "too_cold_at_initialization",
      "description": "A standing cold-side violation exposes direction immediately: yFault is clear one tick before 900 s, asserts exactly at 900 s, and remains true after the initialization-safe delay.",
      "inputs": {
        "hws_temp": 57.0,
        "hws_temp_sp": 60.0,
        "boiler_status": true,
        "hw_pump_status": true
      },
      "expect": [
        {
          "output": "yTooCold",
          "from_s": 0,
          "to_s": 3600,
          "equals": true
        },
        {
          "output": "yTooHot",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 840,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 900,
          "to_s": 900,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 960,
          "to_s": 3600,
          "equals": true
        }
      ]
    },
    {
      "name": "too_hot_at_initialization",
      "description": "The mirror hot-side violation uses the same continuous persistence.",
      "inputs": {
        "hws_temp": 63.0,
        "hws_temp_sp": 60.0,
        "boiler_status": true,
        "hw_pump_status": true
      },
      "expect": [
        {
          "output": "yTooHot",
          "from_s": 0,
          "to_s": 3600,
          "equals": true
        },
        {
          "output": "yTooCold",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 840,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 960,
          "to_s": 3600,
          "equals": true
        }
      ]
    },
    {
      "name": "hot_error_exact_boundary_clear",
      "description": "Exactly +2 K is clear under the strict comparator.",
      "inputs": {
        "hws_temp": 62.0,
        "hws_temp_sp": 60.0,
        "boiler_status": true,
        "hw_pump_status": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        },
        {
          "output": "yTooCold",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        },
        {
          "output": "yTooHot",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "hot_error_just_beyond_boundary",
      "description": "One hundredth kelvin above the hot boundary follows the full delay.",
      "inputs": {
        "hws_temp": 62.01,
        "hws_temp_sp": 60.0,
        "boiler_status": true,
        "hw_pump_status": true
      },
      "expect": [
        {
          "output": "yTooHot",
          "from_s": 0,
          "to_s": 3600,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 840,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 960,
          "to_s": 3600,
          "equals": true
        }
      ]
    },
    {
      "name": "cold_error_exact_boundary_clear",
      "description": "Exactly -2 K is clear under the mirrored strict comparator.",
      "inputs": {
        "hws_temp": 58.0,
        "hws_temp_sp": 60.0,
        "boiler_status": true,
        "hw_pump_status": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        },
        {
          "output": "yTooCold",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        },
        {
          "output": "yTooHot",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "cold_error_just_beyond_boundary",
      "description": "One hundredth kelvin past the cold boundary follows the full delay.",
      "inputs": {
        "hws_temp": 57.99,
        "hws_temp_sp": 60.0,
        "boiler_status": true,
        "hw_pump_status": true
      },
      "expect": [
        {
          "output": "yTooCold",
          "from_s": 0,
          "to_s": 3600,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 840,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 960,
          "to_s": 3600,
          "equals": true
        }
      ]
    },
    {
      "name": "boiler_only_is_inactive",
      "description": "Boiler firing without established distribution-pump proof gates off both directions.",
      "inputs": {
        "hws_temp": 50.0,
        "hws_temp_sp": 60.0,
        "boiler_status": true,
        "hw_pump_status": false
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        },
        {
          "output": "yTooCold",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        },
        {
          "output": "yTooHot",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "pump_only_is_inactive",
      "description": "Pump circulation without a firing heat source is not tracking evidence for this rule.",
      "inputs": {
        "hws_temp": 50.0,
        "hws_temp_sp": 60.0,
        "boiler_status": false,
        "hw_pump_status": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        },
        {
          "output": "yTooCold",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        },
        {
          "output": "yTooHot",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "violation_ends_one_tick_before_persistence",
      "description": "The cold error returns to the band one evaluator step before 900 s and never matures.",
      "inputs": {
        "hws_temp": [
          {
            "t": 0,
            "value": 57.0
          },
          {
            "t": 840,
            "value": 60.0
          }
        ],
        "hws_temp_sp": 60.0,
        "boiler_status": true,
        "hw_pump_status": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "mature_alarm_recovers",
      "description": "A mature hot-side alarm and its diagnostic clear immediately after temperature recovers.",
      "inputs": {
        "hws_temp": [
          {
            "t": 0,
            "value": 63.0
          },
          {
            "t": 1800,
            "value": 60.0
          }
        ],
        "hws_temp_sp": 60.0,
        "boiler_status": true,
        "hw_pump_status": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 960,
          "to_s": 1740,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 1860,
          "to_s": 3600,
          "equals": false
        },
        {
          "output": "yTooHot",
          "from_s": 1860,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "two_short_episodes_do_not_accumulate",
      "description": "Two cold episodes separated by an in-band interval each start a fresh timer.",
      "inputs": {
        "hws_temp": [
          {
            "t": 0,
            "value": 57.0
          },
          {
            "t": 600,
            "value": 60.0
          },
          {
            "t": 900,
            "value": 57.0
          },
          {
            "t": 1500,
            "value": 60.0
          }
        ],
        "hws_temp_sp": 60.0,
        "boiler_status": true,
        "hw_pump_status": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "pump_proof_drop_resets_timer",
      "description": "Loss and restoration of pump proof gives the continuing temperature error a fresh 900-second clock.",
      "inputs": {
        "hws_temp": 57.0,
        "hws_temp_sp": 60.0,
        "boiler_status": true,
        "hw_pump_status": [
          {
            "t": 0,
            "value": true
          },
          {
            "t": 600,
            "value": false
          },
          {
            "t": 900,
            "value": true
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1740,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 1860,
          "to_s": 3600,
          "equals": true
        }
      ]
    },
    {
      "name": "boiler_proof_drop_resets_timer",
      "description": "A firing-status interruption independently resets persistence.",
      "inputs": {
        "hws_temp": 63.0,
        "hws_temp_sp": 60.0,
        "boiler_status": [
          {
            "t": 0,
            "value": true
          },
          {
            "t": 600,
            "value": false
          },
          {
            "t": 900,
            "value": true
          }
        ],
        "hw_pump_status": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1740,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 1860,
          "to_s": 3600,
          "equals": true
        }
      ]
    },
    {
      "name": "direct_direction_handoff_preserves_timer",
      "description": "A sampled hot-to-cold handoff never re-enters the band, so the shared out-of-band timer continues while diagnostics swap.",
      "inputs": {
        "hws_temp": [
          {
            "t": 0,
            "value": 63.0
          },
          {
            "t": 600,
            "value": 57.0
          }
        ],
        "hws_temp_sp": 60.0,
        "boiler_status": true,
        "hw_pump_status": true
      },
      "expect": [
        {
          "output": "yTooHot",
          "from_s": 0,
          "to_s": 540,
          "equals": true
        },
        {
          "output": "yTooCold",
          "from_s": 660,
          "to_s": 3600,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 840,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 960,
          "to_s": 3600,
          "equals": true
        }
      ]
    },
    {
      "name": "setpoint_step_settles_before_delay",
      "description": "A reset step creates raw cold error, but temperature catches the new target before persistence.",
      "inputs": {
        "hws_temp": [
          {
            "t": 0,
            "value": 60.0
          },
          {
            "t": 600,
            "value": 64.0
          }
        ],
        "hws_temp_sp": [
          {
            "t": 0,
            "value": 60.0
          },
          {
            "t": 300,
            "value": 64.0
          }
        ],
        "boiler_status": true,
        "hw_pump_status": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "temperature_threshold_chatter_resets_delay",
      "description": "Alternating just inside and outside the hot boundary never accumulates continuous persistence.",
      "inputs": {
        "hws_temp": [
          {
            "t": 0,
            "value": 62.01
          },
          {
            "t": 180,
            "value": 61.99
          },
          {
            "t": 360,
            "value": 62.01
          },
          {
            "t": 540,
            "value": 61.99
          },
          {
            "t": 720,
            "value": 62.01
          },
          {
            "t": 900,
            "value": 61.99
          }
        ],
        "hws_temp_sp": 60.0,
        "boiler_status": true,
        "hw_pump_status": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "common_header_binding_is_host_excluded",
      "description": "The graph alarms on a coherent raw tuple even if this header is not the firing boiler's controlled target; topology proof is a mandatory host gate.",
      "inputs": {
        "hws_temp": 55.0,
        "hws_temp_sp": 60.0,
        "boiler_status": true,
        "hw_pump_status": true
      },
      "expect": [
        {
          "output": "yTooCold",
          "from_s": 0,
          "to_s": 3600,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 960,
          "to_s": 3600,
          "equals": true
        }
      ]
    }
  ]
}

HW-0011 — Hot-water temperature-control hunting

Statusverified — engine e2ff2f8, cxf:fnv1a128:8ffe23a0fcbd7cff7187ccd1d65a5c7f, 2026-08-20
Severity3
Methodrule
Phase2
CategoryEFFICIENCY_LOSS
ConfidenceMEDIUM
EstimationQUALITATIVE_ONLY
G36
Clusters
Suppresses
Suppressed by
RelatedHW-0001, HW-0010, HW-0012
Playbookshot-water-plant-faults
SourceNIST, Automatically Detecting Faulty Regulation in HVAC Controls (2013), pp. 412 and 416-419 — oscillatory regulated/actuating variables, setpoint allowance, reset exclusions, and field-tuned parameters; LBNL Simulated Boiler Plant dataset inventory, PDF pp.4-8 — an explicitly faulted boiler-supply PI controller and one-minute HWS/setpoint/status/gas channels; no native normalized firing-rate percentage; Library precedent VFD-0004 — verified dual rolling-mean crossing-count plus rolling-deviation topology and its tick-coupled count contract
Operating statesone boiler, or one semantically continuous capacity-weighted aggregate, in settled normal automatic temperature control

Preconditions (host-enforced): Use actual normalized firing feedback where available. A firing command or rated-input-normalized gas-power signal is a disclosed proxy; never average percentages across unequal boilers. Boiler stage identity and count must remain stable for the full evaluation window. hws_temp and hws_temp_sp must be the same controlled loop/header in the same units, with a stable final active target. Exclude startup, purge/light-off, minimum-fire cycling that is normal for the appliance, setpoint reset, warm-up, lead/lag transfer, stage change, tuning tests, real load steps, and safety/current/fuel/demand/emissions limits, then restart the warm-up. The host must report NO_EVAL for at least the first evaluation_window after every valid-state entry. Use a fixed tick in [28.6 s, 300 s) at defaults, 60 s recommended; irregular or change-of-value sampling invalidates the crossing counts. Firing-rate and temperature signals must be fresh, aligned, and resolved below the configured materiality bands.

Points: boiler_firing_rate, hws_temp, hws_temp_sp

Outputs:

  • yFault — True after material firing-rate hunting and unstable temperature regulation overlap continuously for alarm_persistence
  • yFiringRateHunting — Immediate diagnostic; true when rolling firing-rate mean crossings and rolling MAD both exceed their strict limits
  • yTemperatureUnstable — Immediate diagnostic; true when temperature-error mean crossings and rolling MAE from setpoint both exceed their strict limits

Parameters:

NameDefaultUnitCXF pathDescription
evaluation_window1800.0smuFiring.delta, firingCrossRate.delta, firingMad.delta, muTemperatureError.delta, temperatureCrossRate.delta, temperatureMae.deltaADOPTED_TUNABLE 30-minute hydronic evidence window. All six rolling means must change together.
max_crossings_per_window6.01firingCrossHigh.t, temperatureCrossHigh.tADOPTED_TUNABLE strict maximum for both lanes. Six clears and seven faults; one physical cycle normally contributes two mean crossings.
min_firing_rate_mad7.5%firingAmplitudeHigh.tADOPTED_TUNABLE rolling mean absolute firing-rate deviation in percentage points. For a square wave only, 7.5 MAD points correspond to the brief’s 15-point peak-to-peak excursion.
temperature_allowance1.5KtemperatureBandHigh.tADOPTED_TUNABLE strict rolling mean absolute temperature error from setpoint, not an instantaneous +/-1.5 K band.
count_scale30.01firingCrossCount.k, temperatureCrossCount.kDERIVED as evaluation_window / fixed tick = 1800/60. Both event-count multipliers must be retuned together whenever window or cadence changes.
alarm_persistence300.0spersist.delayTimeADOPTED_TUNABLE five-minute continuous overlap after the already 30-minute rolling evidence.

Description

This rule looks for a plant that is repeatedly moving its heat input and its controlled water temperature without settling. It requires both actuator-side and process-side evidence, rejecting harmless modulation with stable water and temperature disturbance behind a steady burner. The two diagnostic outputs show which half is present before the combined finding matures.

Detection Logic

firing_mean  = MovingAverage(boiler_firing_rate, evaluation_window)
firing_count = MovingAverage(Change(boiler_firing_rate > firing_mean), window)
               * count_scale
firing_mad   = MovingAverage(abs(boiler_firing_rate - firing_mean), window)
yFiringRateHunting = firing_count > max_crossings_per_window
                   AND firing_mad > min_firing_rate_mad

temperature_error = hws_temp - hws_temp_sp
temperature_mean  = MovingAverage(temperature_error, evaluation_window)
temperature_count = MovingAverage(Change(temperature_error > temperature_mean), window)
                    * count_scale
temperature_mae   = MovingAverage(abs(temperature_error), window)
yTemperatureUnstable = temperature_count > max_crossings_per_window
                     AND temperature_mae > temperature_allowance

yFault = TrueDelay(yFiringRateHunting AND yTemperatureUnstable,
                   alarm_persistence)

Block graph (rule.cxf.jsonld):

HW-0011 block graph

Each Change pulse is converted Boolean -> Integer -> Real before its moving average. count_scale converts a one-tick pulse area back to an event count. Diagnostics age out with their trailing windows; the final delay drops on the tick either diagnostic clears and uses delayOnInit=true.

Possible Diagnoses

  1. Temperature-loop gain too high or integral time too short for plant delay.
  2. Burner minimum-fire limit interacting with plant load or stage sequencing.
  3. Noisy, quantized, biased, poorly placed, or intermittently stale HWS sensor.
  4. Competing header, boiler-local, mixing-valve, and supervisory controllers.
  5. Lead/lag transfers or an invalid aggregate firing signal admitted by host.
  6. A real load/setpoint transition or safety/application limit not excluded.

Energy Impact

The finding is qualitative. Hunting can add fuel and distribution loss through overshoot, increase purge/light-off loss if modulation becomes cycling, and add wear. The rule carries neither fuel nor useful-load data and cannot assign a savings percentage from its rolling statistics.

Emissions Impact

Scope-1 emissions may rise when hunting increases boiler fuel use, purge, or light-off loss. The graph has no fuel channel, so it cannot quantify that change or claim a portable emissions benefit.

Deviations

  • Mean crossings replace literal direction reversals, following VFD-0004 and VAV-0005. Periodic cycles normally produce two of either, but drift with ripple can differ; parameter names state the implemented statistic.
  • The brief’s 15-point excursion becomes 7.5 MAD points only for a square wave. A sine requires about 23.6 points peak-to-peak to reach MAD 7.5.
  • temperature_allowance is rolling MAE, not repeated instantaneous exits from a +/-1.5 K band. A +/-1.5 K square is equality-clear; a sinusoid needs amplitude above about 2.36 K. The mean-crossing lane intentionally admits a one-sided oscillatory offset, pinned in vectors.
  • This is an application of NIST regulation concepts, not its two-sided CUSUM.
  • Event counts are tick-coupled. The 64-checkpoint ring requires dt >= 1800/63 = 28.6 s, while strict count > 6 requires dt < 300 s. Sixty seconds is the exercised cadence; COV and sub-tick reversals can hide events.
  • Partial-window divisors can extrapolate a warm-up burst, so the first 1800 s and every excluded discontinuity are host NO_EVAL despite raw outputs.
  • No simulation FPR/TPR is claimed. EnergyPlus lacks realistic burner PI dynamics and no local LBNL dataset copy was available for a faithful replay.

Notes

If only yFiringRateHunting is true, inspect modulation feedback, burner limits, and staging before retuning. If only yTemperatureUnstable is true, look for a sensor, load, flow, or competing controller. When both are true, first prove the host did not admit a legitimate transition.

Test Vectors

22 scenarios, clock step 60 s over 14400 s.

ScenarioDescription
constant_firing_rate_first_sample_artifactConstant firing_rate and satisfied HWS temperature remain clear. The firing_rate-mean crossing branch has a zero-area initialization pulse and one harmless fill artifact, neither of which can satisfy both firing_rate criteria.
load_step_monotonic_recoveryAfter a settled baseline, firing rate moves once from 50% to 65% while HWS carries a 3 K error for only ten minutes. The response is monotonic and cannot satisfy both rolling instability lanes.
fast_shallow_ditherA four-minute firing-rate cycle crosses often but stays at +/-6 percentage points; its settled MAD is below the strict 7.5-point materiality threshold. HWS remains satisfied.
firing_mad_just_below_thresholdA +/-7.35-point square firing-rate wave brackets the strict 7.5-point rolling-MAD boundary from the clear side while the temperature lane is independently active.
firing_mad_just_above_thresholdA +/-7.65-point square firing-rate wave brackets the strict 7.5-point rolling-MAD boundary from the active side; fast crossings and unstable temperature permit the combined fault.
firing_rate_hunting_temperature_stableMaterial fast firing_rate motion is present, but HWS temperature stays on setpoint; the firing_rate diagnostic can guide tuning without producing the combined fault.
slow_deep_firing_motionA deep twenty-minute firing-rate cycle has large MAD but no more than six mean crossings per 1800-second window. HWS instability alone cannot create the combined fault.
temperature_unstable_firing_stableHWS alternates +/-1.8 K around setpoint while firing rate is fixed. The temperature diagnostic is active after warm-up, but the combined fault remains clear.
count_exactly_allowedA repeating firing-rate pattern produces exactly six rolling-mean crossings per 1800-second window with material MAD. The strict count > 6 comparison remains clear while the temperature lane is active.
one_crossing_over_limitA repeating seven-crossing firing-rate pattern is one event above the allowed six, with material MAD and unstable temperature; both diagnostics and the delayed fault mature after warm-up.
temperature_mae_exactly_thresholdWith established firing-rate hunting, a constant absolute HWS error of exactly 1.5 K leaves the strict rolling-MAE comparison false and blocks the fault.
temperature_mae_just_over_thresholdA 1.515 K mean absolute HWS error brackets the rolling-MAE boundary from the active side while firing rate is materially hunting.
persistent_both_error_directionsMaterial firing-rate hunting paired with HWS alternating above and below setpoint proves the temperature statistic is symmetric and the combined fault persists.
one_sided_oscillatory_temperature_offsetHWS oscillates 3 K and 4.5 K above setpoint while firing rate hunts. Mean crossings plus MAE intentionally classify this one-sided offset as unstable; this is broader than NIST’s two-sided allowance-band CUSUM.
window_aging_clearsThe loop hunts and then firing rate settles while HWS remains 1.65 K off setpoint. Temperature remains unstable until its events age; firing crossings and MAD must also age before the combined fault clears.
warmup_burst_raw_behaviorEight tick-rate firing changes during partial-window fill can extrapolate above the crossing limit while HWS is unstable. Both raw diagnostics overlap from 960 s; yFault is clear at 1200 s, asserts exactly at the 1260 s persistence boundary, and remains true after it. The host reports NO_EVAL through the first 1800 seconds.
setpoint_step_excluded_raw_behaviorA setpoint step at t=1800 followed by sustained materially reversing firing_rate raises the raw diagnostics and fault. Deployment must instead report NO_EVAL until reset and disturbance response are settled.
subtick_hunting_aliases_clearA host sampling once per minute cannot see an even number of reversals that occur and return between samples; the staged trace is therefore constant and every output stays clear. This pins the Nyquist limitation, not healthy plant behavior.
firing_mad_exact_threshold_clearA +/-7.5-percentage-point square wave has exactly 7.5 points settled rolling MAD. The strict materiality comparator stays false while the temperature lane remains active.
temperature_count_exactly_allowedFiring rate hunts materially, but HWS produces exactly six rolling-error-mean crossings per 1800-second window. The strict temperature count > 6 comparison and combined fault stay clear.
diagnostic_overlap_shorter_than_persistenceDuring host-excluded warm-up, both raw diagnostics are simultaneously true from 1680 through 1740 s, less than the 300-second final persistence. Firing hunting clears at 1800 s, so the candidate resets before maturity and yFault never asserts.
stage_change_discontinuity_is_host_excludedRepeated lead/lag stage changes can make an aggregate firing-rate channel and header temperature alternate by construction. After the settled input transition, temperature instability joins firing hunting at 5280 s; yFault is clear at 5520 s, asserts exactly at 5580 s after the 300 s delay, and remains true after it. The raw graph alarms, so stable stage identity is a mandatory host gate.
vectors.json
{
  "schema": "cxf-library/vectors/v1",
  "clock": {
    "step_s": 60,
    "horizon_s": 14400
  },
  "scenarios": [
    {
      "name": "constant_firing_rate_first_sample_artifact",
      "description": "Constant firing_rate and satisfied HWS temperature remain clear. The firing_rate-mean crossing branch has a zero-area initialization pulse and one harmless fill artifact, neither of which can satisfy both firing_rate criteria.",
      "inputs": {
        "boiler_firing_rate": 50.0,
        "hws_temp": 60.0,
        "hws_temp_sp": 60.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 14400,
          "equals": false
        },
        {
          "output": "yFiringRateHunting",
          "from_s": 0,
          "to_s": 14400,
          "equals": false
        },
        {
          "output": "yTemperatureUnstable",
          "from_s": 0,
          "to_s": 14400,
          "equals": false
        }
      ]
    },
    {
      "name": "load_step_monotonic_recovery",
      "description": "After a settled baseline, firing rate moves once from 50% to 65% while HWS carries a 3 K error for only ten minutes. The response is monotonic and cannot satisfy both rolling instability lanes.",
      "inputs": {
        "boiler_firing_rate": [
          {
            "t": 0,
            "value": 50.0
          },
          {
            "t": 3600,
            "value": 65.0
          }
        ],
        "hws_temp": [
          {
            "t": 0,
            "value": 60.0
          },
          {
            "t": 3600,
            "value": 63.0
          },
          {
            "t": 4200,
            "value": 60.0
          }
        ],
        "hws_temp_sp": 60.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 14400,
          "equals": false
        },
        {
          "output": "yFiringRateHunting",
          "from_s": 0,
          "to_s": 14400,
          "equals": false
        },
        {
          "output": "yTemperatureUnstable",
          "from_s": 0,
          "to_s": 14400,
          "equals": false
        }
      ]
    },
    {
      "name": "fast_shallow_dither",
      "description": "A four-minute firing-rate cycle crosses often but stays at +/-6 percentage points; its settled MAD is below the strict 7.5-point materiality threshold. HWS remains satisfied.",
      "inputs": {
        "boiler_firing_rate": [
          {
            "t": 0,
            "value": 56.0
          },
          {
            "t": 240,
            "value": 44.0
          },
          {
            "t": 480,
            "value": 56.0
          },
          {
            "t": 720,
            "value": 44.0
          },
          {
            "t": 960,
            "value": 56.0
          },
          {
            "t": 1200,
            "value": 44.0
          },
          {
            "t": 1440,
            "value": 56.0
          },
          {
            "t": 1680,
            "value": 44.0
          },
          {
            "t": 1920,
            "value": 56.0
          },
          {
            "t": 2160,
            "value": 44.0
          },
          {
            "t": 2400,
            "value": 56.0
          },
          {
            "t": 2640,
            "value": 44.0
          },
          {
            "t": 2880,
            "value": 56.0
          },
          {
            "t": 3120,
            "value": 44.0
          },
          {
            "t": 3360,
            "value": 56.0
          },
          {
            "t": 3600,
            "value": 44.0
          },
          {
            "t": 3840,
            "value": 56.0
          },
          {
            "t": 4080,
            "value": 44.0
          },
          {
            "t": 4320,
            "value": 56.0
          },
          {
            "t": 4560,
            "value": 44.0
          },
          {
            "t": 4800,
            "value": 56.0
          },
          {
            "t": 5040,
            "value": 44.0
          },
          {
            "t": 5280,
            "value": 56.0
          },
          {
            "t": 5520,
            "value": 44.0
          },
          {
            "t": 5760,
            "value": 56.0
          },
          {
            "t": 6000,
            "value": 44.0
          },
          {
            "t": 6240,
            "value": 56.0
          },
          {
            "t": 6480,
            "value": 44.0
          },
          {
            "t": 6720,
            "value": 56.0
          },
          {
            "t": 6960,
            "value": 44.0
          },
          {
            "t": 7200,
            "value": 56.0
          },
          {
            "t": 7440,
            "value": 44.0
          },
          {
            "t": 7680,
            "value": 56.0
          },
          {
            "t": 7920,
            "value": 44.0
          },
          {
            "t": 8160,
            "value": 56.0
          },
          {
            "t": 8400,
            "value": 44.0
          },
          {
            "t": 8640,
            "value": 56.0
          },
          {
            "t": 8880,
            "value": 44.0
          },
          {
            "t": 9120,
            "value": 56.0
          },
          {
            "t": 9360,
            "value": 44.0
          },
          {
            "t": 9600,
            "value": 56.0
          },
          {
            "t": 9840,
            "value": 44.0
          },
          {
            "t": 10080,
            "value": 56.0
          },
          {
            "t": 10320,
            "value": 44.0
          },
          {
            "t": 10560,
            "value": 56.0
          },
          {
            "t": 10800,
            "value": 44.0
          },
          {
            "t": 11040,
            "value": 56.0
          },
          {
            "t": 11280,
            "value": 44.0
          },
          {
            "t": 11520,
            "value": 56.0
          },
          {
            "t": 11760,
            "value": 44.0
          },
          {
            "t": 12000,
            "value": 56.0
          },
          {
            "t": 12240,
            "value": 44.0
          },
          {
            "t": 12480,
            "value": 56.0
          },
          {
            "t": 12720,
            "value": 44.0
          },
          {
            "t": 12960,
            "value": 56.0
          },
          {
            "t": 13200,
            "value": 44.0
          },
          {
            "t": 13440,
            "value": 56.0
          },
          {
            "t": 13680,
            "value": 44.0
          },
          {
            "t": 13920,
            "value": 56.0
          },
          {
            "t": 14160,
            "value": 44.0
          }
        ],
        "hws_temp": 60.0,
        "hws_temp_sp": 60.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 14400,
          "equals": false
        },
        {
          "output": "yFiringRateHunting",
          "from_s": 3600,
          "to_s": 14400,
          "equals": false
        },
        {
          "output": "yTemperatureUnstable",
          "from_s": 0,
          "to_s": 14400,
          "equals": false
        }
      ]
    },
    {
      "name": "firing_mad_just_below_threshold",
      "description": "A +/-7.35-point square firing-rate wave brackets the strict 7.5-point rolling-MAD boundary from the clear side while the temperature lane is independently active.",
      "inputs": {
        "boiler_firing_rate": [
          {
            "t": 0,
            "value": 57.35
          },
          {
            "t": 240,
            "value": 42.65
          },
          {
            "t": 480,
            "value": 57.35
          },
          {
            "t": 720,
            "value": 42.65
          },
          {
            "t": 960,
            "value": 57.35
          },
          {
            "t": 1200,
            "value": 42.65
          },
          {
            "t": 1440,
            "value": 57.35
          },
          {
            "t": 1680,
            "value": 42.65
          },
          {
            "t": 1920,
            "value": 57.35
          },
          {
            "t": 2160,
            "value": 42.65
          },
          {
            "t": 2400,
            "value": 57.35
          },
          {
            "t": 2640,
            "value": 42.65
          },
          {
            "t": 2880,
            "value": 57.35
          },
          {
            "t": 3120,
            "value": 42.65
          },
          {
            "t": 3360,
            "value": 57.35
          },
          {
            "t": 3600,
            "value": 42.65
          },
          {
            "t": 3840,
            "value": 57.35
          },
          {
            "t": 4080,
            "value": 42.65
          },
          {
            "t": 4320,
            "value": 57.35
          },
          {
            "t": 4560,
            "value": 42.65
          },
          {
            "t": 4800,
            "value": 57.35
          },
          {
            "t": 5040,
            "value": 42.65
          },
          {
            "t": 5280,
            "value": 57.35
          },
          {
            "t": 5520,
            "value": 42.65
          },
          {
            "t": 5760,
            "value": 57.35
          },
          {
            "t": 6000,
            "value": 42.65
          },
          {
            "t": 6240,
            "value": 57.35
          },
          {
            "t": 6480,
            "value": 42.65
          },
          {
            "t": 6720,
            "value": 57.35
          },
          {
            "t": 6960,
            "value": 42.65
          },
          {
            "t": 7200,
            "value": 57.35
          },
          {
            "t": 7440,
            "value": 42.65
          },
          {
            "t": 7680,
            "value": 57.35
          },
          {
            "t": 7920,
            "value": 42.65
          },
          {
            "t": 8160,
            "value": 57.35
          },
          {
            "t": 8400,
            "value": 42.65
          },
          {
            "t": 8640,
            "value": 57.35
          },
          {
            "t": 8880,
            "value": 42.65
          },
          {
            "t": 9120,
            "value": 57.35
          },
          {
            "t": 9360,
            "value": 42.65
          },
          {
            "t": 9600,
            "value": 57.35
          },
          {
            "t": 9840,
            "value": 42.65
          },
          {
            "t": 10080,
            "value": 57.35
          },
          {
            "t": 10320,
            "value": 42.65
          },
          {
            "t": 10560,
            "value": 57.35
          },
          {
            "t": 10800,
            "value": 42.65
          },
          {
            "t": 11040,
            "value": 57.35
          },
          {
            "t": 11280,
            "value": 42.65
          },
          {
            "t": 11520,
            "value": 57.35
          },
          {
            "t": 11760,
            "value": 42.65
          },
          {
            "t": 12000,
            "value": 57.35
          },
          {
            "t": 12240,
            "value": 42.65
          },
          {
            "t": 12480,
            "value": 57.35
          },
          {
            "t": 12720,
            "value": 42.65
          },
          {
            "t": 12960,
            "value": 57.35
          },
          {
            "t": 13200,
            "value": 42.65
          },
          {
            "t": 13440,
            "value": 57.35
          },
          {
            "t": 13680,
            "value": 42.65
          },
          {
            "t": 13920,
            "value": 57.35
          },
          {
            "t": 14160,
            "value": 42.65
          }
        ],
        "hws_temp": [
          {
            "t": 0,
            "value": 61.8
          },
          {
            "t": 240,
            "value": 58.2
          },
          {
            "t": 480,
            "value": 61.8
          },
          {
            "t": 720,
            "value": 58.2
          },
          {
            "t": 960,
            "value": 61.8
          },
          {
            "t": 1200,
            "value": 58.2
          },
          {
            "t": 1440,
            "value": 61.8
          },
          {
            "t": 1680,
            "value": 58.2
          },
          {
            "t": 1920,
            "value": 61.8
          },
          {
            "t": 2160,
            "value": 58.2
          },
          {
            "t": 2400,
            "value": 61.8
          },
          {
            "t": 2640,
            "value": 58.2
          },
          {
            "t": 2880,
            "value": 61.8
          },
          {
            "t": 3120,
            "value": 58.2
          },
          {
            "t": 3360,
            "value": 61.8
          },
          {
            "t": 3600,
            "value": 58.2
          },
          {
            "t": 3840,
            "value": 61.8
          },
          {
            "t": 4080,
            "value": 58.2
          },
          {
            "t": 4320,
            "value": 61.8
          },
          {
            "t": 4560,
            "value": 58.2
          },
          {
            "t": 4800,
            "value": 61.8
          },
          {
            "t": 5040,
            "value": 58.2
          },
          {
            "t": 5280,
            "value": 61.8
          },
          {
            "t": 5520,
            "value": 58.2
          },
          {
            "t": 5760,
            "value": 61.8
          },
          {
            "t": 6000,
            "value": 58.2
          },
          {
            "t": 6240,
            "value": 61.8
          },
          {
            "t": 6480,
            "value": 58.2
          },
          {
            "t": 6720,
            "value": 61.8
          },
          {
            "t": 6960,
            "value": 58.2
          },
          {
            "t": 7200,
            "value": 61.8
          },
          {
            "t": 7440,
            "value": 58.2
          },
          {
            "t": 7680,
            "value": 61.8
          },
          {
            "t": 7920,
            "value": 58.2
          },
          {
            "t": 8160,
            "value": 61.8
          },
          {
            "t": 8400,
            "value": 58.2
          },
          {
            "t": 8640,
            "value": 61.8
          },
          {
            "t": 8880,
            "value": 58.2
          },
          {
            "t": 9120,
            "value": 61.8
          },
          {
            "t": 9360,
            "value": 58.2
          },
          {
            "t": 9600,
            "value": 61.8
          },
          {
            "t": 9840,
            "value": 58.2
          },
          {
            "t": 10080,
            "value": 61.8
          },
          {
            "t": 10320,
            "value": 58.2
          },
          {
            "t": 10560,
            "value": 61.8
          },
          {
            "t": 10800,
            "value": 58.2
          },
          {
            "t": 11040,
            "value": 61.8
          },
          {
            "t": 11280,
            "value": 58.2
          },
          {
            "t": 11520,
            "value": 61.8
          },
          {
            "t": 11760,
            "value": 58.2
          },
          {
            "t": 12000,
            "value": 61.8
          },
          {
            "t": 12240,
            "value": 58.2
          },
          {
            "t": 12480,
            "value": 61.8
          },
          {
            "t": 12720,
            "value": 58.2
          },
          {
            "t": 12960,
            "value": 61.8
          },
          {
            "t": 13200,
            "value": 58.2
          },
          {
            "t": 13440,
            "value": 61.8
          },
          {
            "t": 13680,
            "value": 58.2
          },
          {
            "t": 13920,
            "value": 61.8
          },
          {
            "t": 14160,
            "value": 58.2
          }
        ],
        "hws_temp_sp": 60.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 14400,
          "equals": false
        },
        {
          "output": "yFiringRateHunting",
          "from_s": 3600,
          "to_s": 14400,
          "equals": false
        },
        {
          "output": "yTemperatureUnstable",
          "from_s": 3600,
          "to_s": 14400,
          "equals": true
        }
      ]
    },
    {
      "name": "firing_mad_just_above_threshold",
      "description": "A +/-7.65-point square firing-rate wave brackets the strict 7.5-point rolling-MAD boundary from the active side; fast crossings and unstable temperature permit the combined fault.",
      "inputs": {
        "boiler_firing_rate": [
          {
            "t": 0,
            "value": 57.65
          },
          {
            "t": 240,
            "value": 42.35
          },
          {
            "t": 480,
            "value": 57.65
          },
          {
            "t": 720,
            "value": 42.35
          },
          {
            "t": 960,
            "value": 57.65
          },
          {
            "t": 1200,
            "value": 42.35
          },
          {
            "t": 1440,
            "value": 57.65
          },
          {
            "t": 1680,
            "value": 42.35
          },
          {
            "t": 1920,
            "value": 57.65
          },
          {
            "t": 2160,
            "value": 42.35
          },
          {
            "t": 2400,
            "value": 57.65
          },
          {
            "t": 2640,
            "value": 42.35
          },
          {
            "t": 2880,
            "value": 57.65
          },
          {
            "t": 3120,
            "value": 42.35
          },
          {
            "t": 3360,
            "value": 57.65
          },
          {
            "t": 3600,
            "value": 42.35
          },
          {
            "t": 3840,
            "value": 57.65
          },
          {
            "t": 4080,
            "value": 42.35
          },
          {
            "t": 4320,
            "value": 57.65
          },
          {
            "t": 4560,
            "value": 42.35
          },
          {
            "t": 4800,
            "value": 57.65
          },
          {
            "t": 5040,
            "value": 42.35
          },
          {
            "t": 5280,
            "value": 57.65
          },
          {
            "t": 5520,
            "value": 42.35
          },
          {
            "t": 5760,
            "value": 57.65
          },
          {
            "t": 6000,
            "value": 42.35
          },
          {
            "t": 6240,
            "value": 57.65
          },
          {
            "t": 6480,
            "value": 42.35
          },
          {
            "t": 6720,
            "value": 57.65
          },
          {
            "t": 6960,
            "value": 42.35
          },
          {
            "t": 7200,
            "value": 57.65
          },
          {
            "t": 7440,
            "value": 42.35
          },
          {
            "t": 7680,
            "value": 57.65
          },
          {
            "t": 7920,
            "value": 42.35
          },
          {
            "t": 8160,
            "value": 57.65
          },
          {
            "t": 8400,
            "value": 42.35
          },
          {
            "t": 8640,
            "value": 57.65
          },
          {
            "t": 8880,
            "value": 42.35
          },
          {
            "t": 9120,
            "value": 57.65
          },
          {
            "t": 9360,
            "value": 42.35
          },
          {
            "t": 9600,
            "value": 57.65
          },
          {
            "t": 9840,
            "value": 42.35
          },
          {
            "t": 10080,
            "value": 57.65
          },
          {
            "t": 10320,
            "value": 42.35
          },
          {
            "t": 10560,
            "value": 57.65
          },
          {
            "t": 10800,
            "value": 42.35
          },
          {
            "t": 11040,
            "value": 57.65
          },
          {
            "t": 11280,
            "value": 42.35
          },
          {
            "t": 11520,
            "value": 57.65
          },
          {
            "t": 11760,
            "value": 42.35
          },
          {
            "t": 12000,
            "value": 57.65
          },
          {
            "t": 12240,
            "value": 42.35
          },
          {
            "t": 12480,
            "value": 57.65
          },
          {
            "t": 12720,
            "value": 42.35
          },
          {
            "t": 12960,
            "value": 57.65
          },
          {
            "t": 13200,
            "value": 42.35
          },
          {
            "t": 13440,
            "value": 57.65
          },
          {
            "t": 13680,
            "value": 42.35
          },
          {
            "t": 13920,
            "value": 57.65
          },
          {
            "t": 14160,
            "value": 42.35
          }
        ],
        "hws_temp": [
          {
            "t": 0,
            "value": 61.8
          },
          {
            "t": 240,
            "value": 58.2
          },
          {
            "t": 480,
            "value": 61.8
          },
          {
            "t": 720,
            "value": 58.2
          },
          {
            "t": 960,
            "value": 61.8
          },
          {
            "t": 1200,
            "value": 58.2
          },
          {
            "t": 1440,
            "value": 61.8
          },
          {
            "t": 1680,
            "value": 58.2
          },
          {
            "t": 1920,
            "value": 61.8
          },
          {
            "t": 2160,
            "value": 58.2
          },
          {
            "t": 2400,
            "value": 61.8
          },
          {
            "t": 2640,
            "value": 58.2
          },
          {
            "t": 2880,
            "value": 61.8
          },
          {
            "t": 3120,
            "value": 58.2
          },
          {
            "t": 3360,
            "value": 61.8
          },
          {
            "t": 3600,
            "value": 58.2
          },
          {
            "t": 3840,
            "value": 61.8
          },
          {
            "t": 4080,
            "value": 58.2
          },
          {
            "t": 4320,
            "value": 61.8
          },
          {
            "t": 4560,
            "value": 58.2
          },
          {
            "t": 4800,
            "value": 61.8
          },
          {
            "t": 5040,
            "value": 58.2
          },
          {
            "t": 5280,
            "value": 61.8
          },
          {
            "t": 5520,
            "value": 58.2
          },
          {
            "t": 5760,
            "value": 61.8
          },
          {
            "t": 6000,
            "value": 58.2
          },
          {
            "t": 6240,
            "value": 61.8
          },
          {
            "t": 6480,
            "value": 58.2
          },
          {
            "t": 6720,
            "value": 61.8
          },
          {
            "t": 6960,
            "value": 58.2
          },
          {
            "t": 7200,
            "value": 61.8
          },
          {
            "t": 7440,
            "value": 58.2
          },
          {
            "t": 7680,
            "value": 61.8
          },
          {
            "t": 7920,
            "value": 58.2
          },
          {
            "t": 8160,
            "value": 61.8
          },
          {
            "t": 8400,
            "value": 58.2
          },
          {
            "t": 8640,
            "value": 61.8
          },
          {
            "t": 8880,
            "value": 58.2
          },
          {
            "t": 9120,
            "value": 61.8
          },
          {
            "t": 9360,
            "value": 58.2
          },
          {
            "t": 9600,
            "value": 61.8
          },
          {
            "t": 9840,
            "value": 58.2
          },
          {
            "t": 10080,
            "value": 61.8
          },
          {
            "t": 10320,
            "value": 58.2
          },
          {
            "t": 10560,
            "value": 61.8
          },
          {
            "t": 10800,
            "value": 58.2
          },
          {
            "t": 11040,
            "value": 61.8
          },
          {
            "t": 11280,
            "value": 58.2
          },
          {
            "t": 11520,
            "value": 61.8
          },
          {
            "t": 11760,
            "value": 58.2
          },
          {
            "t": 12000,
            "value": 61.8
          },
          {
            "t": 12240,
            "value": 58.2
          },
          {
            "t": 12480,
            "value": 61.8
          },
          {
            "t": 12720,
            "value": 58.2
          },
          {
            "t": 12960,
            "value": 61.8
          },
          {
            "t": 13200,
            "value": 58.2
          },
          {
            "t": 13440,
            "value": 61.8
          },
          {
            "t": 13680,
            "value": 58.2
          },
          {
            "t": 13920,
            "value": 61.8
          },
          {
            "t": 14160,
            "value": 58.2
          }
        ],
        "hws_temp_sp": 60.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 4800,
          "to_s": 14400,
          "equals": true
        },
        {
          "output": "yFiringRateHunting",
          "from_s": 3600,
          "to_s": 14400,
          "equals": true
        },
        {
          "output": "yTemperatureUnstable",
          "from_s": 3600,
          "to_s": 14400,
          "equals": true
        }
      ]
    },
    {
      "name": "firing_rate_hunting_temperature_stable",
      "description": "Material fast firing_rate motion is present, but HWS temperature stays on setpoint; the firing_rate diagnostic can guide tuning without producing the combined fault.",
      "inputs": {
        "boiler_firing_rate": [
          {
            "t": 0,
            "value": 65.0
          },
          {
            "t": 240,
            "value": 35.0
          },
          {
            "t": 480,
            "value": 65.0
          },
          {
            "t": 720,
            "value": 35.0
          },
          {
            "t": 960,
            "value": 65.0
          },
          {
            "t": 1200,
            "value": 35.0
          },
          {
            "t": 1440,
            "value": 65.0
          },
          {
            "t": 1680,
            "value": 35.0
          },
          {
            "t": 1920,
            "value": 65.0
          },
          {
            "t": 2160,
            "value": 35.0
          },
          {
            "t": 2400,
            "value": 65.0
          },
          {
            "t": 2640,
            "value": 35.0
          },
          {
            "t": 2880,
            "value": 65.0
          },
          {
            "t": 3120,
            "value": 35.0
          },
          {
            "t": 3360,
            "value": 65.0
          },
          {
            "t": 3600,
            "value": 35.0
          },
          {
            "t": 3840,
            "value": 65.0
          },
          {
            "t": 4080,
            "value": 35.0
          },
          {
            "t": 4320,
            "value": 65.0
          },
          {
            "t": 4560,
            "value": 35.0
          },
          {
            "t": 4800,
            "value": 65.0
          },
          {
            "t": 5040,
            "value": 35.0
          },
          {
            "t": 5280,
            "value": 65.0
          },
          {
            "t": 5520,
            "value": 35.0
          },
          {
            "t": 5760,
            "value": 65.0
          },
          {
            "t": 6000,
            "value": 35.0
          },
          {
            "t": 6240,
            "value": 65.0
          },
          {
            "t": 6480,
            "value": 35.0
          },
          {
            "t": 6720,
            "value": 65.0
          },
          {
            "t": 6960,
            "value": 35.0
          },
          {
            "t": 7200,
            "value": 65.0
          },
          {
            "t": 7440,
            "value": 35.0
          },
          {
            "t": 7680,
            "value": 65.0
          },
          {
            "t": 7920,
            "value": 35.0
          },
          {
            "t": 8160,
            "value": 65.0
          },
          {
            "t": 8400,
            "value": 35.0
          },
          {
            "t": 8640,
            "value": 65.0
          },
          {
            "t": 8880,
            "value": 35.0
          },
          {
            "t": 9120,
            "value": 65.0
          },
          {
            "t": 9360,
            "value": 35.0
          },
          {
            "t": 9600,
            "value": 65.0
          },
          {
            "t": 9840,
            "value": 35.0
          },
          {
            "t": 10080,
            "value": 65.0
          },
          {
            "t": 10320,
            "value": 35.0
          },
          {
            "t": 10560,
            "value": 65.0
          },
          {
            "t": 10800,
            "value": 35.0
          },
          {
            "t": 11040,
            "value": 65.0
          },
          {
            "t": 11280,
            "value": 35.0
          },
          {
            "t": 11520,
            "value": 65.0
          },
          {
            "t": 11760,
            "value": 35.0
          },
          {
            "t": 12000,
            "value": 65.0
          },
          {
            "t": 12240,
            "value": 35.0
          },
          {
            "t": 12480,
            "value": 65.0
          },
          {
            "t": 12720,
            "value": 35.0
          },
          {
            "t": 12960,
            "value": 65.0
          },
          {
            "t": 13200,
            "value": 35.0
          },
          {
            "t": 13440,
            "value": 65.0
          },
          {
            "t": 13680,
            "value": 35.0
          },
          {
            "t": 13920,
            "value": 65.0
          },
          {
            "t": 14160,
            "value": 35.0
          }
        ],
        "hws_temp": 60.0,
        "hws_temp_sp": 60.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 14400,
          "equals": false
        },
        {
          "output": "yFiringRateHunting",
          "from_s": 3600,
          "to_s": 14400,
          "equals": true
        },
        {
          "output": "yTemperatureUnstable",
          "from_s": 0,
          "to_s": 14400,
          "equals": false
        }
      ]
    },
    {
      "name": "slow_deep_firing_motion",
      "description": "A deep twenty-minute firing-rate cycle has large MAD but no more than six mean crossings per 1800-second window. HWS instability alone cannot create the combined fault.",
      "inputs": {
        "boiler_firing_rate": [
          {
            "t": 0,
            "value": 72.5
          },
          {
            "t": 600,
            "value": 27.5
          },
          {
            "t": 1200,
            "value": 72.5
          },
          {
            "t": 1800,
            "value": 27.5
          },
          {
            "t": 2400,
            "value": 72.5
          },
          {
            "t": 3000,
            "value": 27.5
          },
          {
            "t": 3600,
            "value": 72.5
          },
          {
            "t": 4200,
            "value": 27.5
          },
          {
            "t": 4800,
            "value": 72.5
          },
          {
            "t": 5400,
            "value": 27.5
          },
          {
            "t": 6000,
            "value": 72.5
          },
          {
            "t": 6600,
            "value": 27.5
          },
          {
            "t": 7200,
            "value": 72.5
          },
          {
            "t": 7800,
            "value": 27.5
          },
          {
            "t": 8400,
            "value": 72.5
          },
          {
            "t": 9000,
            "value": 27.5
          },
          {
            "t": 9600,
            "value": 72.5
          },
          {
            "t": 10200,
            "value": 27.5
          },
          {
            "t": 10800,
            "value": 72.5
          },
          {
            "t": 11400,
            "value": 27.5
          },
          {
            "t": 12000,
            "value": 72.5
          },
          {
            "t": 12600,
            "value": 27.5
          },
          {
            "t": 13200,
            "value": 72.5
          },
          {
            "t": 13800,
            "value": 27.5
          }
        ],
        "hws_temp": [
          {
            "t": 0,
            "value": 61.8
          },
          {
            "t": 240,
            "value": 58.2
          },
          {
            "t": 480,
            "value": 61.8
          },
          {
            "t": 720,
            "value": 58.2
          },
          {
            "t": 960,
            "value": 61.8
          },
          {
            "t": 1200,
            "value": 58.2
          },
          {
            "t": 1440,
            "value": 61.8
          },
          {
            "t": 1680,
            "value": 58.2
          },
          {
            "t": 1920,
            "value": 61.8
          },
          {
            "t": 2160,
            "value": 58.2
          },
          {
            "t": 2400,
            "value": 61.8
          },
          {
            "t": 2640,
            "value": 58.2
          },
          {
            "t": 2880,
            "value": 61.8
          },
          {
            "t": 3120,
            "value": 58.2
          },
          {
            "t": 3360,
            "value": 61.8
          },
          {
            "t": 3600,
            "value": 58.2
          },
          {
            "t": 3840,
            "value": 61.8
          },
          {
            "t": 4080,
            "value": 58.2
          },
          {
            "t": 4320,
            "value": 61.8
          },
          {
            "t": 4560,
            "value": 58.2
          },
          {
            "t": 4800,
            "value": 61.8
          },
          {
            "t": 5040,
            "value": 58.2
          },
          {
            "t": 5280,
            "value": 61.8
          },
          {
            "t": 5520,
            "value": 58.2
          },
          {
            "t": 5760,
            "value": 61.8
          },
          {
            "t": 6000,
            "value": 58.2
          },
          {
            "t": 6240,
            "value": 61.8
          },
          {
            "t": 6480,
            "value": 58.2
          },
          {
            "t": 6720,
            "value": 61.8
          },
          {
            "t": 6960,
            "value": 58.2
          },
          {
            "t": 7200,
            "value": 61.8
          },
          {
            "t": 7440,
            "value": 58.2
          },
          {
            "t": 7680,
            "value": 61.8
          },
          {
            "t": 7920,
            "value": 58.2
          },
          {
            "t": 8160,
            "value": 61.8
          },
          {
            "t": 8400,
            "value": 58.2
          },
          {
            "t": 8640,
            "value": 61.8
          },
          {
            "t": 8880,
            "value": 58.2
          },
          {
            "t": 9120,
            "value": 61.8
          },
          {
            "t": 9360,
            "value": 58.2
          },
          {
            "t": 9600,
            "value": 61.8
          },
          {
            "t": 9840,
            "value": 58.2
          },
          {
            "t": 10080,
            "value": 61.8
          },
          {
            "t": 10320,
            "value": 58.2
          },
          {
            "t": 10560,
            "value": 61.8
          },
          {
            "t": 10800,
            "value": 58.2
          },
          {
            "t": 11040,
            "value": 61.8
          },
          {
            "t": 11280,
            "value": 58.2
          },
          {
            "t": 11520,
            "value": 61.8
          },
          {
            "t": 11760,
            "value": 58.2
          },
          {
            "t": 12000,
            "value": 61.8
          },
          {
            "t": 12240,
            "value": 58.2
          },
          {
            "t": 12480,
            "value": 61.8
          },
          {
            "t": 12720,
            "value": 58.2
          },
          {
            "t": 12960,
            "value": 61.8
          },
          {
            "t": 13200,
            "value": 58.2
          },
          {
            "t": 13440,
            "value": 61.8
          },
          {
            "t": 13680,
            "value": 58.2
          },
          {
            "t": 13920,
            "value": 61.8
          },
          {
            "t": 14160,
            "value": 58.2
          }
        ],
        "hws_temp_sp": 60.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 14400,
          "equals": false
        },
        {
          "output": "yFiringRateHunting",
          "from_s": 3600,
          "to_s": 14400,
          "equals": false
        },
        {
          "output": "yTemperatureUnstable",
          "from_s": 3600,
          "to_s": 14400,
          "equals": true
        }
      ]
    },
    {
      "name": "temperature_unstable_firing_stable",
      "description": "HWS alternates +/-1.8 K around setpoint while firing rate is fixed. The temperature diagnostic is active after warm-up, but the combined fault remains clear.",
      "inputs": {
        "boiler_firing_rate": 50.0,
        "hws_temp": [
          {
            "t": 0,
            "value": 61.8
          },
          {
            "t": 240,
            "value": 58.2
          },
          {
            "t": 480,
            "value": 61.8
          },
          {
            "t": 720,
            "value": 58.2
          },
          {
            "t": 960,
            "value": 61.8
          },
          {
            "t": 1200,
            "value": 58.2
          },
          {
            "t": 1440,
            "value": 61.8
          },
          {
            "t": 1680,
            "value": 58.2
          },
          {
            "t": 1920,
            "value": 61.8
          },
          {
            "t": 2160,
            "value": 58.2
          },
          {
            "t": 2400,
            "value": 61.8
          },
          {
            "t": 2640,
            "value": 58.2
          },
          {
            "t": 2880,
            "value": 61.8
          },
          {
            "t": 3120,
            "value": 58.2
          },
          {
            "t": 3360,
            "value": 61.8
          },
          {
            "t": 3600,
            "value": 58.2
          },
          {
            "t": 3840,
            "value": 61.8
          },
          {
            "t": 4080,
            "value": 58.2
          },
          {
            "t": 4320,
            "value": 61.8
          },
          {
            "t": 4560,
            "value": 58.2
          },
          {
            "t": 4800,
            "value": 61.8
          },
          {
            "t": 5040,
            "value": 58.2
          },
          {
            "t": 5280,
            "value": 61.8
          },
          {
            "t": 5520,
            "value": 58.2
          },
          {
            "t": 5760,
            "value": 61.8
          },
          {
            "t": 6000,
            "value": 58.2
          },
          {
            "t": 6240,
            "value": 61.8
          },
          {
            "t": 6480,
            "value": 58.2
          },
          {
            "t": 6720,
            "value": 61.8
          },
          {
            "t": 6960,
            "value": 58.2
          },
          {
            "t": 7200,
            "value": 61.8
          },
          {
            "t": 7440,
            "value": 58.2
          },
          {
            "t": 7680,
            "value": 61.8
          },
          {
            "t": 7920,
            "value": 58.2
          },
          {
            "t": 8160,
            "value": 61.8
          },
          {
            "t": 8400,
            "value": 58.2
          },
          {
            "t": 8640,
            "value": 61.8
          },
          {
            "t": 8880,
            "value": 58.2
          },
          {
            "t": 9120,
            "value": 61.8
          },
          {
            "t": 9360,
            "value": 58.2
          },
          {
            "t": 9600,
            "value": 61.8
          },
          {
            "t": 9840,
            "value": 58.2
          },
          {
            "t": 10080,
            "value": 61.8
          },
          {
            "t": 10320,
            "value": 58.2
          },
          {
            "t": 10560,
            "value": 61.8
          },
          {
            "t": 10800,
            "value": 58.2
          },
          {
            "t": 11040,
            "value": 61.8
          },
          {
            "t": 11280,
            "value": 58.2
          },
          {
            "t": 11520,
            "value": 61.8
          },
          {
            "t": 11760,
            "value": 58.2
          },
          {
            "t": 12000,
            "value": 61.8
          },
          {
            "t": 12240,
            "value": 58.2
          },
          {
            "t": 12480,
            "value": 61.8
          },
          {
            "t": 12720,
            "value": 58.2
          },
          {
            "t": 12960,
            "value": 61.8
          },
          {
            "t": 13200,
            "value": 58.2
          },
          {
            "t": 13440,
            "value": 61.8
          },
          {
            "t": 13680,
            "value": 58.2
          },
          {
            "t": 13920,
            "value": 61.8
          },
          {
            "t": 14160,
            "value": 58.2
          }
        ],
        "hws_temp_sp": 60.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 14400,
          "equals": false
        },
        {
          "output": "yFiringRateHunting",
          "from_s": 0,
          "to_s": 14400,
          "equals": false
        },
        {
          "output": "yTemperatureUnstable",
          "from_s": 3600,
          "to_s": 14400,
          "equals": true
        }
      ]
    },
    {
      "name": "count_exactly_allowed",
      "description": "A repeating firing-rate pattern produces exactly six rolling-mean crossings per 1800-second window with material MAD. The strict count > 6 comparison remains clear while the temperature lane is active.",
      "inputs": {
        "boiler_firing_rate": [
          {
            "t": 0,
            "value": 65.0
          },
          {
            "t": 300,
            "value": 35.0
          },
          {
            "t": 600,
            "value": 65.0
          },
          {
            "t": 900,
            "value": 35.0
          },
          {
            "t": 1200,
            "value": 65.0
          },
          {
            "t": 1500,
            "value": 35.0
          },
          {
            "t": 1800,
            "value": 65.0
          },
          {
            "t": 2100,
            "value": 35.0
          },
          {
            "t": 2400,
            "value": 65.0
          },
          {
            "t": 2700,
            "value": 35.0
          },
          {
            "t": 3000,
            "value": 65.0
          },
          {
            "t": 3300,
            "value": 35.0
          },
          {
            "t": 3600,
            "value": 65.0
          },
          {
            "t": 3900,
            "value": 35.0
          },
          {
            "t": 4200,
            "value": 65.0
          },
          {
            "t": 4500,
            "value": 35.0
          },
          {
            "t": 4800,
            "value": 65.0
          },
          {
            "t": 5100,
            "value": 35.0
          },
          {
            "t": 5400,
            "value": 65.0
          },
          {
            "t": 5700,
            "value": 35.0
          },
          {
            "t": 6000,
            "value": 65.0
          },
          {
            "t": 6300,
            "value": 35.0
          },
          {
            "t": 6600,
            "value": 65.0
          },
          {
            "t": 6900,
            "value": 35.0
          },
          {
            "t": 7200,
            "value": 65.0
          },
          {
            "t": 7500,
            "value": 35.0
          },
          {
            "t": 7800,
            "value": 65.0
          },
          {
            "t": 8100,
            "value": 35.0
          },
          {
            "t": 8400,
            "value": 65.0
          },
          {
            "t": 8700,
            "value": 35.0
          },
          {
            "t": 9000,
            "value": 65.0
          },
          {
            "t": 9300,
            "value": 35.0
          },
          {
            "t": 9600,
            "value": 65.0
          },
          {
            "t": 9900,
            "value": 35.0
          },
          {
            "t": 10200,
            "value": 65.0
          },
          {
            "t": 10500,
            "value": 35.0
          },
          {
            "t": 10800,
            "value": 65.0
          },
          {
            "t": 11100,
            "value": 35.0
          },
          {
            "t": 11400,
            "value": 65.0
          },
          {
            "t": 11700,
            "value": 35.0
          },
          {
            "t": 12000,
            "value": 65.0
          },
          {
            "t": 12300,
            "value": 35.0
          },
          {
            "t": 12600,
            "value": 65.0
          },
          {
            "t": 12900,
            "value": 35.0
          },
          {
            "t": 13200,
            "value": 65.0
          },
          {
            "t": 13500,
            "value": 35.0
          },
          {
            "t": 13800,
            "value": 65.0
          },
          {
            "t": 14100,
            "value": 35.0
          }
        ],
        "hws_temp": [
          {
            "t": 0,
            "value": 61.8
          },
          {
            "t": 240,
            "value": 58.2
          },
          {
            "t": 480,
            "value": 61.8
          },
          {
            "t": 720,
            "value": 58.2
          },
          {
            "t": 960,
            "value": 61.8
          },
          {
            "t": 1200,
            "value": 58.2
          },
          {
            "t": 1440,
            "value": 61.8
          },
          {
            "t": 1680,
            "value": 58.2
          },
          {
            "t": 1920,
            "value": 61.8
          },
          {
            "t": 2160,
            "value": 58.2
          },
          {
            "t": 2400,
            "value": 61.8
          },
          {
            "t": 2640,
            "value": 58.2
          },
          {
            "t": 2880,
            "value": 61.8
          },
          {
            "t": 3120,
            "value": 58.2
          },
          {
            "t": 3360,
            "value": 61.8
          },
          {
            "t": 3600,
            "value": 58.2
          },
          {
            "t": 3840,
            "value": 61.8
          },
          {
            "t": 4080,
            "value": 58.2
          },
          {
            "t": 4320,
            "value": 61.8
          },
          {
            "t": 4560,
            "value": 58.2
          },
          {
            "t": 4800,
            "value": 61.8
          },
          {
            "t": 5040,
            "value": 58.2
          },
          {
            "t": 5280,
            "value": 61.8
          },
          {
            "t": 5520,
            "value": 58.2
          },
          {
            "t": 5760,
            "value": 61.8
          },
          {
            "t": 6000,
            "value": 58.2
          },
          {
            "t": 6240,
            "value": 61.8
          },
          {
            "t": 6480,
            "value": 58.2
          },
          {
            "t": 6720,
            "value": 61.8
          },
          {
            "t": 6960,
            "value": 58.2
          },
          {
            "t": 7200,
            "value": 61.8
          },
          {
            "t": 7440,
            "value": 58.2
          },
          {
            "t": 7680,
            "value": 61.8
          },
          {
            "t": 7920,
            "value": 58.2
          },
          {
            "t": 8160,
            "value": 61.8
          },
          {
            "t": 8400,
            "value": 58.2
          },
          {
            "t": 8640,
            "value": 61.8
          },
          {
            "t": 8880,
            "value": 58.2
          },
          {
            "t": 9120,
            "value": 61.8
          },
          {
            "t": 9360,
            "value": 58.2
          },
          {
            "t": 9600,
            "value": 61.8
          },
          {
            "t": 9840,
            "value": 58.2
          },
          {
            "t": 10080,
            "value": 61.8
          },
          {
            "t": 10320,
            "value": 58.2
          },
          {
            "t": 10560,
            "value": 61.8
          },
          {
            "t": 10800,
            "value": 58.2
          },
          {
            "t": 11040,
            "value": 61.8
          },
          {
            "t": 11280,
            "value": 58.2
          },
          {
            "t": 11520,
            "value": 61.8
          },
          {
            "t": 11760,
            "value": 58.2
          },
          {
            "t": 12000,
            "value": 61.8
          },
          {
            "t": 12240,
            "value": 58.2
          },
          {
            "t": 12480,
            "value": 61.8
          },
          {
            "t": 12720,
            "value": 58.2
          },
          {
            "t": 12960,
            "value": 61.8
          },
          {
            "t": 13200,
            "value": 58.2
          },
          {
            "t": 13440,
            "value": 61.8
          },
          {
            "t": 13680,
            "value": 58.2
          },
          {
            "t": 13920,
            "value": 61.8
          },
          {
            "t": 14160,
            "value": 58.2
          }
        ],
        "hws_temp_sp": 60.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 14400,
          "equals": false
        },
        {
          "output": "yFiringRateHunting",
          "from_s": 3600,
          "to_s": 14400,
          "equals": false
        },
        {
          "output": "yTemperatureUnstable",
          "from_s": 3600,
          "to_s": 14400,
          "equals": true
        }
      ]
    },
    {
      "name": "one_crossing_over_limit",
      "description": "A repeating seven-crossing firing-rate pattern is one event above the allowed six, with material MAD and unstable temperature; both diagnostics and the delayed fault mature after warm-up.",
      "inputs": {
        "boiler_firing_rate": [
          {
            "t": 0,
            "value": 65.0
          },
          {
            "t": 240,
            "value": 35.0
          },
          {
            "t": 480,
            "value": 65.0
          },
          {
            "t": 720,
            "value": 35.0
          },
          {
            "t": 960,
            "value": 65.0
          },
          {
            "t": 1200,
            "value": 35.0
          },
          {
            "t": 1440,
            "value": 65.0
          },
          {
            "t": 1800,
            "value": 35.0
          },
          {
            "t": 2040,
            "value": 65.0
          },
          {
            "t": 2280,
            "value": 35.0
          },
          {
            "t": 2520,
            "value": 65.0
          },
          {
            "t": 2760,
            "value": 35.0
          },
          {
            "t": 3000,
            "value": 65.0
          },
          {
            "t": 3240,
            "value": 35.0
          },
          {
            "t": 3600,
            "value": 65.0
          },
          {
            "t": 3840,
            "value": 35.0
          },
          {
            "t": 4080,
            "value": 65.0
          },
          {
            "t": 4320,
            "value": 35.0
          },
          {
            "t": 4560,
            "value": 65.0
          },
          {
            "t": 4800,
            "value": 35.0
          },
          {
            "t": 5040,
            "value": 65.0
          },
          {
            "t": 5400,
            "value": 35.0
          },
          {
            "t": 5640,
            "value": 65.0
          },
          {
            "t": 5880,
            "value": 35.0
          },
          {
            "t": 6120,
            "value": 65.0
          },
          {
            "t": 6360,
            "value": 35.0
          },
          {
            "t": 6600,
            "value": 65.0
          },
          {
            "t": 6840,
            "value": 35.0
          },
          {
            "t": 7200,
            "value": 65.0
          },
          {
            "t": 7440,
            "value": 35.0
          },
          {
            "t": 7680,
            "value": 65.0
          },
          {
            "t": 7920,
            "value": 35.0
          },
          {
            "t": 8160,
            "value": 65.0
          },
          {
            "t": 8400,
            "value": 35.0
          },
          {
            "t": 8640,
            "value": 65.0
          },
          {
            "t": 9000,
            "value": 35.0
          },
          {
            "t": 9240,
            "value": 65.0
          },
          {
            "t": 9480,
            "value": 35.0
          },
          {
            "t": 9720,
            "value": 65.0
          },
          {
            "t": 9960,
            "value": 35.0
          },
          {
            "t": 10200,
            "value": 65.0
          },
          {
            "t": 10440,
            "value": 35.0
          },
          {
            "t": 10800,
            "value": 65.0
          },
          {
            "t": 11040,
            "value": 35.0
          },
          {
            "t": 11280,
            "value": 65.0
          },
          {
            "t": 11520,
            "value": 35.0
          },
          {
            "t": 11760,
            "value": 65.0
          },
          {
            "t": 12000,
            "value": 35.0
          },
          {
            "t": 12240,
            "value": 65.0
          },
          {
            "t": 12600,
            "value": 35.0
          },
          {
            "t": 12840,
            "value": 65.0
          },
          {
            "t": 13080,
            "value": 35.0
          },
          {
            "t": 13320,
            "value": 65.0
          },
          {
            "t": 13560,
            "value": 35.0
          },
          {
            "t": 13800,
            "value": 65.0
          },
          {
            "t": 14040,
            "value": 35.0
          }
        ],
        "hws_temp": [
          {
            "t": 0,
            "value": 61.8
          },
          {
            "t": 240,
            "value": 58.2
          },
          {
            "t": 480,
            "value": 61.8
          },
          {
            "t": 720,
            "value": 58.2
          },
          {
            "t": 960,
            "value": 61.8
          },
          {
            "t": 1200,
            "value": 58.2
          },
          {
            "t": 1440,
            "value": 61.8
          },
          {
            "t": 1680,
            "value": 58.2
          },
          {
            "t": 1920,
            "value": 61.8
          },
          {
            "t": 2160,
            "value": 58.2
          },
          {
            "t": 2400,
            "value": 61.8
          },
          {
            "t": 2640,
            "value": 58.2
          },
          {
            "t": 2880,
            "value": 61.8
          },
          {
            "t": 3120,
            "value": 58.2
          },
          {
            "t": 3360,
            "value": 61.8
          },
          {
            "t": 3600,
            "value": 58.2
          },
          {
            "t": 3840,
            "value": 61.8
          },
          {
            "t": 4080,
            "value": 58.2
          },
          {
            "t": 4320,
            "value": 61.8
          },
          {
            "t": 4560,
            "value": 58.2
          },
          {
            "t": 4800,
            "value": 61.8
          },
          {
            "t": 5040,
            "value": 58.2
          },
          {
            "t": 5280,
            "value": 61.8
          },
          {
            "t": 5520,
            "value": 58.2
          },
          {
            "t": 5760,
            "value": 61.8
          },
          {
            "t": 6000,
            "value": 58.2
          },
          {
            "t": 6240,
            "value": 61.8
          },
          {
            "t": 6480,
            "value": 58.2
          },
          {
            "t": 6720,
            "value": 61.8
          },
          {
            "t": 6960,
            "value": 58.2
          },
          {
            "t": 7200,
            "value": 61.8
          },
          {
            "t": 7440,
            "value": 58.2
          },
          {
            "t": 7680,
            "value": 61.8
          },
          {
            "t": 7920,
            "value": 58.2
          },
          {
            "t": 8160,
            "value": 61.8
          },
          {
            "t": 8400,
            "value": 58.2
          },
          {
            "t": 8640,
            "value": 61.8
          },
          {
            "t": 8880,
            "value": 58.2
          },
          {
            "t": 9120,
            "value": 61.8
          },
          {
            "t": 9360,
            "value": 58.2
          },
          {
            "t": 9600,
            "value": 61.8
          },
          {
            "t": 9840,
            "value": 58.2
          },
          {
            "t": 10080,
            "value": 61.8
          },
          {
            "t": 10320,
            "value": 58.2
          },
          {
            "t": 10560,
            "value": 61.8
          },
          {
            "t": 10800,
            "value": 58.2
          },
          {
            "t": 11040,
            "value": 61.8
          },
          {
            "t": 11280,
            "value": 58.2
          },
          {
            "t": 11520,
            "value": 61.8
          },
          {
            "t": 11760,
            "value": 58.2
          },
          {
            "t": 12000,
            "value": 61.8
          },
          {
            "t": 12240,
            "value": 58.2
          },
          {
            "t": 12480,
            "value": 61.8
          },
          {
            "t": 12720,
            "value": 58.2
          },
          {
            "t": 12960,
            "value": 61.8
          },
          {
            "t": 13200,
            "value": 58.2
          },
          {
            "t": 13440,
            "value": 61.8
          },
          {
            "t": 13680,
            "value": 58.2
          },
          {
            "t": 13920,
            "value": 61.8
          },
          {
            "t": 14160,
            "value": 58.2
          }
        ],
        "hws_temp_sp": 60.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 4800,
          "to_s": 13800,
          "equals": true
        },
        {
          "output": "yFiringRateHunting",
          "from_s": 3600,
          "to_s": 13800,
          "equals": true
        },
        {
          "output": "yTemperatureUnstable",
          "from_s": 3600,
          "to_s": 13800,
          "equals": true
        }
      ]
    },
    {
      "name": "temperature_mae_exactly_threshold",
      "description": "With established firing-rate hunting, a constant absolute HWS error of exactly 1.5 K leaves the strict rolling-MAE comparison false and blocks the fault.",
      "inputs": {
        "boiler_firing_rate": [
          {
            "t": 0,
            "value": 65.0
          },
          {
            "t": 240,
            "value": 35.0
          },
          {
            "t": 480,
            "value": 65.0
          },
          {
            "t": 720,
            "value": 35.0
          },
          {
            "t": 960,
            "value": 65.0
          },
          {
            "t": 1200,
            "value": 35.0
          },
          {
            "t": 1440,
            "value": 65.0
          },
          {
            "t": 1680,
            "value": 35.0
          },
          {
            "t": 1920,
            "value": 65.0
          },
          {
            "t": 2160,
            "value": 35.0
          },
          {
            "t": 2400,
            "value": 65.0
          },
          {
            "t": 2640,
            "value": 35.0
          },
          {
            "t": 2880,
            "value": 65.0
          },
          {
            "t": 3120,
            "value": 35.0
          },
          {
            "t": 3360,
            "value": 65.0
          },
          {
            "t": 3600,
            "value": 35.0
          },
          {
            "t": 3840,
            "value": 65.0
          },
          {
            "t": 4080,
            "value": 35.0
          },
          {
            "t": 4320,
            "value": 65.0
          },
          {
            "t": 4560,
            "value": 35.0
          },
          {
            "t": 4800,
            "value": 65.0
          },
          {
            "t": 5040,
            "value": 35.0
          },
          {
            "t": 5280,
            "value": 65.0
          },
          {
            "t": 5520,
            "value": 35.0
          },
          {
            "t": 5760,
            "value": 65.0
          },
          {
            "t": 6000,
            "value": 35.0
          },
          {
            "t": 6240,
            "value": 65.0
          },
          {
            "t": 6480,
            "value": 35.0
          },
          {
            "t": 6720,
            "value": 65.0
          },
          {
            "t": 6960,
            "value": 35.0
          },
          {
            "t": 7200,
            "value": 65.0
          },
          {
            "t": 7440,
            "value": 35.0
          },
          {
            "t": 7680,
            "value": 65.0
          },
          {
            "t": 7920,
            "value": 35.0
          },
          {
            "t": 8160,
            "value": 65.0
          },
          {
            "t": 8400,
            "value": 35.0
          },
          {
            "t": 8640,
            "value": 65.0
          },
          {
            "t": 8880,
            "value": 35.0
          },
          {
            "t": 9120,
            "value": 65.0
          },
          {
            "t": 9360,
            "value": 35.0
          },
          {
            "t": 9600,
            "value": 65.0
          },
          {
            "t": 9840,
            "value": 35.0
          },
          {
            "t": 10080,
            "value": 65.0
          },
          {
            "t": 10320,
            "value": 35.0
          },
          {
            "t": 10560,
            "value": 65.0
          },
          {
            "t": 10800,
            "value": 35.0
          },
          {
            "t": 11040,
            "value": 65.0
          },
          {
            "t": 11280,
            "value": 35.0
          },
          {
            "t": 11520,
            "value": 65.0
          },
          {
            "t": 11760,
            "value": 35.0
          },
          {
            "t": 12000,
            "value": 65.0
          },
          {
            "t": 12240,
            "value": 35.0
          },
          {
            "t": 12480,
            "value": 65.0
          },
          {
            "t": 12720,
            "value": 35.0
          },
          {
            "t": 12960,
            "value": 65.0
          },
          {
            "t": 13200,
            "value": 35.0
          },
          {
            "t": 13440,
            "value": 65.0
          },
          {
            "t": 13680,
            "value": 35.0
          },
          {
            "t": 13920,
            "value": 65.0
          },
          {
            "t": 14160,
            "value": 35.0
          }
        ],
        "hws_temp": [
          {
            "t": 0,
            "value": 61.5
          },
          {
            "t": 240,
            "value": 58.5
          },
          {
            "t": 480,
            "value": 61.5
          },
          {
            "t": 720,
            "value": 58.5
          },
          {
            "t": 960,
            "value": 61.5
          },
          {
            "t": 1200,
            "value": 58.5
          },
          {
            "t": 1440,
            "value": 61.5
          },
          {
            "t": 1680,
            "value": 58.5
          },
          {
            "t": 1920,
            "value": 61.5
          },
          {
            "t": 2160,
            "value": 58.5
          },
          {
            "t": 2400,
            "value": 61.5
          },
          {
            "t": 2640,
            "value": 58.5
          },
          {
            "t": 2880,
            "value": 61.5
          },
          {
            "t": 3120,
            "value": 58.5
          },
          {
            "t": 3360,
            "value": 61.5
          },
          {
            "t": 3600,
            "value": 58.5
          },
          {
            "t": 3840,
            "value": 61.5
          },
          {
            "t": 4080,
            "value": 58.5
          },
          {
            "t": 4320,
            "value": 61.5
          },
          {
            "t": 4560,
            "value": 58.5
          },
          {
            "t": 4800,
            "value": 61.5
          },
          {
            "t": 5040,
            "value": 58.5
          },
          {
            "t": 5280,
            "value": 61.5
          },
          {
            "t": 5520,
            "value": 58.5
          },
          {
            "t": 5760,
            "value": 61.5
          },
          {
            "t": 6000,
            "value": 58.5
          },
          {
            "t": 6240,
            "value": 61.5
          },
          {
            "t": 6480,
            "value": 58.5
          },
          {
            "t": 6720,
            "value": 61.5
          },
          {
            "t": 6960,
            "value": 58.5
          },
          {
            "t": 7200,
            "value": 61.5
          },
          {
            "t": 7440,
            "value": 58.5
          },
          {
            "t": 7680,
            "value": 61.5
          },
          {
            "t": 7920,
            "value": 58.5
          },
          {
            "t": 8160,
            "value": 61.5
          },
          {
            "t": 8400,
            "value": 58.5
          },
          {
            "t": 8640,
            "value": 61.5
          },
          {
            "t": 8880,
            "value": 58.5
          },
          {
            "t": 9120,
            "value": 61.5
          },
          {
            "t": 9360,
            "value": 58.5
          },
          {
            "t": 9600,
            "value": 61.5
          },
          {
            "t": 9840,
            "value": 58.5
          },
          {
            "t": 10080,
            "value": 61.5
          },
          {
            "t": 10320,
            "value": 58.5
          },
          {
            "t": 10560,
            "value": 61.5
          },
          {
            "t": 10800,
            "value": 58.5
          },
          {
            "t": 11040,
            "value": 61.5
          },
          {
            "t": 11280,
            "value": 58.5
          },
          {
            "t": 11520,
            "value": 61.5
          },
          {
            "t": 11760,
            "value": 58.5
          },
          {
            "t": 12000,
            "value": 61.5
          },
          {
            "t": 12240,
            "value": 58.5
          },
          {
            "t": 12480,
            "value": 61.5
          },
          {
            "t": 12720,
            "value": 58.5
          },
          {
            "t": 12960,
            "value": 61.5
          },
          {
            "t": 13200,
            "value": 58.5
          },
          {
            "t": 13440,
            "value": 61.5
          },
          {
            "t": 13680,
            "value": 58.5
          },
          {
            "t": 13920,
            "value": 61.5
          },
          {
            "t": 14160,
            "value": 58.5
          }
        ],
        "hws_temp_sp": 60.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 14400,
          "equals": false
        },
        {
          "output": "yFiringRateHunting",
          "from_s": 3600,
          "to_s": 14400,
          "equals": true
        },
        {
          "output": "yTemperatureUnstable",
          "from_s": 3600,
          "to_s": 14400,
          "equals": false
        }
      ]
    },
    {
      "name": "temperature_mae_just_over_threshold",
      "description": "A 1.515 K mean absolute HWS error brackets the rolling-MAE boundary from the active side while firing rate is materially hunting.",
      "inputs": {
        "boiler_firing_rate": [
          {
            "t": 0,
            "value": 65.0
          },
          {
            "t": 240,
            "value": 35.0
          },
          {
            "t": 480,
            "value": 65.0
          },
          {
            "t": 720,
            "value": 35.0
          },
          {
            "t": 960,
            "value": 65.0
          },
          {
            "t": 1200,
            "value": 35.0
          },
          {
            "t": 1440,
            "value": 65.0
          },
          {
            "t": 1680,
            "value": 35.0
          },
          {
            "t": 1920,
            "value": 65.0
          },
          {
            "t": 2160,
            "value": 35.0
          },
          {
            "t": 2400,
            "value": 65.0
          },
          {
            "t": 2640,
            "value": 35.0
          },
          {
            "t": 2880,
            "value": 65.0
          },
          {
            "t": 3120,
            "value": 35.0
          },
          {
            "t": 3360,
            "value": 65.0
          },
          {
            "t": 3600,
            "value": 35.0
          },
          {
            "t": 3840,
            "value": 65.0
          },
          {
            "t": 4080,
            "value": 35.0
          },
          {
            "t": 4320,
            "value": 65.0
          },
          {
            "t": 4560,
            "value": 35.0
          },
          {
            "t": 4800,
            "value": 65.0
          },
          {
            "t": 5040,
            "value": 35.0
          },
          {
            "t": 5280,
            "value": 65.0
          },
          {
            "t": 5520,
            "value": 35.0
          },
          {
            "t": 5760,
            "value": 65.0
          },
          {
            "t": 6000,
            "value": 35.0
          },
          {
            "t": 6240,
            "value": 65.0
          },
          {
            "t": 6480,
            "value": 35.0
          },
          {
            "t": 6720,
            "value": 65.0
          },
          {
            "t": 6960,
            "value": 35.0
          },
          {
            "t": 7200,
            "value": 65.0
          },
          {
            "t": 7440,
            "value": 35.0
          },
          {
            "t": 7680,
            "value": 65.0
          },
          {
            "t": 7920,
            "value": 35.0
          },
          {
            "t": 8160,
            "value": 65.0
          },
          {
            "t": 8400,
            "value": 35.0
          },
          {
            "t": 8640,
            "value": 65.0
          },
          {
            "t": 8880,
            "value": 35.0
          },
          {
            "t": 9120,
            "value": 65.0
          },
          {
            "t": 9360,
            "value": 35.0
          },
          {
            "t": 9600,
            "value": 65.0
          },
          {
            "t": 9840,
            "value": 35.0
          },
          {
            "t": 10080,
            "value": 65.0
          },
          {
            "t": 10320,
            "value": 35.0
          },
          {
            "t": 10560,
            "value": 65.0
          },
          {
            "t": 10800,
            "value": 35.0
          },
          {
            "t": 11040,
            "value": 65.0
          },
          {
            "t": 11280,
            "value": 35.0
          },
          {
            "t": 11520,
            "value": 65.0
          },
          {
            "t": 11760,
            "value": 35.0
          },
          {
            "t": 12000,
            "value": 65.0
          },
          {
            "t": 12240,
            "value": 35.0
          },
          {
            "t": 12480,
            "value": 65.0
          },
          {
            "t": 12720,
            "value": 35.0
          },
          {
            "t": 12960,
            "value": 65.0
          },
          {
            "t": 13200,
            "value": 35.0
          },
          {
            "t": 13440,
            "value": 65.0
          },
          {
            "t": 13680,
            "value": 35.0
          },
          {
            "t": 13920,
            "value": 65.0
          },
          {
            "t": 14160,
            "value": 35.0
          }
        ],
        "hws_temp": [
          {
            "t": 0,
            "value": 61.515
          },
          {
            "t": 240,
            "value": 58.485
          },
          {
            "t": 480,
            "value": 61.515
          },
          {
            "t": 720,
            "value": 58.485
          },
          {
            "t": 960,
            "value": 61.515
          },
          {
            "t": 1200,
            "value": 58.485
          },
          {
            "t": 1440,
            "value": 61.515
          },
          {
            "t": 1680,
            "value": 58.485
          },
          {
            "t": 1920,
            "value": 61.515
          },
          {
            "t": 2160,
            "value": 58.485
          },
          {
            "t": 2400,
            "value": 61.515
          },
          {
            "t": 2640,
            "value": 58.485
          },
          {
            "t": 2880,
            "value": 61.515
          },
          {
            "t": 3120,
            "value": 58.485
          },
          {
            "t": 3360,
            "value": 61.515
          },
          {
            "t": 3600,
            "value": 58.485
          },
          {
            "t": 3840,
            "value": 61.515
          },
          {
            "t": 4080,
            "value": 58.485
          },
          {
            "t": 4320,
            "value": 61.515
          },
          {
            "t": 4560,
            "value": 58.485
          },
          {
            "t": 4800,
            "value": 61.515
          },
          {
            "t": 5040,
            "value": 58.485
          },
          {
            "t": 5280,
            "value": 61.515
          },
          {
            "t": 5520,
            "value": 58.485
          },
          {
            "t": 5760,
            "value": 61.515
          },
          {
            "t": 6000,
            "value": 58.485
          },
          {
            "t": 6240,
            "value": 61.515
          },
          {
            "t": 6480,
            "value": 58.485
          },
          {
            "t": 6720,
            "value": 61.515
          },
          {
            "t": 6960,
            "value": 58.485
          },
          {
            "t": 7200,
            "value": 61.515
          },
          {
            "t": 7440,
            "value": 58.485
          },
          {
            "t": 7680,
            "value": 61.515
          },
          {
            "t": 7920,
            "value": 58.485
          },
          {
            "t": 8160,
            "value": 61.515
          },
          {
            "t": 8400,
            "value": 58.485
          },
          {
            "t": 8640,
            "value": 61.515
          },
          {
            "t": 8880,
            "value": 58.485
          },
          {
            "t": 9120,
            "value": 61.515
          },
          {
            "t": 9360,
            "value": 58.485
          },
          {
            "t": 9600,
            "value": 61.515
          },
          {
            "t": 9840,
            "value": 58.485
          },
          {
            "t": 10080,
            "value": 61.515
          },
          {
            "t": 10320,
            "value": 58.485
          },
          {
            "t": 10560,
            "value": 61.515
          },
          {
            "t": 10800,
            "value": 58.485
          },
          {
            "t": 11040,
            "value": 61.515
          },
          {
            "t": 11280,
            "value": 58.485
          },
          {
            "t": 11520,
            "value": 61.515
          },
          {
            "t": 11760,
            "value": 58.485
          },
          {
            "t": 12000,
            "value": 61.515
          },
          {
            "t": 12240,
            "value": 58.485
          },
          {
            "t": 12480,
            "value": 61.515
          },
          {
            "t": 12720,
            "value": 58.485
          },
          {
            "t": 12960,
            "value": 61.515
          },
          {
            "t": 13200,
            "value": 58.485
          },
          {
            "t": 13440,
            "value": 61.515
          },
          {
            "t": 13680,
            "value": 58.485
          },
          {
            "t": 13920,
            "value": 61.515
          },
          {
            "t": 14160,
            "value": 58.485
          }
        ],
        "hws_temp_sp": 60.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 4800,
          "to_s": 14400,
          "equals": true
        },
        {
          "output": "yFiringRateHunting",
          "from_s": 3600,
          "to_s": 14400,
          "equals": true
        },
        {
          "output": "yTemperatureUnstable",
          "from_s": 3600,
          "to_s": 14400,
          "equals": true
        }
      ]
    },
    {
      "name": "persistent_both_error_directions",
      "description": "Material firing-rate hunting paired with HWS alternating above and below setpoint proves the temperature statistic is symmetric and the combined fault persists.",
      "inputs": {
        "boiler_firing_rate": [
          {
            "t": 0,
            "value": 65.0
          },
          {
            "t": 240,
            "value": 35.0
          },
          {
            "t": 480,
            "value": 65.0
          },
          {
            "t": 720,
            "value": 35.0
          },
          {
            "t": 960,
            "value": 65.0
          },
          {
            "t": 1200,
            "value": 35.0
          },
          {
            "t": 1440,
            "value": 65.0
          },
          {
            "t": 1680,
            "value": 35.0
          },
          {
            "t": 1920,
            "value": 65.0
          },
          {
            "t": 2160,
            "value": 35.0
          },
          {
            "t": 2400,
            "value": 65.0
          },
          {
            "t": 2640,
            "value": 35.0
          },
          {
            "t": 2880,
            "value": 65.0
          },
          {
            "t": 3120,
            "value": 35.0
          },
          {
            "t": 3360,
            "value": 65.0
          },
          {
            "t": 3600,
            "value": 35.0
          },
          {
            "t": 3840,
            "value": 65.0
          },
          {
            "t": 4080,
            "value": 35.0
          },
          {
            "t": 4320,
            "value": 65.0
          },
          {
            "t": 4560,
            "value": 35.0
          },
          {
            "t": 4800,
            "value": 65.0
          },
          {
            "t": 5040,
            "value": 35.0
          },
          {
            "t": 5280,
            "value": 65.0
          },
          {
            "t": 5520,
            "value": 35.0
          },
          {
            "t": 5760,
            "value": 65.0
          },
          {
            "t": 6000,
            "value": 35.0
          },
          {
            "t": 6240,
            "value": 65.0
          },
          {
            "t": 6480,
            "value": 35.0
          },
          {
            "t": 6720,
            "value": 65.0
          },
          {
            "t": 6960,
            "value": 35.0
          },
          {
            "t": 7200,
            "value": 65.0
          },
          {
            "t": 7440,
            "value": 35.0
          },
          {
            "t": 7680,
            "value": 65.0
          },
          {
            "t": 7920,
            "value": 35.0
          },
          {
            "t": 8160,
            "value": 65.0
          },
          {
            "t": 8400,
            "value": 35.0
          },
          {
            "t": 8640,
            "value": 65.0
          },
          {
            "t": 8880,
            "value": 35.0
          },
          {
            "t": 9120,
            "value": 65.0
          },
          {
            "t": 9360,
            "value": 35.0
          },
          {
            "t": 9600,
            "value": 65.0
          },
          {
            "t": 9840,
            "value": 35.0
          },
          {
            "t": 10080,
            "value": 65.0
          },
          {
            "t": 10320,
            "value": 35.0
          },
          {
            "t": 10560,
            "value": 65.0
          },
          {
            "t": 10800,
            "value": 35.0
          },
          {
            "t": 11040,
            "value": 65.0
          },
          {
            "t": 11280,
            "value": 35.0
          },
          {
            "t": 11520,
            "value": 65.0
          },
          {
            "t": 11760,
            "value": 35.0
          },
          {
            "t": 12000,
            "value": 65.0
          },
          {
            "t": 12240,
            "value": 35.0
          },
          {
            "t": 12480,
            "value": 65.0
          },
          {
            "t": 12720,
            "value": 35.0
          },
          {
            "t": 12960,
            "value": 65.0
          },
          {
            "t": 13200,
            "value": 35.0
          },
          {
            "t": 13440,
            "value": 65.0
          },
          {
            "t": 13680,
            "value": 35.0
          },
          {
            "t": 13920,
            "value": 65.0
          },
          {
            "t": 14160,
            "value": 35.0
          }
        ],
        "hws_temp": [
          {
            "t": 0,
            "value": 61.8
          },
          {
            "t": 240,
            "value": 58.2
          },
          {
            "t": 480,
            "value": 61.8
          },
          {
            "t": 720,
            "value": 58.2
          },
          {
            "t": 960,
            "value": 61.8
          },
          {
            "t": 1200,
            "value": 58.2
          },
          {
            "t": 1440,
            "value": 61.8
          },
          {
            "t": 1680,
            "value": 58.2
          },
          {
            "t": 1920,
            "value": 61.8
          },
          {
            "t": 2160,
            "value": 58.2
          },
          {
            "t": 2400,
            "value": 61.8
          },
          {
            "t": 2640,
            "value": 58.2
          },
          {
            "t": 2880,
            "value": 61.8
          },
          {
            "t": 3120,
            "value": 58.2
          },
          {
            "t": 3360,
            "value": 61.8
          },
          {
            "t": 3600,
            "value": 58.2
          },
          {
            "t": 3840,
            "value": 61.8
          },
          {
            "t": 4080,
            "value": 58.2
          },
          {
            "t": 4320,
            "value": 61.8
          },
          {
            "t": 4560,
            "value": 58.2
          },
          {
            "t": 4800,
            "value": 61.8
          },
          {
            "t": 5040,
            "value": 58.2
          },
          {
            "t": 5280,
            "value": 61.8
          },
          {
            "t": 5520,
            "value": 58.2
          },
          {
            "t": 5760,
            "value": 61.8
          },
          {
            "t": 6000,
            "value": 58.2
          },
          {
            "t": 6240,
            "value": 61.8
          },
          {
            "t": 6480,
            "value": 58.2
          },
          {
            "t": 6720,
            "value": 61.8
          },
          {
            "t": 6960,
            "value": 58.2
          },
          {
            "t": 7200,
            "value": 61.8
          },
          {
            "t": 7440,
            "value": 58.2
          },
          {
            "t": 7680,
            "value": 61.8
          },
          {
            "t": 7920,
            "value": 58.2
          },
          {
            "t": 8160,
            "value": 61.8
          },
          {
            "t": 8400,
            "value": 58.2
          },
          {
            "t": 8640,
            "value": 61.8
          },
          {
            "t": 8880,
            "value": 58.2
          },
          {
            "t": 9120,
            "value": 61.8
          },
          {
            "t": 9360,
            "value": 58.2
          },
          {
            "t": 9600,
            "value": 61.8
          },
          {
            "t": 9840,
            "value": 58.2
          },
          {
            "t": 10080,
            "value": 61.8
          },
          {
            "t": 10320,
            "value": 58.2
          },
          {
            "t": 10560,
            "value": 61.8
          },
          {
            "t": 10800,
            "value": 58.2
          },
          {
            "t": 11040,
            "value": 61.8
          },
          {
            "t": 11280,
            "value": 58.2
          },
          {
            "t": 11520,
            "value": 61.8
          },
          {
            "t": 11760,
            "value": 58.2
          },
          {
            "t": 12000,
            "value": 61.8
          },
          {
            "t": 12240,
            "value": 58.2
          },
          {
            "t": 12480,
            "value": 61.8
          },
          {
            "t": 12720,
            "value": 58.2
          },
          {
            "t": 12960,
            "value": 61.8
          },
          {
            "t": 13200,
            "value": 58.2
          },
          {
            "t": 13440,
            "value": 61.8
          },
          {
            "t": 13680,
            "value": 58.2
          },
          {
            "t": 13920,
            "value": 61.8
          },
          {
            "t": 14160,
            "value": 58.2
          }
        ],
        "hws_temp_sp": 60.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 4800,
          "to_s": 14400,
          "equals": true
        },
        {
          "output": "yFiringRateHunting",
          "from_s": 3600,
          "to_s": 14400,
          "equals": true
        },
        {
          "output": "yTemperatureUnstable",
          "from_s": 3600,
          "to_s": 14400,
          "equals": true
        }
      ]
    },
    {
      "name": "one_sided_oscillatory_temperature_offset",
      "description": "HWS oscillates 3 K and 4.5 K above setpoint while firing rate hunts. Mean crossings plus MAE intentionally classify this one-sided offset as unstable; this is broader than NIST's two-sided allowance-band CUSUM.",
      "inputs": {
        "boiler_firing_rate": [
          {
            "t": 0,
            "value": 65.0
          },
          {
            "t": 240,
            "value": 35.0
          },
          {
            "t": 480,
            "value": 65.0
          },
          {
            "t": 720,
            "value": 35.0
          },
          {
            "t": 960,
            "value": 65.0
          },
          {
            "t": 1200,
            "value": 35.0
          },
          {
            "t": 1440,
            "value": 65.0
          },
          {
            "t": 1680,
            "value": 35.0
          },
          {
            "t": 1920,
            "value": 65.0
          },
          {
            "t": 2160,
            "value": 35.0
          },
          {
            "t": 2400,
            "value": 65.0
          },
          {
            "t": 2640,
            "value": 35.0
          },
          {
            "t": 2880,
            "value": 65.0
          },
          {
            "t": 3120,
            "value": 35.0
          },
          {
            "t": 3360,
            "value": 65.0
          },
          {
            "t": 3600,
            "value": 35.0
          },
          {
            "t": 3840,
            "value": 65.0
          },
          {
            "t": 4080,
            "value": 35.0
          },
          {
            "t": 4320,
            "value": 65.0
          },
          {
            "t": 4560,
            "value": 35.0
          },
          {
            "t": 4800,
            "value": 65.0
          },
          {
            "t": 5040,
            "value": 35.0
          },
          {
            "t": 5280,
            "value": 65.0
          },
          {
            "t": 5520,
            "value": 35.0
          },
          {
            "t": 5760,
            "value": 65.0
          },
          {
            "t": 6000,
            "value": 35.0
          },
          {
            "t": 6240,
            "value": 65.0
          },
          {
            "t": 6480,
            "value": 35.0
          },
          {
            "t": 6720,
            "value": 65.0
          },
          {
            "t": 6960,
            "value": 35.0
          },
          {
            "t": 7200,
            "value": 65.0
          },
          {
            "t": 7440,
            "value": 35.0
          },
          {
            "t": 7680,
            "value": 65.0
          },
          {
            "t": 7920,
            "value": 35.0
          },
          {
            "t": 8160,
            "value": 65.0
          },
          {
            "t": 8400,
            "value": 35.0
          },
          {
            "t": 8640,
            "value": 65.0
          },
          {
            "t": 8880,
            "value": 35.0
          },
          {
            "t": 9120,
            "value": 65.0
          },
          {
            "t": 9360,
            "value": 35.0
          },
          {
            "t": 9600,
            "value": 65.0
          },
          {
            "t": 9840,
            "value": 35.0
          },
          {
            "t": 10080,
            "value": 65.0
          },
          {
            "t": 10320,
            "value": 35.0
          },
          {
            "t": 10560,
            "value": 65.0
          },
          {
            "t": 10800,
            "value": 35.0
          },
          {
            "t": 11040,
            "value": 65.0
          },
          {
            "t": 11280,
            "value": 35.0
          },
          {
            "t": 11520,
            "value": 65.0
          },
          {
            "t": 11760,
            "value": 35.0
          },
          {
            "t": 12000,
            "value": 65.0
          },
          {
            "t": 12240,
            "value": 35.0
          },
          {
            "t": 12480,
            "value": 65.0
          },
          {
            "t": 12720,
            "value": 35.0
          },
          {
            "t": 12960,
            "value": 65.0
          },
          {
            "t": 13200,
            "value": 35.0
          },
          {
            "t": 13440,
            "value": 65.0
          },
          {
            "t": 13680,
            "value": 35.0
          },
          {
            "t": 13920,
            "value": 65.0
          },
          {
            "t": 14160,
            "value": 35.0
          }
        ],
        "hws_temp": [
          {
            "t": 0,
            "value": 64.5
          },
          {
            "t": 240,
            "value": 63.0
          },
          {
            "t": 480,
            "value": 64.5
          },
          {
            "t": 720,
            "value": 63.0
          },
          {
            "t": 960,
            "value": 64.5
          },
          {
            "t": 1200,
            "value": 63.0
          },
          {
            "t": 1440,
            "value": 64.5
          },
          {
            "t": 1680,
            "value": 63.0
          },
          {
            "t": 1920,
            "value": 64.5
          },
          {
            "t": 2160,
            "value": 63.0
          },
          {
            "t": 2400,
            "value": 64.5
          },
          {
            "t": 2640,
            "value": 63.0
          },
          {
            "t": 2880,
            "value": 64.5
          },
          {
            "t": 3120,
            "value": 63.0
          },
          {
            "t": 3360,
            "value": 64.5
          },
          {
            "t": 3600,
            "value": 63.0
          },
          {
            "t": 3840,
            "value": 64.5
          },
          {
            "t": 4080,
            "value": 63.0
          },
          {
            "t": 4320,
            "value": 64.5
          },
          {
            "t": 4560,
            "value": 63.0
          },
          {
            "t": 4800,
            "value": 64.5
          },
          {
            "t": 5040,
            "value": 63.0
          },
          {
            "t": 5280,
            "value": 64.5
          },
          {
            "t": 5520,
            "value": 63.0
          },
          {
            "t": 5760,
            "value": 64.5
          },
          {
            "t": 6000,
            "value": 63.0
          },
          {
            "t": 6240,
            "value": 64.5
          },
          {
            "t": 6480,
            "value": 63.0
          },
          {
            "t": 6720,
            "value": 64.5
          },
          {
            "t": 6960,
            "value": 63.0
          },
          {
            "t": 7200,
            "value": 64.5
          },
          {
            "t": 7440,
            "value": 63.0
          },
          {
            "t": 7680,
            "value": 64.5
          },
          {
            "t": 7920,
            "value": 63.0
          },
          {
            "t": 8160,
            "value": 64.5
          },
          {
            "t": 8400,
            "value": 63.0
          },
          {
            "t": 8640,
            "value": 64.5
          },
          {
            "t": 8880,
            "value": 63.0
          },
          {
            "t": 9120,
            "value": 64.5
          },
          {
            "t": 9360,
            "value": 63.0
          },
          {
            "t": 9600,
            "value": 64.5
          },
          {
            "t": 9840,
            "value": 63.0
          },
          {
            "t": 10080,
            "value": 64.5
          },
          {
            "t": 10320,
            "value": 63.0
          },
          {
            "t": 10560,
            "value": 64.5
          },
          {
            "t": 10800,
            "value": 63.0
          },
          {
            "t": 11040,
            "value": 64.5
          },
          {
            "t": 11280,
            "value": 63.0
          },
          {
            "t": 11520,
            "value": 64.5
          },
          {
            "t": 11760,
            "value": 63.0
          },
          {
            "t": 12000,
            "value": 64.5
          },
          {
            "t": 12240,
            "value": 63.0
          },
          {
            "t": 12480,
            "value": 64.5
          },
          {
            "t": 12720,
            "value": 63.0
          },
          {
            "t": 12960,
            "value": 64.5
          },
          {
            "t": 13200,
            "value": 63.0
          },
          {
            "t": 13440,
            "value": 64.5
          },
          {
            "t": 13680,
            "value": 63.0
          },
          {
            "t": 13920,
            "value": 64.5
          },
          {
            "t": 14160,
            "value": 63.0
          }
        ],
        "hws_temp_sp": 60.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 4800,
          "to_s": 14400,
          "equals": true
        },
        {
          "output": "yFiringRateHunting",
          "from_s": 3600,
          "to_s": 14400,
          "equals": true
        },
        {
          "output": "yTemperatureUnstable",
          "from_s": 3600,
          "to_s": 14400,
          "equals": true
        }
      ]
    },
    {
      "name": "window_aging_clears",
      "description": "The loop hunts and then firing rate settles while HWS remains 1.65 K off setpoint. Temperature remains unstable until its events age; firing crossings and MAD must also age before the combined fault clears.",
      "inputs": {
        "boiler_firing_rate": [
          {
            "t": 0,
            "value": 65.0
          },
          {
            "t": 240,
            "value": 35.0
          },
          {
            "t": 480,
            "value": 65.0
          },
          {
            "t": 720,
            "value": 35.0
          },
          {
            "t": 960,
            "value": 65.0
          },
          {
            "t": 1200,
            "value": 35.0
          },
          {
            "t": 1440,
            "value": 65.0
          },
          {
            "t": 1680,
            "value": 35.0
          },
          {
            "t": 1920,
            "value": 65.0
          },
          {
            "t": 2160,
            "value": 35.0
          },
          {
            "t": 2400,
            "value": 65.0
          },
          {
            "t": 2640,
            "value": 35.0
          },
          {
            "t": 2880,
            "value": 65.0
          },
          {
            "t": 3120,
            "value": 35.0
          },
          {
            "t": 3360,
            "value": 65.0
          },
          {
            "t": 3600,
            "value": 35.0
          },
          {
            "t": 3840,
            "value": 65.0
          },
          {
            "t": 4080,
            "value": 35.0
          },
          {
            "t": 4320,
            "value": 65.0
          },
          {
            "t": 4560,
            "value": 35.0
          },
          {
            "t": 4800,
            "value": 65.0
          },
          {
            "t": 5040,
            "value": 35.0
          },
          {
            "t": 5280,
            "value": 65.0
          },
          {
            "t": 5520,
            "value": 35.0
          },
          {
            "t": 5760,
            "value": 65.0
          },
          {
            "t": 6000,
            "value": 35.0
          },
          {
            "t": 6240,
            "value": 65.0
          },
          {
            "t": 6480,
            "value": 35.0
          },
          {
            "t": 6720,
            "value": 65.0
          },
          {
            "t": 6960,
            "value": 35.0
          },
          {
            "t": 7200,
            "value": 50.0
          }
        ],
        "hws_temp": [
          {
            "t": 0,
            "value": 61.8
          },
          {
            "t": 240,
            "value": 58.2
          },
          {
            "t": 480,
            "value": 61.8
          },
          {
            "t": 720,
            "value": 58.2
          },
          {
            "t": 960,
            "value": 61.8
          },
          {
            "t": 1200,
            "value": 58.2
          },
          {
            "t": 1440,
            "value": 61.8
          },
          {
            "t": 1680,
            "value": 58.2
          },
          {
            "t": 1920,
            "value": 61.8
          },
          {
            "t": 2160,
            "value": 58.2
          },
          {
            "t": 2400,
            "value": 61.8
          },
          {
            "t": 2640,
            "value": 58.2
          },
          {
            "t": 2880,
            "value": 61.8
          },
          {
            "t": 3120,
            "value": 58.2
          },
          {
            "t": 3360,
            "value": 61.8
          },
          {
            "t": 3600,
            "value": 58.2
          },
          {
            "t": 3840,
            "value": 61.8
          },
          {
            "t": 4080,
            "value": 58.2
          },
          {
            "t": 4320,
            "value": 61.8
          },
          {
            "t": 4560,
            "value": 58.2
          },
          {
            "t": 4800,
            "value": 61.8
          },
          {
            "t": 5040,
            "value": 58.2
          },
          {
            "t": 5280,
            "value": 61.8
          },
          {
            "t": 5520,
            "value": 58.2
          },
          {
            "t": 5760,
            "value": 61.8
          },
          {
            "t": 6000,
            "value": 58.2
          },
          {
            "t": 6240,
            "value": 61.8
          },
          {
            "t": 6480,
            "value": 58.2
          },
          {
            "t": 6720,
            "value": 61.8
          },
          {
            "t": 6960,
            "value": 58.2
          },
          {
            "t": 7200,
            "value": 61.8
          },
          {
            "t": 7440,
            "value": 58.2
          },
          {
            "t": 7680,
            "value": 61.8
          },
          {
            "t": 7920,
            "value": 58.2
          },
          {
            "t": 8160,
            "value": 61.8
          },
          {
            "t": 8400,
            "value": 58.2
          },
          {
            "t": 8640,
            "value": 61.8
          },
          {
            "t": 8880,
            "value": 58.2
          },
          {
            "t": 9120,
            "value": 61.8
          },
          {
            "t": 9360,
            "value": 58.2
          },
          {
            "t": 9600,
            "value": 61.8
          },
          {
            "t": 9840,
            "value": 58.2
          },
          {
            "t": 10080,
            "value": 61.8
          },
          {
            "t": 10320,
            "value": 58.2
          },
          {
            "t": 10560,
            "value": 61.8
          },
          {
            "t": 10800,
            "value": 58.2
          },
          {
            "t": 11040,
            "value": 61.8
          },
          {
            "t": 11280,
            "value": 58.2
          },
          {
            "t": 11520,
            "value": 61.8
          },
          {
            "t": 11760,
            "value": 58.2
          },
          {
            "t": 12000,
            "value": 61.8
          },
          {
            "t": 12240,
            "value": 58.2
          },
          {
            "t": 12480,
            "value": 61.8
          },
          {
            "t": 12720,
            "value": 58.2
          },
          {
            "t": 12960,
            "value": 61.8
          },
          {
            "t": 13200,
            "value": 58.2
          },
          {
            "t": 13440,
            "value": 61.8
          },
          {
            "t": 13680,
            "value": 58.2
          },
          {
            "t": 13920,
            "value": 61.8
          },
          {
            "t": 14160,
            "value": 58.2
          }
        ],
        "hws_temp_sp": 60.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 4800,
          "to_s": 6960,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 10800,
          "to_s": 14400,
          "equals": false
        },
        {
          "output": "yFiringRateHunting",
          "from_s": 3600,
          "to_s": 6960,
          "equals": true
        },
        {
          "output": "yFiringRateHunting",
          "from_s": 10800,
          "to_s": 14400,
          "equals": false
        },
        {
          "output": "yTemperatureUnstable",
          "from_s": 3600,
          "to_s": 14400,
          "equals": true
        }
      ]
    },
    {
      "name": "warmup_burst_raw_behavior",
      "description": "Eight tick-rate firing changes during partial-window fill can extrapolate above the crossing limit while HWS is unstable. Both raw diagnostics overlap from 960 s; yFault is clear at 1200 s, asserts exactly at the 1260 s persistence boundary, and remains true after it. The host reports NO_EVAL through the first 1800 seconds.",
      "inputs": {
        "boiler_firing_rate": [
          {
            "t": 0,
            "value": 80.0
          },
          {
            "t": 120,
            "value": 20.0
          },
          {
            "t": 240,
            "value": 80.0
          },
          {
            "t": 360,
            "value": 20.0
          },
          {
            "t": 480,
            "value": 80.0
          },
          {
            "t": 600,
            "value": 20.0
          },
          {
            "t": 720,
            "value": 80.0
          },
          {
            "t": 840,
            "value": 20.0
          },
          {
            "t": 960,
            "value": 50.0
          }
        ],
        "hws_temp": [
          {
            "t": 0,
            "value": 61.8
          },
          {
            "t": 240,
            "value": 58.2
          },
          {
            "t": 480,
            "value": 61.8
          },
          {
            "t": 720,
            "value": 58.2
          },
          {
            "t": 960,
            "value": 61.8
          },
          {
            "t": 1200,
            "value": 58.2
          },
          {
            "t": 1440,
            "value": 61.8
          },
          {
            "t": 1680,
            "value": 58.2
          },
          {
            "t": 1920,
            "value": 61.8
          },
          {
            "t": 2160,
            "value": 58.2
          },
          {
            "t": 2400,
            "value": 61.8
          },
          {
            "t": 2640,
            "value": 58.2
          },
          {
            "t": 2880,
            "value": 61.8
          },
          {
            "t": 3120,
            "value": 58.2
          },
          {
            "t": 3360,
            "value": 61.8
          },
          {
            "t": 3600,
            "value": 58.2
          },
          {
            "t": 3840,
            "value": 61.8
          },
          {
            "t": 4080,
            "value": 58.2
          },
          {
            "t": 4320,
            "value": 61.8
          },
          {
            "t": 4560,
            "value": 58.2
          },
          {
            "t": 4800,
            "value": 61.8
          },
          {
            "t": 5040,
            "value": 58.2
          },
          {
            "t": 5280,
            "value": 61.8
          },
          {
            "t": 5520,
            "value": 58.2
          },
          {
            "t": 5760,
            "value": 61.8
          },
          {
            "t": 6000,
            "value": 58.2
          },
          {
            "t": 6240,
            "value": 61.8
          },
          {
            "t": 6480,
            "value": 58.2
          },
          {
            "t": 6720,
            "value": 61.8
          },
          {
            "t": 6960,
            "value": 58.2
          },
          {
            "t": 7200,
            "value": 61.8
          },
          {
            "t": 7440,
            "value": 58.2
          },
          {
            "t": 7680,
            "value": 61.8
          },
          {
            "t": 7920,
            "value": 58.2
          },
          {
            "t": 8160,
            "value": 61.8
          },
          {
            "t": 8400,
            "value": 58.2
          },
          {
            "t": 8640,
            "value": 61.8
          },
          {
            "t": 8880,
            "value": 58.2
          },
          {
            "t": 9120,
            "value": 61.8
          },
          {
            "t": 9360,
            "value": 58.2
          },
          {
            "t": 9600,
            "value": 61.8
          },
          {
            "t": 9840,
            "value": 58.2
          },
          {
            "t": 10080,
            "value": 61.8
          },
          {
            "t": 10320,
            "value": 58.2
          },
          {
            "t": 10560,
            "value": 61.8
          },
          {
            "t": 10800,
            "value": 58.2
          },
          {
            "t": 11040,
            "value": 61.8
          },
          {
            "t": 11280,
            "value": 58.2
          },
          {
            "t": 11520,
            "value": 61.8
          },
          {
            "t": 11760,
            "value": 58.2
          },
          {
            "t": 12000,
            "value": 61.8
          },
          {
            "t": 12240,
            "value": 58.2
          },
          {
            "t": 12480,
            "value": 61.8
          },
          {
            "t": 12720,
            "value": 58.2
          },
          {
            "t": 12960,
            "value": 61.8
          },
          {
            "t": 13200,
            "value": 58.2
          },
          {
            "t": 13440,
            "value": 61.8
          },
          {
            "t": 13680,
            "value": 58.2
          },
          {
            "t": 13920,
            "value": 61.8
          },
          {
            "t": 14160,
            "value": 58.2
          }
        ],
        "hws_temp_sp": 60.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1200,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 1260,
          "to_s": 1320,
          "equals": true
        },
        {
          "output": "yFiringRateHunting",
          "from_s": 960,
          "to_s": 960,
          "equals": true
        },
        {
          "output": "yTemperatureUnstable",
          "from_s": 960,
          "to_s": 960,
          "equals": true
        },
        {
          "output": "yFiringRateHunting",
          "from_s": 600,
          "to_s": 1680,
          "equals": true
        },
        {
          "output": "yTemperatureUnstable",
          "from_s": 1200,
          "to_s": 1680,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 6000,
          "to_s": 14400,
          "equals": false
        },
        {
          "output": "yFiringRateHunting",
          "from_s": 6000,
          "to_s": 14400,
          "equals": false
        },
        {
          "output": "yTemperatureUnstable",
          "from_s": 1200,
          "to_s": 14400,
          "equals": true
        }
      ]
    },
    {
      "name": "setpoint_step_excluded_raw_behavior",
      "description": "A setpoint step at t=1800 followed by sustained materially reversing firing_rate raises the raw diagnostics and fault. Deployment must instead report NO_EVAL until reset and disturbance response are settled.",
      "inputs": {
        "boiler_firing_rate": [
          {
            "t": 0,
            "value": 50.0
          },
          {
            "t": 3600,
            "value": 65.0
          },
          {
            "t": 3840,
            "value": 35.0
          },
          {
            "t": 4080,
            "value": 65.0
          },
          {
            "t": 4320,
            "value": 35.0
          },
          {
            "t": 4560,
            "value": 65.0
          },
          {
            "t": 4800,
            "value": 35.0
          },
          {
            "t": 5040,
            "value": 65.0
          },
          {
            "t": 5280,
            "value": 35.0
          },
          {
            "t": 5520,
            "value": 65.0
          },
          {
            "t": 5760,
            "value": 35.0
          },
          {
            "t": 6000,
            "value": 65.0
          },
          {
            "t": 6240,
            "value": 35.0
          },
          {
            "t": 6480,
            "value": 65.0
          },
          {
            "t": 6720,
            "value": 35.0
          },
          {
            "t": 6960,
            "value": 65.0
          },
          {
            "t": 7200,
            "value": 35.0
          },
          {
            "t": 7440,
            "value": 65.0
          },
          {
            "t": 7680,
            "value": 35.0
          },
          {
            "t": 7920,
            "value": 65.0
          },
          {
            "t": 8160,
            "value": 35.0
          },
          {
            "t": 8400,
            "value": 65.0
          },
          {
            "t": 8640,
            "value": 35.0
          },
          {
            "t": 8880,
            "value": 65.0
          },
          {
            "t": 9120,
            "value": 35.0
          },
          {
            "t": 9360,
            "value": 65.0
          },
          {
            "t": 9600,
            "value": 35.0
          },
          {
            "t": 9840,
            "value": 65.0
          },
          {
            "t": 10080,
            "value": 35.0
          },
          {
            "t": 10320,
            "value": 65.0
          },
          {
            "t": 10560,
            "value": 35.0
          },
          {
            "t": 10800,
            "value": 65.0
          },
          {
            "t": 11040,
            "value": 35.0
          },
          {
            "t": 11280,
            "value": 65.0
          },
          {
            "t": 11520,
            "value": 35.0
          },
          {
            "t": 11760,
            "value": 65.0
          },
          {
            "t": 12000,
            "value": 35.0
          },
          {
            "t": 12240,
            "value": 65.0
          },
          {
            "t": 12480,
            "value": 35.0
          },
          {
            "t": 12720,
            "value": 65.0
          },
          {
            "t": 12960,
            "value": 35.0
          },
          {
            "t": 13200,
            "value": 65.0
          },
          {
            "t": 13440,
            "value": 35.0
          },
          {
            "t": 13680,
            "value": 65.0
          },
          {
            "t": 13920,
            "value": 35.0
          },
          {
            "t": 14160,
            "value": 65.0
          }
        ],
        "hws_temp": [
          {
            "t": 0,
            "value": 60.0
          },
          {
            "t": 3600,
            "value": 63.6
          },
          {
            "t": 3840,
            "value": 60.0
          },
          {
            "t": 4080,
            "value": 63.6
          },
          {
            "t": 4320,
            "value": 60.0
          },
          {
            "t": 4560,
            "value": 63.6
          },
          {
            "t": 4800,
            "value": 60.0
          },
          {
            "t": 5040,
            "value": 63.6
          },
          {
            "t": 5280,
            "value": 60.0
          },
          {
            "t": 5520,
            "value": 63.6
          },
          {
            "t": 5760,
            "value": 60.0
          },
          {
            "t": 6000,
            "value": 63.6
          },
          {
            "t": 6240,
            "value": 60.0
          },
          {
            "t": 6480,
            "value": 63.6
          },
          {
            "t": 6720,
            "value": 60.0
          },
          {
            "t": 6960,
            "value": 63.6
          },
          {
            "t": 7200,
            "value": 60.0
          },
          {
            "t": 7440,
            "value": 63.6
          },
          {
            "t": 7680,
            "value": 60.0
          },
          {
            "t": 7920,
            "value": 63.6
          },
          {
            "t": 8160,
            "value": 60.0
          },
          {
            "t": 8400,
            "value": 63.6
          },
          {
            "t": 8640,
            "value": 60.0
          },
          {
            "t": 8880,
            "value": 63.6
          },
          {
            "t": 9120,
            "value": 60.0
          },
          {
            "t": 9360,
            "value": 63.6
          },
          {
            "t": 9600,
            "value": 60.0
          },
          {
            "t": 9840,
            "value": 63.6
          },
          {
            "t": 10080,
            "value": 60.0
          },
          {
            "t": 10320,
            "value": 63.6
          },
          {
            "t": 10560,
            "value": 60.0
          },
          {
            "t": 10800,
            "value": 63.6
          },
          {
            "t": 11040,
            "value": 60.0
          },
          {
            "t": 11280,
            "value": 63.6
          },
          {
            "t": 11520,
            "value": 60.0
          },
          {
            "t": 11760,
            "value": 63.6
          },
          {
            "t": 12000,
            "value": 60.0
          },
          {
            "t": 12240,
            "value": 63.6
          },
          {
            "t": 12480,
            "value": 60.0
          },
          {
            "t": 12720,
            "value": 63.6
          },
          {
            "t": 12960,
            "value": 60.0
          },
          {
            "t": 13200,
            "value": 63.6
          },
          {
            "t": 13440,
            "value": 60.0
          },
          {
            "t": 13680,
            "value": 63.6
          },
          {
            "t": 13920,
            "value": 60.0
          },
          {
            "t": 14160,
            "value": 63.6
          }
        ],
        "hws_temp_sp": [
          {
            "t": 0,
            "value": 60.0
          },
          {
            "t": 3600,
            "value": 61.8
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 8400,
          "to_s": 14400,
          "equals": true
        },
        {
          "output": "yFiringRateHunting",
          "from_s": 7200,
          "to_s": 14400,
          "equals": true
        },
        {
          "output": "yTemperatureUnstable",
          "from_s": 7200,
          "to_s": 14400,
          "equals": true
        }
      ]
    },
    {
      "name": "subtick_hunting_aliases_clear",
      "description": "A host sampling once per minute cannot see an even number of reversals that occur and return between samples; the staged trace is therefore constant and every output stays clear. This pins the Nyquist limitation, not healthy plant behavior.",
      "inputs": {
        "boiler_firing_rate": 50.0,
        "hws_temp": 60.0,
        "hws_temp_sp": 60.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 14400,
          "equals": false
        },
        {
          "output": "yFiringRateHunting",
          "from_s": 0,
          "to_s": 14400,
          "equals": false
        },
        {
          "output": "yTemperatureUnstable",
          "from_s": 0,
          "to_s": 14400,
          "equals": false
        }
      ]
    },
    {
      "name": "firing_mad_exact_threshold_clear",
      "description": "A +/-7.5-percentage-point square wave has exactly 7.5 points settled rolling MAD. The strict materiality comparator stays false while the temperature lane remains active.",
      "inputs": {
        "boiler_firing_rate": [
          {
            "t": 0,
            "value": 57.5
          },
          {
            "t": 60,
            "value": 42.5
          },
          {
            "t": 120,
            "value": 57.5
          },
          {
            "t": 180,
            "value": 42.5
          },
          {
            "t": 240,
            "value": 57.5
          },
          {
            "t": 300,
            "value": 42.5
          },
          {
            "t": 360,
            "value": 57.5
          },
          {
            "t": 420,
            "value": 42.5
          },
          {
            "t": 480,
            "value": 57.5
          },
          {
            "t": 540,
            "value": 42.5
          },
          {
            "t": 600,
            "value": 57.5
          },
          {
            "t": 660,
            "value": 42.5
          },
          {
            "t": 720,
            "value": 57.5
          },
          {
            "t": 780,
            "value": 42.5
          },
          {
            "t": 840,
            "value": 57.5
          },
          {
            "t": 900,
            "value": 42.5
          },
          {
            "t": 960,
            "value": 57.5
          },
          {
            "t": 1020,
            "value": 42.5
          },
          {
            "t": 1080,
            "value": 57.5
          },
          {
            "t": 1140,
            "value": 42.5
          },
          {
            "t": 1200,
            "value": 57.5
          },
          {
            "t": 1260,
            "value": 42.5
          },
          {
            "t": 1320,
            "value": 57.5
          },
          {
            "t": 1380,
            "value": 42.5
          },
          {
            "t": 1440,
            "value": 57.5
          },
          {
            "t": 1500,
            "value": 42.5
          },
          {
            "t": 1560,
            "value": 57.5
          },
          {
            "t": 1620,
            "value": 42.5
          },
          {
            "t": 1680,
            "value": 57.5
          },
          {
            "t": 1740,
            "value": 42.5
          },
          {
            "t": 1800,
            "value": 57.5
          },
          {
            "t": 1860,
            "value": 42.5
          },
          {
            "t": 1920,
            "value": 57.5
          },
          {
            "t": 1980,
            "value": 42.5
          },
          {
            "t": 2040,
            "value": 57.5
          },
          {
            "t": 2100,
            "value": 42.5
          },
          {
            "t": 2160,
            "value": 57.5
          },
          {
            "t": 2220,
            "value": 42.5
          },
          {
            "t": 2280,
            "value": 57.5
          },
          {
            "t": 2340,
            "value": 42.5
          },
          {
            "t": 2400,
            "value": 57.5
          },
          {
            "t": 2460,
            "value": 42.5
          },
          {
            "t": 2520,
            "value": 57.5
          },
          {
            "t": 2580,
            "value": 42.5
          },
          {
            "t": 2640,
            "value": 57.5
          },
          {
            "t": 2700,
            "value": 42.5
          },
          {
            "t": 2760,
            "value": 57.5
          },
          {
            "t": 2820,
            "value": 42.5
          },
          {
            "t": 2880,
            "value": 57.5
          },
          {
            "t": 2940,
            "value": 42.5
          },
          {
            "t": 3000,
            "value": 57.5
          },
          {
            "t": 3060,
            "value": 42.5
          },
          {
            "t": 3120,
            "value": 57.5
          },
          {
            "t": 3180,
            "value": 42.5
          },
          {
            "t": 3240,
            "value": 57.5
          },
          {
            "t": 3300,
            "value": 42.5
          },
          {
            "t": 3360,
            "value": 57.5
          },
          {
            "t": 3420,
            "value": 42.5
          },
          {
            "t": 3480,
            "value": 57.5
          },
          {
            "t": 3540,
            "value": 42.5
          },
          {
            "t": 3600,
            "value": 57.5
          },
          {
            "t": 3660,
            "value": 42.5
          },
          {
            "t": 3720,
            "value": 57.5
          },
          {
            "t": 3780,
            "value": 42.5
          },
          {
            "t": 3840,
            "value": 57.5
          },
          {
            "t": 3900,
            "value": 42.5
          },
          {
            "t": 3960,
            "value": 57.5
          },
          {
            "t": 4020,
            "value": 42.5
          },
          {
            "t": 4080,
            "value": 57.5
          },
          {
            "t": 4140,
            "value": 42.5
          },
          {
            "t": 4200,
            "value": 57.5
          },
          {
            "t": 4260,
            "value": 42.5
          },
          {
            "t": 4320,
            "value": 57.5
          },
          {
            "t": 4380,
            "value": 42.5
          },
          {
            "t": 4440,
            "value": 57.5
          },
          {
            "t": 4500,
            "value": 42.5
          },
          {
            "t": 4560,
            "value": 57.5
          },
          {
            "t": 4620,
            "value": 42.5
          },
          {
            "t": 4680,
            "value": 57.5
          },
          {
            "t": 4740,
            "value": 42.5
          },
          {
            "t": 4800,
            "value": 57.5
          },
          {
            "t": 4860,
            "value": 42.5
          },
          {
            "t": 4920,
            "value": 57.5
          },
          {
            "t": 4980,
            "value": 42.5
          },
          {
            "t": 5040,
            "value": 57.5
          },
          {
            "t": 5100,
            "value": 42.5
          },
          {
            "t": 5160,
            "value": 57.5
          },
          {
            "t": 5220,
            "value": 42.5
          },
          {
            "t": 5280,
            "value": 57.5
          },
          {
            "t": 5340,
            "value": 42.5
          },
          {
            "t": 5400,
            "value": 57.5
          },
          {
            "t": 5460,
            "value": 42.5
          },
          {
            "t": 5520,
            "value": 57.5
          },
          {
            "t": 5580,
            "value": 42.5
          },
          {
            "t": 5640,
            "value": 57.5
          },
          {
            "t": 5700,
            "value": 42.5
          },
          {
            "t": 5760,
            "value": 57.5
          },
          {
            "t": 5820,
            "value": 42.5
          },
          {
            "t": 5880,
            "value": 57.5
          },
          {
            "t": 5940,
            "value": 42.5
          },
          {
            "t": 6000,
            "value": 57.5
          },
          {
            "t": 6060,
            "value": 42.5
          },
          {
            "t": 6120,
            "value": 57.5
          },
          {
            "t": 6180,
            "value": 42.5
          },
          {
            "t": 6240,
            "value": 57.5
          },
          {
            "t": 6300,
            "value": 42.5
          },
          {
            "t": 6360,
            "value": 57.5
          },
          {
            "t": 6420,
            "value": 42.5
          },
          {
            "t": 6480,
            "value": 57.5
          },
          {
            "t": 6540,
            "value": 42.5
          },
          {
            "t": 6600,
            "value": 57.5
          },
          {
            "t": 6660,
            "value": 42.5
          },
          {
            "t": 6720,
            "value": 57.5
          },
          {
            "t": 6780,
            "value": 42.5
          },
          {
            "t": 6840,
            "value": 57.5
          },
          {
            "t": 6900,
            "value": 42.5
          },
          {
            "t": 6960,
            "value": 57.5
          },
          {
            "t": 7020,
            "value": 42.5
          },
          {
            "t": 7080,
            "value": 57.5
          },
          {
            "t": 7140,
            "value": 42.5
          },
          {
            "t": 7200,
            "value": 57.5
          },
          {
            "t": 7260,
            "value": 42.5
          },
          {
            "t": 7320,
            "value": 57.5
          },
          {
            "t": 7380,
            "value": 42.5
          },
          {
            "t": 7440,
            "value": 57.5
          },
          {
            "t": 7500,
            "value": 42.5
          },
          {
            "t": 7560,
            "value": 57.5
          },
          {
            "t": 7620,
            "value": 42.5
          },
          {
            "t": 7680,
            "value": 57.5
          },
          {
            "t": 7740,
            "value": 42.5
          },
          {
            "t": 7800,
            "value": 57.5
          },
          {
            "t": 7860,
            "value": 42.5
          },
          {
            "t": 7920,
            "value": 57.5
          },
          {
            "t": 7980,
            "value": 42.5
          },
          {
            "t": 8040,
            "value": 57.5
          },
          {
            "t": 8100,
            "value": 42.5
          },
          {
            "t": 8160,
            "value": 57.5
          },
          {
            "t": 8220,
            "value": 42.5
          },
          {
            "t": 8280,
            "value": 57.5
          },
          {
            "t": 8340,
            "value": 42.5
          },
          {
            "t": 8400,
            "value": 57.5
          },
          {
            "t": 8460,
            "value": 42.5
          },
          {
            "t": 8520,
            "value": 57.5
          },
          {
            "t": 8580,
            "value": 42.5
          },
          {
            "t": 8640,
            "value": 57.5
          },
          {
            "t": 8700,
            "value": 42.5
          },
          {
            "t": 8760,
            "value": 57.5
          },
          {
            "t": 8820,
            "value": 42.5
          },
          {
            "t": 8880,
            "value": 57.5
          },
          {
            "t": 8940,
            "value": 42.5
          },
          {
            "t": 9000,
            "value": 57.5
          },
          {
            "t": 9060,
            "value": 42.5
          },
          {
            "t": 9120,
            "value": 57.5
          },
          {
            "t": 9180,
            "value": 42.5
          },
          {
            "t": 9240,
            "value": 57.5
          },
          {
            "t": 9300,
            "value": 42.5
          },
          {
            "t": 9360,
            "value": 57.5
          },
          {
            "t": 9420,
            "value": 42.5
          },
          {
            "t": 9480,
            "value": 57.5
          },
          {
            "t": 9540,
            "value": 42.5
          },
          {
            "t": 9600,
            "value": 57.5
          },
          {
            "t": 9660,
            "value": 42.5
          },
          {
            "t": 9720,
            "value": 57.5
          },
          {
            "t": 9780,
            "value": 42.5
          },
          {
            "t": 9840,
            "value": 57.5
          },
          {
            "t": 9900,
            "value": 42.5
          },
          {
            "t": 9960,
            "value": 57.5
          },
          {
            "t": 10020,
            "value": 42.5
          },
          {
            "t": 10080,
            "value": 57.5
          },
          {
            "t": 10140,
            "value": 42.5
          },
          {
            "t": 10200,
            "value": 57.5
          },
          {
            "t": 10260,
            "value": 42.5
          },
          {
            "t": 10320,
            "value": 57.5
          },
          {
            "t": 10380,
            "value": 42.5
          },
          {
            "t": 10440,
            "value": 57.5
          },
          {
            "t": 10500,
            "value": 42.5
          },
          {
            "t": 10560,
            "value": 57.5
          },
          {
            "t": 10620,
            "value": 42.5
          },
          {
            "t": 10680,
            "value": 57.5
          },
          {
            "t": 10740,
            "value": 42.5
          },
          {
            "t": 10800,
            "value": 57.5
          },
          {
            "t": 10860,
            "value": 42.5
          },
          {
            "t": 10920,
            "value": 57.5
          },
          {
            "t": 10980,
            "value": 42.5
          },
          {
            "t": 11040,
            "value": 57.5
          },
          {
            "t": 11100,
            "value": 42.5
          },
          {
            "t": 11160,
            "value": 57.5
          },
          {
            "t": 11220,
            "value": 42.5
          },
          {
            "t": 11280,
            "value": 57.5
          },
          {
            "t": 11340,
            "value": 42.5
          },
          {
            "t": 11400,
            "value": 57.5
          },
          {
            "t": 11460,
            "value": 42.5
          },
          {
            "t": 11520,
            "value": 57.5
          },
          {
            "t": 11580,
            "value": 42.5
          },
          {
            "t": 11640,
            "value": 57.5
          },
          {
            "t": 11700,
            "value": 42.5
          },
          {
            "t": 11760,
            "value": 57.5
          },
          {
            "t": 11820,
            "value": 42.5
          },
          {
            "t": 11880,
            "value": 57.5
          },
          {
            "t": 11940,
            "value": 42.5
          },
          {
            "t": 12000,
            "value": 57.5
          },
          {
            "t": 12060,
            "value": 42.5
          },
          {
            "t": 12120,
            "value": 57.5
          },
          {
            "t": 12180,
            "value": 42.5
          },
          {
            "t": 12240,
            "value": 57.5
          },
          {
            "t": 12300,
            "value": 42.5
          },
          {
            "t": 12360,
            "value": 57.5
          },
          {
            "t": 12420,
            "value": 42.5
          },
          {
            "t": 12480,
            "value": 57.5
          },
          {
            "t": 12540,
            "value": 42.5
          },
          {
            "t": 12600,
            "value": 57.5
          },
          {
            "t": 12660,
            "value": 42.5
          },
          {
            "t": 12720,
            "value": 57.5
          },
          {
            "t": 12780,
            "value": 42.5
          },
          {
            "t": 12840,
            "value": 57.5
          },
          {
            "t": 12900,
            "value": 42.5
          },
          {
            "t": 12960,
            "value": 57.5
          },
          {
            "t": 13020,
            "value": 42.5
          },
          {
            "t": 13080,
            "value": 57.5
          },
          {
            "t": 13140,
            "value": 42.5
          },
          {
            "t": 13200,
            "value": 57.5
          },
          {
            "t": 13260,
            "value": 42.5
          },
          {
            "t": 13320,
            "value": 57.5
          },
          {
            "t": 13380,
            "value": 42.5
          },
          {
            "t": 13440,
            "value": 57.5
          },
          {
            "t": 13500,
            "value": 42.5
          },
          {
            "t": 13560,
            "value": 57.5
          },
          {
            "t": 13620,
            "value": 42.5
          },
          {
            "t": 13680,
            "value": 57.5
          },
          {
            "t": 13740,
            "value": 42.5
          },
          {
            "t": 13800,
            "value": 57.5
          },
          {
            "t": 13860,
            "value": 42.5
          },
          {
            "t": 13920,
            "value": 57.5
          },
          {
            "t": 13980,
            "value": 42.5
          },
          {
            "t": 14040,
            "value": 57.5
          },
          {
            "t": 14100,
            "value": 42.5
          },
          {
            "t": 14160,
            "value": 57.5
          },
          {
            "t": 14220,
            "value": 42.5
          },
          {
            "t": 14280,
            "value": 57.5
          },
          {
            "t": 14340,
            "value": 42.5
          },
          {
            "t": 14400,
            "value": 57.5
          }
        ],
        "hws_temp": [
          {
            "t": 0,
            "value": 61.8
          },
          {
            "t": 240,
            "value": 58.2
          },
          {
            "t": 480,
            "value": 61.8
          },
          {
            "t": 720,
            "value": 58.2
          },
          {
            "t": 960,
            "value": 61.8
          },
          {
            "t": 1200,
            "value": 58.2
          },
          {
            "t": 1440,
            "value": 61.8
          },
          {
            "t": 1680,
            "value": 58.2
          },
          {
            "t": 1920,
            "value": 61.8
          },
          {
            "t": 2160,
            "value": 58.2
          },
          {
            "t": 2400,
            "value": 61.8
          },
          {
            "t": 2640,
            "value": 58.2
          },
          {
            "t": 2880,
            "value": 61.8
          },
          {
            "t": 3120,
            "value": 58.2
          },
          {
            "t": 3360,
            "value": 61.8
          },
          {
            "t": 3600,
            "value": 58.2
          },
          {
            "t": 3840,
            "value": 61.8
          },
          {
            "t": 4080,
            "value": 58.2
          },
          {
            "t": 4320,
            "value": 61.8
          },
          {
            "t": 4560,
            "value": 58.2
          },
          {
            "t": 4800,
            "value": 61.8
          },
          {
            "t": 5040,
            "value": 58.2
          },
          {
            "t": 5280,
            "value": 61.8
          },
          {
            "t": 5520,
            "value": 58.2
          },
          {
            "t": 5760,
            "value": 61.8
          },
          {
            "t": 6000,
            "value": 58.2
          },
          {
            "t": 6240,
            "value": 61.8
          },
          {
            "t": 6480,
            "value": 58.2
          },
          {
            "t": 6720,
            "value": 61.8
          },
          {
            "t": 6960,
            "value": 58.2
          },
          {
            "t": 7200,
            "value": 61.8
          },
          {
            "t": 7440,
            "value": 58.2
          },
          {
            "t": 7680,
            "value": 61.8
          },
          {
            "t": 7920,
            "value": 58.2
          },
          {
            "t": 8160,
            "value": 61.8
          },
          {
            "t": 8400,
            "value": 58.2
          },
          {
            "t": 8640,
            "value": 61.8
          },
          {
            "t": 8880,
            "value": 58.2
          },
          {
            "t": 9120,
            "value": 61.8
          },
          {
            "t": 9360,
            "value": 58.2
          },
          {
            "t": 9600,
            "value": 61.8
          },
          {
            "t": 9840,
            "value": 58.2
          },
          {
            "t": 10080,
            "value": 61.8
          },
          {
            "t": 10320,
            "value": 58.2
          },
          {
            "t": 10560,
            "value": 61.8
          },
          {
            "t": 10800,
            "value": 58.2
          },
          {
            "t": 11040,
            "value": 61.8
          },
          {
            "t": 11280,
            "value": 58.2
          },
          {
            "t": 11520,
            "value": 61.8
          },
          {
            "t": 11760,
            "value": 58.2
          },
          {
            "t": 12000,
            "value": 61.8
          },
          {
            "t": 12240,
            "value": 58.2
          },
          {
            "t": 12480,
            "value": 61.8
          },
          {
            "t": 12720,
            "value": 58.2
          },
          {
            "t": 12960,
            "value": 61.8
          },
          {
            "t": 13200,
            "value": 58.2
          },
          {
            "t": 13440,
            "value": 61.8
          },
          {
            "t": 13680,
            "value": 58.2
          },
          {
            "t": 13920,
            "value": 61.8
          },
          {
            "t": 14160,
            "value": 58.2
          }
        ],
        "hws_temp_sp": 60.0
      },
      "expect": [
        {
          "output": "yFiringRateHunting",
          "from_s": 7200,
          "to_s": 14400,
          "equals": false
        },
        {
          "output": "yTemperatureUnstable",
          "from_s": 7200,
          "to_s": 14400,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 7200,
          "to_s": 14400,
          "equals": false
        }
      ]
    },
    {
      "name": "temperature_count_exactly_allowed",
      "description": "Firing rate hunts materially, but HWS produces exactly six rolling-error-mean crossings per 1800-second window. The strict temperature count > 6 comparison and combined fault stay clear.",
      "inputs": {
        "boiler_firing_rate": [
          {
            "t": 0,
            "value": 65.0
          },
          {
            "t": 240,
            "value": 35.0
          },
          {
            "t": 480,
            "value": 65.0
          },
          {
            "t": 720,
            "value": 35.0
          },
          {
            "t": 960,
            "value": 65.0
          },
          {
            "t": 1200,
            "value": 35.0
          },
          {
            "t": 1440,
            "value": 65.0
          },
          {
            "t": 1680,
            "value": 35.0
          },
          {
            "t": 1920,
            "value": 65.0
          },
          {
            "t": 2160,
            "value": 35.0
          },
          {
            "t": 2400,
            "value": 65.0
          },
          {
            "t": 2640,
            "value": 35.0
          },
          {
            "t": 2880,
            "value": 65.0
          },
          {
            "t": 3120,
            "value": 35.0
          },
          {
            "t": 3360,
            "value": 65.0
          },
          {
            "t": 3600,
            "value": 35.0
          },
          {
            "t": 3840,
            "value": 65.0
          },
          {
            "t": 4080,
            "value": 35.0
          },
          {
            "t": 4320,
            "value": 65.0
          },
          {
            "t": 4560,
            "value": 35.0
          },
          {
            "t": 4800,
            "value": 65.0
          },
          {
            "t": 5040,
            "value": 35.0
          },
          {
            "t": 5280,
            "value": 65.0
          },
          {
            "t": 5520,
            "value": 35.0
          },
          {
            "t": 5760,
            "value": 65.0
          },
          {
            "t": 6000,
            "value": 35.0
          },
          {
            "t": 6240,
            "value": 65.0
          },
          {
            "t": 6480,
            "value": 35.0
          },
          {
            "t": 6720,
            "value": 65.0
          },
          {
            "t": 6960,
            "value": 35.0
          },
          {
            "t": 7200,
            "value": 65.0
          },
          {
            "t": 7440,
            "value": 35.0
          },
          {
            "t": 7680,
            "value": 65.0
          },
          {
            "t": 7920,
            "value": 35.0
          },
          {
            "t": 8160,
            "value": 65.0
          },
          {
            "t": 8400,
            "value": 35.0
          },
          {
            "t": 8640,
            "value": 65.0
          },
          {
            "t": 8880,
            "value": 35.0
          },
          {
            "t": 9120,
            "value": 65.0
          },
          {
            "t": 9360,
            "value": 35.0
          },
          {
            "t": 9600,
            "value": 65.0
          },
          {
            "t": 9840,
            "value": 35.0
          },
          {
            "t": 10080,
            "value": 65.0
          },
          {
            "t": 10320,
            "value": 35.0
          },
          {
            "t": 10560,
            "value": 65.0
          },
          {
            "t": 10800,
            "value": 35.0
          },
          {
            "t": 11040,
            "value": 65.0
          },
          {
            "t": 11280,
            "value": 35.0
          },
          {
            "t": 11520,
            "value": 65.0
          },
          {
            "t": 11760,
            "value": 35.0
          },
          {
            "t": 12000,
            "value": 65.0
          },
          {
            "t": 12240,
            "value": 35.0
          },
          {
            "t": 12480,
            "value": 65.0
          },
          {
            "t": 12720,
            "value": 35.0
          },
          {
            "t": 12960,
            "value": 65.0
          },
          {
            "t": 13200,
            "value": 35.0
          },
          {
            "t": 13440,
            "value": 65.0
          },
          {
            "t": 13680,
            "value": 35.0
          },
          {
            "t": 13920,
            "value": 65.0
          },
          {
            "t": 14160,
            "value": 35.0
          }
        ],
        "hws_temp": [
          {
            "t": 0,
            "value": 62.0
          },
          {
            "t": 300,
            "value": 58.0
          },
          {
            "t": 600,
            "value": 62.0
          },
          {
            "t": 900,
            "value": 58.0
          },
          {
            "t": 1200,
            "value": 62.0
          },
          {
            "t": 1500,
            "value": 58.0
          },
          {
            "t": 1800,
            "value": 62.0
          },
          {
            "t": 2100,
            "value": 58.0
          },
          {
            "t": 2400,
            "value": 62.0
          },
          {
            "t": 2700,
            "value": 58.0
          },
          {
            "t": 3000,
            "value": 62.0
          },
          {
            "t": 3300,
            "value": 58.0
          },
          {
            "t": 3600,
            "value": 62.0
          },
          {
            "t": 3900,
            "value": 58.0
          },
          {
            "t": 4200,
            "value": 62.0
          },
          {
            "t": 4500,
            "value": 58.0
          },
          {
            "t": 4800,
            "value": 62.0
          },
          {
            "t": 5100,
            "value": 58.0
          },
          {
            "t": 5400,
            "value": 62.0
          },
          {
            "t": 5700,
            "value": 58.0
          },
          {
            "t": 6000,
            "value": 62.0
          },
          {
            "t": 6300,
            "value": 58.0
          },
          {
            "t": 6600,
            "value": 62.0
          },
          {
            "t": 6900,
            "value": 58.0
          },
          {
            "t": 7200,
            "value": 62.0
          },
          {
            "t": 7500,
            "value": 58.0
          },
          {
            "t": 7800,
            "value": 62.0
          },
          {
            "t": 8100,
            "value": 58.0
          },
          {
            "t": 8400,
            "value": 62.0
          },
          {
            "t": 8700,
            "value": 58.0
          },
          {
            "t": 9000,
            "value": 62.0
          },
          {
            "t": 9300,
            "value": 58.0
          },
          {
            "t": 9600,
            "value": 62.0
          },
          {
            "t": 9900,
            "value": 58.0
          },
          {
            "t": 10200,
            "value": 62.0
          },
          {
            "t": 10500,
            "value": 58.0
          },
          {
            "t": 10800,
            "value": 62.0
          },
          {
            "t": 11100,
            "value": 58.0
          },
          {
            "t": 11400,
            "value": 62.0
          },
          {
            "t": 11700,
            "value": 58.0
          },
          {
            "t": 12000,
            "value": 62.0
          },
          {
            "t": 12300,
            "value": 58.0
          },
          {
            "t": 12600,
            "value": 62.0
          },
          {
            "t": 12900,
            "value": 58.0
          },
          {
            "t": 13200,
            "value": 62.0
          },
          {
            "t": 13500,
            "value": 58.0
          },
          {
            "t": 13800,
            "value": 62.0
          },
          {
            "t": 14100,
            "value": 58.0
          }
        ],
        "hws_temp_sp": 60.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 14400,
          "equals": false
        },
        {
          "output": "yFiringRateHunting",
          "from_s": 3600,
          "to_s": 14400,
          "equals": true
        },
        {
          "output": "yTemperatureUnstable",
          "from_s": 3600,
          "to_s": 14400,
          "equals": false
        }
      ]
    },
    {
      "name": "diagnostic_overlap_shorter_than_persistence",
      "description": "During host-excluded warm-up, both raw diagnostics are simultaneously true from 1680 through 1740 s, less than the 300-second final persistence. Firing hunting clears at 1800 s, so the candidate resets before maturity and yFault never asserts.",
      "inputs": {
        "boiler_firing_rate": [
          {
            "t": 0,
            "value": 80.0
          },
          {
            "t": 120,
            "value": 20.0
          },
          {
            "t": 240,
            "value": 80.0
          },
          {
            "t": 360,
            "value": 20.0
          },
          {
            "t": 480,
            "value": 80.0
          },
          {
            "t": 600,
            "value": 20.0
          },
          {
            "t": 720,
            "value": 50.0
          }
        ],
        "hws_temp": [
          {
            "t": 0,
            "value": 60.0
          },
          {
            "t": 1320,
            "value": 50.0
          },
          {
            "t": 1380,
            "value": 70.0
          },
          {
            "t": 1440,
            "value": 50.0
          },
          {
            "t": 1500,
            "value": 70.0
          },
          {
            "t": 1560,
            "value": 50.0
          },
          {
            "t": 1620,
            "value": 70.0
          },
          {
            "t": 1680,
            "value": 50.0
          },
          {
            "t": 1740,
            "value": 70.0
          },
          {
            "t": 1800,
            "value": 50.0
          },
          {
            "t": 1860,
            "value": 70.0
          },
          {
            "t": 1920,
            "value": 50.0
          },
          {
            "t": 1980,
            "value": 70.0
          },
          {
            "t": 2040,
            "value": 50.0
          },
          {
            "t": 2100,
            "value": 70.0
          },
          {
            "t": 2160,
            "value": 50.0
          },
          {
            "t": 2220,
            "value": 70.0
          },
          {
            "t": 2280,
            "value": 50.0
          },
          {
            "t": 2340,
            "value": 70.0
          },
          {
            "t": 2400,
            "value": 50.0
          },
          {
            "t": 2460,
            "value": 70.0
          },
          {
            "t": 2520,
            "value": 50.0
          },
          {
            "t": 2580,
            "value": 70.0
          },
          {
            "t": 2640,
            "value": 50.0
          },
          {
            "t": 2700,
            "value": 70.0
          },
          {
            "t": 2760,
            "value": 50.0
          },
          {
            "t": 2820,
            "value": 70.0
          },
          {
            "t": 2880,
            "value": 50.0
          },
          {
            "t": 2940,
            "value": 70.0
          },
          {
            "t": 3000,
            "value": 50.0
          },
          {
            "t": 3060,
            "value": 70.0
          },
          {
            "t": 3120,
            "value": 50.0
          },
          {
            "t": 3180,
            "value": 70.0
          },
          {
            "t": 3240,
            "value": 50.0
          },
          {
            "t": 3300,
            "value": 70.0
          },
          {
            "t": 3360,
            "value": 50.0
          },
          {
            "t": 3420,
            "value": 70.0
          },
          {
            "t": 3480,
            "value": 50.0
          },
          {
            "t": 3540,
            "value": 70.0
          },
          {
            "t": 3600,
            "value": 50.0
          },
          {
            "t": 3660,
            "value": 70.0
          },
          {
            "t": 3720,
            "value": 50.0
          },
          {
            "t": 3780,
            "value": 70.0
          },
          {
            "t": 3840,
            "value": 50.0
          },
          {
            "t": 3900,
            "value": 70.0
          },
          {
            "t": 3960,
            "value": 50.0
          },
          {
            "t": 4020,
            "value": 70.0
          },
          {
            "t": 4080,
            "value": 50.0
          },
          {
            "t": 4140,
            "value": 70.0
          },
          {
            "t": 4200,
            "value": 50.0
          },
          {
            "t": 4260,
            "value": 70.0
          },
          {
            "t": 4320,
            "value": 50.0
          },
          {
            "t": 4380,
            "value": 70.0
          },
          {
            "t": 4440,
            "value": 50.0
          },
          {
            "t": 4500,
            "value": 70.0
          },
          {
            "t": 4560,
            "value": 50.0
          },
          {
            "t": 4620,
            "value": 70.0
          },
          {
            "t": 4680,
            "value": 50.0
          },
          {
            "t": 4740,
            "value": 70.0
          },
          {
            "t": 4800,
            "value": 50.0
          },
          {
            "t": 4860,
            "value": 70.0
          },
          {
            "t": 4920,
            "value": 50.0
          },
          {
            "t": 4980,
            "value": 70.0
          },
          {
            "t": 5040,
            "value": 50.0
          },
          {
            "t": 5100,
            "value": 70.0
          },
          {
            "t": 5160,
            "value": 50.0
          },
          {
            "t": 5220,
            "value": 70.0
          },
          {
            "t": 5280,
            "value": 50.0
          },
          {
            "t": 5340,
            "value": 70.0
          },
          {
            "t": 5400,
            "value": 50.0
          },
          {
            "t": 5460,
            "value": 70.0
          },
          {
            "t": 5520,
            "value": 50.0
          },
          {
            "t": 5580,
            "value": 70.0
          },
          {
            "t": 5640,
            "value": 50.0
          },
          {
            "t": 5700,
            "value": 70.0
          },
          {
            "t": 5760,
            "value": 50.0
          },
          {
            "t": 5820,
            "value": 70.0
          },
          {
            "t": 5880,
            "value": 50.0
          },
          {
            "t": 5940,
            "value": 70.0
          },
          {
            "t": 6000,
            "value": 50.0
          },
          {
            "t": 6060,
            "value": 70.0
          },
          {
            "t": 6120,
            "value": 50.0
          },
          {
            "t": 6180,
            "value": 70.0
          },
          {
            "t": 6240,
            "value": 50.0
          },
          {
            "t": 6300,
            "value": 70.0
          },
          {
            "t": 6360,
            "value": 50.0
          },
          {
            "t": 6420,
            "value": 70.0
          },
          {
            "t": 6480,
            "value": 50.0
          },
          {
            "t": 6540,
            "value": 70.0
          },
          {
            "t": 6600,
            "value": 50.0
          },
          {
            "t": 6660,
            "value": 70.0
          },
          {
            "t": 6720,
            "value": 50.0
          },
          {
            "t": 6780,
            "value": 70.0
          },
          {
            "t": 6840,
            "value": 50.0
          },
          {
            "t": 6900,
            "value": 70.0
          },
          {
            "t": 6960,
            "value": 50.0
          },
          {
            "t": 7020,
            "value": 70.0
          },
          {
            "t": 7080,
            "value": 50.0
          },
          {
            "t": 7140,
            "value": 70.0
          },
          {
            "t": 7200,
            "value": 50.0
          },
          {
            "t": 7260,
            "value": 70.0
          },
          {
            "t": 7320,
            "value": 50.0
          },
          {
            "t": 7380,
            "value": 70.0
          },
          {
            "t": 7440,
            "value": 50.0
          },
          {
            "t": 7500,
            "value": 70.0
          },
          {
            "t": 7560,
            "value": 50.0
          },
          {
            "t": 7620,
            "value": 70.0
          },
          {
            "t": 7680,
            "value": 50.0
          },
          {
            "t": 7740,
            "value": 70.0
          },
          {
            "t": 7800,
            "value": 50.0
          },
          {
            "t": 7860,
            "value": 70.0
          },
          {
            "t": 7920,
            "value": 50.0
          },
          {
            "t": 7980,
            "value": 70.0
          },
          {
            "t": 8040,
            "value": 50.0
          },
          {
            "t": 8100,
            "value": 70.0
          },
          {
            "t": 8160,
            "value": 50.0
          },
          {
            "t": 8220,
            "value": 70.0
          },
          {
            "t": 8280,
            "value": 50.0
          },
          {
            "t": 8340,
            "value": 70.0
          },
          {
            "t": 8400,
            "value": 50.0
          },
          {
            "t": 8460,
            "value": 70.0
          },
          {
            "t": 8520,
            "value": 50.0
          },
          {
            "t": 8580,
            "value": 70.0
          },
          {
            "t": 8640,
            "value": 50.0
          },
          {
            "t": 8700,
            "value": 70.0
          },
          {
            "t": 8760,
            "value": 50.0
          },
          {
            "t": 8820,
            "value": 70.0
          },
          {
            "t": 8880,
            "value": 50.0
          },
          {
            "t": 8940,
            "value": 70.0
          },
          {
            "t": 9000,
            "value": 50.0
          },
          {
            "t": 9060,
            "value": 70.0
          },
          {
            "t": 9120,
            "value": 50.0
          },
          {
            "t": 9180,
            "value": 70.0
          },
          {
            "t": 9240,
            "value": 50.0
          },
          {
            "t": 9300,
            "value": 70.0
          },
          {
            "t": 9360,
            "value": 50.0
          },
          {
            "t": 9420,
            "value": 70.0
          },
          {
            "t": 9480,
            "value": 50.0
          },
          {
            "t": 9540,
            "value": 70.0
          },
          {
            "t": 9600,
            "value": 50.0
          },
          {
            "t": 9660,
            "value": 70.0
          },
          {
            "t": 9720,
            "value": 50.0
          },
          {
            "t": 9780,
            "value": 70.0
          },
          {
            "t": 9840,
            "value": 50.0
          },
          {
            "t": 9900,
            "value": 70.0
          },
          {
            "t": 9960,
            "value": 50.0
          },
          {
            "t": 10020,
            "value": 70.0
          },
          {
            "t": 10080,
            "value": 50.0
          },
          {
            "t": 10140,
            "value": 70.0
          },
          {
            "t": 10200,
            "value": 50.0
          },
          {
            "t": 10260,
            "value": 70.0
          },
          {
            "t": 10320,
            "value": 50.0
          },
          {
            "t": 10380,
            "value": 70.0
          },
          {
            "t": 10440,
            "value": 50.0
          },
          {
            "t": 10500,
            "value": 70.0
          },
          {
            "t": 10560,
            "value": 50.0
          },
          {
            "t": 10620,
            "value": 70.0
          },
          {
            "t": 10680,
            "value": 50.0
          },
          {
            "t": 10740,
            "value": 70.0
          },
          {
            "t": 10800,
            "value": 50.0
          },
          {
            "t": 10860,
            "value": 70.0
          },
          {
            "t": 10920,
            "value": 50.0
          },
          {
            "t": 10980,
            "value": 70.0
          },
          {
            "t": 11040,
            "value": 50.0
          },
          {
            "t": 11100,
            "value": 70.0
          },
          {
            "t": 11160,
            "value": 50.0
          },
          {
            "t": 11220,
            "value": 70.0
          },
          {
            "t": 11280,
            "value": 50.0
          },
          {
            "t": 11340,
            "value": 70.0
          },
          {
            "t": 11400,
            "value": 50.0
          },
          {
            "t": 11460,
            "value": 70.0
          },
          {
            "t": 11520,
            "value": 50.0
          },
          {
            "t": 11580,
            "value": 70.0
          },
          {
            "t": 11640,
            "value": 50.0
          },
          {
            "t": 11700,
            "value": 70.0
          },
          {
            "t": 11760,
            "value": 50.0
          },
          {
            "t": 11820,
            "value": 70.0
          },
          {
            "t": 11880,
            "value": 50.0
          },
          {
            "t": 11940,
            "value": 70.0
          },
          {
            "t": 12000,
            "value": 50.0
          },
          {
            "t": 12060,
            "value": 70.0
          },
          {
            "t": 12120,
            "value": 50.0
          },
          {
            "t": 12180,
            "value": 70.0
          },
          {
            "t": 12240,
            "value": 50.0
          },
          {
            "t": 12300,
            "value": 70.0
          },
          {
            "t": 12360,
            "value": 50.0
          },
          {
            "t": 12420,
            "value": 70.0
          },
          {
            "t": 12480,
            "value": 50.0
          },
          {
            "t": 12540,
            "value": 70.0
          },
          {
            "t": 12600,
            "value": 50.0
          },
          {
            "t": 12660,
            "value": 70.0
          },
          {
            "t": 12720,
            "value": 50.0
          },
          {
            "t": 12780,
            "value": 70.0
          },
          {
            "t": 12840,
            "value": 50.0
          },
          {
            "t": 12900,
            "value": 70.0
          },
          {
            "t": 12960,
            "value": 50.0
          },
          {
            "t": 13020,
            "value": 70.0
          },
          {
            "t": 13080,
            "value": 50.0
          },
          {
            "t": 13140,
            "value": 70.0
          },
          {
            "t": 13200,
            "value": 50.0
          },
          {
            "t": 13260,
            "value": 70.0
          },
          {
            "t": 13320,
            "value": 50.0
          },
          {
            "t": 13380,
            "value": 70.0
          },
          {
            "t": 13440,
            "value": 50.0
          },
          {
            "t": 13500,
            "value": 70.0
          },
          {
            "t": 13560,
            "value": 50.0
          },
          {
            "t": 13620,
            "value": 70.0
          },
          {
            "t": 13680,
            "value": 50.0
          },
          {
            "t": 13740,
            "value": 70.0
          },
          {
            "t": 13800,
            "value": 50.0
          },
          {
            "t": 13860,
            "value": 70.0
          },
          {
            "t": 13920,
            "value": 50.0
          },
          {
            "t": 13980,
            "value": 70.0
          },
          {
            "t": 14040,
            "value": 50.0
          },
          {
            "t": 14100,
            "value": 70.0
          },
          {
            "t": 14160,
            "value": 50.0
          },
          {
            "t": 14220,
            "value": 70.0
          },
          {
            "t": 14280,
            "value": 50.0
          },
          {
            "t": 14340,
            "value": 70.0
          },
          {
            "t": 14400,
            "value": 50.0
          }
        ],
        "hws_temp_sp": 60.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 14400,
          "equals": false
        },
        {
          "output": "yFiringRateHunting",
          "from_s": 1200,
          "to_s": 1260,
          "equals": true
        },
        {
          "output": "yFiringRateHunting",
          "from_s": 1680,
          "to_s": 1740,
          "equals": true
        },
        {
          "output": "yFiringRateHunting",
          "from_s": 1800,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yFiringRateHunting",
          "from_s": 1860,
          "to_s": 14400,
          "equals": false
        },
        {
          "output": "yTemperatureUnstable",
          "from_s": 1680,
          "to_s": 1740,
          "equals": true
        },
        {
          "output": "yTemperatureUnstable",
          "from_s": 1860,
          "to_s": 14400,
          "equals": true
        }
      ]
    },
    {
      "name": "stage_change_discontinuity_is_host_excluded",
      "description": "Repeated lead/lag stage changes can make an aggregate firing-rate channel and header temperature alternate by construction. After the settled input transition, temperature instability joins firing hunting at 5280 s; yFault is clear at 5520 s, asserts exactly at 5580 s after the 300 s delay, and remains true after it. The raw graph alarms, so stable stage identity is a mandatory host gate.",
      "inputs": {
        "boiler_firing_rate": [
          {
            "t": 0,
            "value": 50.0
          },
          {
            "t": 3600,
            "value": 20.0
          },
          {
            "t": 3840,
            "value": 80.0
          },
          {
            "t": 4080,
            "value": 20.0
          },
          {
            "t": 4320,
            "value": 80.0
          },
          {
            "t": 4560,
            "value": 20.0
          },
          {
            "t": 4800,
            "value": 80.0
          },
          {
            "t": 5040,
            "value": 20.0
          },
          {
            "t": 5280,
            "value": 80.0
          },
          {
            "t": 5520,
            "value": 20.0
          },
          {
            "t": 5760,
            "value": 80.0
          },
          {
            "t": 6000,
            "value": 20.0
          },
          {
            "t": 6240,
            "value": 80.0
          },
          {
            "t": 6480,
            "value": 20.0
          },
          {
            "t": 6720,
            "value": 80.0
          },
          {
            "t": 6960,
            "value": 20.0
          },
          {
            "t": 7200,
            "value": 80.0
          },
          {
            "t": 7440,
            "value": 20.0
          },
          {
            "t": 7680,
            "value": 80.0
          },
          {
            "t": 7920,
            "value": 20.0
          },
          {
            "t": 8160,
            "value": 80.0
          },
          {
            "t": 8400,
            "value": 20.0
          },
          {
            "t": 8640,
            "value": 80.0
          },
          {
            "t": 8880,
            "value": 20.0
          },
          {
            "t": 9120,
            "value": 80.0
          },
          {
            "t": 9360,
            "value": 20.0
          },
          {
            "t": 9600,
            "value": 80.0
          },
          {
            "t": 9840,
            "value": 20.0
          },
          {
            "t": 10080,
            "value": 80.0
          },
          {
            "t": 10320,
            "value": 20.0
          },
          {
            "t": 10560,
            "value": 80.0
          },
          {
            "t": 10800,
            "value": 20.0
          },
          {
            "t": 11040,
            "value": 80.0
          },
          {
            "t": 11280,
            "value": 20.0
          },
          {
            "t": 11520,
            "value": 80.0
          },
          {
            "t": 11760,
            "value": 20.0
          },
          {
            "t": 12000,
            "value": 80.0
          },
          {
            "t": 12240,
            "value": 20.0
          },
          {
            "t": 12480,
            "value": 80.0
          },
          {
            "t": 12720,
            "value": 20.0
          },
          {
            "t": 12960,
            "value": 80.0
          },
          {
            "t": 13200,
            "value": 20.0
          },
          {
            "t": 13440,
            "value": 80.0
          },
          {
            "t": 13680,
            "value": 20.0
          },
          {
            "t": 13920,
            "value": 80.0
          },
          {
            "t": 14160,
            "value": 20.0
          },
          {
            "t": 14400,
            "value": 80.0
          }
        ],
        "hws_temp": [
          {
            "t": 0,
            "value": 60.0
          },
          {
            "t": 3600,
            "value": 58.0
          },
          {
            "t": 3840,
            "value": 62.0
          },
          {
            "t": 4080,
            "value": 58.0
          },
          {
            "t": 4320,
            "value": 62.0
          },
          {
            "t": 4560,
            "value": 58.0
          },
          {
            "t": 4800,
            "value": 62.0
          },
          {
            "t": 5040,
            "value": 58.0
          },
          {
            "t": 5280,
            "value": 62.0
          },
          {
            "t": 5520,
            "value": 58.0
          },
          {
            "t": 5760,
            "value": 62.0
          },
          {
            "t": 6000,
            "value": 58.0
          },
          {
            "t": 6240,
            "value": 62.0
          },
          {
            "t": 6480,
            "value": 58.0
          },
          {
            "t": 6720,
            "value": 62.0
          },
          {
            "t": 6960,
            "value": 58.0
          },
          {
            "t": 7200,
            "value": 62.0
          },
          {
            "t": 7440,
            "value": 58.0
          },
          {
            "t": 7680,
            "value": 62.0
          },
          {
            "t": 7920,
            "value": 58.0
          },
          {
            "t": 8160,
            "value": 62.0
          },
          {
            "t": 8400,
            "value": 58.0
          },
          {
            "t": 8640,
            "value": 62.0
          },
          {
            "t": 8880,
            "value": 58.0
          },
          {
            "t": 9120,
            "value": 62.0
          },
          {
            "t": 9360,
            "value": 58.0
          },
          {
            "t": 9600,
            "value": 62.0
          },
          {
            "t": 9840,
            "value": 58.0
          },
          {
            "t": 10080,
            "value": 62.0
          },
          {
            "t": 10320,
            "value": 58.0
          },
          {
            "t": 10560,
            "value": 62.0
          },
          {
            "t": 10800,
            "value": 58.0
          },
          {
            "t": 11040,
            "value": 62.0
          },
          {
            "t": 11280,
            "value": 58.0
          },
          {
            "t": 11520,
            "value": 62.0
          },
          {
            "t": 11760,
            "value": 58.0
          },
          {
            "t": 12000,
            "value": 62.0
          },
          {
            "t": 12240,
            "value": 58.0
          },
          {
            "t": 12480,
            "value": 62.0
          },
          {
            "t": 12720,
            "value": 58.0
          },
          {
            "t": 12960,
            "value": 62.0
          },
          {
            "t": 13200,
            "value": 58.0
          },
          {
            "t": 13440,
            "value": 62.0
          },
          {
            "t": 13680,
            "value": 58.0
          },
          {
            "t": 13920,
            "value": 62.0
          },
          {
            "t": 14160,
            "value": 58.0
          },
          {
            "t": 14400,
            "value": 62.0
          }
        ],
        "hws_temp_sp": 60.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5520,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 5580,
          "to_s": 5640,
          "equals": true
        },
        {
          "output": "yFiringRateHunting",
          "from_s": 5280,
          "to_s": 5280,
          "equals": true
        },
        {
          "output": "yTemperatureUnstable",
          "from_s": 5220,
          "to_s": 5220,
          "equals": false
        },
        {
          "output": "yTemperatureUnstable",
          "from_s": 5280,
          "to_s": 5280,
          "equals": true
        },
        {
          "output": "yFiringRateHunting",
          "from_s": 7200,
          "to_s": 14400,
          "equals": true
        },
        {
          "output": "yTemperatureUnstable",
          "from_s": 7200,
          "to_s": 14400,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 7800,
          "to_s": 14400,
          "equals": true
        }
      ]
    }
  ]
}

HW-0012 — Excess boiler stages at low plant load

Statusverified — engine e2ff2f8, cxf:fnv1a128:5f5748540da32f7ac07fe2d05e6c46cb, 2026-08-20
Severity3
Methodrule
Phase2
CategoryEXCESS_CONSUMPTION
ConfidenceMEDIUM
EstimationQUALITATIVE_ONLY
G36
Clusters
Suppresses
Suppressed by
RelatedHW-0001, HW-0002, HW-0011
Playbookshot-water-plant-faults
SourceLBNL Simulated Boiler Plant dataset inventory, PDF pp.4-8 — two identical parallel boilers, staged heat-load control, status channels, and useful secondary-loop power; it does not publish the shipped generic thresholds or an over-staging fault; PNNL Heating Plant Equipment Modeling Inputs — boiler type, sizing, efficiency, and stage-up part-load ratios are plant-specific inputs, supporting an adoption-blocking staging map rather than a portable threshold; Library precedent RTU-0001/TOWER-0003 — verified native integer stage/count comparison and initialization-safe continuous persistence
Operating statesnormal automatic boiler staging after fleet, availability, load basis, minimum-time, and stage-transition state have settled

Preconditions (host-enforced): boiler_stage_count must count proven firing staging units in one configured eligible fleet, not enabled or available units. A unit may be a whole boiler or a comparable modular burner section, but unlike units must not be mixed in one scalar count. hw_plant_load_fraction must be useful plant heating load divided by one commissioned eligible-fleet capacity basis, remain in 0..1, and keep that denominator stable through the evaluation window. If fleet membership or capacity basis changes, restart evaluation or report NO_EVAL. The threshold and allowed count require site commissioning against sizes, turndown, minimum flow, venting, emissions, redundancy, and minimum on/off policy; the shipped numbers are adoption-blocking placeholders. Exclude warm-up, freeze protection, emergency redundancy, exercise, maintenance, rotation, recent starts/stops, stage overlap, and intentional safety/application limits. OAT alone is not a valid load proxy. Freshness, alignment, finite values, and derivation provenance remain host obligations.

Points: boiler_stage_count, hw_plant_load_fraction

Outputs:

  • yFault — True after a valid low-load plant operates above the commissioned allowed firing-stage count continuously for sustained_duration
  • yLoadOk — Evaluability output; true only for an inclusive 0..1 load fraction. False means NO_EVAL and every other output is uninterpretable
  • yLowLoad — Raw diagnostic; true when load fraction is strictly below low_load_fraction, including for invalid negative values, so consult yLoadOk first
  • yExcessStages — Raw diagnostic; true when firing stage count is strictly above max_stages_at_low_load

Parameters:

NameDefaultUnitCXF pathDescription
low_load_fraction0.351lowLoad.tNO_PORTABLE_DEFAULT: executable adoption-blocking placeholder. Replace from the commissioned staging map, boiler sizes, turndown, minimum-flow, and emissions constraints before deployment.
max_stages_at_low_load11excessStages.tNO_PORTABLE_DEFAULT: executable adoption-blocking placeholder. Replace with the allowed comparable firing-unit count for the configured fleet and load region.
sustained_duration900.0spersist.delayTimeADOPTED_TUNABLE continuous over-staging proof. Set beyond ordinary stage overlap, rotation, and minimum-on/off transitions.

Description

This rule identifies a configured boiler fleet keeping more firing units on than its commissioned staging map permits at low useful load. The point is not that two boilers are universally wrong: unequal machines, modular burners, minimum-flow requirements, redundancy, emissions, and minimum run time can make two units correct. Those facts define the adoption contract and are why both shipped decision thresholds deliberately block portable deployment.

Detection Logic

negative_load = hw_plant_load_fraction < 0
above_one     = hw_plant_load_fraction > 1
yLoadOk       = NOT negative_load AND NOT above_one

yLowLoad      = hw_plant_load_fraction < low_load_fraction
yExcessStages = boiler_stage_count > max_stages_at_low_load

yFault = TrueDelay(yLoadOk AND yLowLoad AND yExcessStages,
                   sustained_duration)

Block graph (rule.cxf.jsonld):

HW-0012 block graph

The load-validity interval includes exactly 0 and 1. Exact load 0.35 and exact stage count 1 are clear because both decision comparisons are strict. The graph uses CDL.Integers.GreaterThreshold directly. delayOnInit=true requires the full interval on evaluator startup and any false conjunct resets the timer.

Read yLoadOk first. A negative load makes raw yLowLoad=true, but it gates yFault off and means NO_EVAL; non-finite/freshness checks remain host-side.

Possible Diagnoses

  1. Stage-down threshold, timer, or minimum-run logic set too conservatively.
  2. Lead/lag sequence leaving a second boiler latched after load falls.
  3. Enabled/available units mistakenly counted as proven firing units.
  4. Load numerator, commissioned capacity, or fleet membership derived wrong.
  5. Boiler sizes/turndown make the adopted scalar stage rule invalid.
  6. Redundancy, exercise, freeze, emissions, or minimum-flow mode not excluded.

Energy Impact

At the same useful load, excess firing machines can add jacket and standby loss, operate each burner below its efficient modulation region, and add purge or light-off cycles. Magnitude depends on equipment and sequence. Without measured fuel and a commissioned alternative staging model, the result remains qualitative and no generic savings percentage is claimed.

Emissions Impact

Any scope-1 impact follows the site-specific fuel penalty of the actual staging sequence. This rule has neither a fuel measurement nor a counterfactual staging model, so it does not assign an emissions quantity.

Deviations

  • The brief classed 0.35 and one stage as adopted tunables. They are reclassified NO_PORTABLE_DEFAULT: source material does not establish them, and boiler sizing, turndown, topology, and policy change the correct values materially.
  • The load denominator is one stable commissioned eligible-fleet capacity, not ambiguously “current” capacity. Availability changes require a new explicit configuration and evaluation restart.
  • Range validity is implemented in-graph as yLoadOk; finite values, source quality, and derivation provenance cannot be proven by inverted comparisons.
  • The LBNL plant has useful healthy staging channels but no injected over-stage fault. No local dataset was available and EnergyPlus’s target loop has only one real boiler, so this card records no simulation validation claim.
  • No Boiler Control Instability cluster is created. Short cycling, hunting, and over-staging can co-occur, but no one trigger reliably occurs first or shares one repair that clears all members.

Notes

Investigate HW-0001 for resulting starts, HW-0011 for unstable modulation and temperature, and HW-0002 for measured efficiency degradation. Never reduce stages until manufacturer turndown, minimum flow, venting, emissions, safety, redundancy, and minimum-time requirements have been checked.

Test Vectors

22 scenarios, clock step 60 s over 3600 s.

ScenarioDescription
zero_stages_at_low_loadA stopped plant at low load exposes only the low-load diagnostic and is not over-staged.
one_stage_at_low_loadOne firing boiler is the shipped allowance below the low-load threshold.
two_stages_at_low_load_initializationTwo firing boilers at low load assert both diagnostics immediately: yFault is clear one tick before 900 s, asserts exactly at 900 s, and remains true after the full delay.
multiple_stages_at_high_loadMultiple boilers are appropriate above the configured low-load region.
load_exact_threshold_clearExactly 0.35 is not low load under the strict comparator.
load_just_below_thresholdOne thousandth below the load threshold activates the low-load branch.
stage_count_exact_threshold_clearExactly one firing stage is not excess under the integer comparison.
both_just_beyond_boundariesTwo stages and 0.349 load are the nearest representable faulting values on both branches.
load_zero_endpoint_is_validZero is an inclusive valid load endpoint and can support an over-staging finding.
load_one_endpoint_is_validOne is an inclusive valid load endpoint and is outside the low-load region.
stage_overlap_ends_one_tick_before_persistenceA second stage drops out one evaluator step before 900 seconds and never alarms.
mature_alarm_clears_when_stage_dropsA mature over-staging alarm clears immediately when the extra boiler stops firing.
mature_alarm_clears_when_load_risesA real load increase clears the low-load premise even while two boilers continue firing.
two_short_overlaps_do_not_accumulateSeparated transient stage overlaps each receive a fresh timer.
load_rise_resets_timerA temporary load rise resets persistence before the original low-load condition returns.
stage_drop_resets_timerA one-stage interval resets persistence before the second stage returns.
load_threshold_chatter_resets_delayLoad alternating around 0.35 never stays low for the full delay.
stage_count_chatter_resets_delayA second stage repeatedly entering and leaving below the proof duration never matures.
negative_load_is_not_evaluableA negative derived load is numerically low, but the in-graph range gate blocks the alarm and exposes NO_EVAL.
load_above_one_is_not_evaluableA derived load above one is invalid; yLoadOk prevents a false healthy interpretation even though low-load is false.
enabled_count_misbinding_raw_false_positiveCounting enabled or available boilers instead of firing machines can create this raw alarm; the derivation contract must reject that binding.
redundancy_warmup_is_host_excludedTwo firing boilers during an emergency-redundancy or warm-up interval are raw over-staging but explicitly outside normal automatic sequencing.
vectors.json
{
  "schema": "cxf-library/vectors/v1",
  "clock": {
    "step_s": 60,
    "horizon_s": 3600
  },
  "scenarios": [
    {
      "name": "zero_stages_at_low_load",
      "description": "A stopped plant at low load exposes only the low-load diagnostic and is not over-staged.",
      "inputs": {
        "boiler_stage_count": 0,
        "hw_plant_load_fraction": 0.1
      },
      "expect": [
        {
          "output": "yLowLoad",
          "from_s": 0,
          "to_s": 3600,
          "equals": true
        },
        {
          "output": "yExcessStages",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        },
        {
          "output": "yLoadOk",
          "from_s": 0,
          "to_s": 3600,
          "equals": true
        }
      ]
    },
    {
      "name": "one_stage_at_low_load",
      "description": "One firing boiler is the shipped allowance below the low-load threshold.",
      "inputs": {
        "boiler_stage_count": 1,
        "hw_plant_load_fraction": 0.2
      },
      "expect": [
        {
          "output": "yLowLoad",
          "from_s": 0,
          "to_s": 3600,
          "equals": true
        },
        {
          "output": "yExcessStages",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        },
        {
          "output": "yLoadOk",
          "from_s": 0,
          "to_s": 3600,
          "equals": true
        }
      ]
    },
    {
      "name": "two_stages_at_low_load_initialization",
      "description": "Two firing boilers at low load assert both diagnostics immediately: yFault is clear one tick before 900 s, asserts exactly at 900 s, and remains true after the full delay.",
      "inputs": {
        "boiler_stage_count": 2,
        "hw_plant_load_fraction": 0.2
      },
      "expect": [
        {
          "output": "yLowLoad",
          "from_s": 0,
          "to_s": 3600,
          "equals": true
        },
        {
          "output": "yExcessStages",
          "from_s": 0,
          "to_s": 3600,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 840,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 900,
          "to_s": 900,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 960,
          "to_s": 3600,
          "equals": true
        },
        {
          "output": "yLoadOk",
          "from_s": 0,
          "to_s": 3600,
          "equals": true
        }
      ]
    },
    {
      "name": "multiple_stages_at_high_load",
      "description": "Multiple boilers are appropriate above the configured low-load region.",
      "inputs": {
        "boiler_stage_count": 3,
        "hw_plant_load_fraction": 0.8
      },
      "expect": [
        {
          "output": "yLowLoad",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        },
        {
          "output": "yExcessStages",
          "from_s": 0,
          "to_s": 3600,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        },
        {
          "output": "yLoadOk",
          "from_s": 0,
          "to_s": 3600,
          "equals": true
        }
      ]
    },
    {
      "name": "load_exact_threshold_clear",
      "description": "Exactly 0.35 is not low load under the strict comparator.",
      "inputs": {
        "boiler_stage_count": 2,
        "hw_plant_load_fraction": 0.35
      },
      "expect": [
        {
          "output": "yLowLoad",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        },
        {
          "output": "yExcessStages",
          "from_s": 0,
          "to_s": 3600,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        },
        {
          "output": "yLoadOk",
          "from_s": 0,
          "to_s": 3600,
          "equals": true
        }
      ]
    },
    {
      "name": "load_just_below_threshold",
      "description": "One thousandth below the load threshold activates the low-load branch.",
      "inputs": {
        "boiler_stage_count": 1,
        "hw_plant_load_fraction": 0.349
      },
      "expect": [
        {
          "output": "yLowLoad",
          "from_s": 0,
          "to_s": 3600,
          "equals": true
        },
        {
          "output": "yExcessStages",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        },
        {
          "output": "yLoadOk",
          "from_s": 0,
          "to_s": 3600,
          "equals": true
        }
      ]
    },
    {
      "name": "stage_count_exact_threshold_clear",
      "description": "Exactly one firing stage is not excess under the integer comparison.",
      "inputs": {
        "boiler_stage_count": 1,
        "hw_plant_load_fraction": 0.1
      },
      "expect": [
        {
          "output": "yLowLoad",
          "from_s": 0,
          "to_s": 3600,
          "equals": true
        },
        {
          "output": "yExcessStages",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        },
        {
          "output": "yLoadOk",
          "from_s": 0,
          "to_s": 3600,
          "equals": true
        }
      ]
    },
    {
      "name": "both_just_beyond_boundaries",
      "description": "Two stages and 0.349 load are the nearest representable faulting values on both branches.",
      "inputs": {
        "boiler_stage_count": 2,
        "hw_plant_load_fraction": 0.349
      },
      "expect": [
        {
          "output": "yLowLoad",
          "from_s": 0,
          "to_s": 3600,
          "equals": true
        },
        {
          "output": "yExcessStages",
          "from_s": 0,
          "to_s": 3600,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 840,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 960,
          "to_s": 3600,
          "equals": true
        },
        {
          "output": "yLoadOk",
          "from_s": 0,
          "to_s": 3600,
          "equals": true
        }
      ]
    },
    {
      "name": "load_zero_endpoint_is_valid",
      "description": "Zero is an inclusive valid load endpoint and can support an over-staging finding.",
      "inputs": {
        "boiler_stage_count": 2,
        "hw_plant_load_fraction": 0.0
      },
      "expect": [
        {
          "output": "yLowLoad",
          "from_s": 0,
          "to_s": 3600,
          "equals": true
        },
        {
          "output": "yExcessStages",
          "from_s": 0,
          "to_s": 3600,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 840,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 960,
          "to_s": 3600,
          "equals": true
        },
        {
          "output": "yLoadOk",
          "from_s": 0,
          "to_s": 3600,
          "equals": true
        }
      ]
    },
    {
      "name": "load_one_endpoint_is_valid",
      "description": "One is an inclusive valid load endpoint and is outside the low-load region.",
      "inputs": {
        "boiler_stage_count": 2,
        "hw_plant_load_fraction": 1.0
      },
      "expect": [
        {
          "output": "yLowLoad",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        },
        {
          "output": "yExcessStages",
          "from_s": 0,
          "to_s": 3600,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        },
        {
          "output": "yLoadOk",
          "from_s": 0,
          "to_s": 3600,
          "equals": true
        }
      ]
    },
    {
      "name": "stage_overlap_ends_one_tick_before_persistence",
      "description": "A second stage drops out one evaluator step before 900 seconds and never alarms.",
      "inputs": {
        "boiler_stage_count": [
          {
            "t": 0,
            "value": 2
          },
          {
            "t": 840,
            "value": 1
          }
        ],
        "hw_plant_load_fraction": 0.2
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        },
        {
          "output": "yLoadOk",
          "from_s": 0,
          "to_s": 3600,
          "equals": true
        }
      ]
    },
    {
      "name": "mature_alarm_clears_when_stage_drops",
      "description": "A mature over-staging alarm clears immediately when the extra boiler stops firing.",
      "inputs": {
        "boiler_stage_count": [
          {
            "t": 0,
            "value": 2
          },
          {
            "t": 1800,
            "value": 1
          }
        ],
        "hw_plant_load_fraction": 0.2
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 960,
          "to_s": 1740,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 1860,
          "to_s": 3600,
          "equals": false
        },
        {
          "output": "yExcessStages",
          "from_s": 1860,
          "to_s": 3600,
          "equals": false
        },
        {
          "output": "yLoadOk",
          "from_s": 0,
          "to_s": 3600,
          "equals": true
        }
      ]
    },
    {
      "name": "mature_alarm_clears_when_load_rises",
      "description": "A real load increase clears the low-load premise even while two boilers continue firing.",
      "inputs": {
        "boiler_stage_count": 2,
        "hw_plant_load_fraction": [
          {
            "t": 0,
            "value": 0.2
          },
          {
            "t": 1800,
            "value": 0.5
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 960,
          "to_s": 1740,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 1860,
          "to_s": 3600,
          "equals": false
        },
        {
          "output": "yLowLoad",
          "from_s": 1860,
          "to_s": 3600,
          "equals": false
        },
        {
          "output": "yLoadOk",
          "from_s": 0,
          "to_s": 3600,
          "equals": true
        }
      ]
    },
    {
      "name": "two_short_overlaps_do_not_accumulate",
      "description": "Separated transient stage overlaps each receive a fresh timer.",
      "inputs": {
        "boiler_stage_count": [
          {
            "t": 0,
            "value": 2
          },
          {
            "t": 600,
            "value": 1
          },
          {
            "t": 900,
            "value": 2
          },
          {
            "t": 1500,
            "value": 1
          }
        ],
        "hw_plant_load_fraction": 0.2
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        },
        {
          "output": "yLoadOk",
          "from_s": 0,
          "to_s": 3600,
          "equals": true
        }
      ]
    },
    {
      "name": "load_rise_resets_timer",
      "description": "A temporary load rise resets persistence before the original low-load condition returns.",
      "inputs": {
        "boiler_stage_count": 2,
        "hw_plant_load_fraction": [
          {
            "t": 0,
            "value": 0.2
          },
          {
            "t": 600,
            "value": 0.5
          },
          {
            "t": 900,
            "value": 0.2
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1740,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 1860,
          "to_s": 3600,
          "equals": true
        },
        {
          "output": "yLoadOk",
          "from_s": 0,
          "to_s": 3600,
          "equals": true
        }
      ]
    },
    {
      "name": "stage_drop_resets_timer",
      "description": "A one-stage interval resets persistence before the second stage returns.",
      "inputs": {
        "boiler_stage_count": [
          {
            "t": 0,
            "value": 2
          },
          {
            "t": 600,
            "value": 1
          },
          {
            "t": 900,
            "value": 2
          }
        ],
        "hw_plant_load_fraction": 0.2
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1740,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 1860,
          "to_s": 3600,
          "equals": true
        },
        {
          "output": "yLoadOk",
          "from_s": 0,
          "to_s": 3600,
          "equals": true
        }
      ]
    },
    {
      "name": "load_threshold_chatter_resets_delay",
      "description": "Load alternating around 0.35 never stays low for the full delay.",
      "inputs": {
        "boiler_stage_count": 2,
        "hw_plant_load_fraction": [
          {
            "t": 0,
            "value": 0.349
          },
          {
            "t": 180,
            "value": 0.351
          },
          {
            "t": 360,
            "value": 0.349
          },
          {
            "t": 540,
            "value": 0.351
          },
          {
            "t": 720,
            "value": 0.349
          },
          {
            "t": 900,
            "value": 0.351
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        },
        {
          "output": "yLoadOk",
          "from_s": 0,
          "to_s": 3600,
          "equals": true
        }
      ]
    },
    {
      "name": "stage_count_chatter_resets_delay",
      "description": "A second stage repeatedly entering and leaving below the proof duration never matures.",
      "inputs": {
        "boiler_stage_count": [
          {
            "t": 0,
            "value": 2
          },
          {
            "t": 180,
            "value": 1
          },
          {
            "t": 360,
            "value": 2
          },
          {
            "t": 540,
            "value": 1
          },
          {
            "t": 720,
            "value": 2
          },
          {
            "t": 900,
            "value": 1
          }
        ],
        "hw_plant_load_fraction": 0.2
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        },
        {
          "output": "yLoadOk",
          "from_s": 0,
          "to_s": 3600,
          "equals": true
        }
      ]
    },
    {
      "name": "negative_load_is_not_evaluable",
      "description": "A negative derived load is numerically low, but the in-graph range gate blocks the alarm and exposes NO_EVAL.",
      "inputs": {
        "boiler_stage_count": 2,
        "hw_plant_load_fraction": -0.1
      },
      "expect": [
        {
          "output": "yLoadOk",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        },
        {
          "output": "yLowLoad",
          "from_s": 0,
          "to_s": 3600,
          "equals": true
        },
        {
          "output": "yExcessStages",
          "from_s": 0,
          "to_s": 3600,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "load_above_one_is_not_evaluable",
      "description": "A derived load above one is invalid; yLoadOk prevents a false healthy interpretation even though low-load is false.",
      "inputs": {
        "boiler_stage_count": 2,
        "hw_plant_load_fraction": 1.1
      },
      "expect": [
        {
          "output": "yLoadOk",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        },
        {
          "output": "yLowLoad",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        },
        {
          "output": "yExcessStages",
          "from_s": 0,
          "to_s": 3600,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "enabled_count_misbinding_raw_false_positive",
      "description": "Counting enabled or available boilers instead of firing machines can create this raw alarm; the derivation contract must reject that binding.",
      "inputs": {
        "boiler_stage_count": 2,
        "hw_plant_load_fraction": 0.1
      },
      "expect": [
        {
          "output": "yLowLoad",
          "from_s": 0,
          "to_s": 3600,
          "equals": true
        },
        {
          "output": "yExcessStages",
          "from_s": 0,
          "to_s": 3600,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 960,
          "to_s": 3600,
          "equals": true
        },
        {
          "output": "yLoadOk",
          "from_s": 0,
          "to_s": 3600,
          "equals": true
        }
      ]
    },
    {
      "name": "redundancy_warmup_is_host_excluded",
      "description": "Two firing boilers during an emergency-redundancy or warm-up interval are raw over-staging but explicitly outside normal automatic sequencing.",
      "inputs": {
        "boiler_stage_count": 2,
        "hw_plant_load_fraction": 0.2
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 960,
          "to_s": 3600,
          "equals": true
        },
        {
          "output": "yLoadOk",
          "from_s": 0,
          "to_s": 3600,
          "equals": true
        }
      ]
    }
  ]
}

Hydronic Heat-Exchanger Fault Rules

Hydronic heat-exchanger rules (HX-*) cover one indirect liquid-to-liquid four-port heat exchanger. The primary side normally belongs to the external or source network; the secondary side normally belongs to the served/load loop. Both identities stay fixed while signed heat transfer may reverse.

Point dictionary: points/hx.points.json. Brick 1.4.4 provides exact generic Heat_Exchanger; ASHRAE 223 PPR2.1 provides exact HydronicHeatExchanger with paired primary/secondary inlet and outlet connection points.

Excluded from the initial family: air/refrigerant heat exchangers and coils, direct-contact devices, phase-changing steam service, potable-water service, and an HX bank whose temperatures/flows do not preserve one coherent identity.

Index

IDNameSevMethodStatus
HX-0001Hydronic heat-exchanger effectiveness degradation3statisticalverified
HX-0002Heat exchanger active with one-side flow missing2ruleverified
HX-0003Heat transfer persists with control valve commanded closed3ruleverified

Topology and telemetry

TierRequired contractSupported rule
Flow prooffinal both-flow exchange command plus individual primary/secondary branch flowsHX-0002
Performancefour connection-point temperatures, both branch flows, configured fluid properties, validated actual and frozen expected effectivenessHX-0001
Isolationfinal isolating-valve command plus validated signed heat-transfer derivationHX-0003

primary and secondary are side identities, never synonyms for hot/cold or supply/return. Positive heat_transfer_rate means primary loses heat and secondary gains it; cooling normally has the opposite sign. HX-0001 and HX-0003 are magnitude-based and work in either direction.

Common-header/fleet flow, duplicated side sensors, unaligned timestamps, a baseline fitted on the scored interval, or missing glycol properties make the thermal rules NO_EVAL. The CXF graphs do not calculate effectiveness: the host must validate every denominator and side energy balance before publishing the derived point.

Source and validation posture

EnergyPlus 25.1.0’s official HeatExchanger:FluidToFluid model and PlantLoopChainHeating.idf/PlantLoopChainCooling.idf testfiles provide the healthy Layer 3 path. The library has not yet claimed a completed simulation campaign for these rules. Guelpa and Verda (Applied Energy 258, 2020, doi:10.1016/j.apenergy.2019.114059) provide field precedent for fouling detection from primary flow and both-side temperatures across 325 district heating substations, but no source provides portable alarm thresholds.

Relationships

  • HX-0002 is proof/evaluability context for HX-0001, but no global suppression is encoded because fault IDs are not instance-scoped.
  • HX-0003 can explain degradation or wasted transfer while HX-0001 remains a separate performance verdict.
  • PMP-0001/PMP-0003 may locate a failed pump behind HX-0002; they are related workflows, not duplicate HX signatures.

HX-0001 — Hydronic heat-exchanger effectiveness degradation

Statusverified — engine e2ff2f8, cxf:fnv1a128:95d59df8187626eb12cc97061e4b7f9b, 2026-08-20
Severity3
Methodstatistical
Phase2
CategoryEFFICIENCY_LOSS
ConfidenceMEDIUM
EstimationBASELINE_COMPARISON
G36
Clusters
Suppresses
Suppressed by
RelatedHX-0002, HX-0003
Playbookshydronic-heat-exchanger-faults
SourceEnergyPlus 25.1 Engineering Reference, Heat Exchangers — epsilon-NTU model using both flow-capacity rates and inlet temperatures: https://bigladdersoftware.com/epx/docs/25-1/engineering-reference/heat-exchangers.html; Guelpa and Verda, Applied Energy 258 (2020), DOI 10.1016/j.apenergy.2019.114059 — field fouling detection on 325 district-heating HX substations from primary mass flow and temperatures on both sides; DOE FEMP, Energy Management Information System Capabilities — reduced HX heat transfer from temperature sensors as a condition-based maintenance signal: https://www.energy.gov/cmei/femp/energy-management-information-system-capabilities
Operating statesOne indirect liquid-to-liquid HX exchanging heat in a settled heating or cooling state, with both branch flows established and a frozen clean/design expected-effectiveness model ready and in domain

Preconditions (host-enforced): All six physical derivation inputs must describe the same HX: primary/secondary entering/leaving temperatures plus individual branch flows, aligned in time and correctly scaled. The host computes effectiveness only after proving positive finite thermal capacity rates, sufficient entering-temperature separation, configured density/cp for each fluid (including glycol concentration), and agreement of independently calculated side heat rates within commissioned uncertainty. The expected model must be frozen, independently fitted/commissioned, ready, fresh, and in domain for the current flow-capacity ratio, entering temperatures, direction, and control state. Suspend and re-warm after starts, direction/setpoint/pump/valve/stage changes. A common-header flow, duplicated side point, same-window fitted target, imbalance, or invalid denominator means NO_EVAL, not healthy. Steam/phase-change, air/refrigerant, potable, direct-contact, and aggregate-bank service are excluded.

Points: effectiveness, effectiveness_expected

Outputs:

  • yFault — True after actual effectiveness remains more than effectiveness_allowance below the valid expected value for alarm_delay
  • yEffectivenessLow — Diagnostic sub-condition flag; true when expected minus actual effectiveness strictly exceeds the allowance. False never means NO_EVAL

Parameters:

NameDefaultUnitCXF pathDescription
effectiveness_allowance0.1251shortfallHigh.tNO_PORTABLE_DEFAULT executable placeholder: 0.125 effectiveness points is a binary-exact vector fixture, not a field recommendation. Commission from clean-model error, sensor/fluid-property uncertainty, and the minimum actionable degradation before enabling this rule.
alarm_delay900.0spersist.delayTimeADOPTED_TUNABLE 15-minute persistence after the host’s independent settling/re-warm gate. Retune to the installation time constant and data cadence; no cited source establishes a universal duration.

Description

An indirect liquid heat exchanger loses effectiveness when fouling, scale, blocked channels, internal bypass, wrong fluid properties, or hydraulic changes reduce the heat it moves for the opportunity available. This rule compares a host-validated actual thermal effectiveness with a frozen clean/design expected value for the same operating condition. It reports degradation, not a root cause and not a raw “approach” temperature.

The four-port point identity matters as much as the arithmetic. Primary and secondary are fixed topology labels; heating usually makes signed transfer positive and cooling negative. The host converts both directions to a positive effectiveness before the graph sees them.

Detection Logic

shortfall = effectiveness_expected - effectiveness
yEffectivenessLow = shortfall > effectiveness_allowance
yFault = yEffectivenessLow continuously for alarm_delay

HX-0001 block graph

The graph has no Divide. The host publishes effectiveness only after safe denominator, fluid-property, timestamp, and side-energy-balance checks. A denominator guard downstream of a division would not prevent that division from evaluating; moving the validated thermodynamic derivation to the host also supports water/glycol properties the CXF graph does not carry.

Both comparisons use finite dimensionless scalars and the threshold is strict. yEffectivenessLow is immediate diagnostic evidence; only yFault is delayed.

Possible Diagnoses

  1. Plate/tube fouling, scale, biological film, or blocked channels.
  2. Internal gasket/bypass leakage or incorrect HX piping.
  3. Insufficient or maldistributed flow not caught by the commissioned floors.
  4. Degraded or misconfigured glycol concentration/fluid properties.
  5. Temperature/flow sensor bias, time misalignment, or swapped side/location.
  6. Expected model drift, wrong domain, or baseline trained on abnormal data.

Energy Impact

EFFICIENCY_LOSS with BASELINE_COMPARISON and MEDIUM confidence. Lost transfer must be replaced by upstream boilers, chillers, heat pumps, district energy, or longer pumping. The estimator uses the same validated available-rate basis as the effectiveness calculation; this two-point graph alone cannot produce kW. Guelpa and Verda’s 1.6% is a network-wide expected benefit from a cleaning program across 325 substations, not a savings range to assign to one alarm.

Emissions Impact

Scope 1+2, PROXY_EMISSIONS. Apply the marginal emissions rate of the actual replacement heat source and electricity used while the fault is active. Do not infer fuel/electric split from transfer direction alone.

Deviations

  • The thermodynamic ratio is host-derived. EnergyPlus documents the epsilon-NTU physics, but the repository graph intentionally compares two safe scalars instead of dividing inside CXF. This is a safety and fluid-property adaptation, not a claim that the host model is standardized.
  • effectiveness_allowance = 0.125 is not portable. No source supplies a universal threshold. The exact binary value makes strict-boundary vectors unambiguous; deployment must replace it before enabling evaluation.
  • The field method is precedent, not a transcribed algorithm. Guelpa and Verda use a calibrated fouling workflow under variable district-heating conditions. This card keeps the baseline/error-domain obligation but does not claim to reproduce their full method.
  • No in-graph readiness flag. Baseline/domain, denominator, and balance validity depend on provenance and configuration beyond two boundary points; they are mandatory host NO_EVAL gates.
  • No suppression. HX-0002 may explain why HX-0001 is unevaluable, but rule IDs are not equipment-instance scoped. A host gates the same instance rather than globally suppressing every HX-0001 when any HX-0002 is active.
  • Initial scope excludes steam. Phase change needs a different capacity and topology contract even though some trade usage calls it hydronic.

Test Vectors

16 scenarios, clock step 60 s over 3000 s.

ScenarioDescription
healthy_heating_effectivenessA commissioned heating point inside its expected-effectiveness allowance is healthy; direction is resolved before this sign-independent graph.
healthy_cooling_effectivenessCooling service uses the same effectiveness comparison after the host reconciles negative signed heat transfer into a positive effectiveness.
sustained_effectiveness_degradationA 0.15 shortfall exceeds the 0.125 executable placeholder and matures with a one-step assertion margin.
shortfall_exactly_at_thresholdBinary-exact 0.75 - 0.625 equals 0.125 and remains clear under strict Greater.
shortfall_just_below_thresholdA binary-exact 0.1240234375 shortfall is just inside the strict 0.125 allowance and remains clear.
shortfall_just_above_thresholdA 0.126 shortfall is just beyond the executable threshold.
actual_above_expectedA negative shortfall is not degradation.
violation_ends_one_step_before_delayThe candidate clears at 840 s, one evaluator step before 900 s, and never alarms.
mature_alarm_recoversA mature degradation clears immediately when effectiveness recovers.
two_short_violations_resetTwo sub-delay degradations separated by a healthy interval cannot accumulate.
initialization_violation_obeys_delayA violation present on the first tick is held by delayOnInit.
zero_expected_is_raw_host_no_evalA zero expected value is invalid host evidence; the raw graph stays clear and does not divide by it.
out_of_domain_expected_is_host_gateThe raw graph can compare any finite scalars; an out-of-domain baseline must be NO_EVAL at the host, not silently normalized in CXF.
expected_model_not_ready_is_host_gateThe raw graph has no readiness input and would alarm on these scalars; a baseline that is not frozen, fresh, and ready must be NO_EVAL at the host.
unbalanced_side_heat_rates_are_host_gateThe raw graph cannot inspect the two side heat rates and would alarm on these derived scalars; a failed side-energy-balance check must prevent publication and force host NO_EVAL.
invalid_thermal_denominator_is_host_gateZero flow or insufficient entering-temperature separation is not represented by these raw scalar ports; the host must withhold effectiveness and report NO_EVAL before the graph, which never divides.
vectors.json
{
  "schema": "cxf-library/vectors/v1",
  "clock": {
    "step_s": 60,
    "horizon_s": 3000
  },
  "scenarios": [
    {
      "name": "healthy_heating_effectiveness",
      "description": "A commissioned heating point inside its expected-effectiveness allowance is healthy; direction is resolved before this sign-independent graph.",
      "inputs": {
        "effectiveness": 0.75,
        "effectiveness_expected": 0.8
      },
      "expect": [
        {
          "output": "yEffectivenessLow",
          "from_s": 0,
          "to_s": 3000,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3000,
          "equals": false
        }
      ]
    },
    {
      "name": "healthy_cooling_effectiveness",
      "description": "Cooling service uses the same effectiveness comparison after the host reconciles negative signed heat transfer into a positive effectiveness.",
      "inputs": {
        "effectiveness": 0.72,
        "effectiveness_expected": 0.8
      },
      "expect": [
        {
          "output": "yEffectivenessLow",
          "from_s": 0,
          "to_s": 3000,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3000,
          "equals": false
        }
      ]
    },
    {
      "name": "sustained_effectiveness_degradation",
      "description": "A 0.15 shortfall exceeds the 0.125 executable placeholder and matures with a one-step assertion margin.",
      "inputs": {
        "effectiveness": 0.65,
        "effectiveness_expected": 0.8
      },
      "expect": [
        {
          "output": "yEffectivenessLow",
          "from_s": 0,
          "to_s": 3000,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 840,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 960,
          "to_s": 3000,
          "equals": true
        }
      ]
    },
    {
      "name": "shortfall_exactly_at_threshold",
      "description": "Binary-exact 0.75 - 0.625 equals 0.125 and remains clear under strict Greater.",
      "inputs": {
        "effectiveness": 0.625,
        "effectiveness_expected": 0.75
      },
      "expect": [
        {
          "output": "yEffectivenessLow",
          "from_s": 0,
          "to_s": 3000,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3000,
          "equals": false
        }
      ]
    },
    {
      "name": "shortfall_just_below_threshold",
      "description": "A binary-exact 0.1240234375 shortfall is just inside the strict 0.125 allowance and remains clear.",
      "inputs": {
        "effectiveness": 0.6259765625,
        "effectiveness_expected": 0.75
      },
      "expect": [
        {
          "output": "yEffectivenessLow",
          "from_s": 0,
          "to_s": 3000,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3000,
          "equals": false
        }
      ]
    },
    {
      "name": "shortfall_just_above_threshold",
      "description": "A 0.126 shortfall is just beyond the executable threshold.",
      "inputs": {
        "effectiveness": 0.624,
        "effectiveness_expected": 0.75
      },
      "expect": [
        {
          "output": "yEffectivenessLow",
          "from_s": 0,
          "to_s": 3000,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 840,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 960,
          "to_s": 3000,
          "equals": true
        }
      ]
    },
    {
      "name": "actual_above_expected",
      "description": "A negative shortfall is not degradation.",
      "inputs": {
        "effectiveness": 0.85,
        "effectiveness_expected": 0.8
      },
      "expect": [
        {
          "output": "yEffectivenessLow",
          "from_s": 0,
          "to_s": 3000,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3000,
          "equals": false
        }
      ]
    },
    {
      "name": "violation_ends_one_step_before_delay",
      "description": "The candidate clears at 840 s, one evaluator step before 900 s, and never alarms.",
      "inputs": {
        "effectiveness": [
          {
            "t": 0,
            "value": 0.6
          },
          {
            "t": 840,
            "value": 0.8
          }
        ],
        "effectiveness_expected": 0.8
      },
      "expect": [
        {
          "output": "yEffectivenessLow",
          "from_s": 0,
          "to_s": 780,
          "equals": true
        },
        {
          "output": "yEffectivenessLow",
          "from_s": 840,
          "to_s": 3000,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3000,
          "equals": false
        }
      ]
    },
    {
      "name": "mature_alarm_recovers",
      "description": "A mature degradation clears immediately when effectiveness recovers.",
      "inputs": {
        "effectiveness": [
          {
            "t": 0,
            "value": 0.6
          },
          {
            "t": 1200,
            "value": 0.8
          }
        ],
        "effectiveness_expected": 0.8
      },
      "expect": [
        {
          "output": "yEffectivenessLow",
          "from_s": 0,
          "to_s": 1140,
          "equals": true
        },
        {
          "output": "yEffectivenessLow",
          "from_s": 1200,
          "to_s": 3000,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 840,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 960,
          "to_s": 1140,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 1200,
          "to_s": 3000,
          "equals": false
        }
      ]
    },
    {
      "name": "two_short_violations_reset",
      "description": "Two sub-delay degradations separated by a healthy interval cannot accumulate.",
      "inputs": {
        "effectiveness": [
          {
            "t": 0,
            "value": 0.6
          },
          {
            "t": 600,
            "value": 0.8
          },
          {
            "t": 900,
            "value": 0.6
          },
          {
            "t": 1500,
            "value": 0.8
          }
        ],
        "effectiveness_expected": 0.8
      },
      "expect": [
        {
          "output": "yEffectivenessLow",
          "from_s": 0,
          "to_s": 540,
          "equals": true
        },
        {
          "output": "yEffectivenessLow",
          "from_s": 600,
          "to_s": 840,
          "equals": false
        },
        {
          "output": "yEffectivenessLow",
          "from_s": 900,
          "to_s": 1440,
          "equals": true
        },
        {
          "output": "yEffectivenessLow",
          "from_s": 1500,
          "to_s": 3000,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3000,
          "equals": false
        }
      ]
    },
    {
      "name": "initialization_violation_obeys_delay",
      "description": "A violation present on the first tick is held by delayOnInit.",
      "inputs": {
        "effectiveness": 0.5,
        "effectiveness_expected": 0.8
      },
      "expect": [
        {
          "output": "yEffectivenessLow",
          "from_s": 0,
          "to_s": 3000,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 840,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 960,
          "to_s": 3000,
          "equals": true
        }
      ]
    },
    {
      "name": "zero_expected_is_raw_host_no_eval",
      "description": "A zero expected value is invalid host evidence; the raw graph stays clear and does not divide by it.",
      "inputs": {
        "effectiveness": 0.0,
        "effectiveness_expected": 0.0
      },
      "expect": [
        {
          "output": "yEffectivenessLow",
          "from_s": 0,
          "to_s": 3000,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3000,
          "equals": false
        }
      ]
    },
    {
      "name": "out_of_domain_expected_is_host_gate",
      "description": "The raw graph can compare any finite scalars; an out-of-domain baseline must be NO_EVAL at the host, not silently normalized in CXF.",
      "inputs": {
        "effectiveness": 0.4,
        "effectiveness_expected": 0.8
      },
      "expect": [
        {
          "output": "yEffectivenessLow",
          "from_s": 0,
          "to_s": 3000,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 840,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 960,
          "to_s": 3000,
          "equals": true
        }
      ]
    },
    {
      "name": "expected_model_not_ready_is_host_gate",
      "description": "The raw graph has no readiness input and would alarm on these scalars; a baseline that is not frozen, fresh, and ready must be NO_EVAL at the host.",
      "inputs": {
        "effectiveness": 0.4,
        "effectiveness_expected": 0.8
      },
      "expect": [
        {
          "output": "yEffectivenessLow",
          "from_s": 0,
          "to_s": 3000,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 840,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 960,
          "to_s": 3000,
          "equals": true
        }
      ]
    },
    {
      "name": "unbalanced_side_heat_rates_are_host_gate",
      "description": "The raw graph cannot inspect the two side heat rates and would alarm on these derived scalars; a failed side-energy-balance check must prevent publication and force host NO_EVAL.",
      "inputs": {
        "effectiveness": 0.4,
        "effectiveness_expected": 0.8
      },
      "expect": [
        {
          "output": "yEffectivenessLow",
          "from_s": 0,
          "to_s": 3000,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 840,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 960,
          "to_s": 3000,
          "equals": true
        }
      ]
    },
    {
      "name": "invalid_thermal_denominator_is_host_gate",
      "description": "Zero flow or insufficient entering-temperature separation is not represented by these raw scalar ports; the host must withhold effectiveness and report NO_EVAL before the graph, which never divides.",
      "inputs": {
        "effectiveness": 0.4,
        "effectiveness_expected": 0.8
      },
      "expect": [
        {
          "output": "yEffectivenessLow",
          "from_s": 0,
          "to_s": 3000,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 840,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 960,
          "to_s": 3000,
          "equals": true
        }
      ]
    }
  ]
}

HX-0002 — Heat exchanger active with one-side flow missing

Statusverified — engine e2ff2f8, cxf:fnv1a128:570e5bd05f74bc3a19c7e61c7543a042, 2026-08-20
Severity2
Methodrule
Phase2
CategoryPROTECTIVE
ConfidenceMEDIUM
EstimationDIRECT_MEASUREMENT
G36
Clusters
Suppresses
Suppressed by
RelatedHX-0001, HX-0003, PMP-0001, PMP-0003
Playbookshydronic-heat-exchanger-faults
SourceEnergyPlus 25.1 Input/Output Reference, HeatExchanger:FluidToFluid — generic four-port model and control modes request both connection flows when exchange operates: https://bigladdersoftware.com/epx/docs/25-1/input-output-reference/group-condenser-equipment.html#heatexchangerfluidtofluid; EnergyPlus 25.1 official PlantLoopChainHeating.idf and PlantLoopChainCooling.idf test models — separate supply/demand-side mass flows and operation status
Operating statesA controlled liquid-to-liquid HX in a final automatic state that presently expects both individual branches to flow

Preconditions (host-enforced): exchange_cmd must be the final downstream both-flow expectation after temperature feasibility, pump/valve ownership, anti-cycle, local/HAND, freeze, pressure, minimum-flow, and other normal sequence logic. Availability, an upstream plant enable, or a supervisory status that permits zero flow is invalid. Both meters must be individual branches on the same HX, fresh, nonnegative in the declared inlet-to-outlet direction, and correctly converted to L/s; common-header, fleet, or duplicated flow is invalid. Configure each threshold above meter zero/noise but below the minimum legitimate established flow, and configure alarm_delay above the slowest permitted start/transport latency. Passive/uncontrolled exchangers and sequences that intentionally flow one side only while armed are NO_EVAL. Exclude maintenance, flushing, fill/purge, drain-down, exercise, and sensor invalidity.

Points: exchange_cmd, primary_flow, secondary_flow

Outputs:

  • yFault — True while either side-specific missing-flow diagnostic has matured
  • yPrimaryFlowMissing — Delayed diagnostic direction flag; true when final exchange command is active and primary flow remains below its floor for alarm_delay. False never means NO_EVAL
  • ySecondaryFlowMissing — Delayed diagnostic direction flag for the secondary side; false never means NO_EVAL

Parameters:

NameDefaultUnitCXF pathDescription
primary_flow_min1.0L/sprimaryLow.tNO_PORTABLE_DEFAULT executable placeholder. Commission above the primary meter’s zero/noise/resolution and below minimum legitimate established branch flow before enabling the rule.
secondary_flow_min1.0L/ssecondaryLow.tNO_PORTABLE_DEFAULT executable placeholder with the same side-specific commissioning requirement; unequal sides need not share a threshold.
alarm_delay900.0sprimaryHeld.delayTime, secondaryHeld.delayTimeADOPTED_TUNABLE 15-minute proof window. Set above the slowest valid final-command-to-flow latency and point delivery time. One value is applied to both independent timers.

Description

A four-port liquid heat exchanger cannot transfer useful heat when one required branch has no flow. This rule compares a final both-flow exchange command with individual primary and secondary branch meters, delays each missing-side signature independently, and tells the operator which side failed.

The rule does not prove a pump fault. A closed isolation valve, clogged strainer, air lock, pressure problem, meter failure, or correct local sequence can create the same observation. Final command semantics and branch scope are therefore adoption requirements.

Detection Logic

primary_candidate   = exchange_cmd AND primary_flow < primary_flow_min
secondary_candidate = exchange_cmd AND secondary_flow < secondary_flow_min

yPrimaryFlowMissing   = primary_candidate continuously for alarm_delay
ySecondaryFlowMissing = secondary_candidate continuously for alarm_delay
yFault = yPrimaryFlowMissing OR ySecondaryFlowMissing

HX-0002 block graph

Each side owns a TrueDelay with delayOnInit = true. If the missing side reverses, the old lane clears and the new lane starts from zero; elapsed time is not inherited through an OR. Both flags may mature if both flows are missing. The strict LessThreshold makes exactly the configured floor safe.

Possible Diagnoses

  1. Side pump failed, tripped, lost coupling, or never received its final command.
  2. Isolation/control valve closed, failed, or under local/HAND ownership.
  3. Clogged strainer/plate passages, air lock, low pressure, or frozen path.
  4. Failed check valve or hydraulic interaction preventing the intended branch.
  5. Flow meter zero/scaling/freshness failure or common-header misbinding.
  6. Upstream enable bound instead of the final both-flow expectation.

Energy Impact

PROTECTIVE with DIRECT_MEASUREMENT and MEDIUM confidence. Pumps and plant may consume energy while the exchanger delivers little or no useful transfer, but this Boolean/flow signature cannot quantify the loss safely. In low-temperature or protective service, delivery/freeze/equipment consequences can outweigh energy cost.

Emissions Impact

Scope 1+2, QUALITATIVE_ONLY. Quantify only after measuring the active plant and pump energy plus any replacement heat source used during the incomplete path.

Deviations

  • Thresholds have no portable default. The numeric 1 L/s values exist for executable vectors only. Meter size, design flow, glycol, and minimum stable control flow are installation-specific and must replace them.
  • One delay is intentionally duplicated onto two blocks. This preserves a direction reversal reset that a single delay after (primary OR secondary) cannot provide.
  • The final command is stricter than ordinary enable. EnergyPlus’s operation status/control behavior is physical precedent, not a claim that every BAS exposes the needed state. Without it the rule is not deployable.
  • No raw low-flow outputs. The two exported direction flags are delayed findings, not mathematical evaluability flags. False never means NO_EVAL.
  • No suppression or cluster. Pump proof/delivery rules can help diagnose a side, but no one causal trigger or repair clears all HX findings reliably and global rule-ID suppression would cross equipment instances.

Test Vectors

17 scenarios, clock step 60 s over 3000 s.

ScenarioDescription
disabled_with_no_flowA disabled exchanger is healthy even though both raw flows are below their floors.
disabled_with_both_flowsCommand off with both branch flows established completes the Boolean matrix and remains clear; passive circulation is outside this rule’s commanded-active premise.
disabled_with_primary_flow_onlyCommand off with primary flow established and secondary flow missing completes the Boolean matrix and remains clear.
disabled_with_secondary_flow_onlyCommand off with primary flow missing and secondary flow established completes the Boolean matrix and remains clear.
active_with_both_flowsBoth individual branches deliver flow while exchange is commanded active.
primary_flow_missingOnly the primary lane matures when its branch remains below the configured floor.
secondary_flow_missingOnly the secondary lane matures when its branch remains below the configured floor.
both_sides_missingA valid final exchange command with neither branch flowing matures both diagnostic lanes.
flows_exactly_at_thresholdExactly 1.0 L/s is safe under strict Less on both lanes.
primary_just_below_thresholdPrimary flow at 0.9 L/s is one test increment below the threshold.
secondary_just_below_thresholdSecondary flow at 0.9 L/s is one test increment below the threshold.
primary_violation_ends_one_step_before_delayThe primary candidate ends at 840 s and never matures.
mature_primary_alarm_recoversA mature primary missing-flow alarm clears immediately on restored branch flow.
two_short_primary_dropouts_resetTwo short primary-flow dropouts separated by recovery do not accumulate elapsed time.
missing_side_reversal_resets_timerA primary miss recovers as a secondary miss begins; independent timers create a clear gap rather than inheriting the primary elapsed time.
initial_primary_miss_obeys_delayA primary miss present at initialization still waits for persistence.
upstream_enable_misbound_raw_false_positiveAn upstream enable held true while a healthy controller intentionally requests no flow will alarm; this pins why exchange_cmd must be the final both-flow expectation.
vectors.json
{
  "schema": "cxf-library/vectors/v1",
  "clock": {
    "step_s": 60,
    "horizon_s": 3000
  },
  "scenarios": [
    {
      "name": "disabled_with_no_flow",
      "description": "A disabled exchanger is healthy even though both raw flows are below their floors.",
      "inputs": {
        "exchange_cmd": false,
        "primary_flow": 0.0,
        "secondary_flow": 0.0
      },
      "expect": [
        {
          "output": "yPrimaryFlowMissing",
          "from_s": 0,
          "to_s": 3000,
          "equals": false
        },
        {
          "output": "ySecondaryFlowMissing",
          "from_s": 0,
          "to_s": 3000,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3000,
          "equals": false
        }
      ]
    },
    {
      "name": "disabled_with_both_flows",
      "description": "Command off with both branch flows established completes the Boolean matrix and remains clear; passive circulation is outside this rule's commanded-active premise.",
      "inputs": {
        "exchange_cmd": false,
        "primary_flow": 10.0,
        "secondary_flow": 10.0
      },
      "expect": [
        {
          "output": "yPrimaryFlowMissing",
          "from_s": 0,
          "to_s": 3000,
          "equals": false
        },
        {
          "output": "ySecondaryFlowMissing",
          "from_s": 0,
          "to_s": 3000,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3000,
          "equals": false
        }
      ]
    },
    {
      "name": "disabled_with_primary_flow_only",
      "description": "Command off with primary flow established and secondary flow missing completes the Boolean matrix and remains clear.",
      "inputs": {
        "exchange_cmd": false,
        "primary_flow": 10.0,
        "secondary_flow": 0.0
      },
      "expect": [
        {
          "output": "yPrimaryFlowMissing",
          "from_s": 0,
          "to_s": 3000,
          "equals": false
        },
        {
          "output": "ySecondaryFlowMissing",
          "from_s": 0,
          "to_s": 3000,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3000,
          "equals": false
        }
      ]
    },
    {
      "name": "disabled_with_secondary_flow_only",
      "description": "Command off with primary flow missing and secondary flow established completes the Boolean matrix and remains clear.",
      "inputs": {
        "exchange_cmd": false,
        "primary_flow": 0.0,
        "secondary_flow": 10.0
      },
      "expect": [
        {
          "output": "yPrimaryFlowMissing",
          "from_s": 0,
          "to_s": 3000,
          "equals": false
        },
        {
          "output": "ySecondaryFlowMissing",
          "from_s": 0,
          "to_s": 3000,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3000,
          "equals": false
        }
      ]
    },
    {
      "name": "active_with_both_flows",
      "description": "Both individual branches deliver flow while exchange is commanded active.",
      "inputs": {
        "exchange_cmd": true,
        "primary_flow": 10.0,
        "secondary_flow": 9.5
      },
      "expect": [
        {
          "output": "yPrimaryFlowMissing",
          "from_s": 0,
          "to_s": 3000,
          "equals": false
        },
        {
          "output": "ySecondaryFlowMissing",
          "from_s": 0,
          "to_s": 3000,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3000,
          "equals": false
        }
      ]
    },
    {
      "name": "primary_flow_missing",
      "description": "Only the primary lane matures when its branch remains below the configured floor.",
      "inputs": {
        "exchange_cmd": true,
        "primary_flow": 0.0,
        "secondary_flow": 10.0
      },
      "expect": [
        {
          "output": "yPrimaryFlowMissing",
          "from_s": 0,
          "to_s": 840,
          "equals": false
        },
        {
          "output": "yPrimaryFlowMissing",
          "from_s": 960,
          "to_s": 3000,
          "equals": true
        },
        {
          "output": "ySecondaryFlowMissing",
          "from_s": 0,
          "to_s": 3000,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 840,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 960,
          "to_s": 3000,
          "equals": true
        }
      ]
    },
    {
      "name": "secondary_flow_missing",
      "description": "Only the secondary lane matures when its branch remains below the configured floor.",
      "inputs": {
        "exchange_cmd": true,
        "primary_flow": 10.0,
        "secondary_flow": 0.0
      },
      "expect": [
        {
          "output": "yPrimaryFlowMissing",
          "from_s": 0,
          "to_s": 3000,
          "equals": false
        },
        {
          "output": "ySecondaryFlowMissing",
          "from_s": 0,
          "to_s": 840,
          "equals": false
        },
        {
          "output": "ySecondaryFlowMissing",
          "from_s": 960,
          "to_s": 3000,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 840,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 960,
          "to_s": 3000,
          "equals": true
        }
      ]
    },
    {
      "name": "both_sides_missing",
      "description": "A valid final exchange command with neither branch flowing matures both diagnostic lanes.",
      "inputs": {
        "exchange_cmd": true,
        "primary_flow": 0.0,
        "secondary_flow": 0.0
      },
      "expect": [
        {
          "output": "yPrimaryFlowMissing",
          "from_s": 0,
          "to_s": 840,
          "equals": false
        },
        {
          "output": "ySecondaryFlowMissing",
          "from_s": 0,
          "to_s": 840,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 840,
          "equals": false
        },
        {
          "output": "yPrimaryFlowMissing",
          "from_s": 960,
          "to_s": 3000,
          "equals": true
        },
        {
          "output": "ySecondaryFlowMissing",
          "from_s": 960,
          "to_s": 3000,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 960,
          "to_s": 3000,
          "equals": true
        }
      ]
    },
    {
      "name": "flows_exactly_at_threshold",
      "description": "Exactly 1.0 L/s is safe under strict Less on both lanes.",
      "inputs": {
        "exchange_cmd": true,
        "primary_flow": 1.0,
        "secondary_flow": 1.0
      },
      "expect": [
        {
          "output": "yPrimaryFlowMissing",
          "from_s": 0,
          "to_s": 3000,
          "equals": false
        },
        {
          "output": "ySecondaryFlowMissing",
          "from_s": 0,
          "to_s": 3000,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3000,
          "equals": false
        }
      ]
    },
    {
      "name": "primary_just_below_threshold",
      "description": "Primary flow at 0.9 L/s is one test increment below the threshold.",
      "inputs": {
        "exchange_cmd": true,
        "primary_flow": 0.9,
        "secondary_flow": 10.0
      },
      "expect": [
        {
          "output": "yPrimaryFlowMissing",
          "from_s": 0,
          "to_s": 840,
          "equals": false
        },
        {
          "output": "yPrimaryFlowMissing",
          "from_s": 960,
          "to_s": 3000,
          "equals": true
        },
        {
          "output": "ySecondaryFlowMissing",
          "from_s": 0,
          "to_s": 3000,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 840,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 960,
          "to_s": 3000,
          "equals": true
        }
      ]
    },
    {
      "name": "secondary_just_below_threshold",
      "description": "Secondary flow at 0.9 L/s is one test increment below the threshold.",
      "inputs": {
        "exchange_cmd": true,
        "primary_flow": 10.0,
        "secondary_flow": 0.9
      },
      "expect": [
        {
          "output": "yPrimaryFlowMissing",
          "from_s": 0,
          "to_s": 3000,
          "equals": false
        },
        {
          "output": "ySecondaryFlowMissing",
          "from_s": 0,
          "to_s": 840,
          "equals": false
        },
        {
          "output": "ySecondaryFlowMissing",
          "from_s": 960,
          "to_s": 3000,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 840,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 960,
          "to_s": 3000,
          "equals": true
        }
      ]
    },
    {
      "name": "primary_violation_ends_one_step_before_delay",
      "description": "The primary candidate ends at 840 s and never matures.",
      "inputs": {
        "exchange_cmd": true,
        "primary_flow": [
          {
            "t": 0,
            "value": 0.0
          },
          {
            "t": 840,
            "value": 10.0
          }
        ],
        "secondary_flow": 10.0
      },
      "expect": [
        {
          "output": "yPrimaryFlowMissing",
          "from_s": 0,
          "to_s": 3000,
          "equals": false
        },
        {
          "output": "ySecondaryFlowMissing",
          "from_s": 0,
          "to_s": 3000,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3000,
          "equals": false
        }
      ]
    },
    {
      "name": "mature_primary_alarm_recovers",
      "description": "A mature primary missing-flow alarm clears immediately on restored branch flow.",
      "inputs": {
        "exchange_cmd": true,
        "primary_flow": [
          {
            "t": 0,
            "value": 0.0
          },
          {
            "t": 1200,
            "value": 10.0
          }
        ],
        "secondary_flow": 10.0
      },
      "expect": [
        {
          "output": "yPrimaryFlowMissing",
          "from_s": 0,
          "to_s": 840,
          "equals": false
        },
        {
          "output": "yPrimaryFlowMissing",
          "from_s": 960,
          "to_s": 1140,
          "equals": true
        },
        {
          "output": "yPrimaryFlowMissing",
          "from_s": 1200,
          "to_s": 3000,
          "equals": false
        },
        {
          "output": "ySecondaryFlowMissing",
          "from_s": 0,
          "to_s": 3000,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 840,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 960,
          "to_s": 1140,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 1200,
          "to_s": 3000,
          "equals": false
        }
      ]
    },
    {
      "name": "two_short_primary_dropouts_reset",
      "description": "Two short primary-flow dropouts separated by recovery do not accumulate elapsed time.",
      "inputs": {
        "exchange_cmd": true,
        "primary_flow": [
          {
            "t": 0,
            "value": 0.0
          },
          {
            "t": 600,
            "value": 10.0
          },
          {
            "t": 900,
            "value": 0.0
          },
          {
            "t": 1500,
            "value": 10.0
          }
        ],
        "secondary_flow": 10.0
      },
      "expect": [
        {
          "output": "yPrimaryFlowMissing",
          "from_s": 0,
          "to_s": 3000,
          "equals": false
        },
        {
          "output": "ySecondaryFlowMissing",
          "from_s": 0,
          "to_s": 3000,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3000,
          "equals": false
        }
      ]
    },
    {
      "name": "missing_side_reversal_resets_timer",
      "description": "A primary miss recovers as a secondary miss begins; independent timers create a clear gap rather than inheriting the primary elapsed time.",
      "inputs": {
        "exchange_cmd": true,
        "primary_flow": [
          {
            "t": 0,
            "value": 0.0
          },
          {
            "t": 1200,
            "value": 10.0
          }
        ],
        "secondary_flow": [
          {
            "t": 0,
            "value": 10.0
          },
          {
            "t": 1200,
            "value": 0.0
          }
        ]
      },
      "expect": [
        {
          "output": "yPrimaryFlowMissing",
          "from_s": 0,
          "to_s": 840,
          "equals": false
        },
        {
          "output": "yPrimaryFlowMissing",
          "from_s": 960,
          "to_s": 1140,
          "equals": true
        },
        {
          "output": "yPrimaryFlowMissing",
          "from_s": 1200,
          "to_s": 3000,
          "equals": false
        },
        {
          "output": "ySecondaryFlowMissing",
          "from_s": 0,
          "to_s": 2040,
          "equals": false
        },
        {
          "output": "ySecondaryFlowMissing",
          "from_s": 2160,
          "to_s": 3000,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 840,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 960,
          "to_s": 1140,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 1200,
          "to_s": 2040,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2160,
          "to_s": 3000,
          "equals": true
        }
      ]
    },
    {
      "name": "initial_primary_miss_obeys_delay",
      "description": "A primary miss present at initialization still waits for persistence.",
      "inputs": {
        "exchange_cmd": true,
        "primary_flow": 0.0,
        "secondary_flow": 10.0
      },
      "expect": [
        {
          "output": "yPrimaryFlowMissing",
          "from_s": 0,
          "to_s": 840,
          "equals": false
        },
        {
          "output": "yPrimaryFlowMissing",
          "from_s": 960,
          "to_s": 3000,
          "equals": true
        },
        {
          "output": "ySecondaryFlowMissing",
          "from_s": 0,
          "to_s": 3000,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 840,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 960,
          "to_s": 3000,
          "equals": true
        }
      ]
    },
    {
      "name": "upstream_enable_misbound_raw_false_positive",
      "description": "An upstream enable held true while a healthy controller intentionally requests no flow will alarm; this pins why exchange_cmd must be the final both-flow expectation.",
      "inputs": {
        "exchange_cmd": true,
        "primary_flow": 0.0,
        "secondary_flow": 0.0
      },
      "expect": [
        {
          "output": "yPrimaryFlowMissing",
          "from_s": 0,
          "to_s": 840,
          "equals": false
        },
        {
          "output": "yPrimaryFlowMissing",
          "from_s": 960,
          "to_s": 3000,
          "equals": true
        },
        {
          "output": "ySecondaryFlowMissing",
          "from_s": 0,
          "to_s": 840,
          "equals": false
        },
        {
          "output": "ySecondaryFlowMissing",
          "from_s": 960,
          "to_s": 3000,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 840,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 960,
          "to_s": 3000,
          "equals": true
        }
      ]
    }
  ]
}

HX-0003 — Heat transfer persists with control valve commanded closed

Statusverified — engine e2ff2f8, cxf:fnv1a128:aff5ae932f23f7127de8a90723fadb04, 2026-08-20
Severity3
Methodrule
Phase2
CategoryCRITICAL_WASTE
ConfidenceMEDIUM
EstimationDIRECT_MEASUREMENT
G36
Clusters
Suppresses
Suppressed by
RelatedHX-0001, HX-0002
Playbookshydronic-heat-exchanger-faults
SourceEnergyPlus 25.1 Engineering Reference, Heat Exchangers — signed heat transfer from both capacity rates and inlet temperatures: https://bigladdersoftware.com/epx/docs/25-1/engineering-reference/heat-exchangers.html; DOE FEMP, Energy Management Information System Capabilities — monitoring reduced/changed HX heat transfer from temperature evidence for condition-based maintenance
Operating statesThe HX’s intended isolating valve is finally commanded closed, residual transport/thermal soak has expired, and validated branch measurements can establish signed heat transfer

Preconditions (host-enforced): The named valve must be intended to isolate the entire monitored HX exchange path; its point is the final physical output after minimum-position, exercise, local/HAND, freeze, and protective logic. control_valve_cmd and the four temperatures/two flows behind heat_transfer_rate must share one HX scope and aligned timestamps. The host publishes signed transfer only after positive finite flow/capacity, fluid-property, point-quality, and side-energy-balance checks; invalid derivation means NO_EVAL. Exclude a site-commissioned transport/thermal-soak interval after closure and after direction, setpoint, pump, or valve changes. Commission the transfer limit above the closed/no-load uncertainty envelope. Natural circulation, a manual/parallel bypass, and a failed check valve remain valid unintended paths rather than suppression reasons. Passive exchangers without an isolating valve are excluded.

Points: control_valve_cmd, heat_transfer_rate

Outputs:

  • yFault — True after absolute heat transfer remains above the commissioned no-load limit with the isolating valve command below its closed limit for alarm_delay
  • yValveClosed — Immediate diagnostic sub-condition; false never means NO_EVAL
  • yTransferPresent — Immediate direction-independent diagnostic sub-condition based on absolute signed transfer; false never means NO_EVAL

Parameters:

NameDefaultUnitCXF pathDescription
closed_command_limit5.0%valveClosed.tADOPTED_TUNABLE library valve-closed starting point. The strict comparator treats exactly 5% as not closed; align with actuator scaling and any intentional minimum position.
unexpected_transfer_limit5.0kWtransferHigh.tNO_PORTABLE_DEFAULT executable placeholder. Commission above reconciled closed/no-load transfer uncertainty, meter resolution, and residual-loss envelope, and below the minimum actionable unintended exchange.
alarm_delay900.0spersist.delayTimeADOPTED_TUNABLE 15-minute persistence after the separate host soak/settling exclusion. It is not a substitute for determining the installation’s water transport and metal/pipe thermal time constant.

Description

When an HX isolation/control valve is finally commanded closed, meaningful continued transfer indicates an unintended hydraulic/thermal path. The valve may be passing, never have reached its seat, leave a bypass open, or permit gravity circulation. This rule treats both heating and cooling directions the same by comparing the absolute value of a validated signed heat-transfer rate.

The observation is broader than “leaking valve.” Position feedback, branch flow, check-valve state, and a piping walk-down distinguish actuator failure, seat leakage, bypass, and thermosiphon after the alarm.

Detection Logic

yValveClosed = control_valve_cmd < closed_command_limit
yTransferPresent = abs(heat_transfer_rate) > unexpected_transfer_limit
yFault = (yValveClosed AND yTransferPresent) continuously for alarm_delay

HX-0003 block graph

The two sub-condition outputs are immediate diagnostics. TrueDelay uses delayOnInit = true, but its 15 minutes do not replace the host’s independent post-close soak exclusion. Both comparisons are strict: exactly 5% is not closed at the shipped setting and exactly 5 kW is not transfer-present.

Possible Diagnoses

  1. Passing valve seat, debris, erosion, or insufficient close-off rating.
  2. Actuator/linkage failed or commanded scaling does not reach physical close.
  3. Manual bypass, three-way/parallel path, or wrong valve bound to the rule.
  4. Failed/missing check valve or gravity/thermosiphon circulation.
  5. Residual transport/metal/pipe soak not actually expired.
  6. Flow/temperature bias, fluid-property error, time skew, or energy imbalance.

Energy Impact

CRITICAL_WASTE with DIRECT_MEASUREMENT and MEDIUM confidence. Once the host has validated signed kW and confirmed the transfer is unwanted, thermal waste is the integral above the commissioned no-load envelope. Source energy depends on the boiler/chiller/heat-pump/district efficiency and concurrent pumping.

Emissions Impact

Scope 1+2, PROXY_EMISSIONS. Apply actual marginal source efficiency/COP and emissions factors to the validated unwanted thermal energy; direction alone does not identify the fuel/electric split.

Deviations

  • The rule says transfer, not valve leakage. A closed command plus transfer cannot uniquely identify the path. Natural circulation and a bypass are real findings with different repairs, kept explicit in diagnosis.
  • Heat transfer is host-derived. The graph applies Abs only after the host validates two side estimates, safe finite capacity rates, and alignment. It does not derive kW from unguarded divisions.
  • 5 kW has no portable authority. It is an executable fixture. Commission a no-load uncertainty envelope before enabling the rule.
  • Persistence is not soak. A host exclusion restarts after closure and every material hydraulic/thermal discontinuity; otherwise a long normal cooldown can consume the timer and manufacture a finding.
  • Optional position feedback stays diagnostic. Requiring it would sharply reduce deployability, and command/position disagreement is a distinct future rule. Use it in the playbook when available.
  • No cluster/suppression. HX-0001 may co-occur, but neither verdict universally invalidates or causally owns the other.

Test Vectors

14 scenarios, clock step 60 s over 3000 s.

ScenarioDescription
closed_valve_no_transferA closed command with no measured transfer is healthy.
open_valve_with_transferExpected exchange with an open valve does not accuse the valve even though transfer is present.
closed_positive_transferSustained primary-to-secondary heat transfer while closed matures a fault.
closed_negative_transferCooling-direction transfer is equally unintended; Abs makes the signature symmetric.
command_exactly_at_closed_limitExactly 5% is not closed under strict Less.
command_just_below_closed_limitA 4.9% final command counts as closed and can mature the transfer finding.
transfer_exactly_at_limitExactly 5.0 kW is clear under strict Greater.
negative_transfer_exactly_at_limitThe mirrored -5.0 kW boundary is also clear after Abs.
transfer_just_above_limit5.1 kW is one test increment beyond the executable placeholder.
thermal_soak_ends_one_step_before_delayA residual transfer episode that ends at 840 s never matures; the host soak exclusion remains independently required.
mature_transfer_alarm_recoversA mature alarm clears immediately when transfer falls below the limit.
two_short_transfer_episodes_resetTwo short residual-transfer episodes separated by zero transfer do not accumulate.
initial_transfer_obeys_delayA closed-and-transferring condition present at initialization waits for delayOnInit.
natural_circulation_is_valid_findingSustained thermosiphon transfer while the isolation command is closed is a real unintended path, even if the control valve itself later proves seated.
vectors.json
{
  "schema": "cxf-library/vectors/v1",
  "clock": {
    "step_s": 60,
    "horizon_s": 3000
  },
  "scenarios": [
    {
      "name": "closed_valve_no_transfer",
      "description": "A closed command with no measured transfer is healthy.",
      "inputs": {
        "control_valve_cmd": 0.0,
        "heat_transfer_rate": 0.0
      },
      "expect": [
        {
          "output": "yValveClosed",
          "from_s": 0,
          "to_s": 3000,
          "equals": true
        },
        {
          "output": "yTransferPresent",
          "from_s": 0,
          "to_s": 3000,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3000,
          "equals": false
        }
      ]
    },
    {
      "name": "open_valve_with_transfer",
      "description": "Expected exchange with an open valve does not accuse the valve even though transfer is present.",
      "inputs": {
        "control_valve_cmd": 100.0,
        "heat_transfer_rate": 20.0
      },
      "expect": [
        {
          "output": "yValveClosed",
          "from_s": 0,
          "to_s": 3000,
          "equals": false
        },
        {
          "output": "yTransferPresent",
          "from_s": 0,
          "to_s": 3000,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3000,
          "equals": false
        }
      ]
    },
    {
      "name": "closed_positive_transfer",
      "description": "Sustained primary-to-secondary heat transfer while closed matures a fault.",
      "inputs": {
        "control_valve_cmd": 0.0,
        "heat_transfer_rate": 5.1
      },
      "expect": [
        {
          "output": "yValveClosed",
          "from_s": 0,
          "to_s": 3000,
          "equals": true
        },
        {
          "output": "yTransferPresent",
          "from_s": 0,
          "to_s": 3000,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 840,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 960,
          "to_s": 3000,
          "equals": true
        }
      ]
    },
    {
      "name": "closed_negative_transfer",
      "description": "Cooling-direction transfer is equally unintended; Abs makes the signature symmetric.",
      "inputs": {
        "control_valve_cmd": 0.0,
        "heat_transfer_rate": -5.1
      },
      "expect": [
        {
          "output": "yValveClosed",
          "from_s": 0,
          "to_s": 3000,
          "equals": true
        },
        {
          "output": "yTransferPresent",
          "from_s": 0,
          "to_s": 3000,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 840,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 960,
          "to_s": 3000,
          "equals": true
        }
      ]
    },
    {
      "name": "command_exactly_at_closed_limit",
      "description": "Exactly 5% is not closed under strict Less.",
      "inputs": {
        "control_valve_cmd": 5.0,
        "heat_transfer_rate": 20.0
      },
      "expect": [
        {
          "output": "yValveClosed",
          "from_s": 0,
          "to_s": 3000,
          "equals": false
        },
        {
          "output": "yTransferPresent",
          "from_s": 0,
          "to_s": 3000,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3000,
          "equals": false
        }
      ]
    },
    {
      "name": "command_just_below_closed_limit",
      "description": "A 4.9% final command counts as closed and can mature the transfer finding.",
      "inputs": {
        "control_valve_cmd": 4.9,
        "heat_transfer_rate": 5.1
      },
      "expect": [
        {
          "output": "yValveClosed",
          "from_s": 0,
          "to_s": 3000,
          "equals": true
        },
        {
          "output": "yTransferPresent",
          "from_s": 0,
          "to_s": 3000,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 840,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 960,
          "to_s": 3000,
          "equals": true
        }
      ]
    },
    {
      "name": "transfer_exactly_at_limit",
      "description": "Exactly 5.0 kW is clear under strict Greater.",
      "inputs": {
        "control_valve_cmd": 0.0,
        "heat_transfer_rate": 5.0
      },
      "expect": [
        {
          "output": "yValveClosed",
          "from_s": 0,
          "to_s": 3000,
          "equals": true
        },
        {
          "output": "yTransferPresent",
          "from_s": 0,
          "to_s": 3000,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3000,
          "equals": false
        }
      ]
    },
    {
      "name": "negative_transfer_exactly_at_limit",
      "description": "The mirrored -5.0 kW boundary is also clear after Abs.",
      "inputs": {
        "control_valve_cmd": 0.0,
        "heat_transfer_rate": -5.0
      },
      "expect": [
        {
          "output": "yValveClosed",
          "from_s": 0,
          "to_s": 3000,
          "equals": true
        },
        {
          "output": "yTransferPresent",
          "from_s": 0,
          "to_s": 3000,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3000,
          "equals": false
        }
      ]
    },
    {
      "name": "transfer_just_above_limit",
      "description": "5.1 kW is one test increment beyond the executable placeholder.",
      "inputs": {
        "control_valve_cmd": 0.0,
        "heat_transfer_rate": 5.1
      },
      "expect": [
        {
          "output": "yValveClosed",
          "from_s": 0,
          "to_s": 3000,
          "equals": true
        },
        {
          "output": "yTransferPresent",
          "from_s": 0,
          "to_s": 3000,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 840,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 960,
          "to_s": 3000,
          "equals": true
        }
      ]
    },
    {
      "name": "thermal_soak_ends_one_step_before_delay",
      "description": "A residual transfer episode that ends at 840 s never matures; the host soak exclusion remains independently required.",
      "inputs": {
        "control_valve_cmd": 0.0,
        "heat_transfer_rate": [
          {
            "t": 0,
            "value": 8.0
          },
          {
            "t": 840,
            "value": 0.0
          }
        ]
      },
      "expect": [
        {
          "output": "yValveClosed",
          "from_s": 0,
          "to_s": 3000,
          "equals": true
        },
        {
          "output": "yTransferPresent",
          "from_s": 0,
          "to_s": 780,
          "equals": true
        },
        {
          "output": "yTransferPresent",
          "from_s": 840,
          "to_s": 3000,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3000,
          "equals": false
        }
      ]
    },
    {
      "name": "mature_transfer_alarm_recovers",
      "description": "A mature alarm clears immediately when transfer falls below the limit.",
      "inputs": {
        "control_valve_cmd": 0.0,
        "heat_transfer_rate": [
          {
            "t": 0,
            "value": 8.0
          },
          {
            "t": 1200,
            "value": 0.0
          }
        ]
      },
      "expect": [
        {
          "output": "yValveClosed",
          "from_s": 0,
          "to_s": 3000,
          "equals": true
        },
        {
          "output": "yTransferPresent",
          "from_s": 0,
          "to_s": 1140,
          "equals": true
        },
        {
          "output": "yTransferPresent",
          "from_s": 1200,
          "to_s": 3000,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 840,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 960,
          "to_s": 1140,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 1200,
          "to_s": 3000,
          "equals": false
        }
      ]
    },
    {
      "name": "two_short_transfer_episodes_reset",
      "description": "Two short residual-transfer episodes separated by zero transfer do not accumulate.",
      "inputs": {
        "control_valve_cmd": 0.0,
        "heat_transfer_rate": [
          {
            "t": 0,
            "value": 8.0
          },
          {
            "t": 600,
            "value": 0.0
          },
          {
            "t": 900,
            "value": -8.0
          },
          {
            "t": 1500,
            "value": 0.0
          }
        ]
      },
      "expect": [
        {
          "output": "yValveClosed",
          "from_s": 0,
          "to_s": 3000,
          "equals": true
        },
        {
          "output": "yTransferPresent",
          "from_s": 0,
          "to_s": 540,
          "equals": true
        },
        {
          "output": "yTransferPresent",
          "from_s": 600,
          "to_s": 840,
          "equals": false
        },
        {
          "output": "yTransferPresent",
          "from_s": 900,
          "to_s": 1440,
          "equals": true
        },
        {
          "output": "yTransferPresent",
          "from_s": 1500,
          "to_s": 3000,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3000,
          "equals": false
        }
      ]
    },
    {
      "name": "initial_transfer_obeys_delay",
      "description": "A closed-and-transferring condition present at initialization waits for delayOnInit.",
      "inputs": {
        "control_valve_cmd": 0.0,
        "heat_transfer_rate": 8.0
      },
      "expect": [
        {
          "output": "yValveClosed",
          "from_s": 0,
          "to_s": 3000,
          "equals": true
        },
        {
          "output": "yTransferPresent",
          "from_s": 0,
          "to_s": 3000,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 840,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 960,
          "to_s": 3000,
          "equals": true
        }
      ]
    },
    {
      "name": "natural_circulation_is_valid_finding",
      "description": "Sustained thermosiphon transfer while the isolation command is closed is a real unintended path, even if the control valve itself later proves seated.",
      "inputs": {
        "control_valve_cmd": 0.0,
        "heat_transfer_rate": -8.0
      },
      "expect": [
        {
          "output": "yValveClosed",
          "from_s": 0,
          "to_s": 3000,
          "equals": true
        },
        {
          "output": "yTransferPresent",
          "from_s": 0,
          "to_s": 3000,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 840,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 960,
          "to_s": 3000,
          "equals": true
        }
      ]
    }
  ]
}

PMP Fault Rules

Hydronic pump fault detection rules (PMP-*). PMP-0001/PMP-0002 originate in the HVAC FDD Reference v1.0 ch.15; the later proof, cycling, stopped-flow, and expected-power rules are library-authored extensions grounded in verified graph precedents and public pump-system guidance. The family covers direct proof, hydraulic delivery, protective cycling, and baseline electrical performance without claiming a universal pump curve.

Point dictionary: points/pmp.points.json. Loop-agnostic: the same rules bind to CHW, HW, or condenser-water pumps.

Index

IDNameSevMethodStatus
PMP-0001Pump commanded on, no flow detected2ruleverified
PMP-0002Pump deadheading (high DP, low/no flow)2ruleverified
PMP-0003Pump proof-of-operation failure2ruleverified
PMP-0004Pump short-cycling3ruleverified
PMP-0005Flow through stopped pump2ruleverified
PMP-0006Pump input-power degradation3statisticalverified

All six are Phase 2. Thresholds that depend on pump size, branch design flow, sensor uncertainty, or a fitted model are explicitly site-configured on their cards.

Relationships

  • PMP-0001 vs -051 differ in what they can see: -050 needs only cmd/status/flow and catches the broad “no useful work” condition; -051 adds the DP signature that distinguishes true deadheading (valves closed against a running pump) from impeller/coupling failure (DP low). Diagnosis order in the shared vfd-pump-faults playbook follows that split.
  • VFD-0001/VFD-0002 watch the same drive from the electrical side; a deadheading pump at fixed speed shows normal VFD tracking — the families are complementary, not redundant.
  • PMP-0003 suppresses PMP-0005 on the same pump because unreliable run proof invalidates the stopped/running premise. The stopped-flow graph remains raw and the host scopes suppression by equipment instance.
  • PMP-0004 complements VFD-0004: pump starts and analog speed hunting are different signatures. Neither suppresses the other.
  • PMP-0006 relates to VFD-0001/VFD-0005 but is not globally suppressed by them. A drive finding invalidates expected power only when the deployment’s model actually consumes the disputed drive state/speed and the host can prove both rules belong to the same pump/drive.

Ontology and cluster decisions

The PR 03 point additions pin ASHRAE 223 to the inspected public-review artifact 1.0.0-ppr.2.1 (SHA-256 1f156f9938c0be430d2216e01e31bb183c438ba318d8d4a23d2f074ebcd6f573), matching the ERV/VFD expansion precedent. This is an explicit migration from the former unverified v1.0.0-2026 label, not a claim of a final release.

No Pump Delivery Failure cluster is added. Running with no flow, deadheading, proof disagreement, and flow through a stopped branch have mutually different premises and no single trigger whose correction should clear the set within the cluster contract’s 24–48 hour window.

PMP-0001 — Pump commanded on, no flow detected

Statusverified — engine e2ff2f8, cxf:fnv1a128:3fa07dea0d2d769e0de5ca7bed13e0c1, 2026-08-17
Severity2
Methodrule
Phase2
CategoryPROTECTIVE
ConfidenceMEDIUM
EstimationDIRECT_MEASUREMENT
G36
Clusters
Suppresses
Suppressed byPMP-0002
RelatedPMP-0002, PMP-0005, PMP-0006, VFD-0001, HX-0002
Playbooksvfd-pump-faults
SourceHVAC FDD Reference v1.0 §15 (ch. ‘Pumps’, pdf pp. 133-134), PMP-0001; G36 alarm patterns (the reference’s citation; no clause identified — see Deviations); Engineering best practice
Operating statespump commanded on and proven running, past the start transient — the rule’s own yRunOk is that state

Preconditions (host-enforced): pump_cmd, pump_status, and pump_flow must belong to the same pump. On a headered set that is the precondition most likely to be violated: sites commonly trend one loop flow meter and bind it to every pump on the header, and a lag pump that is running and delivering nothing then reads the lead pump’s flow and is never detected. Bind the pump’s own flow element, or accept that the rule only sees a whole-header failure. pump_flow must be in L/s (the rule converts nothing) and no_flow_threshold must have been set from this loop’s design flow — the shipped 1.0 L/s is a placeholder, not a site value (see Deviations). The flow measurement itself is uncorroborated: nothing in this rule cross-checks the meter, and the reference’s own diagnosis 5 is that the meter is what failed. Run status should be proof of rotation (a current switch or the drive’s own run feedback), not a repeat of the command from a relay; a status point wired back from the start contactor makes the two conjuncts one conjunct. Evaluability is signalled in-rule by yRunOk: when it is false the verdict is NO_EVAL, not healthy, and that covers an idle pump, either kind of command/status mismatch, and the first flow_check_delay seconds of every start.

Points: pump_cmd, pump_status, pump_flow

Outputs:

  • yFault — True while the pump has been commanded on and proven running for at least flow_check_delay with its flow below no_flow_threshold, continuously for a further alarm_delay
  • yRunOk — Evaluability signal — true when pump_cmd and pump_status have both been true for flow_check_delay, which is when the flow reading means something. False means NO_EVAL and the host must ignore yFault

Parameters:

NameDefaultUnitCXF pathDescription
no_flow_threshold1.0L/snoFlow.tFlow below which the pump is delivering nothing. PER-LOOP SITE CONFIGURATION — the reference states this as 5% of design flow and the rule carries absolute units, so the shipped 1.0 L/s is 5% of a 20 L/s design flow and means nothing on any other loop (see Deviations)
flow_check_delay60.0ssettled.delayTimeHow long the pump must be proven running before its flow reading is believed (1 min). The reference’s own description is ‘wait after pump start’, so it is applied to the run condition rather than to the fault condition — see Deviations
alarm_delay300.0spersist.delayTimeContinuous no-flow required, after the run condition has settled, before the alarm asserts (5 min). The reference’s AlarmDelay, renamed to the library’s convention

Description

A pump that is running and moving no water is the cheapest fault in the building to describe and one of the more expensive to leave alone. Every kilowatt going into the motor goes into churning water and spinning bearings; none of it reaches a coil. The building notices eventually — a chilled water loop that will not hold temperature, a heating branch that never warms — but by then the finding is a comfort complaint being chased at the air handler, a subsystem away from the pump that caused it. The rule is the direct test: the pump says it is on, the starter says it is running, the flow meter says nothing is moving. Its value is coverage — impeller damage, a closed isolation valve, an air lock, a sheared coupling and a failed flow meter all fire it, which is also why the confidence rating is MEDIUM rather than HIGH.

Detection Logic

yRunOk = (pump_cmd AND pump_status) sustained for flow_check_delay
                                        (false ⇒ host reports NO_EVAL)

yFault = (pump_flow < no_flow_threshold AND yRunOk)
         sustained continuously for alarm_delay

Block graph (rule.cxf.jsonld):

PMP-0001 block graph

running forms the reference’s first two conjuncts and settled holds them for flow_check_delay before the rest of the rule believes anything. That placement is the one structural decision in this card: a start is exactly when a healthy pump reads zero flow — the check valve has not lifted, the volute is still filling — so delaying the run condition rather than the fault condition means the rule has no verdict at all for the first minute of every run, and yRunOk tells the host so.

The consequence is that the two delays are not interchangeable with one longer delay, which is where this card differs from VFD-0001’s superficially similar chain. A pump that starts dry alarms at 360 s, the two delays adding; a pump that has been running for hours and loses its flow alarms at 300 s, because the run condition settled long ago and only persist is left to run.

yRunOk is also the rule’s whole answer to the reference’s NO_EVAL row. Four states hold it down and none of them is a healthy pump: the pump is off, it is commanded on and not running, it is running while commanded off, or it started less than a minute ago. noFlow is a strict Reals.LessThreshold, so flow sitting exactly on the threshold is not no-flow.

Possible Diagnoses

Transcribed from the reference’s PMP-0001 card:

  1. Pump impeller failure — eroded, corroded, or spun loose on the shaft; the pump turns, the head collapses, and the loop gets nothing
  2. Isolation valve closed — the cheapest cause and the most common one, and what the playbook’s first on-site step (2.3) goes looking for
  3. Air lock in the pump — a bubble the pump cannot push through, often self-clearing and then recurring
  4. Broken coupling between motor and pump — near no-load motor current with a stationary pump shaft, free to confirm with a clamp meter
  5. Flow meter failure — the pump is fine and the measurement is wrong; this rule cannot tell that from any of the four above, and it is the first to rule out because it is the only one that costs nothing to check

The discriminator for 1 against 2 and 4 is the pump differential pressure, which this rule does not read and PMP-0002 does: high DP with no flow is a deadhead, low DP with no flow is a pump that has stopped making head.

Energy Impact

PROTECTIVE, MEDIUM confidence, DIRECT_MEASUREMENT. The savings line is the whole pump — 100% of its energy while the condition lasts, because none of it is doing work — and climate sensitivity is neutral. DIRECT_MEASUREMENT is the reference’s rating, defensible with one caveat: the measurement it refers to is the pump’s own power, which this rule does not read, so waste_kw = pump_rated_kw × (pump_speed/100)³ is only as good as the nameplate and speed feedback behind it. The larger cost is usually not energy at all — a pump running dry runs its mechanical seal without the water that lubricates and cools it, and that damage is measured in thousands rather than kilowatt-hours.

Emissions Impact

Scope 2, DIRECT_EMISSIONS, MEDIUM confidence; the reference’s typical range is 100–500 kg CO₂e/yr for pump energy doing no useful work, on a marginal operating emissions rate (MOER) basis. Pumps are electric everywhere, so the scope assignment is not site-dependent the way a heating fault’s is. The avoided motor and seal damage is real and deliberately not in the range — the reference counts it as protective value rather than as CO₂e, and so does this card.

Deviations

  • no_flow_threshold ships an absolute placeholder, not the reference’s percentage. The reference gives 5% of design flow; the rule carries L/s because CDL parameters carry units and this library does no unit conversion in v1. The shipped 1.0 L/s is 5% of a 20 L/s design flow and is arbitrary on any other loop — 25% of design on a 4 L/s zone pump (alarms at normal part load), 1% on a 100 L/s primary (detects almost nothing). Hosts MUST set it per loop, as pump_flow’s dictionary notes require. Precedent: VAV-0001’s ventilation_requirement.
  • A variable-speed pump at genuine low load can sit under 5% of design flow with nothing broken, because the threshold is a fraction of design flow, not of current demand. The reference’s stance — a pump making no useful work is a finding regardless of why — is right for a protective rule, but the action differs: that case is a missing minimum-flow bypass or a deadhead, PMP-0002’s territory. Sites seeing this rule fire at low load should read PMP-0002 first.
  • flow_check_delay gates the run condition, not the fault condition, which is what the reference’s own description (“wait after pump start”) asks for. Applying it to the conjunction instead — VFD-0001’s and AHU-0027’s shape — would give the same 360 s alarm on a dry start and a 60 s slower alarm on every mid-run flow loss, for no stated reason. This chain is therefore not a repackaged single delay, and collapsing the two tunables would change behaviour.
  • The command/status mismatch is NO_EVAL here and is nobody’s fault rule today. A pump commanded on that never proves running, and a pump running against a command of off, are both real and both silent in this library: yRunOk goes false and this rule stands down. The check that would cover them is a status-versus-command comparison the library has for fans only in the after-hours sense (AHU-0018, RTU-0006). The reference’s vector table has the same hole — it publishes OFF/OFF as NO_EVAL and states no verdict for the mismatch rows — so this decision is the card’s, not a transcription.
  • The flow meter is uncorroborated and the rule reports its failure as a pump fault. Diagnosis 5 is in the reference’s list and nothing in three points separates it from the other four. The nearest cross-check is pump_dp, which this rule does not bind: a failed meter leaves DP at its normal operating point, an impeller failure collapses it, a deadhead raises it. That is a host-side read of a dictionary point, and the reason to run PMP-0002 on the same pump.
  • yRunOk is the library’s, not the reference’s. The reference writes the command and status tests as conjuncts of the fault condition and the graph computes exactly that; exposing the settled conjunction adds no logic and changes no verdict. It is a computed signal — the AND of two booleans held for a delay — which is what SCHEMA.md asks an evaluability output to be.
  • Strict < at the flow threshold. The reference writes < too, and CDL Reals has no LessEqual in any case, so flow of exactly 1.0 L/s reads as flow. The disagreement is measure-zero and errs toward silence; all three sides are pinned. A site whose BAS quantizes flow coarsely, or clamps small readings to zero as many meters do, should retune rather than rely on the signal landing off the boundary.
  • suppressed_by: [PMP-0002] is an authored relationship, not the reference’s — neither card declares suppression. Both rules fire on one physical event (every valve on the loop closed against a running pump), and PMP-0002 is the specific diagnosis where this is the general condition. Worse than redundant, this card’s diagnosis list is actively misleading on a deadhead: impeller failure, air lock and a broken coupling all produce low pump DP. Precedent for authoring the edge: the VFD-0001/VFD-0002 pair.
  • The two rules do not alarm at the same moment. On a mid-run valve closure both land 300 s after the event; on a pump started into a closed system this rule waits out flow_check_delay and lands at 360 s while PMP-0002 lands at 300 s. A host implementing the suppression as “drop PMP-0001 if PMP-0002 is active” is right in both cases; “drop it only if PMP-0002 alarmed first” is right in one.
  • The energy formula’s inputs are not this rule’s inputs. waste_kw = pump_rated_kw × (pump_speed/100)³ needs nameplate power and drive speed, and the pump dictionary carries neither. The host supplies them — speed is the VFD family’s vfd_speed where a drive exists, and the term is 1 where one does not. Transcribed unchanged otherwise.
  • Persistence stands in for averaging. The rule consumes instantaneous points; the reference specifies no averaging and G36’s AHU set would have used a 5-minute rolling mean. The two are not equivalent — flow alternating between zero and normal every four minutes never accumulates alarm_delay, though a pump catching and losing its prime all day is a genuine finding. A steady failure reads the same either way.
  • AlarmDelay is renamed alarm_delay. The reference’s tunables table spells this one parameter in G36’s PascalCase while spelling its two neighbours in snake_case. The value is unchanged at 5 min and the CXF path (persist.delayTime) is the one every other card exposes for the quantity.
  • The chapter number is uncertain. The reference’s page headers label Energy Recovery, Pumps and Variable Frequency Drives all as “Ch. 15”, which cannot be right for all three. source follows the VFD cards’ §15 and names the chapter title and page range so the citation resolves regardless.
  • g36 is null although the reference cites G36. Its source line reads “G36 alarm patterns; engineering best practice” — a family of patterns, not a clause — so there is nothing to put in the field and the claim is carried in source as prose. Contrast FCU-0005, where the reference names §5.22.6.
  • settled.delayOnInit and persist.delayOnInit are both true (CDL default is false), the library’s standing choice: a pump already running dry at controller restart waits out the full 360 s, and its flow reading is treated as untrustworthy for the first minute exactly as after a real start.
  • Three of the reference’s test vectors are published and reproduced under their own names — normal operation, no flow while running, and pump off; the remaining eleven scenarios in vectors.json are library-authored.
  • clusters is empty: clusters/clusters.json defines no cluster containing a pump rule, and this card does not edit the cluster set.

Notes

Read yRunOk before reading yFault. On a lead/lag pair the standby pump’s yRunOk is false for weeks at a time, and every yFault = false under it means “not evaluated”, not “no problem”. The vfd-pump-faults playbook owns the service procedure — its step 2 takes this fault and PMP-0002 together in the order that costs least: DP setpoint and reset sequence remotely, then closed isolation valves and a blocked strainer on site, then the impeller. Step 2.6 is worth reading before deploying on a variable-primary chilled water plant, where a missing minimum-flow bypass deadheads the lead pump whenever the last AHU valve closes and presents as a fleet of these alarms. That playbook’s header and the chapter README both still list the pump family as future work; both belong to other owners to correct.

Test Vectors

14 scenarios, clock step 60 s over 1800 s.

ScenarioDescription
normal_operationThe reference’s published vector 1: pump commanded on, proven running, 3.2 L/s of flow. Well above the 1.0 L/s no-flow line, so the rule is live and silent. yRunOk is false only on the init tick, while the 60 s flow_check_delay rides out the start transient.
no_flow_pump_runningThe reference’s published vector 2: commanded on, proven running, zero flow. The run condition settles at exactly 60 s and the alarm lands at exactly 360 s — flow_check_delay plus alarm_delay, the two delays adding exactly.
pump_offThe reference’s published vector 3: pump off, no flow, expected NO_EVAL. yRunOk is false for the whole run, which is how the host distinguishes this from a healthy pump — an idle pump makes no flow and that means nothing.
flow_exactly_at_thresholdBoundary: flow sits exactly on no_flow_threshold (1.0 L/s). CDL Reals has no LessEqual, so the comparison is strict and exactly 1.0 is not ‘no flow’.
flow_just_below_thresholdBoundary from below: 0.9 L/s clears the strict comparison and alarms on the same 360 s schedule as zero flow.
flow_just_above_thresholdBoundary from above: 1.1 L/s is flow, however little, and never alarms.
commanded_on_but_not_runningCommanded on with status false and zero flow. This is a real failure — a pump that will not start — and it is not this one: yRunOk is false, so the verdict is NO_EVAL rather than ‘no flow detected’. No library rule covers it today (see Deviations).
running_while_commanded_offThe other mismatch: status true against a command of off, zero flow. A welded contactor or a hand-off-auto switch left in hand. Also NO_EVAL here — the reference’s equation requires both conjuncts and this rule reports on neither mismatch.
flow_in_the_deadhead_band1.5 L/s: above this rule’s 5%-of-design no-flow line and below PMP-0002’s 10%-of-design deadhead line. Silent here by design; PMP-0002 is the rule that reports this band when the pump DP is also high.
flow_lost_mid_runA pump running normally loses its flow at t=600 s. The run condition settled long ago, so only alarm_delay is left to run and the alarm lands at exactly 900 s. This is the vector that pins the structure: with flow_check_delay applied to the whole conjunction instead of to the run condition, the alarm would come at 960 s and this window would fail.
intermittent_flow_never_alarmsFlow alternating between zero and 3.2 L/s every 240 s — an air-locked pump that keeps catching prime, or a flow meter dropping out. No single no-flow episode reaches alarm_delay, so nothing ever fires. Persistence is not averaging: a pump delivering flow half the time is a real finding this rule cannot make.
flow_returns_after_alarmRecovery: the alarm asserts at 360 s and the pump picks up flow at t=900 s. TrueDelay passes the falling edge with no delay, so yFault drops on that tick and the accumulated time is discarded.
status_drops_after_alarmThe evaluability release: an alarming pump loses its run status at t=900 s. yFault and yRunOk go false on the same tick and only the pair tells the host that the pump stopped rather than started delivering — the analogue of VFD-0001’s command_drops_below_floor_after_alarm.
pump_starts_dry_mid_runThe pump starts at t=600 s into a system that never produces flow. yRunOk asserts at exactly 660 s (60 s after the rising edge) and yFault at exactly 960 s — mid-run edges carry the same exact arithmetic as the init case.
vectors.json
{
  "schema": "cxf-library/vectors/v1",
  "clock": {
    "step_s": 60,
    "horizon_s": 1800
  },
  "scenarios": [
    {
      "name": "normal_operation",
      "description": "The reference's published vector 1: pump commanded on, proven running, 3.2 L/s of flow. Well above the 1.0 L/s no-flow line, so the rule is live and silent. yRunOk is false only on the init tick, while the 60 s flow_check_delay rides out the start transient.",
      "inputs": {
        "pump_cmd": true,
        "pump_status": true,
        "pump_flow": 3.2
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yRunOk",
          "from_s": 0,
          "to_s": 0,
          "equals": false
        },
        {
          "output": "yRunOk",
          "from_s": 60,
          "to_s": 1800,
          "equals": true
        }
      ]
    },
    {
      "name": "no_flow_pump_running",
      "description": "The reference's published vector 2: commanded on, proven running, zero flow. The run condition settles at exactly 60 s and the alarm lands at exactly 360 s \u2014 flow_check_delay plus alarm_delay, the two delays adding exactly.",
      "inputs": {
        "pump_cmd": true,
        "pump_status": true,
        "pump_flow": 0.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 300,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 360,
          "to_s": 1800,
          "equals": true
        },
        {
          "output": "yRunOk",
          "from_s": 0,
          "to_s": 0,
          "equals": false
        },
        {
          "output": "yRunOk",
          "from_s": 60,
          "to_s": 1800,
          "equals": true
        }
      ]
    },
    {
      "name": "pump_off",
      "description": "The reference's published vector 3: pump off, no flow, expected NO_EVAL. yRunOk is false for the whole run, which is how the host distinguishes this from a healthy pump \u2014 an idle pump makes no flow and that means nothing.",
      "inputs": {
        "pump_cmd": false,
        "pump_status": false,
        "pump_flow": 0.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yRunOk",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        }
      ]
    },
    {
      "name": "flow_exactly_at_threshold",
      "description": "Boundary: flow sits exactly on no_flow_threshold (1.0 L/s). CDL Reals has no LessEqual, so the comparison is strict and exactly 1.0 is not 'no flow'.",
      "inputs": {
        "pump_cmd": true,
        "pump_status": true,
        "pump_flow": 1.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yRunOk",
          "from_s": 60,
          "to_s": 1800,
          "equals": true
        }
      ]
    },
    {
      "name": "flow_just_below_threshold",
      "description": "Boundary from below: 0.9 L/s clears the strict comparison and alarms on the same 360 s schedule as zero flow.",
      "inputs": {
        "pump_cmd": true,
        "pump_status": true,
        "pump_flow": 0.9
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 300,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 360,
          "to_s": 1800,
          "equals": true
        },
        {
          "output": "yRunOk",
          "from_s": 60,
          "to_s": 1800,
          "equals": true
        }
      ]
    },
    {
      "name": "flow_just_above_threshold",
      "description": "Boundary from above: 1.1 L/s is flow, however little, and never alarms.",
      "inputs": {
        "pump_cmd": true,
        "pump_status": true,
        "pump_flow": 1.1
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yRunOk",
          "from_s": 60,
          "to_s": 1800,
          "equals": true
        }
      ]
    },
    {
      "name": "commanded_on_but_not_running",
      "description": "Commanded on with status false and zero flow. This is a real failure \u2014 a pump that will not start \u2014 and it is not this one: yRunOk is false, so the verdict is NO_EVAL rather than 'no flow detected'. No library rule covers it today (see Deviations).",
      "inputs": {
        "pump_cmd": true,
        "pump_status": false,
        "pump_flow": 0.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yRunOk",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        }
      ]
    },
    {
      "name": "running_while_commanded_off",
      "description": "The other mismatch: status true against a command of off, zero flow. A welded contactor or a hand-off-auto switch left in hand. Also NO_EVAL here \u2014 the reference's equation requires both conjuncts and this rule reports on neither mismatch.",
      "inputs": {
        "pump_cmd": false,
        "pump_status": true,
        "pump_flow": 0.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yRunOk",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        }
      ]
    },
    {
      "name": "flow_in_the_deadhead_band",
      "description": "1.5 L/s: above this rule's 5%-of-design no-flow line and below PMP-0002's 10%-of-design deadhead line. Silent here by design; PMP-0002 is the rule that reports this band when the pump DP is also high.",
      "inputs": {
        "pump_cmd": true,
        "pump_status": true,
        "pump_flow": 1.5
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yRunOk",
          "from_s": 60,
          "to_s": 1800,
          "equals": true
        }
      ]
    },
    {
      "name": "flow_lost_mid_run",
      "description": "A pump running normally loses its flow at t=600 s. The run condition settled long ago, so only alarm_delay is left to run and the alarm lands at exactly 900 s. This is the vector that pins the structure: with flow_check_delay applied to the whole conjunction instead of to the run condition, the alarm would come at 960 s and this window would fail.",
      "inputs": {
        "pump_cmd": true,
        "pump_status": true,
        "pump_flow": [
          {
            "t": 0,
            "value": 3.2
          },
          {
            "t": 600,
            "value": 0.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 840,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 900,
          "to_s": 1800,
          "equals": true
        },
        {
          "output": "yRunOk",
          "from_s": 60,
          "to_s": 1800,
          "equals": true
        }
      ]
    },
    {
      "name": "intermittent_flow_never_alarms",
      "description": "Flow alternating between zero and 3.2 L/s every 240 s \u2014 an air-locked pump that keeps catching prime, or a flow meter dropping out. No single no-flow episode reaches alarm_delay, so nothing ever fires. Persistence is not averaging: a pump delivering flow half the time is a real finding this rule cannot make.",
      "inputs": {
        "pump_cmd": true,
        "pump_status": true,
        "pump_flow": [
          {
            "t": 0,
            "value": 0.0
          },
          {
            "t": 240,
            "value": 3.2
          },
          {
            "t": 480,
            "value": 0.0
          },
          {
            "t": 720,
            "value": 3.2
          },
          {
            "t": 960,
            "value": 0.0
          },
          {
            "t": 1200,
            "value": 3.2
          },
          {
            "t": 1440,
            "value": 0.0
          },
          {
            "t": 1680,
            "value": 3.2
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yRunOk",
          "from_s": 60,
          "to_s": 1800,
          "equals": true
        }
      ]
    },
    {
      "name": "flow_returns_after_alarm",
      "description": "Recovery: the alarm asserts at 360 s and the pump picks up flow at t=900 s. TrueDelay passes the falling edge with no delay, so yFault drops on that tick and the accumulated time is discarded.",
      "inputs": {
        "pump_cmd": true,
        "pump_status": true,
        "pump_flow": [
          {
            "t": 0,
            "value": 0.0
          },
          {
            "t": 900,
            "value": 3.2
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 300,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 360,
          "to_s": 840,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 900,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yRunOk",
          "from_s": 60,
          "to_s": 1800,
          "equals": true
        }
      ]
    },
    {
      "name": "status_drops_after_alarm",
      "description": "The evaluability release: an alarming pump loses its run status at t=900 s. yFault and yRunOk go false on the same tick and only the pair tells the host that the pump stopped rather than started delivering \u2014 the analogue of VFD-0001's command_drops_below_floor_after_alarm.",
      "inputs": {
        "pump_cmd": true,
        "pump_status": [
          {
            "t": 0,
            "value": true
          },
          {
            "t": 900,
            "value": false
          }
        ],
        "pump_flow": 0.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 300,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 360,
          "to_s": 840,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 900,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yRunOk",
          "from_s": 60,
          "to_s": 840,
          "equals": true
        },
        {
          "output": "yRunOk",
          "from_s": 900,
          "to_s": 1800,
          "equals": false
        }
      ]
    },
    {
      "name": "pump_starts_dry_mid_run",
      "description": "The pump starts at t=600 s into a system that never produces flow. yRunOk asserts at exactly 660 s (60 s after the rising edge) and yFault at exactly 960 s \u2014 mid-run edges carry the same exact arithmetic as the init case.",
      "inputs": {
        "pump_cmd": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 600,
            "value": true
          }
        ],
        "pump_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 600,
            "value": true
          }
        ],
        "pump_flow": 0.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 900,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 960,
          "to_s": 1800,
          "equals": true
        },
        {
          "output": "yRunOk",
          "from_s": 0,
          "to_s": 600,
          "equals": false
        },
        {
          "output": "yRunOk",
          "from_s": 660,
          "to_s": 1800,
          "equals": true
        }
      ]
    }
  ]
}

PMP-0002 — Pump deadheading (high DP, low/no flow)

Statusverified — engine e2ff2f8, cxf:fnv1a128:cdf8042c408d62c7d323457a8f2103ac, 2026-08-17
Severity2
Methodrule
Phase2
CategoryPROTECTIVE
ConfidenceHIGH
EstimationDIRECT_MEASUREMENT
G36
Clusters
SuppressesPMP-0001
Suppressed by
RelatedPMP-0001, PMP-0005, PMP-0006, VFD-0002
Playbooksvfd-pump-faults
SourceHVAC FDD Reference v1.0 §15 (ch. ‘Pumps’, pdf pp. 134-135), PMP-0002; Engineering best practice
Operating statespump proven running

Preconditions (host-enforced): pump_dp must be the differential pressure ACROSS THIS PUMP — discharge minus suction — and the point dictionary marks it provisional for exactly this reason. A loop or decoupler DP bound here breaks the rule rather than degrading it: depending on where the taps sit, a deadhead can read high, unchanged, or low, and in the last case the rule is silent forever on the fault it exists to find. Confirm the tap location at binding review. deadhead_dp_threshold must then be set from this pump’s curve and deadhead_flow_threshold from this loop’s design flow; both shipped values are placeholders (see Deviations), and the DP one is the more dangerous of the two because the reference’s 150%-of-design multiplier is above the shutoff head of many pumps. All three points must belong to the same pump; on a headered set a common loop flow meter or a header DP tap bound to each pump makes the rule read one machine’s hydraulics onto another. The pump must be proven running by rotation — a current switch or drive feedback — and when pump_status is false this rule has no verdict at all: the host reports NO_EVAL, not healthy. There is no in-rule evaluability output (see Deviations), so that gate is the host’s to enforce.

Points: pump_status, pump_dp, pump_flow

Outputs:

  • yFault — True while the pump has been proven running with its differential pressure above deadhead_dp_threshold and its flow below deadhead_flow_threshold, continuously for at least alarm_delay

Parameters:

NameDefaultUnitCXF pathDescription
deadhead_dp_threshold300.0kPadpHigh.tDifferential pressure across the pump above which it is working against a closed system. PER-PUMP SITE CONFIGURATION — the reference states 150% of design head and the rule carries absolute units, so the shipped 300.0 kPa is 150% of a 200 kPa (≈20 m) design head and means nothing on any other pump. Set it from the pump curve, not from the multiplier (see Deviations)
deadhead_flow_threshold2.0L/slowFlow.tFlow below which the pump is delivering nothing useful. PER-LOOP SITE CONFIGURATION — the reference states 10% of design flow; the shipped 2.0 L/s is 10% of the same 20 L/s design flow PMP-0001’s placeholder assumes, which keeps the pair’s 5%/10% relationship intact at the defaults
alarm_delay300.0spersist.delayTimeContinuous violation required before the alarm asserts (5 min). ADOPTED — the reference’s tunables line for this card truncates mid-sentence and its equation states no persistence at all; 300 s is the sibling PMP-0001’s published AlarmDelay (see Deviations)

Description

A deadheaded pump is running against a closed system. The discharge valve, or every terminal valve on the loop, or a check valve someone installed backwards, leaves the water nowhere to go, so the pump rides up its curve to shutoff head and recirculates the same volute of water until that water boils. It is the fault on this family’s list that damages hardware fastest — the mechanical seal loses the flow that cools it, the bearings take the radial thrust a pump far off its best efficiency point produces — and the reference prices the outcome at $5,000 to $20,000. The signature is the pair of readings, not either alone: high DP by itself is a loop at high head, low flow by itself is PMP-0001’s condition. Together they mean downstream resistance went up, which is the definition of deadheading and why this card carries HIGH confidence where its sibling carries MEDIUM.

Detection Logic

dp_high  = pump_dp   > deadhead_dp_threshold
low_flow = pump_flow < deadhead_flow_threshold

yFault = (pump_status AND dp_high AND low_flow)
         sustained continuously for alarm_delay

Block graph (rule.cxf.jsonld):

PMP-0002 block graph

dpHigh and lowFlow are the two comparisons, hydraulic conjoins them into the deadhead signature, gate adds the reference’s pump_status conjunct, and persist measures the duration. The nesting is only structural — CDL’s Logical.And takes two inputs — but it groups the graph the way the physics does, hydraulic evidence on one side and run proof on the other.

Both comparisons are strict, so a DP sitting exactly on its threshold is not high and a flow sitting exactly on its threshold is not low; each boundary is pinned from three sides. persist requires five continuous minutes, which is what separates a deadheading pump from the ordinary event that produces the identical trace for half that: every zone valve on a loop closing together at the end of a setback, or a two-way control valve stroking shut while its neighbour has not yet opened. Any moment where the flow returns or the DP falls back drops the timer, so the alarm always describes one continuous episode.

There is no evaluability output. Both hydraulic terms are direct comparisons on bound inputs and the run term is a bound input — exposing pump_status as yStatusOk would echo a point the host already has, which SCHEMA.md’s boundary-output convention exists to prevent. The NO_EVAL cases therefore live entirely in preconditions: a stopped pump, a pump in hand, and any binding where pump_dp is not measured across the pump.

Possible Diagnoses

Transcribed from the reference’s PMP-0002 card:

  1. Downstream isolation valve closed — the single-valve case, usually left shut after service on a branch, and the cheapest of the four to fix
  2. Severe system blockage — a plugged strainer basket after a piping repair, or debris carried into a reducer; the playbook’s step 2.4 checks the strainer before anything is disassembled
  3. Check valve installed backwards — a commissioning error rather than a failure, worth suspecting on a pump that has never made flow since it was installed or repaired
  4. All terminal unit valves closed — nothing is broken at all. The loop is at no load with no minimum-flow path, which is a control or design finding: a DP reset that never trims, a missing bypass, or a lead pump that should have staged off. The common one on a variable-primary chilled water plant

The first three are field failures on one pump; the fourth is a plant-sequencing problem that will recur on every pump in the building, and the distinction is available before anyone goes to site by asking whether the loop was at genuine no load when the alarm started. The DP term is also what makes this rule diagnostic rather than merely detective: a failed impeller or a sheared coupling makes no head while making no flow, and a deadheaded pump makes its maximum.

Energy Impact

PROTECTIVE, HIGH confidence, DIRECT_MEASUREMENT. The energy line is the sibling’s — 100% of the pump’s draw while the condition lasts, since none of it is moving water anywhere — but the number that matters to an owner is the $5K–$20K of seal and bearing damage the reference attaches to this card and not to PMP-0001: a pump can deadhead for an afternoon and cost a few dollars of electricity and a mechanical seal. HIGH confidence is the reference’s rating and is earned by the second term, two independent measurements agreeing on one hydraulic story, though it survives only one direction of sensor failure (see Deviations). Climate sensitivity is neutral.

Emissions Impact

Scope 2, DIRECT_EMISSIONS, HIGH confidence; the reference’s typical range is 100–500 kg CO₂e/yr for pump energy plus the equipment damage risk, on a marginal operating emissions rate (MOER) basis. Pump motors are electric, so the scope assignment does not vary with the plant the way a heating fault’s does. The embodied emissions of a replaced pump end are outside the range and outside this rule’s reach, and are plausibly the larger number over a decade of a recurring deadhead nobody diagnosed.

Deviations

  • The reference’s 150%-of-design-head multiplier is above the shutoff head of many pumps, and this card says so rather than endorsing it. Shutoff head is typically only 110–130% of head at the design point, so a threshold set at 150% exceeds the highest pressure the pump can produce and the rule is silent by construction. Retune to the curve’s shutoff head less a margin (commonly 105–120% of design), or above a reset sequence’s maximum setpoint.
  • deadhead_dp_threshold ships an absolute placeholder. The reference states a percentage and the rule carries kPa, because CDL parameters carry units and this library does no unit conversion in v1; the point dictionary is canonical on it. The shipped 300.0 kPa is 150% of a 200 kPa (≈20 m) design head and is arbitrary on any other pump. Hosts MUST set it per pump, and per the bullet above, from the curve rather than from the multiplier.
  • deadhead_flow_threshold ships an absolute placeholder too, at twice PMP-0001’s. 2.0 L/s is 10% of the same notional 20 L/s design flow that card’s 1.0 L/s assumes, so the pair’s published 5%/10% relationship survives at the defaults and there is a band — 5% to 10% of design — where a high-DP pump trips this rule and not that one. Hosts retuning one threshold should retune both from the same design flow.
  • alarm_delay has no published value, and no published existence. The reference’s equation for this card is the bare three-term conjunction with no “sustained for” clause, and its tunables line ends mid-sentence — the same truncation artifact VFD-0002’s line carries. Both the persistence and its value are therefore ADOPTED, at PMP-0001’s published 300 s: same chapter, same family, same physical event from a second angle. VFD-0002’s 900 s was rejected — fifteen minutes is a control-loop response window, and a pump running its seal dry does not have fifteen minutes. Hosts should shorten rather than lengthen it.
  • The DP tap location is an assumption, and binding the wrong point inverts the rule. The dictionary marks pump_dp provisional for this reason: across the pump, a deadhead reads high; across the loop or a decoupler it may read high or low depending on where the taps sit. This card assumes the across-the-pump reading. A loop-DP binding fails silently — the rule simply never fires — and a header-DP binding can be loud in the wrong direction, as a stopped standby pump whose tap sees the running pump’s header pressure has high DP and no flow, with only the pump_status conjunct keeping it quiet. Binding review owns this; no logic can detect it.
  • No evaluability output, deliberately. pump_status is a boundary input, so exposing it would echo a point the host already reads — the case SCHEMA.md’s convention rules out — and neither hydraulic term is a derived quantity a host could not compute for itself. Contrast PMP-0001’s yRunOk, a conjunction held for a delay, and VFD-0001’s yCmdOk.
  • suppresses: [PMP-0001] is an authored relationship, not the reference’s — neither card declares suppression. Both fire on one physical event and this one is the specific diagnosis where PMP-0001 is the general condition. The direction matters more than usual because the general card’s diagnosis list is wrong for this fault: impeller failure, air lock and a broken coupling all produce low DP, so leaving both alarms up sends a technician to open a volute on a pump whose isolation valve is shut. Precedent for authoring the edge: the VFD-0001/VFD-0002 pair.
  • A DP transmitter reading high and a flow meter reading zero produce this fault exactly. Two failed sensors are less likely than one, which is why this card outranks its sibling on confidence, but the rule has no third measurement and the failure is not exotic: a plugged DP tap reads whatever pressure it last saw, and several flow meter types read zero when they lose signal. Motor current settles it in a minute — a deadheaded centrifugal pump draws noticeably less than at design, not more.
  • A constant-speed pump deadheads differently from a variable-speed one. On DP control, closing valves drives the measured pressure up and the drive slows to its minimum, so the DP this rule finally sees is shutoff head at minimum speed, which can sit well below a threshold derived from a full-speed curve. A second, quieter reason the shipped multiplier can leave the rule silent, and an argument for deriving the threshold from the DP setpoint’s maximum on any drive-controlled loop. VFD-0002 is related for this reason.
  • Strict comparisons at both thresholds. The reference writes > and < too, and CDL Reals has neither GreaterEqual nor LessEqual in any case. A DP of exactly 300.0 kPa is not high and a flow of exactly 2.0 L/s is not low; both disagreements have measure zero and both err toward silence.
  • Persistence stands in for averaging. The rule consumes instantaneous points and the reference specifies no averaging. A loop cycling in and out of a deadhead faster than alarm_delay — a hunting control valve, a pump staging against a badly tuned bypass — is a real finding this rule cannot make, the same blind spot PMP-0001 documents.
  • The energy formula’s inputs are not this rule’s inputs. waste_kw = pump_rated_kw × (pump_speed/100)³ needs nameplate power and drive speed and the pump dictionary carries neither, so the host supplies both. Transcribed unchanged otherwise, including its identity with PMP-0001’s.
  • The chapter number is uncertain. The reference’s page headers label Energy Recovery, Pumps and Variable Frequency Drives all as “Ch. 15”, which cannot be right for all three. source follows the VFD cards’ §15 and names the chapter title and page range so the citation resolves regardless.
  • g36 is null and no G36 provenance is claimed. The reference sources this card to engineering best practice alone — unlike PMP-0001, which cites G36 alarm patterns — so source says only what the reference says.
  • persist.delayOnInit = true (CDL default is false), the library’s standing choice: a pump already deadheading at controller restart waits out the full five minutes rather than alarming on the first tick.
  • The reference publishes a vector table for PMP-0001 and none for this card, so every scenario in vectors.json is library-authored. clusters is empty: clusters/clusters.json defines no cluster containing a pump rule, and this card does not edit the cluster set.

Notes

Treat this card as the pair’s head. When both pump rules are firing, this is the finding and PMP-0001 is its shadow — the two diagnosis lists point in opposite physical directions and only one of them fits a pump making full head. Before deploying on a fleet, do two things that cost one trend each: confirm where the DP transmitter is tapped, and compare deadhead_dp_threshold against the pump curve’s shutoff head. The vfd-pump-faults playbook’s step 2 is the service order, and its first two entries are remote — the DP setpoint may simply be too high, and a loop with no DP reset sequence pumps against closed valves by design (EEM-10, 0.5–2% of site energy). That playbook’s header and the chapter README both still list the pump family as future work; both belong to other owners to correct.

Test Vectors

15 scenarios, clock step 60 s over 1800 s.

ScenarioDescription
normal_operationPump running, 200 kPa across it, 8.0 L/s moving. Below the DP line and well above the flow line — the ordinary state of a healthy loop.
deadheadingThe fault: pump running, 350 kPa across it, zero flow. Every terminal valve is shut and the pump is churning water into heat. The alarm lands at exactly 300 s.
dp_exactly_at_thresholdBoundary: DP sits exactly on deadhead_dp_threshold (300.0 kPa) with no flow. CDL Reals has no GreaterEqual, so the comparison is strict and exactly 300.0 is not high DP.
dp_just_above_thresholdBoundary from above: 300.1 kPa clears the strict comparison and alarms on the ordinary 300 s schedule.
dp_just_below_thresholdBoundary from below: 299.9 kPa with zero flow is silent here. A pump that has lost its impeller sits in this region, and PMP-0001 is the rule that reports it.
flow_exactly_at_thresholdBoundary: flow sits exactly on deadhead_flow_threshold (2.0 L/s) against high DP. Strict LessThreshold, so exactly 2.0 is not low flow.
flow_just_below_thresholdBoundary from below: 1.9 L/s against high DP is a partial deadhead — a couple of bypass paths still open — and it alarms.
flow_just_above_thresholdBoundary from above: 2.1 L/s against the same high DP never alarms.
stopped_pump_reading_header_dpA standby pump, stopped, with its DP tap reading 350 kPa of header pressure the running pump is making, and no flow of its own. The status conjunct is the only thing standing between that geometry and a permanent false alarm — which is exactly why the point dictionary marks pump_dp provisional.
impeller_failure_signaturePump running, no flow, and only 40 kPa across it: the pump is turning and making no head. Broken coupling, failed impeller, or an air lock. Silent here and loud on PMP-0001 — the DP term is what separates the two diagnoses, and this is the vector that proves it.
high_dp_at_normal_flow350 kPa with 8.0 L/s moving: a loop simply running at high head, which is a DP-reset opportunity and not a fault. Both conjuncts are required and only one holds.
valve_closure_transientA four-minute deadhead at t=300 s — every zone valve closing together on a morning setback, then reopening. Shorter than alarm_delay, so nothing fires and the accumulated time is discarded on the release.
deadhead_starts_mid_runA healthy loop deadheads at t=600 s. The alarm lands at exactly 900 s — alarm_delay measured from the rising edge, not from init.
valves_open_after_alarmRecovery: the alarm asserts at 300 s and someone opens the isolation valve at t=900 s. DP falls, flow returns, and yFault drops on that tick.
status_drops_after_alarmA deadheading pump trips out at t=900 s — which is what a deadheaded pump eventually does. yFault goes false with the status and the host must read that as NO_EVAL, not as a resolved fault; the DP and flow signature has not changed at all.
vectors.json
{
  "schema": "cxf-library/vectors/v1",
  "clock": {
    "step_s": 60,
    "horizon_s": 1800
  },
  "scenarios": [
    {
      "name": "normal_operation",
      "description": "Pump running, 200 kPa across it, 8.0 L/s moving. Below the DP line and well above the flow line \u2014 the ordinary state of a healthy loop.",
      "inputs": {
        "pump_status": true,
        "pump_dp": 200.0,
        "pump_flow": 8.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        }
      ]
    },
    {
      "name": "deadheading",
      "description": "The fault: pump running, 350 kPa across it, zero flow. Every terminal valve is shut and the pump is churning water into heat. The alarm lands at exactly 300 s.",
      "inputs": {
        "pump_status": true,
        "pump_dp": 350.0,
        "pump_flow": 0.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 240,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 300,
          "to_s": 1800,
          "equals": true
        }
      ]
    },
    {
      "name": "dp_exactly_at_threshold",
      "description": "Boundary: DP sits exactly on deadhead_dp_threshold (300.0 kPa) with no flow. CDL Reals has no GreaterEqual, so the comparison is strict and exactly 300.0 is not high DP.",
      "inputs": {
        "pump_status": true,
        "pump_dp": 300.0,
        "pump_flow": 0.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        }
      ]
    },
    {
      "name": "dp_just_above_threshold",
      "description": "Boundary from above: 300.1 kPa clears the strict comparison and alarms on the ordinary 300 s schedule.",
      "inputs": {
        "pump_status": true,
        "pump_dp": 300.1,
        "pump_flow": 0.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 240,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 300,
          "to_s": 1800,
          "equals": true
        }
      ]
    },
    {
      "name": "dp_just_below_threshold",
      "description": "Boundary from below: 299.9 kPa with zero flow is silent here. A pump that has lost its impeller sits in this region, and PMP-0001 is the rule that reports it.",
      "inputs": {
        "pump_status": true,
        "pump_dp": 299.9,
        "pump_flow": 0.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        }
      ]
    },
    {
      "name": "flow_exactly_at_threshold",
      "description": "Boundary: flow sits exactly on deadhead_flow_threshold (2.0 L/s) against high DP. Strict LessThreshold, so exactly 2.0 is not low flow.",
      "inputs": {
        "pump_status": true,
        "pump_dp": 350.0,
        "pump_flow": 2.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        }
      ]
    },
    {
      "name": "flow_just_below_threshold",
      "description": "Boundary from below: 1.9 L/s against high DP is a partial deadhead \u2014 a couple of bypass paths still open \u2014 and it alarms.",
      "inputs": {
        "pump_status": true,
        "pump_dp": 350.0,
        "pump_flow": 1.9
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 240,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 300,
          "to_s": 1800,
          "equals": true
        }
      ]
    },
    {
      "name": "flow_just_above_threshold",
      "description": "Boundary from above: 2.1 L/s against the same high DP never alarms.",
      "inputs": {
        "pump_status": true,
        "pump_dp": 350.0,
        "pump_flow": 2.1
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        }
      ]
    },
    {
      "name": "stopped_pump_reading_header_dp",
      "description": "A standby pump, stopped, with its DP tap reading 350 kPa of header pressure the running pump is making, and no flow of its own. The status conjunct is the only thing standing between that geometry and a permanent false alarm \u2014 which is exactly why the point dictionary marks pump_dp provisional.",
      "inputs": {
        "pump_status": false,
        "pump_dp": 350.0,
        "pump_flow": 0.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        }
      ]
    },
    {
      "name": "impeller_failure_signature",
      "description": "Pump running, no flow, and only 40 kPa across it: the pump is turning and making no head. Broken coupling, failed impeller, or an air lock. Silent here and loud on PMP-0001 \u2014 the DP term is what separates the two diagnoses, and this is the vector that proves it.",
      "inputs": {
        "pump_status": true,
        "pump_dp": 40.0,
        "pump_flow": 0.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        }
      ]
    },
    {
      "name": "high_dp_at_normal_flow",
      "description": "350 kPa with 8.0 L/s moving: a loop simply running at high head, which is a DP-reset opportunity and not a fault. Both conjuncts are required and only one holds.",
      "inputs": {
        "pump_status": true,
        "pump_dp": 350.0,
        "pump_flow": 8.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        }
      ]
    },
    {
      "name": "valve_closure_transient",
      "description": "A four-minute deadhead at t=300 s \u2014 every zone valve closing together on a morning setback, then reopening. Shorter than alarm_delay, so nothing fires and the accumulated time is discarded on the release.",
      "inputs": {
        "pump_status": true,
        "pump_dp": [
          {
            "t": 0,
            "value": 200.0
          },
          {
            "t": 300,
            "value": 350.0
          },
          {
            "t": 540,
            "value": 200.0
          }
        ],
        "pump_flow": [
          {
            "t": 0,
            "value": 8.0
          },
          {
            "t": 300,
            "value": 0.5
          },
          {
            "t": 540,
            "value": 8.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        }
      ]
    },
    {
      "name": "deadhead_starts_mid_run",
      "description": "A healthy loop deadheads at t=600 s. The alarm lands at exactly 900 s \u2014 alarm_delay measured from the rising edge, not from init.",
      "inputs": {
        "pump_status": true,
        "pump_dp": [
          {
            "t": 0,
            "value": 200.0
          },
          {
            "t": 600,
            "value": 350.0
          }
        ],
        "pump_flow": [
          {
            "t": 0,
            "value": 8.0
          },
          {
            "t": 600,
            "value": 0.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 840,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 900,
          "to_s": 1800,
          "equals": true
        }
      ]
    },
    {
      "name": "valves_open_after_alarm",
      "description": "Recovery: the alarm asserts at 300 s and someone opens the isolation valve at t=900 s. DP falls, flow returns, and yFault drops on that tick.",
      "inputs": {
        "pump_status": true,
        "pump_dp": [
          {
            "t": 0,
            "value": 350.0
          },
          {
            "t": 900,
            "value": 180.0
          }
        ],
        "pump_flow": [
          {
            "t": 0,
            "value": 0.0
          },
          {
            "t": 900,
            "value": 8.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 240,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 300,
          "to_s": 840,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 900,
          "to_s": 1800,
          "equals": false
        }
      ]
    },
    {
      "name": "status_drops_after_alarm",
      "description": "A deadheading pump trips out at t=900 s \u2014 which is what a deadheaded pump eventually does. yFault goes false with the status and the host must read that as NO_EVAL, not as a resolved fault; the DP and flow signature has not changed at all.",
      "inputs": {
        "pump_status": [
          {
            "t": 0,
            "value": true
          },
          {
            "t": 900,
            "value": false
          }
        ],
        "pump_dp": 350.0,
        "pump_flow": 0.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 240,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 300,
          "to_s": 840,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 900,
          "to_s": 1800,
          "equals": false
        }
      ]
    }
  ]
}

PMP-0003 — Pump proof-of-operation failure

Statusverified — engine e2ff2f8, cxf:fnv1a128:5d2c2578abc92f6bca8468f0bb076494, 2026-08-18
Severity2
Methodrule
Phase2
CategoryPROTECTIVE
ConfidenceHIGH
EstimationPROXY_ESTIMATION
G36
Clusters
SuppressesPMP-0005
Suppressed by
RelatedPMP-0001, PMP-0002, PMP-0004, PMP-0005, VFD-0001, HW-0009, HX-0002
Playbooksproof-of-operation, vfd-pump-faults
SourceASHRAE Guideline 36-2021 §5.20.17.6 (chilled water plant pumps and tower fans) and §5.21.10.5 (hot water plant pumps) — the pump command/status alarm this card instantiates: commanded on with status off is Level 2 after 15 s, commanded off with status on is Level 4 after a separate 60 s window. Two directions, two windows, two severities; both proof-time defaults are the standard’s own numbers; ASHRAE Guideline 36-2021 §5.1.6 — the definition of proven: the equipment’s DI status point matches the state its DO command point was set to. This rule is that comparison, held in both directions; Clause numbers verified against the G36-2021 text by the HW-0009 agent, batch 23, and recorded in _research/local/engine-block-inventory-fdd-candidates.md — which also corrects the §5.1.5 attribution this batch’s brief carried (that clause is outdoor-air sensor selection); The gap is reserved twice inside this library: PMP-0001’s Deviations — ‘the command/status mismatch is NO_EVAL here and is nobody’s fault rule today’ — and VFD-0001’s Deviations — ‘a run-status-versus-command comparison at equipment level, which this library has no drive rule for’; Buildings.Controls.OBC.CDL.Logical.Proof (Hu & Wetter, March 2023), read at engine pin e2ff2f8 from third_party/modelica-buildings-cdl/Buildings/Controls/OBC/CDL/Logical/Proof.mo — the standardized command-vs-status idiom, and the vocabulary this card’s two directions inherit (yLocFal / yLocTru). The block is exported at the pin and is deliberately NOT used; see Deviations; HVAC FDD Reference v1.0 ch.15 ‘Pumps’ carries no command-versus-status card — its two are PMP-0001 and PMP-0002 — so name, severity and category are argued here against G36’s alarm levels rather than transcribed; points/pmp.points.json pump_cmd and pump_status — the status point’s provenance note (current switch or VFD run feedback) is what the retune guidance on both windows is argued from
Operating statesall — both directions are meaningful in every state, and the unoccupied hours are where the unexpected-run direction earns its keep

Preconditions (host-enforced): Both points must belong to the same pump and both must be fresh, and the freshness obligation is sharper here than on any other pump card because the shipped windows are G36’s controller-native 15 s and 60 s. The rule is a pure agreement test between two booleans and has no way to tell a real mismatch from a stale one: a held-over last value, a lapsed COV subscription, or a poll interval longer than the proof time manufactures a fault out of nothing, and a 15 s window against a 60 s poll cycle does so on every start. Both proof times must therefore exceed the host’s worst-case delivery latency for these two points — that is the whole of the delivery-quality obligation, and it is why the rule publishes no evaluability flag (see Deviations). pump_status must prove ROTATION or WORK: a current switch set above the motor’s no-load current, a differential-pressure switch across the pump, or the drive’s own run feedback. A starter auxiliary contact wired from the same relay the command drives makes this rule structurally blind — status echoes command, the two never disagree, and neither direction can ever assert. A current switch set below no-load current is the same failure in one direction only: a sheared coupling or an uncoupled motor reads as proven running. pump_cmd must be the output actually delivered to the starter, not a scheduler’s intent upstream of BAS overrides; bound upstream, a legitimate operator override reads as an unexpected run. On a lead/lag set the changeover dwell must fit inside both proof times, and on a headered set the two points must not be crossed between pumps — the binding error that puts the lead pump’s status against the lag pump’s command produces both faults at once on two healthy machines.

Points: pump_cmd, pump_status

Outputs:

  • yFault — True while either direction has been proven — the OR of the two flags below, taken after their delays. The directions are mutually exclusive by construction, so a live yFault always has exactly one flag under it
  • yFailToStart — Sub-condition flag — pump_cmd true and pump_status false continuously for start_proof_time. DIAGNOSTIC only; false never means NO_EVAL
  • yUnexpectedRun — Sub-condition flag — pump_status true and pump_cmd false continuously for stop_proof_time. Same kind as yFailToStart

Parameters:

NameDefaultUnitCXF pathDescription
start_proof_time15.0sstartProof.delayTimeHow long the pump may be commanded on without proving running before the fault asserts. TRANSCRIBED from G36 §5.20.17.6 / §5.21.10.5, which give 15 s for the commanded-on direction. The standard assumes a controller reading a hardwired DI every scan; a host consuming polled or trended BAS points MUST raise this above its worst-case delivery latency, and a pump whose status is a DP switch downstream of a 30 s VFD ramp needs more than the ramp (vfd_ramp_alarms_at_the_shipped_default pins that case rather than hiding it)
stop_proof_time60.0sstopProof.delayTimeHow long the pump may run against a command of off before the fault asserts. TRANSCRIBED from the same clauses, which publish a SEPARATE 60 s window for the commanded-off direction — four times the start window. The asymmetry is the standard’s and it matches the physics: a stopping pump’s status lingers while the impeller coasts and the check valve settles, and the finding this direction names, a starter left in HAND, lasts nights and weekends, so latency costs nothing while a false alarm on every normal stop costs the rule its credibility

Description

Proof of operation is the question every other pump rule assumes an answer to: did the machine do what it was told? Two booleans settle it — the command the BAS sends the starter, and the status the starter sends back — and their disagreement, held past the window G36 publishes for its direction, is a fault in whichever direction it points. Commanded on and never proven running is a dead loop: a tripped overload, a sheared coupling, a drive locked out on a fault, a disconnect somebody opened for service and left open. Proven running while commanded off is usually a hand-off-auto switch left in HAND, and it bills for that every hour of every night. PMP-0001 and PMP-0002 both begin with a pump that is running and ask what the water is doing; this rule sits upstream of both and asks whether the run state itself is real.

Detection Logic

yFailToStart   = (pump_cmd AND NOT pump_status)   continuously for start_proof_time
yUnexpectedRun = (pump_status AND NOT pump_cmd)   continuously for stop_proof_time

yFault         = yFailToStart OR yUnexpectedRun

Block graph (rule.cxf.jsonld):

PMP-0003 block graph

Seven blocks, two lanes, no thresholds and no reals. The conditions are mutually exclusive at every instant — one needs the command true, the other needs it false — and each delay passes only its own condition through, so the flags can never be true together and yFault always has exactly one direction under it.

The Or sits after both delays rather than before, which is the one behaviour worth knowing at deployment: a mismatch that changes direction is two faults rather than one, and yFault drops while the new lane serves its own window (direction_flip_reserves_the_new_window pins it). Both delays carry delayOnInit = true, so a restart into a standing mismatch waits out the full window instead of announcing itself on tick zero.

No evaluability flag, and none is needed: the rule is evaluable whenever both points are delivered, which is the host’s delivery-quality job. Both sub-condition flags are diagnostic — false on either means that direction is not proven, never NO_EVAL.

Possible Diagnoses

yFailToStart — commanded on, never proved:

  1. Tripped motor overload, or a starter that never pulled in. The first thing to look at and the one visible without tools
  2. VFD locked out on a fault — overcurrent, ground fault, phase loss. The drive display carries the code, and vfd-pump-faults step 1.4 already looks for it
  3. Broken coupling or snapped belt: the motor turns, the pump does not. A current switch set above no-load current catches this; one set below it does not, and reports the pump as running
  4. Disconnect left open after service, or a blown control-circuit fuse — the cheapest cause, and common after a Friday afternoon
  5. The status device rather than the pump: a failed current switch, a DP switch out of adjustment, a broken auxiliary contact. Rule this out with a clamp meter before anyone opens anything

yUnexpectedRun — running, never commanded:

  1. Hand-off-auto switch left in HAND after service. The classic, and the one that shows up on the bill
  2. Welded contactor, or a starter holding in on a stuck relay
  3. BAS output miswired or left in override — a point overridden at the controller, or a command landed on the wrong starter in a pump room with several
  4. A second controller commanding the same pump: a boiler’s own pump control, a chiller interlock, a packaged plant sequencer. The pump is obeying somebody this rule cannot see

Energy Impact

PROTECTIVE, HIGH confidence, PROXY_ESTIMATION. Only one direction spends electricity, and it is exactly calculable. Take a 10 kW pump — an ordinary mid-size building’s secondary chilled-water pump — left in HAND when a service call ends Friday afternoon and found Monday morning: 60 h x 10 kW = 600 kWh, roughly $60-$90 at $0.10-$0.15/kWh. A year of unnoticed weekends is about 31 MWh and $3,000-$4,700, and on a hot-water loop the pump’s own kW is the floor rather than the total, because a circulating loop bleeds heat and the boiler answers. yFailToStart spends no pump energy at all; its cost is the loop that did not circulate — a chiller tripping on low evaporator flow, a heating coil with no water in freezing weather. HIGH confidence because there is nothing to calibrate; PROXY because the hours are measured and the kilowatts are a nameplate.

Emissions Impact

Scope 2, PROXY_EMISSIONS. Pumps are electric everywhere, so the scope never varies with the plant the way a heating fault’s does. The weekend above is about 240 kg CO₂e on a 0.4 kg/kWh marginal operating emissions rate (MOER) and roughly 12 t/yr if it repeats — the sibling pair’s published 100-500 kg/yr band is for a fault that ends when somebody notices; this one’s is driven by hours, not by hydraulics, and has no natural end. PROXY rather than the pair’s DIRECT because this rule reads no meter and no flow.

Deviations

  • CDL.Logical.Proof is exported at the pin and is deliberately not used. Its two outputs map one-for-one onto this card’s two directions, but a single timing window governs both of them — checking begins at the EARLIER of feedbackDelay + debounce after the command changes or debounce after the measurement settles — so G36’s published 15 s and 60 s cannot both be expressed. Composed from Logical.Not, Logical.And, Logical.TrueDelay and Logical.Or instead, all registry-supported classes. The HW-0009 agent’s probes reached the same verdict independently.
  • Three further Proof behaviours contradict this card, all measured against a probe document at e2ff2f8 rather than read off the engine source. With debounce = 60 s and feedbackDelay = 120 s: a start into a standing mismatch raised yLocFal at t = 0 s, because its internal delays behave as delayOnInit = false; a stably-false status therefore gets no proof window at all, asserting on the tick the command rises; and a command held true against a status chattering on a 60 s period raised yLocTru — the unexpected-run output — at t = 240 s, both outputs latched together. This card promises mutually exclusive flags and a full window after restart, so the last result alone settles it. Nothing surprising happened at load: the block imports, exports and runs cleanly, and the rejection is semantic.
  • Two Nots and two Ands rather than one Xor and two Ands. pump_cmd XOR pump_status conjoined with each input in turn is the same truth table in one fewer block, and draws without a wire crossing. The composed form is kept because batch 23 instantiates one template per equipment, and a family whose graphs read the same is worth more than a block.
  • Both proof times are transcribed from G36, not authored. §5.20.17.6 and §5.21.10.5 publish 15 s for the commanded-on direction and a separate 60 s window for the commanded-off direction on exactly this equipment, so this card ships the standard’s pair rather than the 60 s / 120 s it originally argued from VFD ramps and BAS poll cycles. The asymmetry survived the correction and its direction was right; only the magnitudes and the ratio changed, from 2x to the standard’s 4x.
  • The shipped 15 s will alarm on a healthy start behind a slow ramp or a slow poll, and the vectors pin that rather than hide it. G36’s number assumes a controller reading a hardwired DI every scan; this library’s rules read whatever the host delivers. vfd_ramp_alarms_at_the_shipped_default runs a 30 s acceleration ramp through the default and shows the alarm asserting at 45 s and clearing itself at 60 s when the status finally makes. Retuning start_proof_time above the site’s worst-case ramp and delivery latency is a precondition, not an option — the same discipline PMP-0001 applies to its placeholder threshold.
  • delayOnInit = true on both delays (CDL default false), the library’s standing choice, and it does real work in both directions here: a controller restart is exactly when a mismatch is most likely to be an artefact of the restart — outputs re-driven, statuses not yet polled — so serving the full window before alarming is the difference between a rule and a nuisance on every reboot.
  • severity: 2 and category: PROTECTIVE describe the fail-to-start direction only, and G36 agrees that one number cannot cover both. The standard alarms commanded-on/status-off at Level 2 and commanded-off/status-on at Level 4 — the same split this library’s 1-4 scale would make, and this card has one slot for it. Severity 2 takes it because it is the shorter fuse: a dead heating loop in freezing weather costs a coil, not a bill. The unexpected-run direction is G36’s Level 4 and EXCESS_CONSUMPTION by any reading, and is quantified as such above. Hosts routing work by severity or category should route on the sub-condition flag, not on the card.
  • No suppression edge is added against PMP-0001/PMP-0002, but this rule now suppresses PMP-0005 on the same pump. PMP-0001 requires pump_cmd AND pump_status both true, so either of this rule’s directions already puts it in NO_EVAL through its own yRunOk and an edge would be redundant. PMP-0002 gates on pump_status alone and therefore stays live during yUnexpectedRun — and a pump running in HAND against a closed system really is deadheading, so that alarm is true and worth keeping. PMP-0005 instead assumes a false status means a stopped branch; an active proof mismatch invalidates exactly that premise. The host must scope this suppression to the same pump instance.
  • The rule is silent on a chattering status, deliberately. A status that will not settle never accumulates either window (chattering_status_never_matures), which is precisely where CDL.Logical.Proof raises both of its alarms. An unstable point is a delivery-quality or sensor-health finding, and SCHEMA.md’s design stance puts those outside the block graph.
  • estimation_method: PROXY_ESTIMATION and PROXY_EMISSIONS, against the sibling pair’s DIRECT ratings. They read a flow meter; this rule reads two booleans. The run hours are exact — the rule measures them itself — and the kilowatts are a nameplate the host supplies, which is what a proxy is.
  • g36: null even though this card names two verified clauses, and the field is the reason rather than the provenance. SCHEMA.md scopes g36 to “001-range rules” — the cards transcribing the reference’s G36-numbered fault conditions — and every populated value in the library today is a §5.16.14 FC#n or §5.22.6 FC#n. Widening it to a directly-instantiated sequence clause is a schema question for that field’s owner, not a decision to take inside one card, so §5.20.17.6 and §5.21.10.5 are carried in source instead. Batch sibling HW-0009 reaches the same null by the opposite route: G36 §5.1.15.5.b.1 forbids proving a boiler by status at all, so its card departs from the standard where this one instantiates it.
  • Two playbooks are bound and only one names this rule. proof-of-operation is batch 23’s purpose-built procedure and lists PMP-0003 in its Applies-To row; vfd-pump-faults is the pump family’s service playbook and its step 1 is where a drive lockout gets diagnosed, but its Applies-To row still names only VFD-0001/0002 and PMP-0001/0002. That row belongs to the playbook’s owner; the binding is kept because the diagnosis list points at it.
  • related carries one addition beyond the batch brief’s list. VFD-0001 is added because its own Deviations reserve exactly this check from the electrical side, and a pump whose proof fails on a drive fault trips both. HW-0009 is batch 23’s boiler instance of the same template — a sibling instantiation rather than a co-occurring fault, listed for the family rather than for the diagnosis.
  • clusters is empty: clusters/clusters.json defines no cluster containing a pump rule, and this card does not edit the cluster set. Every scenario in vectors.json is library-authored: G36 states the alarm and its two windows but publishes no test vectors, and the reference has no such card at all.

Notes

Read the flag, not the card: the two directions send different people to different places. yFailToStart is a work order at the starter — HOA position, overload, drive fault code, coupling — and it is urgent in proportion to what the loop was supposed to be doing. yUnexpectedRun is usually a five-minute fix and a conversation: put the switch back in AUTO, then find out why it was in HAND, because somebody moved it for a reason and that reason is often another fault this library can name. The proof-of-operation playbook owns both procedures and starts where it should — with which device is providing the status. Run this card first on any pump where PMP-0001 has been quiet for a suspiciously long time: a lead/lag standby whose yRunOk never goes true looks exactly like a healthy idle pump until something reads the two points directly.

Test Vectors

10 scenarios, clock step 5 s over 1800 s.

ScenarioDescription
agreement_running_stays_silentCommanded on and proven running for the whole horizon. Agreement is silence: neither direction can accumulate and yFault never asserts.
agreement_stopped_stays_silentCommanded off and proven off — a standby pump on a lead/lag set, or any pump overnight. The other agreeing state, equally silent.
fail_to_start_asserts_at_start_proof_timeCommanded on from init, never proves running — a tripped overload or an open disconnect. delayOnInit = true serves the full start_proof_time, so the edge lands exactly at G36’s 15 s and not on tick zero.
unexpected_run_asserts_at_stop_proof_timeProven running against a command of off from init — a hand-off-auto switch left in HAND. The edge lands exactly at G36’s separate 60 s window for this direction, four times the start window.
run_proof_lost_mid_cycleA pump running normally loses its proof at 300 s — a coupling shears, an overload trips on a hot afternoon. The window is measured from the event, not from init: the alarm lands at 315 s.
normal_start_and_stop_lag_stay_silentStatus lags the start command by 10 s and the stop command by 45 s — both inside their published windows, which is what the windows are for. Nothing asserts.
vfd_ramp_alarms_at_the_shipped_defaultThe retune obligation, pinned rather than hidden: a 30 s VFD acceleration ramp before a DP-switch status makes is LONGER than G36’s 15 s, so the shipped default alarms on a healthy start and clears itself when the status arrives. Hosts on polled points or slow ramps must raise start_proof_time (see preconditions).
command_flip_during_start_window_resets_timerThe command drops 5 s into an unproven start and returns at 10 s. TrueDelay starts the window over, so the alarm lands at 25 s rather than at 15 s: proof time means CONTINUOUS mismatch.
direction_flip_reserves_the_new_windowA pump running in HAND is commanded on at 600 s while the starter, now on a tripped overload, stops proving. The unexpected-run flag drops on the same tick the fail-to-start condition begins, the new direction serves its own 15 s window, and yFault is false in between. The two flags are never true together — mutual exclusivity is structural, not timed.
chattering_status_never_maturesA marginal current switch makes and breaks every 10 s for five minutes against a steady on command, then settles made. No mismatch is ever continuous for start_proof_time, so the rule stays silent — CDL.Logical.Proof latches BOTH of its outputs on this trace (probe-verified at the pin; see Deviations).
vectors.json
{
  "schema": "cxf-library/vectors/v1",
  "clock": {
    "step_s": 5,
    "horizon_s": 1800
  },
  "scenarios": [
    {
      "name": "agreement_running_stays_silent",
      "description": "Commanded on and proven running for the whole horizon. Agreement is silence: neither direction can accumulate and yFault never asserts.",
      "inputs": {
        "pump_cmd": true,
        "pump_status": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        }
      ]
    },
    {
      "name": "agreement_stopped_stays_silent",
      "description": "Commanded off and proven off \u2014 a standby pump on a lead/lag set, or any pump overnight. The other agreeing state, equally silent.",
      "inputs": {
        "pump_cmd": false,
        "pump_status": false
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        }
      ]
    },
    {
      "name": "fail_to_start_asserts_at_start_proof_time",
      "description": "Commanded on from init, never proves running \u2014 a tripped overload or an open disconnect. delayOnInit = true serves the full start_proof_time, so the edge lands exactly at G36's 15 s and not on tick zero.",
      "inputs": {
        "pump_cmd": true,
        "pump_status": false
      },
      "expect": [
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 10,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 15,
          "to_s": 1800,
          "equals": true
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 10,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 15,
          "to_s": 1800,
          "equals": true
        }
      ]
    },
    {
      "name": "unexpected_run_asserts_at_stop_proof_time",
      "description": "Proven running against a command of off from init \u2014 a hand-off-auto switch left in HAND. The edge lands exactly at G36's separate 60 s window for this direction, four times the start window.",
      "inputs": {
        "pump_cmd": false,
        "pump_status": true
      },
      "expect": [
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 55,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 60,
          "to_s": 1800,
          "equals": true
        },
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 55,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 60,
          "to_s": 1800,
          "equals": true
        }
      ]
    },
    {
      "name": "run_proof_lost_mid_cycle",
      "description": "A pump running normally loses its proof at 300 s \u2014 a coupling shears, an overload trips on a hot afternoon. The window is measured from the event, not from init: the alarm lands at 315 s.",
      "inputs": {
        "pump_cmd": true,
        "pump_status": [
          {
            "t": 0,
            "value": true
          },
          {
            "t": 300,
            "value": false
          }
        ]
      },
      "expect": [
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 310,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 315,
          "to_s": 1800,
          "equals": true
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        }
      ]
    },
    {
      "name": "normal_start_and_stop_lag_stay_silent",
      "description": "Status lags the start command by 10 s and the stop command by 45 s \u2014 both inside their published windows, which is what the windows are for. Nothing asserts.",
      "inputs": {
        "pump_cmd": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 60,
            "value": true
          },
          {
            "t": 600,
            "value": false
          }
        ],
        "pump_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 70,
            "value": true
          },
          {
            "t": 645,
            "value": false
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        }
      ]
    },
    {
      "name": "vfd_ramp_alarms_at_the_shipped_default",
      "description": "The retune obligation, pinned rather than hidden: a 30 s VFD acceleration ramp before a DP-switch status makes is LONGER than G36's 15 s, so the shipped default alarms on a healthy start and clears itself when the status arrives. Hosts on polled points or slow ramps must raise start_proof_time (see preconditions).",
      "inputs": {
        "pump_cmd": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 30,
            "value": true
          }
        ],
        "pump_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 60,
            "value": true
          }
        ]
      },
      "expect": [
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 40,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 45,
          "to_s": 55,
          "equals": true
        },
        {
          "output": "yFailToStart",
          "from_s": 60,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        }
      ]
    },
    {
      "name": "command_flip_during_start_window_resets_timer",
      "description": "The command drops 5 s into an unproven start and returns at 10 s. TrueDelay starts the window over, so the alarm lands at 25 s rather than at 15 s: proof time means CONTINUOUS mismatch.",
      "inputs": {
        "pump_cmd": [
          {
            "t": 0,
            "value": true
          },
          {
            "t": 5,
            "value": false
          },
          {
            "t": 10,
            "value": true
          }
        ],
        "pump_status": false
      },
      "expect": [
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 20,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 25,
          "to_s": 1800,
          "equals": true
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        }
      ]
    },
    {
      "name": "direction_flip_reserves_the_new_window",
      "description": "A pump running in HAND is commanded on at 600 s while the starter, now on a tripped overload, stops proving. The unexpected-run flag drops on the same tick the fail-to-start condition begins, the new direction serves its own 15 s window, and yFault is false in between. The two flags are never true together \u2014 mutual exclusivity is structural, not timed.",
      "inputs": {
        "pump_cmd": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 600,
            "value": true
          }
        ],
        "pump_status": [
          {
            "t": 0,
            "value": true
          },
          {
            "t": 600,
            "value": false
          }
        ]
      },
      "expect": [
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 55,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 60,
          "to_s": 595,
          "equals": true
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 600,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 610,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 615,
          "to_s": 1800,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 60,
          "to_s": 595,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 600,
          "to_s": 610,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 615,
          "to_s": 1800,
          "equals": true
        }
      ]
    },
    {
      "name": "chattering_status_never_matures",
      "description": "A marginal current switch makes and breaks every 10 s for five minutes against a steady on command, then settles made. No mismatch is ever continuous for start_proof_time, so the rule stays silent \u2014 CDL.Logical.Proof latches BOTH of its outputs on this trace (probe-verified at the pin; see Deviations).",
      "inputs": {
        "pump_cmd": true,
        "pump_status": [
          {
            "t": 0,
            "value": true
          },
          {
            "t": 10,
            "value": false
          },
          {
            "t": 20,
            "value": true
          },
          {
            "t": 30,
            "value": false
          },
          {
            "t": 40,
            "value": true
          },
          {
            "t": 50,
            "value": false
          },
          {
            "t": 60,
            "value": true
          },
          {
            "t": 70,
            "value": false
          },
          {
            "t": 80,
            "value": true
          },
          {
            "t": 90,
            "value": false
          },
          {
            "t": 100,
            "value": true
          },
          {
            "t": 110,
            "value": false
          },
          {
            "t": 120,
            "value": true
          },
          {
            "t": 130,
            "value": false
          },
          {
            "t": 140,
            "value": true
          },
          {
            "t": 150,
            "value": false
          },
          {
            "t": 160,
            "value": true
          },
          {
            "t": 170,
            "value": false
          },
          {
            "t": 180,
            "value": true
          },
          {
            "t": 190,
            "value": false
          },
          {
            "t": 200,
            "value": true
          },
          {
            "t": 210,
            "value": false
          },
          {
            "t": 220,
            "value": true
          },
          {
            "t": 230,
            "value": false
          },
          {
            "t": 240,
            "value": true
          },
          {
            "t": 250,
            "value": false
          },
          {
            "t": 260,
            "value": true
          },
          {
            "t": 270,
            "value": false
          },
          {
            "t": 280,
            "value": true
          },
          {
            "t": 290,
            "value": false
          },
          {
            "t": 300,
            "value": true
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        }
      ]
    }
  ]
}

PMP-0004 — Pump short-cycling

Statusverified — engine e2ff2f8, cxf:fnv1a128:33ee395683a57a95cc32a753cedc0482, 2026-08-20
Severity3
Methodrule
Phase2
CategoryPROTECTIVE
ConfidenceMEDIUM
EstimationQUALITATIVE_ONLY
G36
Clusters
Suppresses
Suppressed by
RelatedPMP-0003, VFD-0004
Playbooksvfd-pump-faults
SourceDOE/Hydraulic Institute, Improving Pumping System Performance: A Sourcebook for Industry, 2nd ed., PDF p.50 / printed p.48 — repeated starts wear controllers, contacts, seals, and bearings; Library-authored executable adaptation; no cited source publishes a portable starts-per-hour limit for every hydronic pump; Library graph precedents TOWER-0003, RTU-0001, HW-0001, and HP-0002 — rising-edge pulse integrated into a rolling event count
Operating statesall plant states in which this individual pump may legitimately be started; seasonal lockout with no starts is a valid clear result

Preconditions (host-enforced): Bind pump_status to independent per-pump proof of actual motor operation, not a command or an OR across a lead/lag fleet. Status transitions must represent real starts rather than communication loss, COV replay, or a changing software encoding. The host must report NO_EVAL for the first evaluation_window after engine load or state reset: the MovingAverage publishes an extrapolated pace while its window fills, so even one early start can raise the raw graph. Acquire fast enough to observe the shortest OFF/ON cycle, then evaluate on a fixed tick in the legal 57.2–360 s band at the defaults; 60 s is recommended and count_scale must equal evaluation_window/tick. Exclude approved exercise, commissioning, and functional-test sequences. When any obligation is unmet the verdict is NO_EVAL, not healthy.

Points: pump_status

Outputs:

  • yFault — True while the observed rising-edge count is strictly above max_starts in the trailing evaluation_window

Parameters:

NameDefaultUnitCXF pathDescription
evaluation_window3600.0srate.deltaTrailing start-count window. LIBRARY_PRECEDENT: one hour follows the verified short-cycling family, not a universal pump requirement. Retuning it also requires retuning count_scale.
max_starts4.01/windowcntHigh.tAllowed starts per evaluation_window. ADOPTED_TUNABLE commissioning placeholder; the pump/motor manufacturer and plant sequence are authoritative. The strict comparison leaves exactly four clear and faults at five.
count_scale60.01count.kDerived coupling evaluation_window/evaluator_tick. The default is 3600/60 and is correct only at a 60 s fixed tick; update it whenever the window or tick changes.

Description

This rule reports an individual hydronic pump starting too often. Repeated starts stress the motor, starter or drive, coupling, bearings, and seals, and usually point to unstable staging, a narrow control deadband, insufficient system storage, or unreliable proof. The signature is direct—observed starts per rolling hour—but the cause and acceptable count remain pump- and sequence-specific.

Detection Logic

start  = rising edge of pump_status
count  = MovingAverage(start, evaluation_window) × count_scale
yFault = count > max_starts

PMP-0004 block graph

Logical.Edge counts OFF-to-ON proof transitions only. Its initialization pulse has zero area at the first tick, but the partially filled moving-average window is an extrapolated event pace; the first full window is therefore host-gated. The comparison is strict and has no added persistence: the fifth observed start inside the default trailing hour raises the raw verdict. count_scale is load-bearing because the moving average integrates one-tick pulses.

Possible Diagnoses

  1. Differential-pressure or temperature deadband set too narrowly
  2. Lead/lag staging with inadequate minimum on/off times
  3. Oversized pump or insufficient hydronic buffer at low load
  4. VFD/control-loop hunting that repeatedly crosses the run threshold
  5. Overload, safety, or drive fault repeatedly tripping and auto-resetting
  6. Chattering contactor, current switch, auxiliary contact, or communication
  7. Approved exercise/test sequence not excluded by the host

Energy Impact

PROTECTIVE and QUALITATIVE_ONLY. Short cycles spend proportionally more time accelerating and less time delivering stable flow, but this Boolean rule cannot price that loss. The stronger value is avoided motor, starter/drive, seal, and bearing wear. Size the opportunity host-side from motor power, observed starts, and manufacturer start limits.

Emissions Impact

Scope 2, qualitative. Avoiding unnecessary acceleration and premature component replacement reduces electrical and embodied emissions, but neither term is computable from run proof alone.

Deviations

  • The four-start limit is adopted, not source-transcribed. Pump and motor manufacturers publish application-specific limits; 4/window is an executable commissioning placeholder and must be reviewed per asset.
  • count_scale is added to the planning table. The engine’s continuous-time MovingAverage returns a pulse rate, so evaluation_window/tick is required to recover an event count. Leaving 60 at a 300 s tick reports five times too many.
  • No alarm delay is copied from the sibling cards. The PR brief defines yFault = count > max_starts; adding TOWER-0003’s persistence would change the event-window semantics and introduce an unrequested parameter.
  • The first full window is NO_EVAL. Partial-window extrapolation is pinned in vectors rather than hidden; model reload also loses all prior start history.
  • The evaluator band is [evaluation_window/63, evaluation_window/(2×(max_starts+1))). The lower bound protects the 64-sample MovingAverage ring; the upper bound keeps the first integer count above the strict threshold observable. Defaults yield 57.2–360 s, with 60 s recommended. The evaluator must also resolve the shortest real OFF/ON dwell.
  • Confidence is MEDIUM rather than the brief’s proposed HIGH. Rising-edge detection is direct, but the shipped start limit has no portable source and a status point can be a proof/communications artifact until commissioned.
  • No Pump Delivery Failure cluster is added. Cycling has no single trigger whose repair should clear the mutually different delivery/proof signatures.

Test Vectors

13 scenarios, clock step 60 s over 14400 s.

ScenarioDescription
no_startsA stopped pump produces no rising edges and stays clear.
first_sample_true_is_inertEdge emits at initialization against its false prestate, but the zero-duration first tick contributes no moving-average area.
one_normal_startOne post-warm-up start is well below the adopted four-start allowance.
exactly_four_starts_clearThe strict comparison leaves exactly max_starts clear in a completed rolling window.
half_open_window_boundary_clearThe fifth observed start arrives exactly one window after the first; the oldest pulse has left the trailing window and the count remains four.
spread_starts_never_exceedFive total starts spread far enough apart never place more than four inside one completed trailing hour.
five_starts_faultOne start over the allowance raises the raw rolling-count verdict after warm-up.
oldest_start_ages_outFive starts raise the verdict; when the oldest pulse leaves the trailing hour, the count returns to four and clears.
status_chatter_counts_only_risesTen status changes are five starts, not ten; the rising-edge primitive counts only OFF-to-ON transitions.
warmup_rate_raw_assertsFive early starts can make the partial-window extrapolated pace exceed the threshold; the card requires host NO_EVAL for the first full window.
warmup_single_start_asserts_rawEven one early real start is extrapolated above four per hour until enough partial-window time elapses; host warm-up gating is mandatory.
reload_running_then_real_edgeA pump already true at model load does not create count area; a later real restart is counted once and remains below the limit.
subtick_cycle_aliases_clearA complete OFF-ON-OFF cycle between 60-second evaluator ticks is invisible; acquisition must resolve the shortest cycle.
vectors.json
{
  "schema": "cxf-library/vectors/v1",
  "clock": {
    "step_s": 60,
    "horizon_s": 14400
  },
  "scenarios": [
    {
      "name": "no_starts",
      "description": "A stopped pump produces no rising edges and stays clear.",
      "inputs": {
        "pump_status": false
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 14400,
          "equals": false
        }
      ]
    },
    {
      "name": "first_sample_true_is_inert",
      "description": "Edge emits at initialization against its false prestate, but the zero-duration first tick contributes no moving-average area.",
      "inputs": {
        "pump_status": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 14400,
          "equals": false
        }
      ]
    },
    {
      "name": "one_normal_start",
      "description": "One post-warm-up start is well below the adopted four-start allowance.",
      "inputs": {
        "pump_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 4200,
            "value": true
          },
          {
            "t": 6000,
            "value": false
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 14400,
          "equals": false
        }
      ]
    },
    {
      "name": "exactly_four_starts_clear",
      "description": "The strict comparison leaves exactly max_starts clear in a completed rolling window.",
      "inputs": {
        "pump_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 4200,
            "value": true
          },
          {
            "t": 4320,
            "value": false
          },
          {
            "t": 4800,
            "value": true
          },
          {
            "t": 4920,
            "value": false
          },
          {
            "t": 5400,
            "value": true
          },
          {
            "t": 5520,
            "value": false
          },
          {
            "t": 6000,
            "value": true
          },
          {
            "t": 6120,
            "value": false
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 14400,
          "equals": false
        }
      ]
    },
    {
      "name": "half_open_window_boundary_clear",
      "description": "The fifth observed start arrives exactly one window after the first; the oldest pulse has left the trailing window and the count remains four.",
      "inputs": {
        "pump_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 3600,
            "value": true
          },
          {
            "t": 3720,
            "value": false
          },
          {
            "t": 4500,
            "value": true
          },
          {
            "t": 4620,
            "value": false
          },
          {
            "t": 5400,
            "value": true
          },
          {
            "t": 5520,
            "value": false
          },
          {
            "t": 6300,
            "value": true
          },
          {
            "t": 6420,
            "value": false
          },
          {
            "t": 7200,
            "value": true
          },
          {
            "t": 7320,
            "value": false
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 14400,
          "equals": false
        }
      ]
    },
    {
      "name": "spread_starts_never_exceed",
      "description": "Five total starts spread far enough apart never place more than four inside one completed trailing hour.",
      "inputs": {
        "pump_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 3600,
            "value": true
          },
          {
            "t": 3720,
            "value": false
          },
          {
            "t": 4560,
            "value": true
          },
          {
            "t": 4680,
            "value": false
          },
          {
            "t": 5520,
            "value": true
          },
          {
            "t": 5640,
            "value": false
          },
          {
            "t": 6480,
            "value": true
          },
          {
            "t": 6600,
            "value": false
          },
          {
            "t": 7440,
            "value": true
          },
          {
            "t": 7560,
            "value": false
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 14400,
          "equals": false
        }
      ]
    },
    {
      "name": "five_starts_fault",
      "description": "One start over the allowance raises the raw rolling-count verdict after warm-up.",
      "inputs": {
        "pump_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 4200,
            "value": true
          },
          {
            "t": 4320,
            "value": false
          },
          {
            "t": 4680,
            "value": true
          },
          {
            "t": 4800,
            "value": false
          },
          {
            "t": 5160,
            "value": true
          },
          {
            "t": 5280,
            "value": false
          },
          {
            "t": 5640,
            "value": true
          },
          {
            "t": 5760,
            "value": false
          },
          {
            "t": 6120,
            "value": true
          },
          {
            "t": 6240,
            "value": false
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 6060,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 6240,
          "to_s": 7740,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 7860,
          "to_s": 14400,
          "equals": false
        }
      ]
    },
    {
      "name": "oldest_start_ages_out",
      "description": "Five starts raise the verdict; when the oldest pulse leaves the trailing hour, the count returns to four and clears.",
      "inputs": {
        "pump_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 3600,
            "value": true
          },
          {
            "t": 3720,
            "value": false
          },
          {
            "t": 4200,
            "value": true
          },
          {
            "t": 4320,
            "value": false
          },
          {
            "t": 4800,
            "value": true
          },
          {
            "t": 4920,
            "value": false
          },
          {
            "t": 5400,
            "value": true
          },
          {
            "t": 5520,
            "value": false
          },
          {
            "t": 6000,
            "value": true
          },
          {
            "t": 6120,
            "value": false
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 6120,
          "to_s": 7140,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 7320,
          "to_s": 14400,
          "equals": false
        }
      ]
    },
    {
      "name": "status_chatter_counts_only_rises",
      "description": "Ten status changes are five starts, not ten; the rising-edge primitive counts only OFF-to-ON transitions.",
      "inputs": {
        "pump_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 4200,
            "value": true
          },
          {
            "t": 4260,
            "value": false
          },
          {
            "t": 4440,
            "value": true
          },
          {
            "t": 4500,
            "value": false
          },
          {
            "t": 4680,
            "value": true
          },
          {
            "t": 4740,
            "value": false
          },
          {
            "t": 4920,
            "value": true
          },
          {
            "t": 4980,
            "value": false
          },
          {
            "t": 5160,
            "value": true
          },
          {
            "t": 5220,
            "value": false
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 5280,
          "to_s": 7620,
          "equals": true
        }
      ]
    },
    {
      "name": "warmup_rate_raw_asserts",
      "description": "Five early starts can make the partial-window extrapolated pace exceed the threshold; the card requires host NO_EVAL for the first full window.",
      "inputs": {
        "pump_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 300,
            "value": true
          },
          {
            "t": 360,
            "value": false
          },
          {
            "t": 600,
            "value": true
          },
          {
            "t": 660,
            "value": false
          },
          {
            "t": 900,
            "value": true
          },
          {
            "t": 960,
            "value": false
          },
          {
            "t": 1200,
            "value": true
          },
          {
            "t": 1260,
            "value": false
          },
          {
            "t": 1500,
            "value": true
          },
          {
            "t": 1560,
            "value": false
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 1620,
          "to_s": 3840,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 3960,
          "to_s": 14400,
          "equals": false
        }
      ]
    },
    {
      "name": "warmup_single_start_asserts_raw",
      "description": "Even one early real start is extrapolated above four per hour until enough partial-window time elapses; host warm-up gating is mandatory.",
      "inputs": {
        "pump_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 300,
            "value": true
          },
          {
            "t": 420,
            "value": false
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 420,
          "to_s": 840,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 1020,
          "to_s": 14400,
          "equals": false
        }
      ]
    },
    {
      "name": "reload_running_then_real_edge",
      "description": "A pump already true at model load does not create count area; a later real restart is counted once and remains below the limit.",
      "inputs": {
        "pump_status": [
          {
            "t": 0,
            "value": true
          },
          {
            "t": 600,
            "value": false
          },
          {
            "t": 4200,
            "value": true
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 14400,
          "equals": false
        }
      ]
    },
    {
      "name": "subtick_cycle_aliases_clear",
      "description": "A complete OFF-ON-OFF cycle between 60-second evaluator ticks is invisible; acquisition must resolve the shortest cycle.",
      "inputs": {
        "pump_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 10,
            "value": true
          },
          {
            "t": 20,
            "value": false
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 14400,
          "equals": false
        }
      ]
    }
  ]
}

PMP-0005 — Flow through stopped pump

Statusverified — engine e2ff2f8, cxf:fnv1a128:d567fd02bea2ecc336bdf44f7680de4a, 2026-08-20
Severity2
Methodrule
Phase2
CategoryPROTECTIVE
ConfidenceMEDIUM
EstimationQUALITATIVE_ONLY
G36
Clusters
Suppresses
Suppressed byPMP-0003
RelatedPMP-0001, PMP-0002, PMP-0003
Playbooksvfd-pump-faults
SourceDOE/Hydraulic Institute, Improving Pumping System Performance: A Sourcebook for Industry, 2nd ed., PDF p.9 / printed p.6 — discharge check valves prevent reversal while a pump is stopped; Library-authored signed branch-flow adaptation; no cited source publishes the shipped flow or persistence thresholds; Library precedents PMP-0003 (proof/status timing), SYS-0008 (mirrored signed direction outputs), and VFD-0003 (strict directional comparisons)
Operating statesall states in which this pump branch should be hydraulically isolated whenever pump_status is false

Preconditions (host-enforced): pump_flow must be individual-branch flow for this exact pump; a common-header point cannot distinguish which branch is passing. Full direction semantics require signed flow with positive defined from suction to discharge. A nonnegative magnitude sensor can support yFault, but neither yForwardFlow nor yReverseFlow is then physically trustworthy and the host must mark both direction labels unavailable. The zero offset and uncertainty must be known, and stopped_flow_threshold must exceed them. pump_status must be fresh independent proof; an active same-pump PMP-0003 suppresses this verdict because the stopped/running premise is unreliable. Exclude intentional bypass/gravity paths, thermosiphon designs, maintenance flushing, free cooling, and approved parallel-pump transfer. When scope, proof, or operating intent is unknown the host reports NO_EVAL.

Points: pump_status, pump_flow

Outputs:

  • yFault — True while stopped-branch flow in either signed direction has persisted beyond sustained_duration
  • yForwardFlow — Diagnostic direction flag — true while a stopped pump has signed positive suction-to-discharge flow above the allowance; unavailable on magnitude-only bindings and false is not an evaluability verdict
  • yReverseFlow — Diagnostic direction flag — true while a stopped pump has signed negative flow beyond the allowance; unavailable on magnitude-only bindings

Parameters:

NameDefaultUnitCXF pathDescription
stopped_flow_threshold1.0L/sforward.t, reverse.tAbsolute branch-flow allowance in either direction. NO_PORTABLE_DEFAULT: 1.0 L/s is an adoption-blocking placeholder that must be set above sensor noise/zero drift and against this branch’s design flow.
sustained_duration300.0spersist.delayTimeContinuous stopped-flow duration required before alarm. ADOPTED_TUNABLE; five minutes rejects valve-transfer and coast-down transients but is not a published universal value.

Description

This rule detects material water flow through an individual pump branch while that pump’s independent run proof is false. Forward flow can indicate a passing or missing check valve, parallel-header pressure, thermosiphoning, or bad proof; reverse flow adds the risk of reverse rotation. The rule names the observed hydraulic signature, not which component caused it.

Detection Logic

forward         = pump_flow > stopped_flow_threshold
reverse         = -pump_flow > stopped_flow_threshold
yForwardFlow    = NOT pump_status AND forward
yReverseFlow    = NOT pump_status AND reverse
candidate       = yForwardFlow OR yReverseFlow
yFault           = candidate sustained for sustained_duration

PMP-0005 block graph

Both comparisons are strict, so exactly ±1.0 L/s is clear at the defaults. Direction outputs are raw diagnostic detail gated by stopped status; they are not evaluability flags. TrueDelay(delayOnInit=true) applies to the OR of both directions. A direct forward-to-reverse handoff therefore preserves the timer because material stopped flow never ceased; an actual in-band interval resets it, and recovery clears immediately.

Possible Diagnoses

  1. Passing, failed, reversed, or missing discharge check valve
  2. Reverse flow driven by another pump on a common header
  3. Thermosiphoning or a gravity path not represented in the operating gate
  4. Pump run-proof failure or stale false status (PMP-0003)
  5. Flow sensor zero error, sign inversion, or common-header misbinding
  6. Isolation or bypass valve left open
  7. Approved flushing, free-cooling, or transfer sequence not excluded

Energy Impact

PROTECTIVE, MEDIUM confidence, QUALITATIVE_ONLY. Unintended branch flow can waste active-pump head, transport unwanted heat, defeat staging, and rotate a stopped pump backward. Magnitude depends on loop pressure and temperatures that this graph does not consume.

Emissions Impact

Scope 2, qualitative. Avoided electricity and thermal conditioning are site-specific and require pressure, temperature, and active-equipment context.

Deviations

  • The rule adds a signed convention without narrowing older consumers. Positive is suction-to-discharge. Magnitude-only branch flow still supports yFault and PMP-0001/0002, but neither direction label; common-header flow is not a weaker proxy but the wrong measurement scope.
  • Both thresholds are site configured. The 1.0 L/s and 300 s defaults are adopted executable placeholders, not manufacturer or standard limits.
  • Direction reversal does not reset persistence. The timer watches absolute stopped-flow candidacy; continuous material flow remains one hydraulic event even when its sign changes. Vectors pin this explicitly.
  • PMP-0003 suppression is host-side and same-pump only. A proof mismatch invalidates the stopped premise; the raw graph still alarms, preserving the evidence and avoiding command/status inputs that do not belong here.
  • No Pump Delivery Failure cluster is added. Running-with-no-flow, deadheading, proof mismatch, and stopped-with-flow have incompatible premises and no shared trigger whose correction reliably clears all members.

Test Vectors

21 scenarios, clock step 60 s over 2400 s.

ScenarioDescription
stopped_zero_flowA stopped branch at zero flow is healthy.
stopped_positive_flowForward branch flow present at initialization exposes direction immediately and faults only after persistence.
stopped_negative_flowSigned reverse flow follows the independent reverse diagnostic lane.
running_with_flowFlow is expected while the pump is proven running, so both stopped-flow lanes are gated off.
running_with_negative_flowEven signed reverse flow is outside this stopped-pump rule while run proof is true; other hydraulic/proof rules own the condition.
running_zero_flow_is_other_ruleA running pump with zero flow is silent here and belongs to PMP-0001/PMP-0002.
positive_exact_threshold_clearThe strict comparator leaves +1.0 L/s clear.
negative_exact_threshold_clearThe strict comparator leaves -1.0 L/s clear.
positive_just_over_thresholdOne representable test increment above the forward threshold follows the full delay.
negative_just_over_thresholdOne test increment beyond the reverse threshold follows the full delay.
violation_ends_at_delay_boundaryFlow clears on the 300-second timer boundary before evaluation and never alarms.
two_short_episodes_resetTwo sub-delay forward-flow episodes separated by a clear tick do not accumulate.
short_flow_transientA single forward-flow episode shorter than persistence exposes direction but never alarms.
flow_recovery_resets_timerAn in-band interval resets persistence; the second stopped-flow episode serves a new full 300 seconds.
pump_start_resets_timerRun proof temporarily gates off both directions and resets the timer even while branch flow stays high.
two_opposite_short_episodesPositive and negative sub-delay episodes separated by an in-band interval do not accumulate across direction lanes.
direction_handoff_keeps_candidate_trueA direct forward-to-reverse handoff changes diagnostic lane but not absolute-flow candidacy, so the timer continues.
recovery_after_alarmFalling edges pass immediately when stopped-branch flow returns inside the band.
noise_inside_zero_bandSigned zero drift that stays inside the allowance never raises a direction flag.
threshold_chatter_resets_delayFlow alternating just below and above the forward threshold chatters the diagnostic but never accumulates 300 continuous seconds.
proof_mismatch_raw_fault_host_suppressesFalse status with real flow is indistinguishable from stopped-branch flow in this graph; an active same-pump PMP-0003 suppresses the raw alarm host-side.
vectors.json
{
  "schema": "cxf-library/vectors/v1",
  "clock": {
    "step_s": 60,
    "horizon_s": 2400
  },
  "scenarios": [
    {
      "name": "stopped_zero_flow",
      "description": "A stopped branch at zero flow is healthy.",
      "inputs": {
        "pump_status": false,
        "pump_flow": 0.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yForwardFlow",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yReverseFlow",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        }
      ]
    },
    {
      "name": "stopped_positive_flow",
      "description": "Forward branch flow present at initialization exposes direction immediately and faults only after persistence.",
      "inputs": {
        "pump_status": false,
        "pump_flow": 2.0
      },
      "expect": [
        {
          "output": "yForwardFlow",
          "from_s": 0,
          "to_s": 2400,
          "equals": true
        },
        {
          "output": "yReverseFlow",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 240,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 360,
          "to_s": 2400,
          "equals": true
        }
      ]
    },
    {
      "name": "stopped_negative_flow",
      "description": "Signed reverse flow follows the independent reverse diagnostic lane.",
      "inputs": {
        "pump_status": false,
        "pump_flow": -2.0
      },
      "expect": [
        {
          "output": "yForwardFlow",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yReverseFlow",
          "from_s": 0,
          "to_s": 2400,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 240,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 360,
          "to_s": 2400,
          "equals": true
        }
      ]
    },
    {
      "name": "running_with_flow",
      "description": "Flow is expected while the pump is proven running, so both stopped-flow lanes are gated off.",
      "inputs": {
        "pump_status": true,
        "pump_flow": 2.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yForwardFlow",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yReverseFlow",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        }
      ]
    },
    {
      "name": "running_with_negative_flow",
      "description": "Even signed reverse flow is outside this stopped-pump rule while run proof is true; other hydraulic/proof rules own the condition.",
      "inputs": {
        "pump_status": true,
        "pump_flow": -2.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yForwardFlow",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yReverseFlow",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        }
      ]
    },
    {
      "name": "running_zero_flow_is_other_rule",
      "description": "A running pump with zero flow is silent here and belongs to PMP-0001/PMP-0002.",
      "inputs": {
        "pump_status": true,
        "pump_flow": 0.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yForwardFlow",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yReverseFlow",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        }
      ]
    },
    {
      "name": "positive_exact_threshold_clear",
      "description": "The strict comparator leaves +1.0 L/s clear.",
      "inputs": {
        "pump_status": false,
        "pump_flow": 1.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yForwardFlow",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yReverseFlow",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        }
      ]
    },
    {
      "name": "negative_exact_threshold_clear",
      "description": "The strict comparator leaves -1.0 L/s clear.",
      "inputs": {
        "pump_status": false,
        "pump_flow": -1.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yForwardFlow",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yReverseFlow",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        }
      ]
    },
    {
      "name": "positive_just_over_threshold",
      "description": "One representable test increment above the forward threshold follows the full delay.",
      "inputs": {
        "pump_status": false,
        "pump_flow": 1.1
      },
      "expect": [
        {
          "output": "yForwardFlow",
          "from_s": 0,
          "to_s": 2400,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 240,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 360,
          "to_s": 2400,
          "equals": true
        }
      ]
    },
    {
      "name": "negative_just_over_threshold",
      "description": "One test increment beyond the reverse threshold follows the full delay.",
      "inputs": {
        "pump_status": false,
        "pump_flow": -1.1
      },
      "expect": [
        {
          "output": "yReverseFlow",
          "from_s": 0,
          "to_s": 2400,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 240,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 360,
          "to_s": 2400,
          "equals": true
        }
      ]
    },
    {
      "name": "violation_ends_at_delay_boundary",
      "description": "Flow clears on the 300-second timer boundary before evaluation and never alarms.",
      "inputs": {
        "pump_status": false,
        "pump_flow": [
          {
            "t": 0,
            "value": 2.0
          },
          {
            "t": 300,
            "value": 0.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yForwardFlow",
          "from_s": 0,
          "to_s": 240,
          "equals": true
        },
        {
          "output": "yForwardFlow",
          "from_s": 360,
          "to_s": 2400,
          "equals": false
        }
      ]
    },
    {
      "name": "two_short_episodes_reset",
      "description": "Two sub-delay forward-flow episodes separated by a clear tick do not accumulate.",
      "inputs": {
        "pump_status": false,
        "pump_flow": [
          {
            "t": 0,
            "value": 2.0
          },
          {
            "t": 180,
            "value": 0.0
          },
          {
            "t": 360,
            "value": 2.0
          },
          {
            "t": 600,
            "value": 0.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        }
      ]
    },
    {
      "name": "short_flow_transient",
      "description": "A single forward-flow episode shorter than persistence exposes direction but never alarms.",
      "inputs": {
        "pump_status": false,
        "pump_flow": [
          {
            "t": 0,
            "value": 2.0
          },
          {
            "t": 180,
            "value": 0.0
          }
        ]
      },
      "expect": [
        {
          "output": "yForwardFlow",
          "from_s": 0,
          "to_s": 120,
          "equals": true
        },
        {
          "output": "yForwardFlow",
          "from_s": 240,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        }
      ]
    },
    {
      "name": "flow_recovery_resets_timer",
      "description": "An in-band interval resets persistence; the second stopped-flow episode serves a new full 300 seconds.",
      "inputs": {
        "pump_status": false,
        "pump_flow": [
          {
            "t": 0,
            "value": 2.0
          },
          {
            "t": 180,
            "value": 0.0
          },
          {
            "t": 300,
            "value": 2.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 540,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 660,
          "to_s": 2400,
          "equals": true
        }
      ]
    },
    {
      "name": "pump_start_resets_timer",
      "description": "Run proof temporarily gates off both directions and resets the timer even while branch flow stays high.",
      "inputs": {
        "pump_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 180,
            "value": true
          },
          {
            "t": 360,
            "value": false
          }
        ],
        "pump_flow": 2.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 600,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 720,
          "to_s": 2400,
          "equals": true
        }
      ]
    },
    {
      "name": "two_opposite_short_episodes",
      "description": "Positive and negative sub-delay episodes separated by an in-band interval do not accumulate across direction lanes.",
      "inputs": {
        "pump_status": false,
        "pump_flow": [
          {
            "t": 0,
            "value": 2.0
          },
          {
            "t": 180,
            "value": 0.0
          },
          {
            "t": 300,
            "value": -2.0
          },
          {
            "t": 540,
            "value": 0.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        }
      ]
    },
    {
      "name": "direction_handoff_keeps_candidate_true",
      "description": "A direct forward-to-reverse handoff changes diagnostic lane but not absolute-flow candidacy, so the timer continues.",
      "inputs": {
        "pump_status": false,
        "pump_flow": [
          {
            "t": 0,
            "value": 2.0
          },
          {
            "t": 180,
            "value": -2.0
          }
        ]
      },
      "expect": [
        {
          "output": "yForwardFlow",
          "from_s": 0,
          "to_s": 120,
          "equals": true
        },
        {
          "output": "yReverseFlow",
          "from_s": 240,
          "to_s": 2400,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 240,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 360,
          "to_s": 2400,
          "equals": true
        }
      ]
    },
    {
      "name": "recovery_after_alarm",
      "description": "Falling edges pass immediately when stopped-branch flow returns inside the band.",
      "inputs": {
        "pump_status": false,
        "pump_flow": [
          {
            "t": 0,
            "value": 2.0
          },
          {
            "t": 900,
            "value": 0.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 360,
          "to_s": 840,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 960,
          "to_s": 2400,
          "equals": false
        }
      ]
    },
    {
      "name": "noise_inside_zero_band",
      "description": "Signed zero drift that stays inside the allowance never raises a direction flag.",
      "inputs": {
        "pump_status": false,
        "pump_flow": [
          {
            "t": 0,
            "value": 0.9
          },
          {
            "t": 300,
            "value": -0.9
          },
          {
            "t": 600,
            "value": 0.5
          },
          {
            "t": 900,
            "value": -0.5
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yForwardFlow",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yReverseFlow",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        }
      ]
    },
    {
      "name": "threshold_chatter_resets_delay",
      "description": "Flow alternating just below and above the forward threshold chatters the diagnostic but never accumulates 300 continuous seconds.",
      "inputs": {
        "pump_status": false,
        "pump_flow": [
          {
            "t": 0,
            "value": 0.9
          },
          {
            "t": 60,
            "value": 1.1
          },
          {
            "t": 120,
            "value": 0.9
          },
          {
            "t": 180,
            "value": 1.1
          },
          {
            "t": 240,
            "value": 0.9
          },
          {
            "t": 300,
            "value": 1.1
          },
          {
            "t": 360,
            "value": 0.9
          },
          {
            "t": 420,
            "value": 1.1
          },
          {
            "t": 480,
            "value": 0.9
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        }
      ]
    },
    {
      "name": "proof_mismatch_raw_fault_host_suppresses",
      "description": "False status with real flow is indistinguishable from stopped-branch flow in this graph; an active same-pump PMP-0003 suppresses the raw alarm host-side.",
      "inputs": {
        "pump_status": false,
        "pump_flow": 3.0
      },
      "expect": [
        {
          "output": "yForwardFlow",
          "from_s": 0,
          "to_s": 2400,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 360,
          "to_s": 2400,
          "equals": true
        }
      ]
    }
  ]
}

PMP-0006 — Pump input-power degradation

Statusverified — engine e2ff2f8, cxf:fnv1a128:8d0553cbafa876eb8c3007a3178aa9a3, 2026-08-20
Severity3
Methodstatistical
Phase2
CategoryEFFICIENCY_LOSS
ConfidenceMEDIUM
EstimationBASELINE_COMPARISON
G36
Clusters
Suppresses
Suppressed by
RelatedPMP-0001, PMP-0002, VFD-0001, VFD-0005
Playbooksvfd-pump-faults
SourceDOE/Hydraulic Institute, Improving Pumping System Performance: A Sourcebook for Industry, 2nd ed., PDF pp.106 and 112–113 — assessment uses baseline energy plus flow, head, speed, fluid, and electrical data to identify efficiency degradation; Library-authored host-fitted expected-power residual; no universal pump curve or portable residual threshold is claimed; Library precedents AHU-0038 (host-published baseline and validity gate) and CHW-0001 (positive-baseline cross-multiplied degradation comparison)
Operating statesnormal automatic pump operation after startup and before coast-down, with stable staging and a valid expected-power model

Preconditions (host-enforced): The host owns pump_kw_expected. Train it on known-good operation for this pump, document the fit period and model inputs, freeze or version the fit for evaluation, and publish it only when ready, fresh, and in-domain. Typical inputs may include speed, individual-branch flow, differential pressure, staging, and fluid properties; all used inputs must be valid. pump_kw and pump_kw_expected must cover the same motor/drive electrical boundary and use kW. Exclude startup, coast-down, exercise, manual/bypass operation, safety/current/torque/demand limiting, and changes in parallel-pump configuration. A same-drive VFD-0001 or VFD-0005 is related evidence; suppress only if that deployment proves the active drive state or speed input invalidates this baseline and can scope the association to this pump. yBaselineOk checks numerical positivity only—false means NO_EVAL, while true does not prove freshness/domain validity.

Points: pump_status, pump_kw, pump_kw_expected

Outputs:

  • yFault — True while a running pump’s measured input power has remained above the valid expected baseline by more than the allowed fraction for sustained_duration
  • yBaselineOk — Evaluability flag — true only when expected power is above minimum_expected_kw; false means NO_EVAL
  • yPowerHigh — Diagnostic direction flag — true when a numerically valid baseline has a positive residual above allowance; status is not part of this flag

Parameters:

NameDefaultUnitCXF pathDescription
minimum_expected_kw0.5kWbaselineOk.tExpected-power floor for numerical evaluability. NO_PORTABLE_DEFAULT: configure above the model’s low-load/noise region; equality is not evaluable.
max_positive_residual_fraction0.151allowance.kAllowed positive fraction of expected input power. ADOPTED_TUNABLE; 0.15 is an executable starting band, not a published pump-wide limit.
sustained_duration1800.0spersist.delayTimeContinuous excess required before alarm. ADOPTED_TUNABLE 30-minute window; commission against model residuals and plant time constants.

Description

This rule detects a pump motor/drive assembly drawing materially more active power than a known-good model expects at the same operating condition. The signature can accompany mechanical drag, impeller/strainer degradation, incorrect speed feedback, bypassed control, or reduced motor/drive efficiency; it can also be manufactured by a stale, out-of-domain, or badly scoped baseline. The model remains host-side and is published as an ordinary derived point.

Detection Logic

baseline_ok = pump_kw_expected > minimum_expected_kw
residual    = pump_kw - pump_kw_expected
allowance   = pump_kw_expected × max_positive_residual_fraction
power_high  = baseline_ok AND residual > allowance

yBaselineOk = baseline_ok
yPowerHigh  = power_high
yFault      = pump_status AND power_high, sustained for sustained_duration

PMP-0006 block graph

The graph deliberately cross-multiplies instead of dividing. For a positive valid baseline, actual−expected > fraction×expected is exactly the requested relative-residual test and cannot evaluate a zero denominator. Both comparisons are strict. yPowerHigh exposes residual direction independently of run status; only yFault is status-gated and persisted with delayOnInit=true.

Possible Diagnoses

  1. Fouled or damaged impeller, blocked strainer, or unexpected hydraulic load
  2. Bearing, seal, coupling, or alignment drag
  3. Motor or drive efficiency degradation
  4. Incorrect speed feedback or control in bypass/manual mode
  5. Different parallel-pump staging than the baseline condition
  6. Actual/expected points covering different electrical boundaries
  7. Stale, out-of-domain, or degradation-trained expected-power model
  8. Active power sensor scaling or wiring error

Energy Impact

EFFICIENCY_LOSS with BASELINE_COMPARISON. During evaluable alarm intervals the positive residual is directly measured kW above the host model, so the host may integrate it to kWh. That estimate inherits model error and must exclude invalid domains, startup, and mode changes. A low residual is intentionally not accused by this rule because it can mean successful turndown or a separate delivery failure.

Emissions Impact

Scope 2. Multiply evaluable excess kWh by the applicable marginal or accounting grid factor. Report the baseline/model uncertainty with the estimate.

Deviations

  • No Divide block is used. Downstream Boolean gating does not short-circuit an elementary Divide; cross-multiplication is equivalent in the positive baseline domain and guarantees no zero-denominator evaluation.
  • Expected power is a host-derived point, not a universal curve. The point contract requires model inputs, known-good fit period, electrical boundary, and in-domain readiness. The graph cannot certify those obligations.
  • All three defaults need commissioning. The 0.5 kW floor has NO_PORTABLE_DEFAULT; the 15% residual and 1800 s duration are adopted tunables, not source-transcribed thresholds.
  • Only positive residual is in scope. Low power may indicate successful reset, broken coupling, bad metering, or another delivery fault; conflating the directions would erase diagnosis.
  • VFD relationships stay informational in the library metadata. Whether a same-drive VFD-0001/0005 invalidates this model depends on its inputs and operating state; unconditional ID-level suppression could silence every pump or a baseline that does not use the disputed signal.
  • No simulation validation is claimed. The plant harness now preserves per-pump power, flow, and status proxies, but it has no frozen expected-power model trained on a disjoint known-good period. Synthetic vectors validate graph behavior only.

Test Vectors

23 scenarios, clock step 60 s over 3600 s.

ScenarioDescription
baseline_zero_not_evaluableA zero expected-power baseline is not evaluable; no divide exists and both diagnostic/fault outputs stay false.
baseline_negative_not_evaluableA negative expected value remains numerically safe and not evaluable; cross-multiplication creates no Inf or NaN path.
baseline_just_below_floorThe denominator guard is strict below its configured floor.
baseline_exact_floorExactly minimum_expected_kw remains not evaluable under the strict comparator.
baseline_just_above_floorA small but valid baseline makes evaluability true and can support the full delayed verdict.
actual_equals_expectedMeasured power on the baseline has zero positive residual.
exact_positive_residual_thresholdExactly 15 percent excess is clear because the comparison is strict.
just_above_positive_residualOne test increment above 15 percent asserts yPowerHigh immediately and yFault after 30 minutes.
negative_residual_not_this_ruleActual power below expected is deliberately silent; delivery/proof faults own that direction.
stopped_pump_exposes_diagnostic_onlyPower residual direction is diagnostic detail independent of status, while yFault remains status-gated.
transient_excess_powerA 20-minute power excursion clears before the 30-minute persistence window.
violation_ends_at_delay_boundaryThe residual clears exactly at 1800 seconds before timer evaluation and never alarms.
sustained_excess_and_recoveryA mature excess-power alarm clears immediately when actual power returns to baseline.
baseline_change_resets_timerA valid baseline change that brings the residual inside allowance resets persistence; a later excess serves a new full window.
invalid_baseline_resets_timerExpected power falling to zero clears evaluability and diagnostic state; a later valid baseline must serve a new full persistence interval.
baseline_changes_but_candidate_stays_trueA model update within the still-faulting region does not reset the timer because the Boolean candidate never falls.
pump_start_begins_timerA standing residual does not accumulate while stopped; persistence starts only after proven run status rises.
pump_status_drop_resets_timerA stopped interval resets persistence even while the baseline residual remains high; restart requires a new full duration.
two_short_excess_episodesTwo sub-duration excess-power episodes separated by a compliant interval do not accumulate.
residual_threshold_chatterMeasured power alternates just below and above the 15 percent boundary, so yPowerHigh chatters but persistence never matures.
degraded_fit_learns_fault_as_normalActual and expected are both high because the fit learned degraded operation; the residual is zero and the graph stays silent, pinning the known-good training precondition.
invalid_stale_baseline_raw_behaviorA stale but positive expected-power value looks valid to the graph and can alarm; model freshness/in-domain status is a mandatory host NO_EVAL gate.
vfd_relationship_is_host_sideThe raw pump baseline alarm remains active with sustained excess; a deployment may suppress only when a same-drive VFD finding actually invalidates this model.
vectors.json
{
  "schema": "cxf-library/vectors/v1",
  "clock": {
    "step_s": 60,
    "horizon_s": 3600
  },
  "scenarios": [
    {
      "name": "baseline_zero_not_evaluable",
      "description": "A zero expected-power baseline is not evaluable; no divide exists and both diagnostic/fault outputs stay false.",
      "inputs": {
        "pump_status": true,
        "pump_kw": 10.0,
        "pump_kw_expected": 0.0
      },
      "expect": [
        {
          "output": "yBaselineOk",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        },
        {
          "output": "yPowerHigh",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "baseline_negative_not_evaluable",
      "description": "A negative expected value remains numerically safe and not evaluable; cross-multiplication creates no Inf or NaN path.",
      "inputs": {
        "pump_status": true,
        "pump_kw": 10.0,
        "pump_kw_expected": -1.0
      },
      "expect": [
        {
          "output": "yBaselineOk",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        },
        {
          "output": "yPowerHigh",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "baseline_just_below_floor",
      "description": "The denominator guard is strict below its configured floor.",
      "inputs": {
        "pump_status": true,
        "pump_kw": 2.0,
        "pump_kw_expected": 0.49
      },
      "expect": [
        {
          "output": "yBaselineOk",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        },
        {
          "output": "yPowerHigh",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "baseline_exact_floor",
      "description": "Exactly minimum_expected_kw remains not evaluable under the strict comparator.",
      "inputs": {
        "pump_status": true,
        "pump_kw": 2.0,
        "pump_kw_expected": 0.5
      },
      "expect": [
        {
          "output": "yBaselineOk",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        },
        {
          "output": "yPowerHigh",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "baseline_just_above_floor",
      "description": "A small but valid baseline makes evaluability true and can support the full delayed verdict.",
      "inputs": {
        "pump_status": true,
        "pump_kw": 0.7,
        "pump_kw_expected": 0.51
      },
      "expect": [
        {
          "output": "yBaselineOk",
          "from_s": 0,
          "to_s": 3600,
          "equals": true
        },
        {
          "output": "yPowerHigh",
          "from_s": 0,
          "to_s": 3600,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1740,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 1860,
          "to_s": 3600,
          "equals": true
        }
      ]
    },
    {
      "name": "actual_equals_expected",
      "description": "Measured power on the baseline has zero positive residual.",
      "inputs": {
        "pump_status": true,
        "pump_kw": 10.0,
        "pump_kw_expected": 10.0
      },
      "expect": [
        {
          "output": "yBaselineOk",
          "from_s": 0,
          "to_s": 3600,
          "equals": true
        },
        {
          "output": "yPowerHigh",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "exact_positive_residual_threshold",
      "description": "Exactly 15 percent excess is clear because the comparison is strict.",
      "inputs": {
        "pump_status": true,
        "pump_kw": 11.5,
        "pump_kw_expected": 10.0
      },
      "expect": [
        {
          "output": "yBaselineOk",
          "from_s": 0,
          "to_s": 3600,
          "equals": true
        },
        {
          "output": "yPowerHigh",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "just_above_positive_residual",
      "description": "One test increment above 15 percent asserts yPowerHigh immediately and yFault after 30 minutes.",
      "inputs": {
        "pump_status": true,
        "pump_kw": 11.51,
        "pump_kw_expected": 10.0
      },
      "expect": [
        {
          "output": "yPowerHigh",
          "from_s": 0,
          "to_s": 3600,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1740,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 1860,
          "to_s": 3600,
          "equals": true
        }
      ]
    },
    {
      "name": "negative_residual_not_this_rule",
      "description": "Actual power below expected is deliberately silent; delivery/proof faults own that direction.",
      "inputs": {
        "pump_status": true,
        "pump_kw": 8.0,
        "pump_kw_expected": 10.0
      },
      "expect": [
        {
          "output": "yBaselineOk",
          "from_s": 0,
          "to_s": 3600,
          "equals": true
        },
        {
          "output": "yPowerHigh",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "stopped_pump_exposes_diagnostic_only",
      "description": "Power residual direction is diagnostic detail independent of status, while yFault remains status-gated.",
      "inputs": {
        "pump_status": false,
        "pump_kw": 12.0,
        "pump_kw_expected": 10.0
      },
      "expect": [
        {
          "output": "yBaselineOk",
          "from_s": 0,
          "to_s": 3600,
          "equals": true
        },
        {
          "output": "yPowerHigh",
          "from_s": 0,
          "to_s": 3600,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "transient_excess_power",
      "description": "A 20-minute power excursion clears before the 30-minute persistence window.",
      "inputs": {
        "pump_status": true,
        "pump_kw": [
          {
            "t": 0,
            "value": 12.0
          },
          {
            "t": 1200,
            "value": 10.0
          }
        ],
        "pump_kw_expected": 10.0
      },
      "expect": [
        {
          "output": "yPowerHigh",
          "from_s": 0,
          "to_s": 1140,
          "equals": true
        },
        {
          "output": "yPowerHigh",
          "from_s": 1260,
          "to_s": 3600,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "violation_ends_at_delay_boundary",
      "description": "The residual clears exactly at 1800 seconds before timer evaluation and never alarms.",
      "inputs": {
        "pump_status": true,
        "pump_kw": [
          {
            "t": 0,
            "value": 12.0
          },
          {
            "t": 1800,
            "value": 10.0
          }
        ],
        "pump_kw_expected": 10.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "sustained_excess_and_recovery",
      "description": "A mature excess-power alarm clears immediately when actual power returns to baseline.",
      "inputs": {
        "pump_status": true,
        "pump_kw": [
          {
            "t": 0,
            "value": 12.0
          },
          {
            "t": 2700,
            "value": 10.0
          }
        ],
        "pump_kw_expected": 10.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1740,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 1860,
          "to_s": 2640,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 2760,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "baseline_change_resets_timer",
      "description": "A valid baseline change that brings the residual inside allowance resets persistence; a later excess serves a new full window.",
      "inputs": {
        "pump_status": true,
        "pump_kw": 12.0,
        "pump_kw_expected": [
          {
            "t": 0,
            "value": 10.0
          },
          {
            "t": 1200,
            "value": 11.0
          },
          {
            "t": 1500,
            "value": 10.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3240,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 3360,
          "to_s": 3600,
          "equals": true
        }
      ]
    },
    {
      "name": "invalid_baseline_resets_timer",
      "description": "Expected power falling to zero clears evaluability and diagnostic state; a later valid baseline must serve a new full persistence interval.",
      "inputs": {
        "pump_status": true,
        "pump_kw": 12.0,
        "pump_kw_expected": [
          {
            "t": 0,
            "value": 10.0
          },
          {
            "t": 1200,
            "value": 0.0
          },
          {
            "t": 1500,
            "value": 10.0
          }
        ]
      },
      "expect": [
        {
          "output": "yBaselineOk",
          "from_s": 1260,
          "to_s": 1440,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3240,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 3360,
          "to_s": 3600,
          "equals": true
        }
      ]
    },
    {
      "name": "baseline_changes_but_candidate_stays_true",
      "description": "A model update within the still-faulting region does not reset the timer because the Boolean candidate never falls.",
      "inputs": {
        "pump_status": true,
        "pump_kw": 13.0,
        "pump_kw_expected": [
          {
            "t": 0,
            "value": 10.0
          },
          {
            "t": 900,
            "value": 10.5
          }
        ]
      },
      "expect": [
        {
          "output": "yPowerHigh",
          "from_s": 0,
          "to_s": 3600,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1740,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 1860,
          "to_s": 3600,
          "equals": true
        }
      ]
    },
    {
      "name": "pump_start_begins_timer",
      "description": "A standing residual does not accumulate while stopped; persistence starts only after proven run status rises.",
      "inputs": {
        "pump_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 600,
            "value": true
          }
        ],
        "pump_kw": 12.0,
        "pump_kw_expected": 10.0
      },
      "expect": [
        {
          "output": "yPowerHigh",
          "from_s": 0,
          "to_s": 3600,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2340,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2460,
          "to_s": 3600,
          "equals": true
        }
      ]
    },
    {
      "name": "pump_status_drop_resets_timer",
      "description": "A stopped interval resets persistence even while the baseline residual remains high; restart requires a new full duration.",
      "inputs": {
        "pump_status": [
          {
            "t": 0,
            "value": true
          },
          {
            "t": 1200,
            "value": false
          },
          {
            "t": 1500,
            "value": true
          }
        ],
        "pump_kw": 12.0,
        "pump_kw_expected": 10.0
      },
      "expect": [
        {
          "output": "yPowerHigh",
          "from_s": 0,
          "to_s": 3600,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3240,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 3360,
          "to_s": 3600,
          "equals": true
        }
      ]
    },
    {
      "name": "two_short_excess_episodes",
      "description": "Two sub-duration excess-power episodes separated by a compliant interval do not accumulate.",
      "inputs": {
        "pump_status": true,
        "pump_kw": [
          {
            "t": 0,
            "value": 12.0
          },
          {
            "t": 900,
            "value": 10.0
          },
          {
            "t": 1200,
            "value": 12.0
          },
          {
            "t": 2400,
            "value": 10.0
          }
        ],
        "pump_kw_expected": 10.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "residual_threshold_chatter",
      "description": "Measured power alternates just below and above the 15 percent boundary, so yPowerHigh chatters but persistence never matures.",
      "inputs": {
        "pump_status": true,
        "pump_kw": [
          {
            "t": 0,
            "value": 11.49
          },
          {
            "t": 60,
            "value": 11.51
          },
          {
            "t": 120,
            "value": 11.49
          },
          {
            "t": 180,
            "value": 11.51
          },
          {
            "t": 240,
            "value": 11.49
          },
          {
            "t": 300,
            "value": 11.51
          },
          {
            "t": 360,
            "value": 11.49
          },
          {
            "t": 420,
            "value": 11.51
          },
          {
            "t": 480,
            "value": 11.49
          }
        ],
        "pump_kw_expected": 10.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "degraded_fit_learns_fault_as_normal",
      "description": "Actual and expected are both high because the fit learned degraded operation; the residual is zero and the graph stays silent, pinning the known-good training precondition.",
      "inputs": {
        "pump_status": true,
        "pump_kw": 12.0,
        "pump_kw_expected": 12.0
      },
      "expect": [
        {
          "output": "yBaselineOk",
          "from_s": 0,
          "to_s": 3600,
          "equals": true
        },
        {
          "output": "yPowerHigh",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "invalid_stale_baseline_raw_behavior",
      "description": "A stale but positive expected-power value looks valid to the graph and can alarm; model freshness/in-domain status is a mandatory host NO_EVAL gate.",
      "inputs": {
        "pump_status": true,
        "pump_kw": 12.0,
        "pump_kw_expected": 8.0
      },
      "expect": [
        {
          "output": "yBaselineOk",
          "from_s": 0,
          "to_s": 3600,
          "equals": true
        },
        {
          "output": "yPowerHigh",
          "from_s": 0,
          "to_s": 3600,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 1860,
          "to_s": 3600,
          "equals": true
        }
      ]
    },
    {
      "name": "vfd_relationship_is_host_side",
      "description": "The raw pump baseline alarm remains active with sustained excess; a deployment may suppress only when a same-drive VFD finding actually invalidates this model.",
      "inputs": {
        "pump_status": true,
        "pump_kw": 12.0,
        "pump_kw_expected": 10.0
      },
      "expect": [
        {
          "output": "yBaselineOk",
          "from_s": 0,
          "to_s": 3600,
          "equals": true
        },
        {
          "output": "yPowerHigh",
          "from_s": 0,
          "to_s": 3600,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 1860,
          "to_s": 3600,
          "equals": true
        }
      ]
    }
  ]
}

RTU Fault Rules

Packaged rooftop unit fault detection rules (RTU-*). Source grounding: HVAC FDD Reference v1.0 ch.11 (adapted authority — see each card’s Deviations section). RTUs are the most common commercial HVAC system type, and the chapter’s emphasis follows the field data: economizer problems affect 54% of units (Cowan 2004), and compressor short-cycling and coil fouling dominate the mechanical failures.

Point dictionary: points/rtu.points.json.

Index

IDNameSevMethodStatus
RTU-0001Compressor short-cycling2ruleverified
RTU-0002Evaporator coil fouling3statisticalverified
RTU-0003SAT/MAT inconsistency (AFDD0)3ruleverified
RTU-0004Economizer not modulating3ruleverified
RTU-0005Excess outdoor air3ruleverified
RTU-0006Insufficient ventilation2ruleverified
RTU-0007Condenser airflow restriction3statisticalverified
RTU-0008Refrigerant undercharge — superheat/subcooling divergence3ruleverified
RTU-0009Refrigerant overcharge — high liquid subcooling3ruleverified
RTU-0010RTU supply-fan proof-of-operation failure2ruleverified
RTU-0011RTU supply-air temperature tracking failure3ruleverified

Severity and method for RTU-0001..009 follow the reference’s ch.11 cards (its §5.8.3 index carries no severity column); RTU-0010 is a severity-2 library proof-of-operation adaptation and RTU-0011 is a library tracking adaptation. RTU-0007 shipped once the host-fitted-baseline convention resolved its two-variable (stage AND outdoor temperature) curve as the derived point cond_split_baseline; its remaining constraint is the condenser leaving-air temperature sensor most packaged units lack, declared on the card as a retrofit gate rather than a deferral.

Relationships

  • RTU-0003 is PNNL’s AFDD0 — the sensor-consistency prerequisite for the chapter. While active it suppresses RTU-0002 and RTU-0004 (declared in its frontmatter).
  • RTU-0005/RTU-0006 are the excess/deficit halves of the outdoor-air-fraction measurement; the reference gates both behind the AHU-0028 mixing-box envelope check, instantiated against the RTU’s own mat/oat/rat points (the 062 graph is equipment-agnostic).
  • RTU-0004 is a CLU-03 member (economizer failure, trigger AHU-0017’s cluster) and shares the economizer-failure playbook.
  • RTU-0010 compares the final supply-fan command with independent proof. Read its direction before RTU-0001..006 and RTU-0008/0009: fail-to-start contests their running/airflow premise or evidence quality, while unexpected operation does not make their physical evidence invalid by itself.
  • RTU-0011 compares discharge temperature with the final active mode-specific target while fan and mechanical delivery are proven. Its warm/cold direction leads investigation across capacity, airflow, economizer, refrigerant, and control findings; proof rules remain related rather than unconditional suppressors.

RTU-0001 — Compressor short-cycling

Statusverified — engine e2ff2f8, cxf:fnv1a128:2a5f66097f97bf8154323abae2e36ded, 2026-08-17
Severity2
Methodrule
Phase2
CategoryPROTECTIVE
ConfidenceMEDIUM
EstimationQUALITATIVE_ONLY
G36
Clusters
Suppresses
Suppressed by
RelatedRTU-0002, RTU-0010, RTU-0011, HP-0007
Playbooksrtu-compressor-refrigerant
SourceHVAC FDD Reference v1.0 §11, RTU-0001; Albayati et al. 2023; Ebrahimifakhar et al. 2020
Operating statesall active modes (any cooling or heating call)

Preconditions (host-enforced): The host must report NO_EVAL for the first count_window (1 h) after engine start: while the moving average’s window fills, its divisor is elapsed time, so the output is an extrapolated rate rather than a completed-hour count. Unlike AHU-0004 this gate is load-bearing — alarm_delay (15 min) is shorter than count_window, so delayOnInit does not cover the warm-up window and two starts inside the first few minutes can reach a verdict (warmup_rate_asserts pins it). The unit must be enabled and calling for cooling or heating: a compressor idle because there is no load produces zero starts, and reporting that as healthy cycling is the opposite of information. comp_status must be bound per compressor — on a two-compressor unit the OR of both statuses hides every start that happens while the other circuit is already running, and undercounts the cycling of each. Host tick interval must lie in [57.2 s, 300 s) with count_scale set to match (see Deviations). When any gate is unmet the verdict is NO_EVAL, not healthy.

Points: comp_status

Outputs:

  • yFault — True while the number of compressor starts in the trailing count_window has stayed above max_starts_per_hour for at least alarm_delay

Parameters:

NameDefaultUnitCXF pathDescription
max_starts_per_hour6.01/hcntHigh.tStarts per hour above which cycling counts as short-cycling rather than load-following; the reference’s ceiling, equivalent to a 10-minute minimum interval between starts
count_window3600.0srate.deltaTrailing window the starts are counted over (1 h). It also fixes the units of max_starts_per_hour; a host that shortens it must retune count_scale with it and read max_starts_per_hour as starts per window rather than per hour
count_scale60.01count.kRescales the moving average of the one-tick pulse train back into a start count: k = count_window / host tick interval in seconds. The default 60.0 is 3600/60, correct only at a 60 s tick; a host on a different tick MUST retune this or every count is wrong by the ratio of the two intervals
alarm_delay900.0spersist.delayTimeContinuous fault persistence required before the alarm asserts (15 min)

Description

A compressor starting ten times an hour is not following load. Every start draws locked-rotor current through windings that have not cooled, restarts against a head pressure that has not equalized, and pumps oil out of the sump faster than the return line brings it back. The efficiency loss is modest — the reference puts it at 3–5%, the cost of running the first minutes of every cycle before the coil reaches steady state — but the mechanical damage is what costs money: a compressor worn out early is $2,000–$8,000 plus the days the space spends uncooled, which is why this card is PROTECTIVE rather than an efficiency rule. Short-cycling is a symptom, not a root cause; the rule reports that the compressor is being asked to start too often and the service call decides why.

Detection Logic

start  = rising edge of comp_status                       one tick wide
count  = MovingAverage(start, count_window) × count_scale  starts in the trailing hour
yFault = count > max_starts_per_hour, sustained continuously for alarm_delay

Block graph (rule.cxf.jsonld):

RTU-0001 block graph

Logical.Edge emits u ∧ ¬pre(u), one tick wide, on every OFF→ON transition — stops and run durations are not counted. Reals.MovingAverage is a continuous-time integral mean, so a one-tick pulse of height 1.0 encloses one tick interval of area and n starts inside the window give rate = n · dt / count_window; multiplying by count_scale = count_window / dt recovers n. That makes count_scale a function of the host’s tick interval, and this rule is tighter about the tick than the rest of the library — see the first three Deviations before deploying. The comparison is strict, so exactly six starts an hour reads clear and seven alarms; on an integer-valued count that boundary is unambiguous. persist then requires the count to stay above the ceiling for 15 minutes — two to three more starts on a unit already cycling hard — which rides out a defrost sequence or a one-off pressure trip without leaving a compressor tearing itself apart for an hour. delayOnInit = true holds that window across a controller restart.

Possible Diagnoses

  1. Thermostat or controller differential set too small — the call is satisfied within a minute or two and restarts as soon as the space drifts back
  2. Equipment oversized for the load: at part load the unit can only meet the call by cycling, and no setting adjusts it
  3. Low refrigerant charge — suction pressure falls to the cutout every cycle and the low-pressure switch does the cycling
  4. Defective run capacitor: the compressor stalls on start and drops out on thermal overload
  5. Iced or fouled evaporator coil starving the suction side (RTU-0002 sees the same coil from the airside)
  6. Control board or contactor fault chattering the compressor output

Energy Impact

PROTECTIVE, MEDIUM confidence, QUALITATIVE_ONLY. The rule sees one boolean and cannot price a start, so there is no waste term computable from its inputs. Size the opportunity host-side from cycling hours × rated compressor power × the reference’s 3–5% efficiency penalty, and treat the $2,000–$8,000 avoided compressor replacement as the larger term — that probabilistic term is why the fault is severity 2. MEDIUM confidence: the mechanism is not in doubt and the sources (Albayati et al. 2023; Ebrahimifakhar et al. 2020) are field studies of packaged-unit faults, but the efficiency figure depends on cycle length and ambient conditions, which this rule does not measure. Cooling-dominant.

Emissions Impact

Scope 2, QUALITATIVE_EMISSIONS, MEDIUM confidence. AHU-0001’s convention gives a PROTECTIVE fault with no emitting stream scope “N/A”; this card does not qualify, because the 3–5% efficiency loss is electricity a compressor actually draws. The larger emissions term is indirect: a compressor replaced years early carries the embodied carbon of a new compressor plus its refrigerant charge. Avoided-emissions basis: N/A.

Deviations

  • This rule needs a faster tick than the rest of the library, and the reason is Nyquist. A start is visible only if the compressor is seen OFF on one tick and ON on the next, so the fastest observable cycling is 1800/dt starts per hour; at the library’s usual 300 s tick that ceiling is exactly 6/h — the threshold itself — and the rule could never fire. The default count_scale is therefore 60.0 (a 60 s tick) rather than AHU-0004’s 12.0. Combined with the ring floor below, a legal deployment has 57.2 s ≤ dt < 300 s; 60 s is the recommended value and the only one these vectors have exercised.
  • count_scale is coupled to the host’s tick interval and the failure is silent. k = count_window / dt, so a host ticking every 120 s must set count_scale to 30.0; left at 60.0 it reports double the true count and alarms on four starts an hour. AHU-0004’s deployment constraint verbatim.
  • Minimum tick interval, from the moving average’s ring. Each MovingAverage keeps a fixed 64-checkpoint ring and drops the oldest in-window sample past that. The retained window holds count_window/dt + 1 checkpoints, so dt ≥ 3600/6357.2 s. (AHU-0004 quotes delta/64 = 56.25 s, which omits the boundary checkpoint; neither card’s tick is near it.)
  • min_run_time is not in the graph. The reference lists it as a 5-minute tunable but its printed equation is comp_starts_per_hour > max_starts_per_hour and nothing else, and the starts-per-hour ceiling subsumes the protective intent: ten 2-minute runs is both a min-run-time violation and 10 starts an hour. A site wanting the stricter per-cycle test can add a companion rule.
  • Rolling count built from a moving average, because the block set has no windowed counter. Integers.OnCounter counts monotonically from a reset, so a trailing-hour count would need a host-driven hourly reset — a tumbling count whose verdict depends on where the hour boundary fell. AHU-0004’s trade, taken again.
  • Startup artifact (a): a spurious first-tick pulse, which costs nothing. Logical.Edge compares u against pre_u_start on the first tick, so a unit already running at load registers a start at t = 0. It encloses no area (dt is zero on the first tick) and never reaches the count, so pre_u_start is written explicitly as false and not exposed as a card parameter.
  • Startup artifact (b): the first hour reads as a rate, and here it can reach a verdict. While t < count_window the moving average divides by elapsed time, so two starts in the first three minutes read as 40/h — the pace, extrapolated. Unlike AHU-0004, alarm_delay (15 min) is shorter than the window, so that rate can assert (warmup_rate_asserts pins it). The host NO_EVAL precondition for the first count_window is not optional.
  • Strict > on a discrete count. Exactly six starts an hour is clear and seven alarms, the reference’s > max_starts_per_hour read literally. The boundary is exact in IEEE-754: 60.0 × (6 × 60 / 3600) evaluates to precisely 6.0, so the six-start case is a real pin and not a near-miss.
  • The counting window is half-open. rate compares the accumulated integral now against its value one count_window ago, so a start exactly count_window old has just left the window. The reference is silent; it matters only on the threshold and it errs toward silence.
  • How long a crossing survives is count_window minus the span of the starts that caused it. Seven starts packed into ten minutes hold cntHigh for nearly an hour and always alarm; seven spread across 54 minutes hold it for 300 s and never do — same starts per hour, opposite verdicts, and the difference is not visible in the printed equation. What the rule reports is cycling sustained above the ceiling, not every excursion through it.
  • The reference tags this fault for both RTU and HP. This card is the RTU-family instance (AHU-0025 precedent); the heat-pump sibling would restate the graph against a heat-pump compressor status and would have to say something about defrost cycles, which are starts that mean nothing is wrong.
  • Severity 2 (high), phase 2, method rule, and the tunable defaults are the reference’s chapter 11 card; its §5.8.3 index corroborates and carries no severity column. g36: null — PNNL/research-derived, not a G36 §5.16.14 clause.
  • Operating states are declared, not gated: the reference marks the fault applicable in every active mode, and the graph has nothing to exclude.
  • persist.delayOnInit = true (Modelica/CDL default is false), the library’s standing choice: a count already above the ceiling at load waits out the full 15 minutes instead of alarming on the first tick after a restart.

Notes

Bind comp_status per compressor and deploy one instance per circuit, as the RTU point dictionary requires. An OR across two circuits undercounts: a lag start while the lead is already running never moves the signal, so a unit whose lead runs continuously can cycle its lag circuit all afternoon and read healthy.

Remediation follows the rtu-compressor-refrigerant playbook — thermostat differential, then charge, then the run capacitor — confirming resolution at fewer than 6 starts/hr with a 5-minute minimum on-time over 48 hours. The one remote check worth doing before the truck roll is diagnosis 1: if the cooling call is satisfied within a minute or two of every start, widen the differential. Firing together with RTU-0002 points at the coil rather than the controls (diagnosis 5); firing alone points at the differential or the charge.

Test Vectors

10 scenarios, clock step 60 s over 14400 s.

ScenarioDescription
three_starts_per_hourReference vector: normal cycling, three starts an hour. The compressor is idle for the first 20 minutes, then runs 8 minutes and rests 12 — a 20-minute cycle, twice the 10-minute minimum interval the 6/h ceiling encodes. The rolling count holds at 3.0 for the whole run, including the warm-up window, where three starts an hour also happens to be the extrapolated pace
single_long_runReference vector: one start, one long run. The compressor starts at t=1200 and runs to the horizon. count peaks at 3.0 (the warm-up extrapolation of one start in 20 minutes) and decays from there; one start can never reach the 6/h ceiling
startup_pulse_is_inertStartup artifact: comp_status is already true when the rule loads, so Logical.Edge sees u true against pre_u_start = false and emits a rising edge on tick 0. It costs nothing — MovingAverage integrates u*dt and dt is zero on the first tick, so the pulse encloses no area and count stays at 0.0 for the whole run
short_cycling_ten_per_hourReference vector: short-cycling at ten starts an hour. The unit is off for the first hour (so the verdict rests on a completed-window count, not on the warm-up extrapolation), then runs 2 minutes and rests 4 — a 6-minute cycle. The seventh start enters the trailing hour at t=5760 and count steps to 7.0; yFault follows one alarm_delay later at t=6660, and the count settles at exactly 10.0
six_starts_per_hour_stays_clearBoundary, low side. The compressor runs 2 minutes every 10 — exactly six starts an hour, the reference’s minimum recommended interval met exactly. count lands on exactly 6.0 (60.0 x 6 x 60 / 3600 is exact in IEEE-754) and max_starts_per_hour is a strict >, so six starts an hour reads clear. During the warm-up window the extrapolated pace is 6.0 or a hair under, never above
seven_starts_per_hour_tripsBoundary, high side. Same shape as the six-per-hour case with one more start folded into each hour: cycles of 9 and 8 minutes, seven starts per hour, repeating on a 3600 s pattern so every trailing hour holds exactly seven. The seventh start enters the window at t=6720, count steps from 6.0 to 7.0, and yFault asserts one alarm_delay later at t=7620
cycling_stops_alarm_clearsRecovery. Ten short cycles between t=3600 and t=6840 push count to 10.0 and yFault asserts at t=6660; then the compressor settles into one long run. The count does not drop at once — it decays as starts age out of the trailing hour, one per 360 s — and reaches 6.0 at t=8280, where the strict threshold goes false and the alarm clears on that same tick, since TrueDelay has no off-delay
warmup_rate_assertsStartup artifact, and the reason the host must report NO_EVAL for the first count_window: two starts two minutes apart read as a 40/h pace, because the growing-window divisor is elapsed time rather than count_window. cntHigh is true from t=60, and unlike AHU-0004 (where alarm_delay equals count_window) this rule’s 900 s delay expires long before the window fills, so yFault asserts at t=960 on the strength of two starts. The reading decays as the window grows and falls to 6.0 at t=1200, clearing the alarm
startup_spike_clearsTransient shorter than alarm_delay. A single start one tick after load extrapolates to a 60/h pace, so cntHigh is true from t=60, but the reading decays with the growing window and drops to 6.0 at t=600. cntHigh held for 480 s, barely half the alarm_delay, so nothing asserts — the same single-start history as single_long_run, seen from inside the warm-up window
spread_burst_clears_before_delayTransient in the steady-state regime. Seven starts spread evenly over 54 minutes, after an idle first hour: the seventh start pushes count to 7.0 at t=6840, but the first one leaves the trailing hour at t=7200 and the count falls back to 6.0. cntHigh held for 300 s against a 900 s alarm_delay, so nothing asserts. How long a crossing survives is count_window minus the span of the starts that caused it, which is why a tight burst alarms and a spread-out one does not
vectors.json
{
  "schema": "cxf-library/vectors/v1",
  "clock": {
    "step_s": 60,
    "horizon_s": 14400
  },
  "scenarios": [
    {
      "name": "three_starts_per_hour",
      "description": "Reference vector: normal cycling, three starts an hour. The compressor is idle for the first 20 minutes, then runs 8 minutes and rests 12 \u2014 a 20-minute cycle, twice the 10-minute minimum interval the 6/h ceiling encodes. The rolling count holds at 3.0 for the whole run, including the warm-up window, where three starts an hour also happens to be the extrapolated pace",
      "inputs": {
        "comp_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 1200,
            "value": true
          },
          {
            "t": 1680,
            "value": false
          },
          {
            "t": 2400,
            "value": true
          },
          {
            "t": 2880,
            "value": false
          },
          {
            "t": 3600,
            "value": true
          },
          {
            "t": 4080,
            "value": false
          },
          {
            "t": 4800,
            "value": true
          },
          {
            "t": 5280,
            "value": false
          },
          {
            "t": 6000,
            "value": true
          },
          {
            "t": 6480,
            "value": false
          },
          {
            "t": 7200,
            "value": true
          },
          {
            "t": 7680,
            "value": false
          },
          {
            "t": 8400,
            "value": true
          },
          {
            "t": 8880,
            "value": false
          },
          {
            "t": 9600,
            "value": true
          },
          {
            "t": 10080,
            "value": false
          },
          {
            "t": 10800,
            "value": true
          },
          {
            "t": 11280,
            "value": false
          },
          {
            "t": 12000,
            "value": true
          },
          {
            "t": 12480,
            "value": false
          },
          {
            "t": 13200,
            "value": true
          },
          {
            "t": 13680,
            "value": false
          },
          {
            "t": 14400,
            "value": true
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 14400,
          "equals": false
        }
      ]
    },
    {
      "name": "single_long_run",
      "description": "Reference vector: one start, one long run. The compressor starts at t=1200 and runs to the horizon. count peaks at 3.0 (the warm-up extrapolation of one start in 20 minutes) and decays from there; one start can never reach the 6/h ceiling",
      "inputs": {
        "comp_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 1200,
            "value": true
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 14400,
          "equals": false
        }
      ]
    },
    {
      "name": "startup_pulse_is_inert",
      "description": "Startup artifact: comp_status is already true when the rule loads, so Logical.Edge sees u true against pre_u_start = false and emits a rising edge on tick 0. It costs nothing \u2014 MovingAverage integrates u*dt and dt is zero on the first tick, so the pulse encloses no area and count stays at 0.0 for the whole run",
      "inputs": {
        "comp_status": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 14400,
          "equals": false
        }
      ]
    },
    {
      "name": "short_cycling_ten_per_hour",
      "description": "Reference vector: short-cycling at ten starts an hour. The unit is off for the first hour (so the verdict rests on a completed-window count, not on the warm-up extrapolation), then runs 2 minutes and rests 4 \u2014 a 6-minute cycle. The seventh start enters the trailing hour at t=5760 and count steps to 7.0; yFault follows one alarm_delay later at t=6660, and the count settles at exactly 10.0",
      "inputs": {
        "comp_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 3600,
            "value": true
          },
          {
            "t": 3720,
            "value": false
          },
          {
            "t": 3960,
            "value": true
          },
          {
            "t": 4080,
            "value": false
          },
          {
            "t": 4320,
            "value": true
          },
          {
            "t": 4440,
            "value": false
          },
          {
            "t": 4680,
            "value": true
          },
          {
            "t": 4800,
            "value": false
          },
          {
            "t": 5040,
            "value": true
          },
          {
            "t": 5160,
            "value": false
          },
          {
            "t": 5400,
            "value": true
          },
          {
            "t": 5520,
            "value": false
          },
          {
            "t": 5760,
            "value": true
          },
          {
            "t": 5880,
            "value": false
          },
          {
            "t": 6120,
            "value": true
          },
          {
            "t": 6240,
            "value": false
          },
          {
            "t": 6480,
            "value": true
          },
          {
            "t": 6600,
            "value": false
          },
          {
            "t": 6840,
            "value": true
          },
          {
            "t": 6960,
            "value": false
          },
          {
            "t": 7200,
            "value": true
          },
          {
            "t": 7320,
            "value": false
          },
          {
            "t": 7560,
            "value": true
          },
          {
            "t": 7680,
            "value": false
          },
          {
            "t": 7920,
            "value": true
          },
          {
            "t": 8040,
            "value": false
          },
          {
            "t": 8280,
            "value": true
          },
          {
            "t": 8400,
            "value": false
          },
          {
            "t": 8640,
            "value": true
          },
          {
            "t": 8760,
            "value": false
          },
          {
            "t": 9000,
            "value": true
          },
          {
            "t": 9120,
            "value": false
          },
          {
            "t": 9360,
            "value": true
          },
          {
            "t": 9480,
            "value": false
          },
          {
            "t": 9720,
            "value": true
          },
          {
            "t": 9840,
            "value": false
          },
          {
            "t": 10080,
            "value": true
          },
          {
            "t": 10200,
            "value": false
          },
          {
            "t": 10440,
            "value": true
          },
          {
            "t": 10560,
            "value": false
          },
          {
            "t": 10800,
            "value": true
          },
          {
            "t": 10920,
            "value": false
          },
          {
            "t": 11160,
            "value": true
          },
          {
            "t": 11280,
            "value": false
          },
          {
            "t": 11520,
            "value": true
          },
          {
            "t": 11640,
            "value": false
          },
          {
            "t": 11880,
            "value": true
          },
          {
            "t": 12000,
            "value": false
          },
          {
            "t": 12240,
            "value": true
          },
          {
            "t": 12360,
            "value": false
          },
          {
            "t": 12600,
            "value": true
          },
          {
            "t": 12720,
            "value": false
          },
          {
            "t": 12960,
            "value": true
          },
          {
            "t": 13080,
            "value": false
          },
          {
            "t": 13320,
            "value": true
          },
          {
            "t": 13440,
            "value": false
          },
          {
            "t": 13680,
            "value": true
          },
          {
            "t": 13800,
            "value": false
          },
          {
            "t": 14040,
            "value": true
          },
          {
            "t": 14160,
            "value": false
          },
          {
            "t": 14400,
            "value": true
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 6600,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 6720,
          "to_s": 14400,
          "equals": true
        }
      ]
    },
    {
      "name": "six_starts_per_hour_stays_clear",
      "description": "Boundary, low side. The compressor runs 2 minutes every 10 \u2014 exactly six starts an hour, the reference's minimum recommended interval met exactly. count lands on exactly 6.0 (60.0 x 6 x 60 / 3600 is exact in IEEE-754) and max_starts_per_hour is a strict `>`, so six starts an hour reads clear. During the warm-up window the extrapolated pace is 6.0 or a hair under, never above",
      "inputs": {
        "comp_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 600,
            "value": true
          },
          {
            "t": 720,
            "value": false
          },
          {
            "t": 1200,
            "value": true
          },
          {
            "t": 1320,
            "value": false
          },
          {
            "t": 1800,
            "value": true
          },
          {
            "t": 1920,
            "value": false
          },
          {
            "t": 2400,
            "value": true
          },
          {
            "t": 2520,
            "value": false
          },
          {
            "t": 3000,
            "value": true
          },
          {
            "t": 3120,
            "value": false
          },
          {
            "t": 3600,
            "value": true
          },
          {
            "t": 3720,
            "value": false
          },
          {
            "t": 4200,
            "value": true
          },
          {
            "t": 4320,
            "value": false
          },
          {
            "t": 4800,
            "value": true
          },
          {
            "t": 4920,
            "value": false
          },
          {
            "t": 5400,
            "value": true
          },
          {
            "t": 5520,
            "value": false
          },
          {
            "t": 6000,
            "value": true
          },
          {
            "t": 6120,
            "value": false
          },
          {
            "t": 6600,
            "value": true
          },
          {
            "t": 6720,
            "value": false
          },
          {
            "t": 7200,
            "value": true
          },
          {
            "t": 7320,
            "value": false
          },
          {
            "t": 7800,
            "value": true
          },
          {
            "t": 7920,
            "value": false
          },
          {
            "t": 8400,
            "value": true
          },
          {
            "t": 8520,
            "value": false
          },
          {
            "t": 9000,
            "value": true
          },
          {
            "t": 9120,
            "value": false
          },
          {
            "t": 9600,
            "value": true
          },
          {
            "t": 9720,
            "value": false
          },
          {
            "t": 10200,
            "value": true
          },
          {
            "t": 10320,
            "value": false
          },
          {
            "t": 10800,
            "value": true
          },
          {
            "t": 10920,
            "value": false
          },
          {
            "t": 11400,
            "value": true
          },
          {
            "t": 11520,
            "value": false
          },
          {
            "t": 12000,
            "value": true
          },
          {
            "t": 12120,
            "value": false
          },
          {
            "t": 12600,
            "value": true
          },
          {
            "t": 12720,
            "value": false
          },
          {
            "t": 13200,
            "value": true
          },
          {
            "t": 13320,
            "value": false
          },
          {
            "t": 13800,
            "value": true
          },
          {
            "t": 13920,
            "value": false
          },
          {
            "t": 14400,
            "value": true
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 14400,
          "equals": false
        }
      ]
    },
    {
      "name": "seven_starts_per_hour_trips",
      "description": "Boundary, high side. Same shape as the six-per-hour case with one more start folded into each hour: cycles of 9 and 8 minutes, seven starts per hour, repeating on a 3600 s pattern so every trailing hour holds exactly seven. The seventh start enters the window at t=6720, count steps from 6.0 to 7.0, and yFault asserts one alarm_delay later at t=7620",
      "inputs": {
        "comp_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 3600,
            "value": true
          },
          {
            "t": 3720,
            "value": false
          },
          {
            "t": 4140,
            "value": true
          },
          {
            "t": 4260,
            "value": false
          },
          {
            "t": 4680,
            "value": true
          },
          {
            "t": 4800,
            "value": false
          },
          {
            "t": 5160,
            "value": true
          },
          {
            "t": 5280,
            "value": false
          },
          {
            "t": 5700,
            "value": true
          },
          {
            "t": 5820,
            "value": false
          },
          {
            "t": 6240,
            "value": true
          },
          {
            "t": 6360,
            "value": false
          },
          {
            "t": 6720,
            "value": true
          },
          {
            "t": 6840,
            "value": false
          },
          {
            "t": 7200,
            "value": true
          },
          {
            "t": 7320,
            "value": false
          },
          {
            "t": 7740,
            "value": true
          },
          {
            "t": 7860,
            "value": false
          },
          {
            "t": 8280,
            "value": true
          },
          {
            "t": 8400,
            "value": false
          },
          {
            "t": 8760,
            "value": true
          },
          {
            "t": 8880,
            "value": false
          },
          {
            "t": 9300,
            "value": true
          },
          {
            "t": 9420,
            "value": false
          },
          {
            "t": 9840,
            "value": true
          },
          {
            "t": 9960,
            "value": false
          },
          {
            "t": 10320,
            "value": true
          },
          {
            "t": 10440,
            "value": false
          },
          {
            "t": 10800,
            "value": true
          },
          {
            "t": 10920,
            "value": false
          },
          {
            "t": 11340,
            "value": true
          },
          {
            "t": 11460,
            "value": false
          },
          {
            "t": 11880,
            "value": true
          },
          {
            "t": 12000,
            "value": false
          },
          {
            "t": 12360,
            "value": true
          },
          {
            "t": 12480,
            "value": false
          },
          {
            "t": 12900,
            "value": true
          },
          {
            "t": 13020,
            "value": false
          },
          {
            "t": 13440,
            "value": true
          },
          {
            "t": 13560,
            "value": false
          },
          {
            "t": 13920,
            "value": true
          },
          {
            "t": 14040,
            "value": false
          },
          {
            "t": 14400,
            "value": true
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7560,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 7680,
          "to_s": 14400,
          "equals": true
        }
      ]
    },
    {
      "name": "cycling_stops_alarm_clears",
      "description": "Recovery. Ten short cycles between t=3600 and t=6840 push count to 10.0 and yFault asserts at t=6660; then the compressor settles into one long run. The count does not drop at once \u2014 it decays as starts age out of the trailing hour, one per 360 s \u2014 and reaches 6.0 at t=8280, where the strict threshold goes false and the alarm clears on that same tick, since TrueDelay has no off-delay",
      "inputs": {
        "comp_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 3600,
            "value": true
          },
          {
            "t": 3720,
            "value": false
          },
          {
            "t": 3960,
            "value": true
          },
          {
            "t": 4080,
            "value": false
          },
          {
            "t": 4320,
            "value": true
          },
          {
            "t": 4440,
            "value": false
          },
          {
            "t": 4680,
            "value": true
          },
          {
            "t": 4800,
            "value": false
          },
          {
            "t": 5040,
            "value": true
          },
          {
            "t": 5160,
            "value": false
          },
          {
            "t": 5400,
            "value": true
          },
          {
            "t": 5520,
            "value": false
          },
          {
            "t": 5760,
            "value": true
          },
          {
            "t": 5880,
            "value": false
          },
          {
            "t": 6120,
            "value": true
          },
          {
            "t": 6240,
            "value": false
          },
          {
            "t": 6480,
            "value": true
          },
          {
            "t": 6600,
            "value": false
          },
          {
            "t": 6840,
            "value": true
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 6600,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 6720,
          "to_s": 8160,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 8340,
          "to_s": 14400,
          "equals": false
        }
      ]
    },
    {
      "name": "warmup_rate_asserts",
      "description": "Startup artifact, and the reason the host must report NO_EVAL for the first count_window: two starts two minutes apart read as a 40/h pace, because the growing-window divisor is elapsed time rather than count_window. cntHigh is true from t=60, and unlike AHU-0004 (where alarm_delay equals count_window) this rule's 900 s delay expires long before the window fills, so yFault asserts at t=960 on the strength of two starts. The reading decays as the window grows and falls to 6.0 at t=1200, clearing the alarm",
      "inputs": {
        "comp_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 60,
            "value": true
          },
          {
            "t": 120,
            "value": false
          },
          {
            "t": 180,
            "value": true
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 900,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 1020,
          "to_s": 1080,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 1260,
          "to_s": 14400,
          "equals": false
        }
      ]
    },
    {
      "name": "startup_spike_clears",
      "description": "Transient shorter than alarm_delay. A single start one tick after load extrapolates to a 60/h pace, so cntHigh is true from t=60, but the reading decays with the growing window and drops to 6.0 at t=600. cntHigh held for 480 s, barely half the alarm_delay, so nothing asserts \u2014 the same single-start history as single_long_run, seen from inside the warm-up window",
      "inputs": {
        "comp_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 60,
            "value": true
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 14400,
          "equals": false
        }
      ]
    },
    {
      "name": "spread_burst_clears_before_delay",
      "description": "Transient in the steady-state regime. Seven starts spread evenly over 54 minutes, after an idle first hour: the seventh start pushes count to 7.0 at t=6840, but the first one leaves the trailing hour at t=7200 and the count falls back to 6.0. cntHigh held for 300 s against a 900 s alarm_delay, so nothing asserts. How long a crossing survives is count_window minus the span of the starts that caused it, which is why a tight burst alarms and a spread-out one does not",
      "inputs": {
        "comp_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 3600,
            "value": true
          },
          {
            "t": 3720,
            "value": false
          },
          {
            "t": 4140,
            "value": true
          },
          {
            "t": 4260,
            "value": false
          },
          {
            "t": 4680,
            "value": true
          },
          {
            "t": 4800,
            "value": false
          },
          {
            "t": 5220,
            "value": true
          },
          {
            "t": 5340,
            "value": false
          },
          {
            "t": 5760,
            "value": true
          },
          {
            "t": 5880,
            "value": false
          },
          {
            "t": 6300,
            "value": true
          },
          {
            "t": 6420,
            "value": false
          },
          {
            "t": 6840,
            "value": true
          },
          {
            "t": 6960,
            "value": false
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 14400,
          "equals": false
        }
      ]
    }
  ]
}

RTU-0002 — Evaporator coil fouling — reduced temperature split

Statusverified — engine e2ff2f8, cxf:fnv1a128:da9a9817ba72e7a56a69ec6f4e2c7e9b, 2026-08-17
Severity3
Methodstatistical
Phase2
CategoryEFFICIENCY_LOSS
ConfidenceMEDIUM
EstimationBASELINE_COMPARISON
G36
Clusters
Suppresses
Suppressed byRTU-0003
RelatedRTU-0003, RTU-0007, RTU-0010, RTU-0011
Playbooksrtu-compressor-refrigerant
SourceHVAC FDD Reference v1.0 §11, RTU-0002; Catrini & Piacentino 2023; Ebrahimifakhar et al. 2020
Operating statesmechanical cooling, one or two compressor stages

Preconditions (host-enforced): The compressor must have run at its current stage for min_runtime_for_eval (15 min) before the split is read; the coil is still pulling down after a start or a stage change and reads falsely degraded. sat must pass its integrity gate: RTU-0003 (see suppressed_by) checks it against mat and silences this rule while it is active. Nothing validates rat on its own, so a drifted return-air sensor biases the split with no other symptom. Stage evaluability is signalled in-rule by yStageOk; when it is false the verdict is NO_EVAL, not healthy.

Points: rat, sat, comp_stage

Outputs:

  • yFault — True while the measured temperature split has stayed more than split_degradation_threshold below the baseline for the running stage, for at least alarm_delay
  • yStageOk — Evaluability signal — true when comp_stage is 1 or 2, the stages this rule carries baselines for; false means NO_EVAL and the host must ignore yFault

Parameters:

NameDefaultUnitCXF pathDescription
split_degradation_threshold0.251ratioHigh.tFractional shortfall of the measured split against the stage baseline that counts as fouling (0.25 = 25% below baseline)
baseline_split_stage_18.0°Cbase1.kExpected rat − sat with one compressor stage running on a clean coil at design airflow
baseline_split_stage_212.0°Cbase2.kExpected rat − sat with two compressor stages running on a clean coil at design airflow
alarm_delay3600.0spersist.delayTimeContinuous degradation required before the alarm asserts (60 min)

Description

A clean evaporator coil at design airflow drops the air passing through it by a predictable amount — roughly 8 °C on one compressor stage, 12 °C on two. When that split shrinks while the same stage runs, the coil is no longer moving the heat it should: dust bridging the fins, a loaded filter, ice, or a charge that has leaked away all produce the same reading, and they cost the same way — the unit runs longer for the same cooling, and where the cause is restricted airflow the fan spends more energy per unit of air delivered. Two temperatures and a stage number is the whole measurement, which is what makes it practical on packaged equipment carrying no refrigerant instrumentation, and also why the rule is blind to cause. Catrini & Piacentino (2023) measured 13.3% capacity loss and up to 47% additional fan power on fouled units.

Detection Logic

actual_split   = rat − sat
expected_split = baseline_split_stage_2  if comp_stage = 2
                 baseline_split_stage_1  otherwise
degradation    = (expected_split − actual_split) / expected_split

yStageOk = comp_stage > 0 AND comp_stage ≤ 2       (false ⇒ host reports NO_EVAL)
yFault   = degradation > split_degradation_threshold AND yStageOk,
           sustained for alarm_delay

Block graph (rule.cxf.jsonld):

RTU-0002 block graph

Both baselines are live on every tick and expected selects one, so the baseline can change under the rule mid-run when the unit stages. Because that denominator is always a selected constant — 8 or 12, never zero, never noisy — the division is safe by construction and needs no divide-by-zero branch. yStageOk answers a different question: which stages does the rule carry baselines for? Outside 1–2 it holds yFault down, and that false means “not evaluated”, not “coil is clean”. Stage 0 is the case that matters in practice — with no compressor running, rat − sat collapses toward zero and reads as near-total degradation. stagePos carries no t node because CDL’s default integer threshold is already the 0 this test wants. The comparison is strict, so a split exactly 25% below baseline is not a fault and 25.1% is. persist requires 60 continuous minutes, long enough to ride out swings in return air and to let the split settle after a stage-up; delayOnInit = true holds that window across a controller restart.

Possible Diagnoses

  1. Evaporator coil fouled — dust and lint bridging the fins, usually downstream of a filter that was never changed; a loaded filter alone gives the same reading and is the cheapest thing on this list to rule out
  2. Low refrigerant charge from a leak, which shrinks the split the same way
  3. Evaporator fan motor or drive degradation cutting airflow — belt slip, a failing motor, or a dirty blower wheel
  4. Iced evaporator coil, itself usually a symptom of low charge or low airflow

Energy Impact

EFFICIENCY_LOSS, MEDIUM confidence, BASELINE_COMPARISON. The degradation fraction the rule already computes is the estimator: waste_kw = (expected_split − actual_split) / expected_split × rtu_kw, treating the capacity shortfall as proportional extra runtime at the unit’s rated draw. Catrini & Piacentino (2023) put the measured effect at 13.3% capacity reduction and as much as 47% additional fan power on airflow-restricted cases; PNNL EEM-23 (advanced RTU controls) is the related retrofit package. Confidence is MEDIUM because the baselines are population values, not this unit’s commissioned performance.

Emissions Impact

Scope 2, PROXY_EMISSIONS, MEDIUM confidence; typically 300–2,000 kg CO₂e/yr for a commercial packaged unit, scaling with tonnage and cooling hours. Emissions follow the added compressor and fan electricity, so the avoided-emissions basis is the marginal operating emissions rate (MOER) — fouling costs most on hot afternoons, when the grid is dirtiest and the unit runs longest.

Deviations

  • The per-stage baseline function is a two-way Switch, not a lookup. The reference writes an open-ended baseline_split_for_stage(comp_stage); the block set has no integer-keyed table and the reference supplies exactly two baselines. A host with three or more stages instantiates the rule once per stage pair, rebinding base1, base2, kTwo.k and both integer bounds together. Widening stageKnown.t alone is the trap: it removes the NO_EVAL signal while stage 3 still falls through to the stage-1 baseline.
  • Stage evaluability is an output, not just a precondition. The stage-range test is computable from this rule’s own inputs, so per SCHEMA.md it is exposed as yStageOk. A rule that silently returned false at comp_stage = 0 would be reporting a healthy coil on a unit that is not cooling at all.
  • min_runtime_for_eval (15 min) stays a host precondition. It gates on time since the last stage change, which the block graph cannot see, and this library keeps state gating host-side. The 60-minute alarm_delay does not substitute for it: pull-down after a stage change starts the persistence timer rather than being excluded from it, so a coil taking 20 minutes to settle spends a third of the alarm window looking fouled.
  • method: statistical describes the provenance of the baselines, not the graph. At runtime the graph does one subtraction, one division and one comparison. The classification is the reference’s and it is fair — the 8/12 °C baselines are population values from the fouling literature rather than a commissioned measurement of the unit in front of you.
  • Strict > at the degradation threshold, where the reference’s playbook is inclusive (“a 25% or greater reduction in split indicates fouling”). CDL Reals has no GreaterEqual, so the strict form is the expressible one and a split exactly 25% below baseline reads healthy. The disagreement is measure-zero on a real-valued signal; both sides are pinned by vectors.
  • The threshold is carried as a fraction, not a percentage. The reference writes 25%; ratioHigh.t is 0.25, matching the dimensionless quotient the graph computes. A host that set this parameter to 25 would disable the rule, so the card declares its unit as 1.
  • persist.delayOnInit = true (CDL default is false): a coil already degraded when the controller starts waits out the full hour rather than alarming on the first tick.

Notes

Start at the filter: it reproduces the fouled-coil signature exactly and costs minutes to rule out. If a fresh filter does not restore the split, the question is airflow versus refrigerant, and the two separate at the unit — airflow shows in static pressure across the coil, charge shows in superheat and subcooling at the service ports. Neither is visible from the points this rule reads.

The rtu-compressor-refrigerant playbook orders the remediation (filter, coil cleaning, fan motor, ice) and tests resolution at the split returning to within 15% of baseline — tighter than the 25% this rule alarms at, so a coil cleaned back to 20% degraded clears the alarm without being fixed. RTU-0007 (condenser airflow restriction) is the condenser-side counterpart: its stage-and-OAT baseline ships as the host-fitted point cond_split_baseline, and its own resolution-vs-alarm gap mirrors this one.

Test Vectors

13 scenarios, clock step 300 s over 9000 s.

ScenarioDescription
stage1_healthyStage 1 at its 8 °C baseline split (rat 24 → sat 16): zero degradation, evaluable
stage1_fouledStage 1 split collapsed to 5.5 °C (rat 24 → sat 18.5) = 31% below baseline; alarms after alarm_delay
stage2_healthyStage 2 at its 12 °C baseline split (rat 26 → sat 14): zero degradation
stage1_split_7_is_healthyBaseline selection, low side: a 7 °C split at stage 1 is 12.5% below the 8 °C baseline and clears. Against the stage-2 baseline the same split would read 42% degraded, so this pins that the Switch took the stage-1 leg
stage2_split_7_is_fouledBaseline selection, high side: the identical 7 °C split at stage 2 is 42% below the 12 °C baseline and alarms — two compressors’ worth of capacity producing one stage’s worth of cooling
stage0_not_evaluableCompressor off: rat and sat are equal because no coil is running, which reads as 100% degradation against the stage-1 baseline. yStageOk is false, so yFault stays down and the host reports NO_EVAL rather than healthy
stage3_not_evaluableA third stage on a unit this rule has no baseline for: the split (4 °C) looks badly degraded against the stage-1 constant, but comp_stage > 2 is outside the rule’s calibration, so yStageOk is false and no verdict is issued
edge_degradation_equals_thresholdThreshold edge: a 6 °C split at stage 1 is exactly 25% below baseline; the comparison is strict, so no fault
edge_degradation_just_over_thresholdThreshold edge: a 5.9 °C split at stage 1 is 26.25% below baseline and clears the strict comparison, alarming after alarm_delay
transient_low_split_never_alarmsThe split dips to 5.5 °C for 1800 s — a brief capacity loss such as a head-pressure control step or a momentary airflow disturbance — and recovers before alarm_delay elapses; the timer resets
stage_change_restarts_persistenceA 7 °C split that is healthy on stage 1 becomes a fault the moment the unit stages up at t = 1800 s; the full 60 min runs from the stage change, not from the start of the low split
fault_clears_after_coil_cleaningStage-1 fouling alarms at 3600 s; the split returns to 8 °C at t = 5400 s (filter changed, coil washed) and the alarm drops on that tick
compressor_stop_forces_no_evalA matured stage-1 fault is not reported as cleared when the compressor stops: yStageOk goes false at t = 5400 s and yFault drops with it, which is NO_EVAL, not a repair
vectors.json
{
  "schema": "cxf-library/vectors/v1",
  "clock": {
    "step_s": 300,
    "horizon_s": 9000
  },
  "scenarios": [
    {
      "name": "stage1_healthy",
      "description": "Stage 1 at its 8 \u00b0C baseline split (rat 24 \u2192 sat 16): zero degradation, evaluable",
      "inputs": {
        "rat": 24.0,
        "sat": 16.0,
        "comp_stage": 1
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 9000,
          "equals": false
        },
        {
          "output": "yStageOk",
          "from_s": 0,
          "to_s": 9000,
          "equals": true
        }
      ]
    },
    {
      "name": "stage1_fouled",
      "description": "Stage 1 split collapsed to 5.5 \u00b0C (rat 24 \u2192 sat 18.5) = 31% below baseline; alarms after alarm_delay",
      "inputs": {
        "rat": 24.0,
        "sat": 18.5,
        "comp_stage": 1
      },
      "expect": [
        {
          "output": "yStageOk",
          "from_s": 0,
          "to_s": 9000,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3300,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 3900,
          "to_s": 9000,
          "equals": true
        }
      ]
    },
    {
      "name": "stage2_healthy",
      "description": "Stage 2 at its 12 \u00b0C baseline split (rat 26 \u2192 sat 14): zero degradation",
      "inputs": {
        "rat": 26.0,
        "sat": 14.0,
        "comp_stage": 2
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 9000,
          "equals": false
        },
        {
          "output": "yStageOk",
          "from_s": 0,
          "to_s": 9000,
          "equals": true
        }
      ]
    },
    {
      "name": "stage1_split_7_is_healthy",
      "description": "Baseline selection, low side: a 7 \u00b0C split at stage 1 is 12.5% below the 8 \u00b0C baseline and clears. Against the stage-2 baseline the same split would read 42% degraded, so this pins that the Switch took the stage-1 leg",
      "inputs": {
        "rat": 24.0,
        "sat": 17.0,
        "comp_stage": 1
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 9000,
          "equals": false
        }
      ]
    },
    {
      "name": "stage2_split_7_is_fouled",
      "description": "Baseline selection, high side: the identical 7 \u00b0C split at stage 2 is 42% below the 12 \u00b0C baseline and alarms \u2014 two compressors' worth of capacity producing one stage's worth of cooling",
      "inputs": {
        "rat": 24.0,
        "sat": 17.0,
        "comp_stage": 2
      },
      "expect": [
        {
          "output": "yStageOk",
          "from_s": 0,
          "to_s": 9000,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3300,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 3900,
          "to_s": 9000,
          "equals": true
        }
      ]
    },
    {
      "name": "stage0_not_evaluable",
      "description": "Compressor off: rat and sat are equal because no coil is running, which reads as 100% degradation against the stage-1 baseline. yStageOk is false, so yFault stays down and the host reports NO_EVAL rather than healthy",
      "inputs": {
        "rat": 24.0,
        "sat": 24.0,
        "comp_stage": 0
      },
      "expect": [
        {
          "output": "yStageOk",
          "from_s": 0,
          "to_s": 9000,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 9000,
          "equals": false
        }
      ]
    },
    {
      "name": "stage3_not_evaluable",
      "description": "A third stage on a unit this rule has no baseline for: the split (4 \u00b0C) looks badly degraded against the stage-1 constant, but comp_stage > 2 is outside the rule's calibration, so yStageOk is false and no verdict is issued",
      "inputs": {
        "rat": 24.0,
        "sat": 20.0,
        "comp_stage": 3
      },
      "expect": [
        {
          "output": "yStageOk",
          "from_s": 0,
          "to_s": 9000,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 9000,
          "equals": false
        }
      ]
    },
    {
      "name": "edge_degradation_equals_threshold",
      "description": "Threshold edge: a 6 \u00b0C split at stage 1 is exactly 25% below baseline; the comparison is strict, so no fault",
      "inputs": {
        "rat": 24.0,
        "sat": 18.0,
        "comp_stage": 1
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 9000,
          "equals": false
        }
      ]
    },
    {
      "name": "edge_degradation_just_over_threshold",
      "description": "Threshold edge: a 5.9 \u00b0C split at stage 1 is 26.25% below baseline and clears the strict comparison, alarming after alarm_delay",
      "inputs": {
        "rat": 24.0,
        "sat": 18.1,
        "comp_stage": 1
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3300,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 3900,
          "to_s": 9000,
          "equals": true
        }
      ]
    },
    {
      "name": "transient_low_split_never_alarms",
      "description": "The split dips to 5.5 \u00b0C for 1800 s \u2014 a brief capacity loss such as a head-pressure control step or a momentary airflow disturbance \u2014 and recovers before alarm_delay elapses; the timer resets",
      "inputs": {
        "rat": 24.0,
        "sat": [
          {
            "t": 0,
            "value": 16.0
          },
          {
            "t": 900,
            "value": 18.5
          },
          {
            "t": 2700,
            "value": 16.0
          }
        ],
        "comp_stage": 1
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 9000,
          "equals": false
        }
      ]
    },
    {
      "name": "stage_change_restarts_persistence",
      "description": "A 7 \u00b0C split that is healthy on stage 1 becomes a fault the moment the unit stages up at t = 1800 s; the full 60 min runs from the stage change, not from the start of the low split",
      "inputs": {
        "rat": 24.0,
        "sat": 17.0,
        "comp_stage": [
          {
            "t": 0,
            "value": 1
          },
          {
            "t": 1800,
            "value": 2
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5100,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 5700,
          "to_s": 9000,
          "equals": true
        }
      ]
    },
    {
      "name": "fault_clears_after_coil_cleaning",
      "description": "Stage-1 fouling alarms at 3600 s; the split returns to 8 \u00b0C at t = 5400 s (filter changed, coil washed) and the alarm drops on that tick",
      "inputs": {
        "rat": 24.0,
        "sat": [
          {
            "t": 0,
            "value": 18.5
          },
          {
            "t": 5400,
            "value": 16.0
          }
        ],
        "comp_stage": 1
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3300,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 3900,
          "to_s": 5100,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 5700,
          "to_s": 9000,
          "equals": false
        }
      ]
    },
    {
      "name": "compressor_stop_forces_no_eval",
      "description": "A matured stage-1 fault is not reported as cleared when the compressor stops: yStageOk goes false at t = 5400 s and yFault drops with it, which is NO_EVAL, not a repair",
      "inputs": {
        "rat": 24.0,
        "sat": 18.5,
        "comp_stage": [
          {
            "t": 0,
            "value": 1
          },
          {
            "t": 5400,
            "value": 0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3300,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 3900,
          "to_s": 5100,
          "equals": true
        },
        {
          "output": "yStageOk",
          "from_s": 0,
          "to_s": 5100,
          "equals": true
        },
        {
          "output": "yStageOk",
          "from_s": 5400,
          "to_s": 9000,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 5700,
          "to_s": 9000,
          "equals": false
        }
      ]
    }
  ]
}

RTU-0003 — Discharge and mixed air temperature inconsistency (AFDD0)

Statusverified — engine e2ff2f8, cxf:fnv1a128:bbf281b458fc8b62a9a6634ae288429e, 2026-08-17
Severity3
Methodrule
Phase2
CategoryCOMFORT_ENERGY
ConfidenceLOW
EstimationQUALITATIVE_ONLY
G36
Clusters
SuppressesRTU-0002, RTU-0004
Suppressed by
RelatedRTU-0002, RTU-0004, AHU-0028, RTU-0010, RTU-0011
Playbookssensor-drift
SourceHVAC FDD Reference v1.0 §11, RTU-0003; PNNL-23790 (AFDD0); Schein et al. 2006
Operating statesall active modes — idle, mechanical cooling, and heating each get their own test

Preconditions (host-enforced): Supply fan running and both sensors present; with the fan off there is no air to measure and the verdict is NO_EVAL, not healthy. The host must also hold evaluation off for min_stage_runtime (10 min) after any compressor or heater stage change, while the coil or heat exchanger is still coming up to temperature and the two sensors legitimately disagree with the new state.

Points: sat, mat, comp_status, htg_status

Outputs:

  • yFault — True while the supply/mixed air temperature relationship has contradicted the compressor and heater state for at least alarm_delay

Parameters:

NameDefaultUnitCXF pathDescription
consistency_threshold3.0°CidleDev.tMaximum
cooling_direction_threshold1.0°CcoolDir.tHow far supply air may rise above mixed air with a compressor running before the cooling direction counts as contradicted
heating_direction_threshold1.0°ChtgDir.tHow far supply air may fall below mixed air with heat energized before the heating direction counts as contradicted
alarm_delay1800.0spersist.delayTimeContinuous inconsistency required before the alarm asserts (30 min)

Description

Air leaving a rooftop unit has to be explained by what the unit is doing. With no compressor and no heat, supply air should arrive at the mixed-air condition give or take fan heat and sensor error; with a compressor running it must be colder than the mixture; with gas or electric heat energized it must be warmer. Half an hour of the temperatures saying otherwise means either a sensor is lying or the equipment is not doing what its status point claims. This is PNNL’s AFDD0 (PNNL-23790), the prerequisite the rest of the RTU chapter is built on — the diagnostics it gates read the same two sensors it is checking. Present on roughly 15% of units.

Detection Logic

dev_sm = sat − mat
idle   = NOT comp_status AND NOT htg_status

yFault = ( idle        AND |dev_sm|      > consistency_threshold       )
      OR ( comp_status AND  dev_sm       > cooling_direction_threshold )
      OR ( htg_status  AND  (mat − sat)  > heating_direction_threshold ),
         sustained for alarm_delay

Block graph (rule.cxf.jsonld):

RTU-0003 block graph

The state gating is what makes the three branches safe to OR: a 12 °C spread between supply and mixed air is a gross violation with nothing running and the expected result with the compressor on, and idle holds the magnitude test off in every active mode so the same numbers are never read by two branches with opposite expectations. All three comparisons are strict > against positive thresholds, so a deviation sitting exactly on one reads healthy — 3.0 °C while idle is not a fault, 3.1 °C is. persist requires 30 continuous minutes and resets on any tick where no branch is violated, which rides out the pull-down after a stage start. A violation that hands off from one branch to another within a tick does not reset the timer — the right reading of the physics, and also why the host’s min_stage_runtime precondition earns its keep, since the minutes after a stage change are when a lagging sensor is most likely to carry a stale disagreement across the handoff. delayOnInit = true holds the window across a controller restart.

Possible Diagnoses

  1. sat or mat sensor out of calibration
  2. Supply air temperature sensor in the wrong location — reading a stratified slice of the discharge, or radiant heat from the heat exchanger
  3. Compressor running with no refrigerant flow: lost charge, failed compressor, or a stuck reversing valve on a heat pump
  4. Heater energized with no heat output: failed ignition, tripped high-limit, closed gas valve, or an open electric heat element
  5. Sensor wiring: swapped sat and mat leads, a shorted or open sensor, or a status point wired to the wrong stage

Energy Impact

COMFORT_ENERGY, LOW confidence, QUALITATIVE_ONLY. Nothing here is directly computable: a mis-read temperature burns no fuel by itself, and a compressor running without refrigerant burns plenty but this rule has no capacity data to price it. PNNL EEM-01 (sensor recalibration) covers the sensor half at 0–5% of site energy across a whole sensor population. The value of the rule is the accuracy it restores to RTU-0002 and RTU-0004, which read the same two sensors, plus the mechanical failures it catches on the way.

Emissions Impact

QUALITATIVE_EMISSIONS, LOW confidence. Scope is recorded as 1|2 because it depends on which half of the unit is wrong: a heating-branch violation on a gas-fired RTU points at scope 1 combustion, a cooling-branch violation at scope 2 electricity, and an idle violation at whichever subsystem the bad reading later misdirects. Avoided-emissions basis: N/A.

Deviations

  • Two Subtract blocks rather than one difference and a negation. The heating test is mat − sat against a positive threshold, i.e. −dev_sm, and CDL has no unary negate. This library keeps negative parameter values out of rule documents (precedent: AHU-0021’s designConst); algebraically identical.
  • Three independent thresholds, two of which default to the same number. The cooling and heating direction thresholds are both 1.0 °C but stay separate because the reference lists them separately and they are not the same physical quantity — evaporator approach on one side, heat exchanger effectiveness on the other.
  • min_stage_runtime (10 min) is a host precondition, not a block. It gates on a stage transition the block graph cannot see, and this library keeps state gating host-side (the treatment the G36-derived AHU rules give ModeDelay). The 30-minute persistence does not substitute: a unit that stages up and stays up starts the timer at the moment of the change.
  • Fan-running is a precondition too. With the fan off both sensors read stagnant air in different parts of a cabinet and the comparison means nothing.
  • Suppression is declared, not encoded. The reference’s “when active, suppresses RTU-0002 and RTU-0004” lives in suppresses and is enforced by the host; the engine is status-blind and each rule is an independent composite. Same treatment as AHU-0028, whose CLU-09 role this rule plays for the RTU chapter without being a cluster member — the reference defines no RTU sensor-integrity cluster, so clusters is empty rather than reusing an AHU cluster ID.
  • Simultaneous heating and cooling is reported, not refereed. With both statuses true the idle branch is held off and both directional branches evaluate, so whichever direction the temperatures contradict raises the alarm. A unit heating and cooling at once is already broken; deciding which half is at fault belongs to whoever opens the panel.
  • persist.delayOnInit = true (CDL default is false), the library’s standing choice: an inconsistency already present when the controller starts waits out the full 30 minutes rather than alarming on the first tick.

Notes

The suppression contract is the point of this card. While yFault is true, RTU-0002 and RTU-0004 are computing on numbers known to be wrong and the host must report them as NO_EVAL rather than healthy — a silenced rule is not a passing rule. RTU-0002 consumes sat in its temperature split against an 8 °C baseline, where a 3 °C sensor bias moves the ratio by 37 percentage points: the entire distance from a clean coil to an alarm.

Check the sensors first, because it is the cheap end — two thermometers and ten minutes settle whether sat and mat agree with reality, and the sensor-drift playbook covers the fix ($30–$80 per sensor). Only once both read true does the alarm point at diagnoses 3 and 4, and then it means a compressor or a heater is running and producing nothing.

Test Vectors

17 scenarios, clock step 300 s over 5400 s.

ScenarioDescription
idle_consistentReference vector: nothing running, sat 22 °C equals mat 22 °C — air passes through the unit unchanged
idle_inconsistentReference vector: nothing running but sat 22 °C sits 6 °C below mat 28 °C — no heat transfer surface is active, so one of the two sensors is wrong
cooling_consistentReference vector: compressor on, sat 13 °C is 12 °C below mat 25 °C — the evaporator is doing exactly what it should
cooling_inconsistentReference vector: compressor on but sat 27 °C is 2 °C ABOVE mat 25 °C — the coil is adding heat while nominally cooling (lost charge, failed compressor, or a swapped sensor)
heating_consistentHeating branch, healthy: burner on, sat 35 °C is 15 °C above mat 20 °C
heating_inconsistentHeating branch, faulted: burner energized but sat 18 °C is 4 °C below mat 22 °C — heat is called for and none arrives (failed ignition, tripped limit, or no gas)
edge_idle_deviation_equals_thresholdThreshold edge:
edge_idle_deviation_just_over_thresholdThreshold edge:
edge_cooling_deviation_equals_thresholdThreshold edge: sat − mat = 1.0 °C exactly with the compressor on; strict comparison, no fault
edge_cooling_deviation_just_over_thresholdThreshold edge: sat − mat = 1.1 °C with the compressor on alarms after alarm_delay
edge_heating_deviation_equals_thresholdThreshold edge: mat − sat = 1.0 °C exactly with the burner on; strict comparison, no fault
edge_heating_deviation_just_over_thresholdThreshold edge: mat − sat = 1.1 °C with the burner on alarms after alarm_delay
idle_threshold_not_applied_while_coolingMode conditionality: the same 12 °C spread that would be a gross idle violation is normal cooling, and the idle branch is held off by comp_status
simultaneous_heating_and_coolingCompressor and burner both on with sat 30 °C above mat 25 °C: the heating branch is satisfied, the cooling branch is contradicted, and the rule reports the contradiction rather than refereeing the mode
transient_inconsistency_never_alarmsIdle inconsistency lasts 1200 s — shorter than alarm_delay — then clears; the persistence timer resets and no alarm is raised
stage_start_resets_persistenceAn idle inconsistency accumulates for 900 s, then the compressor starts and the temperatures become consistent for cooling; the idle branch drops out and the timer restarts, so the earlier violation never matures into an alarm
fault_clears_on_recoverySustained idle inconsistency alarms after 1800 s; the alarm drops on the tick the sensors agree again (mat recalibrated at t = 3600 s)
vectors.json
{
  "schema": "cxf-library/vectors/v1",
  "clock": {
    "step_s": 300,
    "horizon_s": 5400
  },
  "scenarios": [
    {
      "name": "idle_consistent",
      "description": "Reference vector: nothing running, sat 22 \u00b0C equals mat 22 \u00b0C \u2014 air passes through the unit unchanged",
      "inputs": {
        "sat": 22.0,
        "mat": 22.0,
        "comp_status": false,
        "htg_status": false
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        }
      ]
    },
    {
      "name": "idle_inconsistent",
      "description": "Reference vector: nothing running but sat 22 \u00b0C sits 6 \u00b0C below mat 28 \u00b0C \u2014 no heat transfer surface is active, so one of the two sensors is wrong",
      "inputs": {
        "sat": 22.0,
        "mat": 28.0,
        "comp_status": false,
        "htg_status": false
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1500,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2100,
          "to_s": 5400,
          "equals": true
        }
      ]
    },
    {
      "name": "cooling_consistent",
      "description": "Reference vector: compressor on, sat 13 \u00b0C is 12 \u00b0C below mat 25 \u00b0C \u2014 the evaporator is doing exactly what it should",
      "inputs": {
        "sat": 13.0,
        "mat": 25.0,
        "comp_status": true,
        "htg_status": false
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        }
      ]
    },
    {
      "name": "cooling_inconsistent",
      "description": "Reference vector: compressor on but sat 27 \u00b0C is 2 \u00b0C ABOVE mat 25 \u00b0C \u2014 the coil is adding heat while nominally cooling (lost charge, failed compressor, or a swapped sensor)",
      "inputs": {
        "sat": 27.0,
        "mat": 25.0,
        "comp_status": true,
        "htg_status": false
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1500,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2100,
          "to_s": 5400,
          "equals": true
        }
      ]
    },
    {
      "name": "heating_consistent",
      "description": "Heating branch, healthy: burner on, sat 35 \u00b0C is 15 \u00b0C above mat 20 \u00b0C",
      "inputs": {
        "sat": 35.0,
        "mat": 20.0,
        "comp_status": false,
        "htg_status": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        }
      ]
    },
    {
      "name": "heating_inconsistent",
      "description": "Heating branch, faulted: burner energized but sat 18 \u00b0C is 4 \u00b0C below mat 22 \u00b0C \u2014 heat is called for and none arrives (failed ignition, tripped limit, or no gas)",
      "inputs": {
        "sat": 18.0,
        "mat": 22.0,
        "comp_status": false,
        "htg_status": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1500,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2100,
          "to_s": 5400,
          "equals": true
        }
      ]
    },
    {
      "name": "edge_idle_deviation_equals_threshold",
      "description": "Threshold edge: |sat \u2212 mat| = 3.0 \u00b0C exactly while idle; the comparison is strict, so no fault",
      "inputs": {
        "sat": 22.0,
        "mat": 25.0,
        "comp_status": false,
        "htg_status": false
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        }
      ]
    },
    {
      "name": "edge_idle_deviation_just_over_threshold",
      "description": "Threshold edge: |sat \u2212 mat| = 3.1 \u00b0C while idle clears the strict comparison and alarms after alarm_delay",
      "inputs": {
        "sat": 22.0,
        "mat": 25.1,
        "comp_status": false,
        "htg_status": false
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1500,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2100,
          "to_s": 5400,
          "equals": true
        }
      ]
    },
    {
      "name": "edge_cooling_deviation_equals_threshold",
      "description": "Threshold edge: sat \u2212 mat = 1.0 \u00b0C exactly with the compressor on; strict comparison, no fault",
      "inputs": {
        "sat": 26.0,
        "mat": 25.0,
        "comp_status": true,
        "htg_status": false
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        }
      ]
    },
    {
      "name": "edge_cooling_deviation_just_over_threshold",
      "description": "Threshold edge: sat \u2212 mat = 1.1 \u00b0C with the compressor on alarms after alarm_delay",
      "inputs": {
        "sat": 26.1,
        "mat": 25.0,
        "comp_status": true,
        "htg_status": false
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1500,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2100,
          "to_s": 5400,
          "equals": true
        }
      ]
    },
    {
      "name": "edge_heating_deviation_equals_threshold",
      "description": "Threshold edge: mat \u2212 sat = 1.0 \u00b0C exactly with the burner on; strict comparison, no fault",
      "inputs": {
        "sat": 21.0,
        "mat": 22.0,
        "comp_status": false,
        "htg_status": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        }
      ]
    },
    {
      "name": "edge_heating_deviation_just_over_threshold",
      "description": "Threshold edge: mat \u2212 sat = 1.1 \u00b0C with the burner on alarms after alarm_delay",
      "inputs": {
        "sat": 20.9,
        "mat": 22.0,
        "comp_status": false,
        "htg_status": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1500,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2100,
          "to_s": 5400,
          "equals": true
        }
      ]
    },
    {
      "name": "idle_threshold_not_applied_while_cooling",
      "description": "Mode conditionality: the same 12 \u00b0C spread that would be a gross idle violation is normal cooling, and the idle branch is held off by comp_status",
      "inputs": {
        "sat": 13.0,
        "mat": 25.0,
        "comp_status": true,
        "htg_status": false
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        }
      ]
    },
    {
      "name": "simultaneous_heating_and_cooling",
      "description": "Compressor and burner both on with sat 30 \u00b0C above mat 25 \u00b0C: the heating branch is satisfied, the cooling branch is contradicted, and the rule reports the contradiction rather than refereeing the mode",
      "inputs": {
        "sat": 30.0,
        "mat": 25.0,
        "comp_status": true,
        "htg_status": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1500,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2100,
          "to_s": 5400,
          "equals": true
        }
      ]
    },
    {
      "name": "transient_inconsistency_never_alarms",
      "description": "Idle inconsistency lasts 1200 s \u2014 shorter than alarm_delay \u2014 then clears; the persistence timer resets and no alarm is raised",
      "inputs": {
        "sat": 22.0,
        "mat": [
          {
            "t": 0,
            "value": 22.0
          },
          {
            "t": 600,
            "value": 28.0
          },
          {
            "t": 1800,
            "value": 22.0
          }
        ],
        "comp_status": false,
        "htg_status": false
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        }
      ]
    },
    {
      "name": "stage_start_resets_persistence",
      "description": "An idle inconsistency accumulates for 900 s, then the compressor starts and the temperatures become consistent for cooling; the idle branch drops out and the timer restarts, so the earlier violation never matures into an alarm",
      "inputs": {
        "sat": [
          {
            "t": 0,
            "value": 22.0
          },
          {
            "t": 900,
            "value": 13.0
          }
        ],
        "mat": 28.0,
        "comp_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 900,
            "value": true
          }
        ],
        "htg_status": false
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        }
      ]
    },
    {
      "name": "fault_clears_on_recovery",
      "description": "Sustained idle inconsistency alarms after 1800 s; the alarm drops on the tick the sensors agree again (mat recalibrated at t = 3600 s)",
      "inputs": {
        "sat": 22.0,
        "mat": [
          {
            "t": 0,
            "value": 28.0
          },
          {
            "t": 3600,
            "value": 22.0
          }
        ],
        "comp_status": false,
        "htg_status": false
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1500,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2100,
          "to_s": 3300,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 3900,
          "to_s": 5400,
          "equals": false
        }
      ]
    }
  ]
}

RTU-0004 — Economizer not modulating properly

Statusverified — engine e2ff2f8, cxf:fnv1a128:ed12779429be89a3c61d6668681918c6, 2026-08-17
Severity3
Methodrule
Phase2
CategoryCRITICAL_WASTE
ConfidenceHIGH
EstimationDIRECT_MEASUREMENT
G36
ClustersCLU-03
Suppresses
Suppressed byRTU-0003
RelatedAHU-0017, RTU-0003, RTU-0005, RTU-0010, RTU-0011
Playbookseconomizer-failure
SourceHVAC FDD Reference v1.0 §11, RTU-0004; PNNL-23790 AFDD1/AFDD3; California Title 24 economizer FDD; PNNL EEM-06, EEM-23; Cowan 2004 (54% of RTUs)
Operating statescooling call active

Preconditions (host-enforced): comp_status gates both branches inside the graph, so the rule is already silent with no mechanical cooling running; a false yFault under a stopped compressor is no verdict, not a healthy economizer. Everything above that stays host-side: mode transitions, morning warm-up, and any period when the damper is under manual override or a commissioning test. The OAT sensor must be trustworthy — a sensor reading high produces branch 1’s signature with the economizer control working exactly as designed (diagnosis 4), which is why RTU-0003 (PNNL’s AFDD0 sensor-consistency check) suppresses this rule while it is active. Damper position is taken from the command, not a feedback signal: a unit whose actuator ignores the command reports the command’s story here and is caught by RTU-0003 or by the playbook’s step 3. When any gate is unmet the verdict is NO_EVAL, not healthy.

Points: oat, oa_dmpr_cmd, comp_status

Outputs:

  • yFault — True while either economizer fault condition — damper at minimum while free cooling is available, or damper open while the economizer should be locked out — has held continuously with the compressor running for at least alarm_delay

Parameters:

NameDefaultUnitCXF pathDescription
econ_lockout_temp21.0°CoatLow.tOutdoor temperature below which the economizer is expected to be modulating open; branch 1 arms below it
econ_relock_temp22.0°CoatHigh.tOutdoor temperature above which the economizer is expected to be at minimum position; branch 2 arms above it. Equals econ_lockout_temp + the reference’s lockout_deadband (1 °C) — a host moving the lockout must move this parameter with it
min_oa_margin25.0%dmprLow.t, dmprHigh.tDamper command that separates “parked at ventilation minimum” from “modulating for free cooling”; binds both branches
alarm_delay1800.0spersist.delayTimeContinuous fault persistence required before the alarm asserts (30 min)

Description

A packaged unit’s economizer has two jobs and this rule watches both of them fail. Branch 1 catches the damper parked at ventilation minimum on a mild day with mechanical cooling running — free cooling standing right there, unused; branch 2 catches 30 °C outdoor air pouring through a damper that should have closed, with the compressor absorbing the difference. The two failures come from different places: stuck-at-minimum is usually mechanical (a popped rod end, a dead actuator, economizing switched off), stuck-open is more often a spring-return actuator that lost its return or a high limit that never locks out. Cowan’s 2004 survey found 54% of RTU economizers carrying at least one fault, and both failures are invisible from a monthly bill.

Detection Logic

fault_1 = oat < econ_lockout_temp AND comp_status AND oa_dmpr_cmd < min_oa_margin
fault_2 = oat > econ_relock_temp  AND comp_status AND oa_dmpr_cmd > min_oa_margin

yFault  = (fault_1 OR fault_2), sustained continuously for alarm_delay

Block graph (rule.cxf.jsonld):

RTU-0004 block graph

comp_status is in the graph rather than in the frontmatter because it is not a gate on data quality — it is part of the fault definition. Neither branch describes waste without a compressor running: a damper at minimum on a cool morning with the unit coasting is a unit that does not need cooling, not a broken economizer. Between econ_lockout_temp and econ_relock_temp the rule is deliberately silent — neither temperature test is true in that band, so a unit changing over at 21.4 °C produces no verdict while its mixed-air loop settles. That silence is what the reference’s lockout_deadband buys. All four comparisons are strict, so a damper resting exactly on min_oa_margin trips neither branch and outdoor air resting exactly on either setpoint arms neither. persist requires 30 minutes of continuous violation, which rides out a damper stroke, a changeover, and the minimum-position dwell an economizer holds while its own loop settles; delayOnInit = true holds that window across a restart.

Possible Diagnoses

  1. OA damper stuck at minimum — disconnected linkage, failed actuator, bound blades (branch 1, and the most likely one by a wide margin)
  2. OA damper stuck open — spring return failed, actuator jammed off its seat (branch 2)
  3. Economizer controller disabled or misconfigured in the unit controller
  4. OAT sensor reading erroneously high, which locks out changeover while the control sequence works correctly (branch 1’s most common false positive, and the reason RTU-0003 suppresses this rule)
  5. Economizer high-limit setpoint set too low for the climate zone, so the unit locks out during weather it should be economizing in

Energy Impact

CRITICAL_WASTE, HIGH confidence, DIRECT_MEASUREMENT. The waste is compressor work the economizer position made unnecessary, and compressor status is one of this rule’s own inputs: waste_kw = comp_status × rtu_cooling_kw — under branch 1 the mechanical cooling free cooling would have displaced, under branch 2 the load the open damper added. The reference’s 5–20% of cooling energy is consistent with PNNL EEM-06 (OA damper and controls) and EEM-23 (RTU advanced controls, 3–11% of unit electricity). HIGH confidence: the condition is read directly from a temperature and two commands with no model in between. Strongly cooling-dominant, and worth the most in shoulder seasons.

Emissions Impact

Scope 2, DIRECT_EMISSIONS, HIGH confidence; typically 800–5,000 kg CO₂e/yr for a single packaged unit. The wasted energy is compressor electricity, so the whole impact lands in purchased power. Free-cooling hours cluster in mild daytime and overnight weather, so use the marginal operating emissions rate (MOER), not an average grid factor, or the estimate misses by the width of the grid’s daily swing.

Deviations

  • min_oa_margin’s default is adopted, not transcribed. The reference states both branches in terms of it but omits it from the tunables table. This card adopts 25.0%, chapter 9’s value for a damper parked at minimum (AHU-0017’s econ_damper_threshold), so the phrase means the same thing across both economizer rules; the reference’s own vectors (10% versus 75–80%) are decidable at any margin between those. AHU-0030 precedent.
  • The reference’s lockout_deadband is folded into a second absolute threshold. Branch 2 is written oat > econ_lockout_temp + lockout_deadband, but a card parameter binds a single CXF path and the block set cannot add two parameters in-graph, so branch 2 compares against econ_relock_temp = 22.0 °C = 21.0 + 1.0. Moving the lockout means retuning both; setting econ_relock_temp below econ_lockout_temp overlaps the temperature tests into a rule that fires at every damper position but the margin itself. Combined-parameter precedent AHU-0005.
  • min_oa_margin is one card parameter bound to two CXF paths (dmprLow.t, dmprHigh.t), matching the reference’s single margin. Hosts must set both together: split them and a band of damper positions is either tested by neither branch or read as parked at minimum and as open at once. AHU-0025 precedent.
  • All four comparisons are strict (<, >, <, >). The reference does not specify boundary behavior and CDL’s Reals family has no GreaterEqual, so the inclusive reading is not expressible. The deviation is measure-zero and it errs toward silence.
  • The changeover band is a blind spot, by construction. Between 21 and 22 °C neither branch can fire whatever the damper is doing, so an economizer that fails while the weather sits in that 1 °C band reports nothing until the weather moves. Reporting inside the band would alarm on every normal changeover, which is what the deadband exists to prevent.
  • comp_status is in-graph; everything else about mode is not. The reference lists “cooling call active” as the operating state and comp_status as a term of both equations. The term is implemented; broader gating (unit mode, occupancy, manual override, RTU-0003’s sensor check) stays host-side, as in AHU-0017.
  • Damper command, not damper feedback. The RTU dictionary carries no damper position feedback point, so an actuator reporting 80% while the blades sit closed is invisible here. That failure belongs to the mixed-air checks (RTU-0003) and to step 3 of the playbook — which is why the two rules are linked by suppression rather than by a shared input.
  • Severity 3 (warning), phase 2, method rule, and the tunable defaults are the reference’s chapter 11 card; its §5.8.3 index corroborates and carries no severity column. g36: null — PNNL/Title 24-derived, not a G36 §5.16.14 clause.
  • persist.delayOnInit = true (Modelica/CDL default is false), the library’s standing choice: a violation already present at load waits out the full 30 minutes instead of alarming on the first tick after a restart.

Notes

This is AHU-0017’s fault seen through packaged-unit points — an RTU has a compressor contactor and a fixed dry-bulb high limit where the AHU has a modulating valve and a differential changeover — so both sit in CLU-03 with AHU-0017 as trigger and this card as member.

Retune econ_lockout_temp for the climate zone before trusting the default: 21 °C is near ASHRAE 90.1’s 70 °F fixed high limit for zones 4A–5A, while zones 1A–3A allow 75 °F (23.9 °C) and zones 5B–8 use 65 °F (18.3 °C). A limit set for the wrong zone produces branch 1’s signature with nothing mechanically wrong (diagnosis 5, a remote fix). Verify order within CLU-03 is RTU-0003 first, then this rule, then RTU-0005, whose excess outdoor air is often branch 2 seen from the airflow side; the economizer-failure playbook carries the climate-zone table and puts the odds of a sibling unit on the same roof having the same problem at 30–50%.

Test Vectors

10 scenarios, clock step 300 s over 7200 s.

ScenarioDescription
economizing_when_favorableReference vector: 15 °C outdoor air, damper at 80%, compressor running. Outdoor air is well below the lockout, so branch 1 is armed, but the damper is nowhere near minimum — the economizer is doing its job and the compressor is trimming what free cooling cannot cover. Clear on both branches
damper_at_minimum_when_favorableReference vector: 15 °C outdoor air, damper at 10%, compressor running — the classic stuck-at-minimum economizer. Branch 1 (oat < econ_lockout_temp AND comp_status AND oa_dmpr_cmd < min_oa_margin) is true from t=0, and yFault asserts one alarm_delay later at t=1800
locked_out_damper_at_minimumReference vector: 30 °C outdoor air, damper at 10%, compressor running. Outdoor air is above the relock temperature, so bringing it in would add load; the damper sitting at its ventilation minimum is exactly right. Clear on both branches
locked_out_damper_openReference vector: 30 °C outdoor air, damper at 75%, compressor running — the economizer failed open, or never locked out, and the compressor is cooling 75% outdoor air on a hot day. Branch 2 (oat > econ_relock_temp AND comp_status AND oa_dmpr_cmd > min_oa_margin) is true from t=0, and yFault asserts at t=1800
changeover_band_is_silentDeadband pin. At 21.5 °C the outdoor air is above econ_lockout_temp and below econ_relock_temp, so neither branch’s temperature test is true and the rule has no opinion about the damper. The damper is parked at minimum for the first hour and wide open for the second — a damper position that would trip branch 1 below 21 °C and branch 2 above 22 °C, and trips neither here
damper_at_margin_is_silentDamper boundary pin, both sides. The damper sits exactly on min_oa_margin (25%), which satisfies neither < 25 nor > 25. Outdoor air runs at 15 °C for the first hour (branch 1 armed) and 30 °C for the second (branch 2 armed), and both stay clear
lockout_setpoints_are_silentTemperature boundary pin, both sides. First hour: oat exactly 21.0 with the damper at minimum — oat < 21 is false, so branch 1 does not fire on a unit sitting precisely on its lockout. Second hour: oat exactly 22.0 with the damper at 80% — oat > 22 is false, so branch 2 does not fire on a unit sitting precisely on its relock point
compressor_off_no_verdictCooling-call gate. Favorable outdoor air and a damper at minimum, but no compressor running: there is no mechanical cooling being wasted, so there is nothing to report. comp_status gates both branches in-graph, and a false yFault here means the rule has no verdict rather than that the economizer is healthy
transient_shorter_than_alarm_delayTransient. The damper drops to minimum at t=600 and returns to 80% at t=1800 — 20 minutes at minimum with the compressor running, the shape of a mixed-air loop resetting or an operator overriding the damper briefly. Branch 1 holds for 1200 s against a 1800 s alarm_delay, so nothing asserts
damper_recovers_after_alarmRecovery. The stuck-at-minimum case asserts at t=1800; at t=3600 the linkage is reconnected and the damper strokes to 60%. Branch 1 goes false on that tick and yFault follows immediately — TrueDelay has no off-delay, so a fixed economizer clears the alarm at the next evaluation rather than one alarm_delay later
vectors.json
{
  "schema": "cxf-library/vectors/v1",
  "clock": {
    "step_s": 300,
    "horizon_s": 7200
  },
  "scenarios": [
    {
      "name": "economizing_when_favorable",
      "description": "Reference vector: 15 \u00b0C outdoor air, damper at 80%, compressor running. Outdoor air is well below the lockout, so branch 1 is armed, but the damper is nowhere near minimum \u2014 the economizer is doing its job and the compressor is trimming what free cooling cannot cover. Clear on both branches",
      "inputs": {
        "oat": 15.0,
        "oa_dmpr_cmd": 80.0,
        "comp_status": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        }
      ]
    },
    {
      "name": "damper_at_minimum_when_favorable",
      "description": "Reference vector: 15 \u00b0C outdoor air, damper at 10%, compressor running \u2014 the classic stuck-at-minimum economizer. Branch 1 (oat < econ_lockout_temp AND comp_status AND oa_dmpr_cmd < min_oa_margin) is true from t=0, and yFault asserts one alarm_delay later at t=1800",
      "inputs": {
        "oat": 15.0,
        "oa_dmpr_cmd": 10.0,
        "comp_status": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1500,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 1980,
          "to_s": 7200,
          "equals": true
        }
      ]
    },
    {
      "name": "locked_out_damper_at_minimum",
      "description": "Reference vector: 30 \u00b0C outdoor air, damper at 10%, compressor running. Outdoor air is above the relock temperature, so bringing it in would add load; the damper sitting at its ventilation minimum is exactly right. Clear on both branches",
      "inputs": {
        "oat": 30.0,
        "oa_dmpr_cmd": 10.0,
        "comp_status": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        }
      ]
    },
    {
      "name": "locked_out_damper_open",
      "description": "Reference vector: 30 \u00b0C outdoor air, damper at 75%, compressor running \u2014 the economizer failed open, or never locked out, and the compressor is cooling 75% outdoor air on a hot day. Branch 2 (oat > econ_relock_temp AND comp_status AND oa_dmpr_cmd > min_oa_margin) is true from t=0, and yFault asserts at t=1800",
      "inputs": {
        "oat": 30.0,
        "oa_dmpr_cmd": 75.0,
        "comp_status": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1500,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 1980,
          "to_s": 7200,
          "equals": true
        }
      ]
    },
    {
      "name": "changeover_band_is_silent",
      "description": "Deadband pin. At 21.5 \u00b0C the outdoor air is above econ_lockout_temp and below econ_relock_temp, so neither branch's temperature test is true and the rule has no opinion about the damper. The damper is parked at minimum for the first hour and wide open for the second \u2014 a damper position that would trip branch 1 below 21 \u00b0C and branch 2 above 22 \u00b0C, and trips neither here",
      "inputs": {
        "oat": 21.5,
        "oa_dmpr_cmd": [
          {
            "t": 0,
            "value": 10.0
          },
          {
            "t": 3600,
            "value": 80.0
          }
        ],
        "comp_status": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        }
      ]
    },
    {
      "name": "damper_at_margin_is_silent",
      "description": "Damper boundary pin, both sides. The damper sits exactly on min_oa_margin (25%), which satisfies neither `< 25` nor `> 25`. Outdoor air runs at 15 \u00b0C for the first hour (branch 1 armed) and 30 \u00b0C for the second (branch 2 armed), and both stay clear",
      "inputs": {
        "oat": [
          {
            "t": 0,
            "value": 15.0
          },
          {
            "t": 3600,
            "value": 30.0
          }
        ],
        "oa_dmpr_cmd": 25.0,
        "comp_status": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        }
      ]
    },
    {
      "name": "lockout_setpoints_are_silent",
      "description": "Temperature boundary pin, both sides. First hour: oat exactly 21.0 with the damper at minimum \u2014 `oat < 21` is false, so branch 1 does not fire on a unit sitting precisely on its lockout. Second hour: oat exactly 22.0 with the damper at 80% \u2014 `oat > 22` is false, so branch 2 does not fire on a unit sitting precisely on its relock point",
      "inputs": {
        "oat": [
          {
            "t": 0,
            "value": 21.0
          },
          {
            "t": 3600,
            "value": 22.0
          }
        ],
        "oa_dmpr_cmd": [
          {
            "t": 0,
            "value": 10.0
          },
          {
            "t": 3600,
            "value": 80.0
          }
        ],
        "comp_status": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        }
      ]
    },
    {
      "name": "compressor_off_no_verdict",
      "description": "Cooling-call gate. Favorable outdoor air and a damper at minimum, but no compressor running: there is no mechanical cooling being wasted, so there is nothing to report. comp_status gates both branches in-graph, and a false yFault here means the rule has no verdict rather than that the economizer is healthy",
      "inputs": {
        "oat": 15.0,
        "oa_dmpr_cmd": 10.0,
        "comp_status": false
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        }
      ]
    },
    {
      "name": "transient_shorter_than_alarm_delay",
      "description": "Transient. The damper drops to minimum at t=600 and returns to 80% at t=1800 \u2014 20 minutes at minimum with the compressor running, the shape of a mixed-air loop resetting or an operator overriding the damper briefly. Branch 1 holds for 1200 s against a 1800 s alarm_delay, so nothing asserts",
      "inputs": {
        "oat": 15.0,
        "oa_dmpr_cmd": [
          {
            "t": 0,
            "value": 80.0
          },
          {
            "t": 600,
            "value": 10.0
          },
          {
            "t": 1800,
            "value": 80.0
          }
        ],
        "comp_status": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        }
      ]
    },
    {
      "name": "damper_recovers_after_alarm",
      "description": "Recovery. The stuck-at-minimum case asserts at t=1800; at t=3600 the linkage is reconnected and the damper strokes to 60%. Branch 1 goes false on that tick and yFault follows immediately \u2014 TrueDelay has no off-delay, so a fixed economizer clears the alarm at the next evaluation rather than one alarm_delay later",
      "inputs": {
        "oat": 15.0,
        "oa_dmpr_cmd": [
          {
            "t": 0,
            "value": 10.0
          },
          {
            "t": 3600,
            "value": 60.0
          }
        ],
        "comp_status": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1500,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 1980,
          "to_s": 3300,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 3900,
          "to_s": 7200,
          "equals": false
        }
      ]
    }
  ]
}

RTU-0005 — Excess outdoor air intake

Statusverified — engine e2ff2f8, cxf:fnv1a128:403a34cdcdb48f2f6c7f4827a80b4ab9, 2026-08-17
Severity3
Methodrule
Phase2
CategoryEXCESS_CONSUMPTION
ConfidenceHIGH
EstimationDIRECT_MEASUREMENT
G36
Clusters
Suppresses
Suppressed byAHU-0028
RelatedAHU-0021, AHU-0030, RTU-0006, RTU-0004, RTU-0010
Playbookseconomizer-failure
SourceHVAC FDD Reference v1.0 §11, RTU-0005; PNNL-23790 (RTU AFDD4/AFDD5); PNNL EEM-17 (demand control ventilation); PNNL EEM-23 (RTU advanced controls)
Operating statesoccupied operation with the economizer locked out (host-gated); the reference’s economizer_should_be_inactive(oat, mode) term lives in preconditions, not in the graph

Preconditions (host-enforced): Supply fan running. The host must not evaluate while the economizer is legitimately open — drawing more than the design minimum is the point of economizing, and these three temperatures cannot tell that apart from a damper that never closed. MAT must pass its integrity gate (AHU-0028, see suppressed_by): the fraction is a ratio of temperature differences, so a biased mixed-air reading moves it directly. The temperature-difference gate is signalled in-rule by yTempDeltaOk; when it is false the verdict is NO_EVAL, not healthy.

Points: oat, rat, mat

Outputs:

  • yFault — True while the outdoor air fraction has stayed more than oa_excess_margin above design_min_oa_fraction for at least alarm_delay, with the temperature difference large enough to evaluate
  • yTempDeltaOk — Evaluability signal — true when |oat − rat| exceeds min_delta; false means NO_EVAL and the host must ignore yFault

Parameters:

NameDefaultUnitCXF pathDescription
design_min_oa_fraction0.151designConst.kDesign minimum outdoor air fraction the unit should hold when it is not economizing (0–1)
oa_excess_margin0.151marginHigh.tTolerance above the design minimum before the excess counts as a fault
min_delta6.0°CdeltaOk.tMinimum
alarm_delay1800.0spersist.delayTimeContinuous fault persistence required before the alarm asserts (30 min)

Description

The unit is drawing well over its design minimum outdoor air at an hour when it has no business economizing. Every extra cubic metre arrives at outdoor temperature and has to be dragged to supply temperature by the gas heat or the compressors, and none of it buys ventilation the code did not already have. Nothing about it is uncomfortable — the space stays on setpoint, the unit simply runs harder — so the defect survives until someone reads the fuel bill. Packaged units make it common: economizer, minimum-position setting and dampers all live in one weather-exposed cabinet, and Cowan’s 2004 survey found at least one economizer fault on 54% of units. The outdoor air fraction is inferred from the mixing-box energy balance rather than measured, which is what makes the diagnostic cheap — three temperatures, no airflow station — and also what makes it conditional, hence the explicit evaluability output.

Detection Logic

oaf          = (mat − rat) / (oat − rat)
yTempDeltaOk = |oat − rat| > min_delta                  (false ⇒ host reports NO_EVAL)
yFault       = (oaf − design_min_oa_fraction > oa_excess_margin) AND yTempDeltaOk,
               sustained for alarm_delay

Block graph (rule.cxf.jsonld):

RTU-0005 block graph

The fraction core is AHU-0021’s, unchanged, bound to the RTU point dictionary. gate is what makes the unguarded division safe: CDL Divide follows IEEE-754, so oat = rat yields ±∞ or NaN and a near-zero denominator amplifies ordinary sensor noise into a fraction of any magnitude. NaN compares false everywhere, and ±∞ or a noise-inflated finite fraction can raise marginHigh but cannot pass gate, because a denominator small enough to misbehave is by construction one below min_delta. Garbage arithmetic can only make the rule report itself unevaluable. yTempDeltaOk leaves the block as well as feeding the gate, so a host that reads it learns the difference between “not faulted” and “cannot tell”. Both comparisons are strict: a fraction sitting exactly at design_min_oa_fraction + oa_excess_margin is not a fault, and a temperature difference of exactly min_delta is not evaluable. The quotient is signed consistently on both sides of the year — winter makes both differences negative, summer both positive — so no seasonal branch is needed. persist requires 30 continuous minutes, riding out a damper stroke and the mixing transient after a stage change; recovery is immediate, and delayOnInit = true holds the window across a restart.

Possible Diagnoses

  1. OA damper minimum position set too high
  2. OA damper not closing to the commanded minimum
  3. Damper blade seals deteriorated
  4. Economizer lockout not engaging

Energy Impact

EXCESS_CONSUMPTION, HIGH confidence, DIRECT_MEASUREMENT. The waste is computable from live data: excess_oa_kw = (actual_oaf − design_min_oa_fraction) × airflow × cp × |oat − rat|, with the excess fraction already on the wire as oaf − designConst.k. Correcting minimum ventilation is worth 2–10% of the unit’s thermal energy, the upper half of that range in heating-dominant climates where the outdoor-to-return difference is largest for months at a time. The reference maps the fault to PNNL EEM-17 (demand control ventilation) and EEM-23 (RTU advanced controls); this rule screens both, since a unit that cannot hold its design fraction will not benefit from either until the mechanical problem is fixed.

Emissions Impact

Scope 1 + 2, DIRECT_EMISSIONS, HIGH confidence; typically 300–2,500 kg CO₂e/yr for the excess ventilation load. The split follows the season and the unit: winter excess burns scope 1 gas at the furnace section, summer excess draws scope 2 electricity at the compressors, and on an all-electric packaged unit the whole exchange collapses to scope 2. Avoided-emissions basis: marginal operating emissions rate (MOER).

Deviations

  • min_delta default adopted, not transcribed. The reference states the fraction is computed only when |OAT − RAT| > min_delta but omits the parameter from its tunables table. This card adopts 6.0 °C, matching AHU-0021, AHU-0030 and RTU-0006 so every rule running this quotient agrees on when it is meaningful (PNNL-27338 uses 5 °F for the same computation). A site that retunes one should retune all of them.
  • The reference’s economizer_should_be_inactive(oat, mode) term is not in the block graph. Economizer state is an operating state, not a measurement, and this library keeps state gating host-side (precedent: AHU-0021, whose reference card carries the same term). A host that evaluates this rule while the unit is economizing gets a sustained fault, and it is the host’s bug; mode is not a canonical RTU point in any case.
  • Design fraction as a constant, excess as a threshold. The reference writes oa_fraction > (design_min_oa_fraction + oa_excess_margin), which implemented literally would sum the two tunables into one threshold and stop a host retuning either alone. Feeding the design fraction in as Reals.Sources.Constant.k keeps both as independent set_param paths with no sign flips. Algebraically identical.
  • Evaluability is an output, not just a precondition. The min_delta test is computable from this rule’s own inputs, so SCHEMA.md requires exposing it as yTempDeltaOk. False yFault under false yTempDeltaOk means “unknown”, not “healthy”, and the host must treat it that way.
  • Both comparisons are strict (>). The reference does not specify boundary behavior and CDL Reals offers no GreaterEqual, so the choice is made rather than inherited; the disagreement with an inclusive reading has measure zero on a real temperature signal and errs toward silence.
  • The reference publishes no worked vectors for this fault, so every scenario in vectors.json is authored from the equation, following AHU-0021’s suite shape.
  • persist.delayOnInit = true (Modelica/CDL default is false), the library’s standing choice: an excess already present at load waits out the full 30 minutes instead of alarming on the first tick after a restart.

Notes

suppressed_by: [AHU-0028] is transcribed from the reference and points across equipment families on purpose: AHU-0028’s graph consumes nothing but mat, oat and rat, so the host instantiates it against this RTU’s own three points. Deploy the pair together — a MAT sensor reading 4.5 °C low in −5 °C weather turns a compliant 0.15 fraction into 0.32 and manufactures this fault out of nothing, and on a milder day with a 10 °C spread under 2 °C of bias does the same.

RTU-0006 is this rule’s mirror on the same three temperatures: this one alarms more than 0.15 above design, that one more than 0.05 below. The margins are deliberately asymmetric — excess air costs money, deficient air costs air quality — and neither can fire while the other does. If the fraction is genuinely high, command the OA damper to minimum and watch mixed air: it should climb toward return temperature within minutes, and if it does not the problem is mechanical (the economizer-failure playbook’s on-site steps). Check the minimum position setpoint first — the most common cause is a number dialled up during a ventilation complaint, and that is a $0 fix.

Test Vectors

10 scenarios, clock step 300 s over 5400 s.

ScenarioDescription
normal_minimum_ventilationOAF 0.18 against a 0.30 alarm point (oat 2 °C, rat 22 °C, mat 18.4 °C) — design minimum plus the spread a real damper holds
excess_outdoor_airOAF 0.40 (oat 2 °C, rat 22 °C, mat 14.0 °C) — 0.25 above design, so 0.10 past the margin; alarms after alarm_delay (1800 s)
summer_excess_oat_above_ratSign case: outdoor air warmer than return (oat 35 °C, rat 24 °C, mat 28.4 °C) — both differences flip sign, the quotient still reads 0.40, and the rule alarms exactly as it does in winter
small_delta_not_evaluableNO_EVAL with the gate doing work:
delta_exactly_at_thresholdEdge case:
delta_just_over_thresholdEdge case, other side:
margin_exactly_at_thresholdEdge case: OAF 0.30 exactly (oat 2 °C, rat 22 °C, mat 16 °C), so the margin equals oa_excess_margin — the strict > means no fault
margin_just_over_thresholdEdge case, other side: OAF 0.31 (mat 15.8 °C) clears the strict comparison by one hundredth and alarms after alarm_delay
transient_excess_clears_before_delayTransient: OAF jumps to 0.40 at t=600 and falls back to 0.18 at t=1800 — 1200 s of excess, short of alarm_delay, so the damper stroke never becomes an alarm
recovery_clears_alarmRecovery: OAF 0.40 alarms after alarm_delay, then the damper returns to minimum at t=3000 (OAF 0.18) — yFault drops on that tick, with no hold-off
vectors.json
{
  "schema": "cxf-library/vectors/v1",
  "clock": {
    "step_s": 300,
    "horizon_s": 5400
  },
  "scenarios": [
    {
      "name": "normal_minimum_ventilation",
      "description": "OAF 0.18 against a 0.30 alarm point (oat 2 \u00b0C, rat 22 \u00b0C, mat 18.4 \u00b0C) \u2014 design minimum plus the spread a real damper holds",
      "inputs": {
        "oat": 2.0,
        "rat": 22.0,
        "mat": 18.4
      },
      "expect": [
        {
          "output": "yTempDeltaOk",
          "from_s": 0,
          "to_s": 5400,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        }
      ]
    },
    {
      "name": "excess_outdoor_air",
      "description": "OAF 0.40 (oat 2 \u00b0C, rat 22 \u00b0C, mat 14.0 \u00b0C) \u2014 0.25 above design, so 0.10 past the margin; alarms after alarm_delay (1800 s)",
      "inputs": {
        "oat": 2.0,
        "rat": 22.0,
        "mat": 14.0
      },
      "expect": [
        {
          "output": "yTempDeltaOk",
          "from_s": 0,
          "to_s": 5400,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1500,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2100,
          "to_s": 5400,
          "equals": true
        }
      ]
    },
    {
      "name": "summer_excess_oat_above_rat",
      "description": "Sign case: outdoor air warmer than return (oat 35 \u00b0C, rat 24 \u00b0C, mat 28.4 \u00b0C) \u2014 both differences flip sign, the quotient still reads 0.40, and the rule alarms exactly as it does in winter",
      "inputs": {
        "oat": 35.0,
        "rat": 24.0,
        "mat": 28.4
      },
      "expect": [
        {
          "output": "yTempDeltaOk",
          "from_s": 0,
          "to_s": 5400,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1500,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2100,
          "to_s": 5400,
          "equals": true
        }
      ]
    },
    {
      "name": "small_delta_not_evaluable",
      "description": "NO_EVAL with the gate doing work: |oat \u2212 rat| = 2 \u00b0C (oat 20 \u00b0C, rat 22 \u00b0C) and mat 21 \u00b0C, so the raw fraction reads 0.50 and marginHigh is true \u2014 an ungated rule would alarm; yTempDeltaOk stays false and holds yFault down",
      "inputs": {
        "oat": 20.0,
        "rat": 22.0,
        "mat": 21.0
      },
      "expect": [
        {
          "output": "yTempDeltaOk",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        }
      ]
    },
    {
      "name": "delta_exactly_at_threshold",
      "description": "Edge case: |oat \u2212 rat| = 6.0 \u00b0C exactly (oat 16 \u00b0C, rat 22 \u00b0C) with OAF 0.50 (mat 19 \u00b0C) \u2014 the strict `>` leaves the rule not evaluable, so a fraction well past the alarm point still reports NO_EVAL",
      "inputs": {
        "oat": 16.0,
        "rat": 22.0,
        "mat": 19.0
      },
      "expect": [
        {
          "output": "yTempDeltaOk",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        }
      ]
    },
    {
      "name": "delta_just_over_threshold",
      "description": "Edge case, other side: |oat \u2212 rat| = 6.5 \u00b0C (oat 15.5 \u00b0C, rat 22 \u00b0C) with OAF 0.40 (mat 19.4 \u00b0C) \u2014 evaluable, and the excess alarms after alarm_delay",
      "inputs": {
        "oat": 15.5,
        "rat": 22.0,
        "mat": 19.4
      },
      "expect": [
        {
          "output": "yTempDeltaOk",
          "from_s": 0,
          "to_s": 5400,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1500,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2100,
          "to_s": 5400,
          "equals": true
        }
      ]
    },
    {
      "name": "margin_exactly_at_threshold",
      "description": "Edge case: OAF 0.30 exactly (oat 2 \u00b0C, rat 22 \u00b0C, mat 16 \u00b0C), so the margin equals oa_excess_margin \u2014 the strict `>` means no fault",
      "inputs": {
        "oat": 2.0,
        "rat": 22.0,
        "mat": 16.0
      },
      "expect": [
        {
          "output": "yTempDeltaOk",
          "from_s": 0,
          "to_s": 5400,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        }
      ]
    },
    {
      "name": "margin_just_over_threshold",
      "description": "Edge case, other side: OAF 0.31 (mat 15.8 \u00b0C) clears the strict comparison by one hundredth and alarms after alarm_delay",
      "inputs": {
        "oat": 2.0,
        "rat": 22.0,
        "mat": 15.8
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1500,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2100,
          "to_s": 5400,
          "equals": true
        }
      ]
    },
    {
      "name": "transient_excess_clears_before_delay",
      "description": "Transient: OAF jumps to 0.40 at t=600 and falls back to 0.18 at t=1800 \u2014 1200 s of excess, short of alarm_delay, so the damper stroke never becomes an alarm",
      "inputs": {
        "oat": 2.0,
        "rat": 22.0,
        "mat": [
          {
            "t": 0,
            "value": 18.4
          },
          {
            "t": 600,
            "value": 14.0
          },
          {
            "t": 1800,
            "value": 18.4
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        }
      ]
    },
    {
      "name": "recovery_clears_alarm",
      "description": "Recovery: OAF 0.40 alarms after alarm_delay, then the damper returns to minimum at t=3000 (OAF 0.18) \u2014 yFault drops on that tick, with no hold-off",
      "inputs": {
        "oat": 2.0,
        "rat": 22.0,
        "mat": [
          {
            "t": 0,
            "value": 14.0
          },
          {
            "t": 3000,
            "value": 18.4
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1500,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2100,
          "to_s": 2700,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 3000,
          "to_s": 5400,
          "equals": false
        }
      ]
    }
  ]
}

RTU-0006 — Insufficient ventilation air

Statusverified — engine e2ff2f8, cxf:fnv1a128:2d1a469cb6689db185713b59c4047434, 2026-08-17
Severity2
Methodrule
Phase2
CategoryCOMFORT_ENERGY
ConfidenceMEDIUM
EstimationQUALITATIVE_ONLY
G36
Clusters
Suppresses
Suppressed byAHU-0028
RelatedRTU-0005, AHU-0006, AHU-0026, RTU-0010
Playbookseconomizer-failure
SourceHVAC FDD Reference v1.0 §11, RTU-0006; PNNL-23790 (RTU AFDD6); ASHRAE Standard 62.1; PNNL EEM-06 (OA damper faults)
Operating statesoccupied with the supply fan running — both conjuncts are in the graph (occ_schedule, sf_status), because the reference writes them into the fault equation and both are canonical RTU points

Preconditions (host-enforced): Occupancy schedule data available and current; the host evaluates the schedule (time zone, calendar, holidays) into the boolean occ_schedule point, and a stale or unknown schedule makes the verdict NO_EVAL rather than healthy. MAT must pass its integrity gate (AHU-0028, see suppressed_by): the fraction is a ratio of temperature differences, so a biased mixed-air reading moves it directly, and this rule’s deficit branch is exactly where a low MAT lands. The temperature-difference gate is signalled in-rule by yTempDeltaOk; when it is false the verdict is NO_EVAL, not healthy.

Points: oat, rat, mat, sf_status, occ_schedule

Outputs:

  • yFault — True while the outdoor air fraction has stayed more than oa_deficit_margin below design_min_oa_fraction, occupied and with the supply fan running, for at least alarm_delay, with the temperature difference large enough to evaluate
  • yTempDeltaOk — Evaluability signal — true when |oat − rat| exceeds min_delta; false means NO_EVAL and the host must ignore yFault

Parameters:

NameDefaultUnitCXF pathDescription
design_min_oa_fraction0.151designConst.kDesign minimum outdoor air fraction the unit owes its occupants (0–1)
oa_deficit_margin0.051deficitBig.tTolerance below the design minimum before the shortfall counts as a fault
min_delta6.0°CdeltaOk.tMinimum
alarm_delay1800.0spersist.delayTimeContinuous fault persistence required before the alarm asserts (30 min)

Description

The unit is not delivering the outdoor air its occupants are owed: the building is occupied, the fan is running, and the mixing-box energy balance puts the outdoor share of the supply below the design minimum by more than the allowance. Unlike every other fault on this quotient the finding is a health one — ASHRAE 62.1 sets the minimum for a reason, and a unit that misses it accumulates CO₂, humidity and whatever else the space generates — which is why the reference rates it severity 2 against its excess-air twin’s 3. Nothing about under-ventilation announces itself: the space holds temperature better than it should, and the energy signature runs the wrong way, so a bill review will never find it. The fraction is inferred from three temperatures rather than measured, which makes the diagnostic cheap and makes it conditional — hence the explicit evaluability output.

Detection Logic

oaf          = (mat − rat) / (oat − rat)
yTempDeltaOk = |oat − rat| > min_delta                  (false ⇒ host reports NO_EVAL)
yFault       = (design_min_oa_fraction − oaf > oa_deficit_margin)
               AND occ_schedule AND sf_status AND yTempDeltaOk,
               sustained for alarm_delay

Block graph (rule.cxf.jsonld):

RTU-0006 block graph

The fraction core is RTU-0005’s, unchanged; deficit subtracts the other way round so the test is a positive gap against a positive threshold — the same identity read from the other side, since oaf < design − margin exactly when design − oaf > margin. Occupancy and fan status are conjoined in-graph and participate in the persistence: the 30-minute clock starts when the last conjunct becomes true, so a deficit that predates occupancy is timed from the start of the occupied period, not from the start of the deficit. gate is what makes the unguarded division safe. CDL Divide follows IEEE-754, so oat = rat yields ±∞ or NaN and a near-zero denominator amplifies sensor noise into a fraction of any magnitude; NaN compares false everywhere, and −∞ or a noise-inflated finite fraction can raise deficitBig but cannot pass gate, because a denominator small enough to misbehave is by construction one below min_delta. Garbage arithmetic can only make the rule report itself unevaluable. Both comparisons are strict: a fraction sitting exactly at design_min_oa_fraction − oa_deficit_margin is not a fault, and a difference of exactly min_delta is not evaluable. persist requires 30 continuous minutes, riding out a damper stroke and a purge cycle; recovery is immediate, and delayOnInit = true holds the window across a restart.

Possible Diagnoses

  1. OA damper stuck closed or nearly closed
  2. OA damper minimum position set too low
  3. OA intake blocked — debris, snow, or ice
  4. Exhaust fan creating negative building pressure

Energy Impact

COMFORT_ENERGY, MEDIUM confidence, QUALITATIVE_ONLY, mapped to PNNL EEM-06 (OA damper faults). The reference publishes no savings range or runtime formula because there is nothing to compute: under-ventilation is not waste. A unit conditioning 5% outdoor air instead of 15% spends less on that air than it should, and correcting the damper raises the heating and cooling load rather than lowering it.

A host that accumulates energy savings across the fault library must exclude this rule explicitly. AHU-0006 carries the same warning for its low branch; this rule is that branch made reachable, so it matters more here. The number worth carrying runs the other way — the ventilation the occupants did not get — and the rule has the fraction for it (deficit.y) but not the airflow to turn it into cubic metres.

Emissions Impact

Scope 1 or 2 depending on how the unit heats, QUALITATIVE_EMISSIONS, MEDIUM confidence. The reference’s figure is 50–300 kg CO₂e/yr with IAQ primary and emissions secondary; the sign is negative, in that fixing the fault raises emissions slightly by restoring the ventilation load the unit was supposed to carry. No avoided-emissions basis applies, and none is claimed.

Deviations

  • min_delta default adopted, not transcribed. The reference states the fraction is computed only when |OAT − RAT| > min_delta but omits the parameter from its tunables table. This card adopts 6.0 °C, the value RTU-0005, AHU-0021 and AHU-0030 use, so every rule running this quotient agrees on when it is meaningful (PNNL-27338 uses 5 °F for the same computation). A site that retunes one should retune all of them.
  • The deficit is computed as a positive gap. The reference writes oa_fraction < (design_min_oa_fraction − oa_deficit_margin), which implemented literally folds the two tunables into one threshold and stops a host retuning either alone. Subtracting the fraction from a Reals.Sources.Constant keeps both as independent set_param paths, keeps every parameter non-negative, and is algebraically identical.
  • Occupancy and fan status are in the graph, unlike RTU-0005’s economizer term. occ_schedule and sf_status are canonical RTU points with measured or host-published values, so the reference’s in_occupied_schedule AND sf_status = ON transcribes directly rather than needing a mode enumeration the point dictionary does not carry (precedents: AHU-0018, AHU-0030). What stays host-side is the schedule’s provenance — time zone, calendar, holidays.
  • Evaluability is an output, not just a precondition. The min_delta test is computable from this rule’s own inputs, so SCHEMA.md requires exposing it as yTempDeltaOk. False yFault under false yTempDeltaOk means “unknown”, not “healthy”, and on a health fault that distinction is the whole point.
  • Both comparisons are strict (>). The reference does not specify boundary behavior and CDL Reals offers no GreaterEqual. One caveat on the deficit edge: the nominal alarm point — a fraction of exactly 0.10 against a 0.15 design and a 0.05 margin — is not representable in binary, and the computed gap lands two ulps below the double nearest 0.05, so it reads healthy. Decimal arithmetic gives the same verdict through the strict >, so the rounding hides nothing; a host binding coarsely quantized temperatures should still not read anything into a fraction sitting on the threshold.
  • The reference publishes no worked vectors for this fault, so every scenario in vectors.json is authored from the equation.
  • persist.delayOnInit = true (Modelica/CDL default is false), the library’s standing choice: a deficit already present at load waits out the full 30 minutes instead of alarming on the first tick after a restart.

Notes

This rule catches what AHU-0006 cannot. FC#6 tests the same quotient symmetrically against a 0.30 tolerance, so with %OAmin at 0.15 its low-side alarm point is a fraction below −0.15 — which no physical mixing box can produce, so a damper welded shut reads healthy there. Here the alarm point is a fraction below 0.10 and the same shut damper alarms. The two are complementary: FC#6 polices deviation from a G36 minimum-OA state in either direction with a band sized to suppress false alarms, and this is the dedicated under-ventilation alarm. A site that wants a real ventilation-deficit alarm deploys this one.

A negative inferred fraction reads as a large deficit and alarms. With honest sensors that is a genuine finding — a shut damper plus heat picked up before the sensor. With a lying mat it is not a ventilation finding at all, which is what the suppressed_by: [AHU-0028] contract exists to silence: AHU-0028’s graph consumes nothing but mat, oat and rat, so the host instantiates it against this RTU’s own three points and deploys the pair together. When both are active the sensor is the story and the fraction is noise.

Field-verify before dispatching a damper repair: a CO₂ reading in the space, or a smoke pencil at the intake, measures the thing that matters and costs an afternoon. If the fraction is genuinely low, command the damper open and watch mixed air move toward outdoor — no movement means actuator, linkage, or a blocked intake (economizer-failure playbook), movement means the sequence never commanded minimum position and the fix is at a desk. Check the intake screen first in a climate that gets snow.

Test Vectors

13 scenarios, clock step 300 s over 5400 s.

ScenarioDescription
healthy_minimum_ventilationOAF 0.15 exactly on design (oat 2 °C, rat 22 °C, mat 19 °C), occupied with the fan running — the deficit is zero and the rule is silent
damper_shut_no_outdoor_airThe failure this rule exists for: OA damper shut, so mat tracks rat (22 °C) and the inferred fraction is 0 against a 0.15 design. Deficit 0.15 > 0.05 and the rule alarms after alarm_delay. AHU-0006 runs the same quotient with a symmetric 0.30 band, whose low side sits below a fraction of −0.15, and reads this same unit as healthy
unoccupied_deficit_stays_clearSame shut damper, unoccupied: the reference scopes under-ventilation to occupied hours because there is nobody to under-ventilate, and the occ_schedule conjunct holds yFault down
fan_off_deficit_stays_clearSame shut damper, occupied, fan off: with no airflow the mixing box delivers nothing to infer a fraction from, and sf_status holds yFault down
negative_fraction_alarmsmat 23 °C sits above both rat (22 °C) and oat (2 °C), so the inferred fraction is −0.05 and the deficit reads 0.20. The rule alarms: with honest sensors that is a shut damper plus duct conduction, and with a lying mat it is the reading AHU-0028 suppression exists to silence
deficit_exactly_at_thresholdEdge case: OAF 0.10 exactly (oat 2 °C, rat 22 °C, mat 20 °C), so the deficit equals oa_deficit_margin — the strict > means no fault
deficit_just_over_thresholdEdge case, other side: OAF 0.09 (mat 20.2 °C) puts the deficit at 0.06 and the rule alarms after alarm_delay
small_delta_not_evaluableNO_EVAL with the gate doing work:
delta_exactly_at_thresholdEdge case:
delta_just_over_thresholdEdge case, other side:
transient_deficit_clears_before_delayTransient: the damper drops to shut at t=600 and returns to minimum at t=1500 — 900 s of deficit, short of alarm_delay, so a damper stroke or a purge cycle never becomes an alarm
occupancy_start_restarts_persistenceThe deficit is present from t=0 but the building is unoccupied until t=1200, so the 30-minute clock starts at occupancy rather than at the deficit — the alarm lands at 3000 s, not 1800 s
recovery_clears_alarmRecovery: the shut damper alarms after alarm_delay, then reopens to design minimum at t=3000 (OAF 0.15) — yFault drops on that tick, with no hold-off
vectors.json
{
  "schema": "cxf-library/vectors/v1",
  "clock": {
    "step_s": 300,
    "horizon_s": 5400
  },
  "scenarios": [
    {
      "name": "healthy_minimum_ventilation",
      "description": "OAF 0.15 exactly on design (oat 2 \u00b0C, rat 22 \u00b0C, mat 19 \u00b0C), occupied with the fan running \u2014 the deficit is zero and the rule is silent",
      "inputs": {
        "oat": 2.0,
        "rat": 22.0,
        "mat": 19.0,
        "sf_status": true,
        "occ_schedule": true
      },
      "expect": [
        {
          "output": "yTempDeltaOk",
          "from_s": 0,
          "to_s": 5400,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        }
      ]
    },
    {
      "name": "damper_shut_no_outdoor_air",
      "description": "The failure this rule exists for: OA damper shut, so mat tracks rat (22 \u00b0C) and the inferred fraction is 0 against a 0.15 design. Deficit 0.15 > 0.05 and the rule alarms after alarm_delay. AHU-0006 runs the same quotient with a symmetric 0.30 band, whose low side sits below a fraction of \u22120.15, and reads this same unit as healthy",
      "inputs": {
        "oat": 2.0,
        "rat": 22.0,
        "mat": 22.0,
        "sf_status": true,
        "occ_schedule": true
      },
      "expect": [
        {
          "output": "yTempDeltaOk",
          "from_s": 0,
          "to_s": 5400,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1500,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2100,
          "to_s": 5400,
          "equals": true
        }
      ]
    },
    {
      "name": "unoccupied_deficit_stays_clear",
      "description": "Same shut damper, unoccupied: the reference scopes under-ventilation to occupied hours because there is nobody to under-ventilate, and the occ_schedule conjunct holds yFault down",
      "inputs": {
        "oat": 2.0,
        "rat": 22.0,
        "mat": 22.0,
        "sf_status": true,
        "occ_schedule": false
      },
      "expect": [
        {
          "output": "yTempDeltaOk",
          "from_s": 0,
          "to_s": 5400,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        }
      ]
    },
    {
      "name": "fan_off_deficit_stays_clear",
      "description": "Same shut damper, occupied, fan off: with no airflow the mixing box delivers nothing to infer a fraction from, and sf_status holds yFault down",
      "inputs": {
        "oat": 2.0,
        "rat": 22.0,
        "mat": 22.0,
        "sf_status": false,
        "occ_schedule": true
      },
      "expect": [
        {
          "output": "yTempDeltaOk",
          "from_s": 0,
          "to_s": 5400,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        }
      ]
    },
    {
      "name": "negative_fraction_alarms",
      "description": "mat 23 \u00b0C sits above both rat (22 \u00b0C) and oat (2 \u00b0C), so the inferred fraction is \u22120.05 and the deficit reads 0.20. The rule alarms: with honest sensors that is a shut damper plus duct conduction, and with a lying mat it is the reading AHU-0028 suppression exists to silence",
      "inputs": {
        "oat": 2.0,
        "rat": 22.0,
        "mat": 23.0,
        "sf_status": true,
        "occ_schedule": true
      },
      "expect": [
        {
          "output": "yTempDeltaOk",
          "from_s": 0,
          "to_s": 5400,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1500,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2100,
          "to_s": 5400,
          "equals": true
        }
      ]
    },
    {
      "name": "deficit_exactly_at_threshold",
      "description": "Edge case: OAF 0.10 exactly (oat 2 \u00b0C, rat 22 \u00b0C, mat 20 \u00b0C), so the deficit equals oa_deficit_margin \u2014 the strict `>` means no fault",
      "inputs": {
        "oat": 2.0,
        "rat": 22.0,
        "mat": 20.0,
        "sf_status": true,
        "occ_schedule": true
      },
      "expect": [
        {
          "output": "yTempDeltaOk",
          "from_s": 0,
          "to_s": 5400,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        }
      ]
    },
    {
      "name": "deficit_just_over_threshold",
      "description": "Edge case, other side: OAF 0.09 (mat 20.2 \u00b0C) puts the deficit at 0.06 and the rule alarms after alarm_delay",
      "inputs": {
        "oat": 2.0,
        "rat": 22.0,
        "mat": 20.2,
        "sf_status": true,
        "occ_schedule": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1500,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2100,
          "to_s": 5400,
          "equals": true
        }
      ]
    },
    {
      "name": "small_delta_not_evaluable",
      "description": "NO_EVAL with the gate doing work: |oat \u2212 rat| = 2 \u00b0C (oat 20 \u00b0C, rat 22 \u00b0C) and mat 21.9 \u00b0C, so the fraction reads 0.05 and deficitBig is true \u2014 an ungated rule would alarm on a mixture the two sources cannot locate; yTempDeltaOk stays false and holds yFault down",
      "inputs": {
        "oat": 20.0,
        "rat": 22.0,
        "mat": 21.9,
        "sf_status": true,
        "occ_schedule": true
      },
      "expect": [
        {
          "output": "yTempDeltaOk",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        }
      ]
    },
    {
      "name": "delta_exactly_at_threshold",
      "description": "Edge case: |oat \u2212 rat| = 6.0 \u00b0C exactly (oat 16 \u00b0C, rat 22 \u00b0C) with the damper shut (mat 22 \u00b0C) \u2014 the strict `>` leaves the rule not evaluable, so a full ventilation deficit still reports NO_EVAL rather than healthy",
      "inputs": {
        "oat": 16.0,
        "rat": 22.0,
        "mat": 22.0,
        "sf_status": true,
        "occ_schedule": true
      },
      "expect": [
        {
          "output": "yTempDeltaOk",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        }
      ]
    },
    {
      "name": "delta_just_over_threshold",
      "description": "Edge case, other side: |oat \u2212 rat| = 6.5 \u00b0C (oat 15.5 \u00b0C, rat 22 \u00b0C) with the same shut damper \u2014 evaluable, and the deficit alarms after alarm_delay",
      "inputs": {
        "oat": 15.5,
        "rat": 22.0,
        "mat": 22.0,
        "sf_status": true,
        "occ_schedule": true
      },
      "expect": [
        {
          "output": "yTempDeltaOk",
          "from_s": 0,
          "to_s": 5400,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1500,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2100,
          "to_s": 5400,
          "equals": true
        }
      ]
    },
    {
      "name": "transient_deficit_clears_before_delay",
      "description": "Transient: the damper drops to shut at t=600 and returns to minimum at t=1500 \u2014 900 s of deficit, short of alarm_delay, so a damper stroke or a purge cycle never becomes an alarm",
      "inputs": {
        "oat": 2.0,
        "rat": 22.0,
        "mat": [
          {
            "t": 0,
            "value": 19.0
          },
          {
            "t": 600,
            "value": 22.0
          },
          {
            "t": 1500,
            "value": 19.0
          }
        ],
        "sf_status": true,
        "occ_schedule": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        }
      ]
    },
    {
      "name": "occupancy_start_restarts_persistence",
      "description": "The deficit is present from t=0 but the building is unoccupied until t=1200, so the 30-minute clock starts at occupancy rather than at the deficit \u2014 the alarm lands at 3000 s, not 1800 s",
      "inputs": {
        "oat": 2.0,
        "rat": 22.0,
        "mat": 22.0,
        "sf_status": true,
        "occ_schedule": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 1200,
            "value": true
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2700,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 3300,
          "to_s": 5400,
          "equals": true
        }
      ]
    },
    {
      "name": "recovery_clears_alarm",
      "description": "Recovery: the shut damper alarms after alarm_delay, then reopens to design minimum at t=3000 (OAF 0.15) \u2014 yFault drops on that tick, with no hold-off",
      "inputs": {
        "oat": 2.0,
        "rat": 22.0,
        "mat": [
          {
            "t": 0,
            "value": 22.0
          },
          {
            "t": 3000,
            "value": 19.0
          }
        ],
        "sf_status": true,
        "occ_schedule": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1500,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2100,
          "to_s": 2700,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 3000,
          "to_s": 5400,
          "equals": false
        }
      ]
    }
  ]
}

RTU-0007 — Condenser airflow restriction (fouling)

Statusverified — engine e2ff2f8, cxf:fnv1a128:736a68798b5fe88d033216cdb84a4959, 2026-08-18
Severity3
Methodstatistical
Phase2
CategoryEFFICIENCY_LOSS
ConfidenceMEDIUM
EstimationBASELINE_COMPARISON
G36
Clusters
Suppresses
Suppressed by
RelatedRTU-0002, HP-0004, RTU-0011
Playbooksrtu-compressor-refrigerant
SourceHVAC FDD Reference v1.0 §11 — this chapter’s condenser card (listed pre-renumbering as RTU-FC-100): the condenser-split equation, the 30% relative-excess threshold, the 15-minute compressor-runtime gate, the four diagnoses, and the 5-15% compressor-energy band; HVAC FDD Reference v1.0, Remediation Playbooks (pp. 168-169), carried in playbooks/rtu-compressor-refrigerant.md: Step 1.3 (measure leaving-condenser-air minus OAT against the baseline for the current stage and OAT; a 30% or greater increase indicates fouling), Step 2.3 (coil, fan motor, blade, adjacent-unit recirculation, in that order) and Step 3.3 (confirm the split back within 20% of baseline); PNNL-23790 — the RTU AFDD algorithm report the rest of this chapter binds (AFDD0 in RTU-0003, AFDD1/AFDD3 in RTU-0004, AFDD4/AFDD5 in RTU-0005, AFDD6 in RTU-0006), cited here for its refrigerant-side fault studies; this condenser test is not one of its numbered algorithms; Catrini & Piacentino (2023) — the packaged-unit fouling measurements RTU-0002 is built on (13.3% capacity reduction, up to 47% additional fan power); the same study family is the evidence that a coil-side airflow restriction shows up as a temperature split before it shows up as a complaint; PNNL EEM-23 (RTU advanced controls, 3-11% electricity) — the retrofit package this finding maps to, as in RTU-0004 and RTU-0005; BEE/Devki Energy Consultancy, Best Practice Manual: HVAC Chillers (2006) §2.5.2 — the 2-4% of compressor power per °C of lift thumb-rule used in runtime_estimation (a chiller source applied to a DX machine; see Deviations); points/rtu.points.json cond_split_baseline — the host-fitted baseline contract (per-stage curve against OAT, published strictly positive) that this rule divides by, and cond_leaving_temp, whose absence on most packaged units was the second half of the original deferral; Sibling precedent: RTU-0002 (relative-degradation ratio against a stage baseline, statistical method label, evaluability output), CHW-0005 (host-derived point the graph cannot see behind), HP-0004 (compressor settling as a runtime gate)
Operating statesmechanical cooling, compressor running and settled — one instance per unit, each carrying that unit’s own fit. The rule’s yRuntimeOk covers the settling half of that state in-graph.

Preconditions (host-enforced): cond_leaving_temp must read the air LEAVING the condenser coil, not the air entering it: entering-air is outdoor air, so a probe on the wrong side of the coil reduces the split to sensor error and the rule goes permanently silent. Most packaged units carry no such sensor at all — a strap-on or discharge-plenum probe is the usual retrofit, and its radiation shielding matters, because an unshaded probe in the condenser discharge reads the sun as fouling. cond_split_baseline must be this unit’s own fit against its own stage and OAT, refreshed after any coil cleaning or fan replacement, and the host must publish it strictly positive: the graph divides by it with no guard (see Deviations). oat sits on both sides of the test — it makes the split and it selects the baseline — so a biased outdoor-air sensor moves both and the graph cannot see it; shield and calibrate it before trusting a marginal finding. The host must additionally hold evaluation off across a compressor STAGE change: comp_status does not move when a unit stages 1 to 2, so yRuntimeOk stays armed while the published baseline switches instantly and the physical split takes minutes to follow. Condenser fan speed must be at its normal control point for the conditions — a head-pressure control that is deliberately slowing the fan on a cool day widens the split by design, and that is not this fault. Evaluability is signalled in-rule by yRuntimeOk; when it is false the verdict is NO_EVAL, not a clean coil.

Points: cond_leaving_temp, oat, cond_split_baseline, comp_status

Outputs:

  • yFault — True while the condenser split has exceeded the host’s published baseline by more than fouling_threshold, on a compressor that has been running continuously for at least min_compressor_runtime
  • yRuntimeOk — Evaluability signal — true when comp_status has been continuously true for min_compressor_runtime, the settling time below which the split says nothing about the coil. False means NO_EVAL and the host must ignore yFault

Parameters:

NameDefaultUnitCXF pathDescription
fouling_threshold0.31excessHigh.tFractional excess of the measured condenser split over the host’s baseline that counts as a restriction (0.30 = 30% wider than expected). The reference’s own number, and the one its playbook Step 1.3 states for the field measurement. Carried as a fraction, not a percentage, because the graph compares it against a dimensionless quotient — a host that sets 30 here disables the rule.
min_compressor_runtime900.0sruntimeOk.delayTimeContinuous compressor runtime before the split is read (15 min). Head pressure and condenser discharge climb for minutes after a start, so a unit that just came on looks restricted; this is a settling gate, not an alarm debounce, and it drives yRuntimeOk. Same 15 minutes RTU-0002 and HP-0004 require after a start or a capacity step, and the reference’s own figure.

Description

An air-cooled condenser rejects heat by warming the air it pulls through the coil, so the split between leaving air and outdoor air is a signature of how much air moves across how much clean fin area. Restrict either and the same heat leaves in less air: cottonwood seed matted into the fins, a fan motor losing speed, a bent blade, a discharge hood aimed so the neighbouring unit’s hot air is drawn back in. The split widens, head pressure climbs with it, and the compressor pays for the extra lift every hour the unit cools — without missing a setpoint, which is why nobody on the ground notices. Two temperatures and a baseline is the whole measurement, and the baseline has to come from the host: the split a healthy unit makes depends on both compressor stage and outdoor air.

Detection Logic

condenser_split = cond_leaving_temp − oat
relative_excess = (condenser_split − cond_split_baseline) / cond_split_baseline

yRuntimeOk = comp_status held continuously true for min_compressor_runtime
             (false ⇒ host reports NO_EVAL)
yFault     = relative_excess > fouling_threshold AND yRuntimeOk

Block graph (rule.cxf.jsonld):

RTU-0007 block graph

runtimeOk is the only timer, and it debounces the compressor rather than the ratio: it asserts exactly 900 s after comp_status rises, any stop restarts it, and once armed yFault follows the split tick by tick with no persistence of its own (excess_flickering_across_the_threshold_chatters pins that). The delayOnInit = true house choice makes a compressor already running at engine start wait out the full window, which is the right reading here — the rule cannot know how long that machine has been on.

cond_split_baseline reaches the graph twice, once as the subtrahend and once as the divisor, and nothing guards the division: the point contract carries that obligation (see Deviations). The threshold comparison is strict, so a split exactly 30% wide reads healthy; the boundary is bit-exact for a baseline of 10.0 and a split of 13.0, and both sides are pinned.

Possible Diagnoses

  1. Fouled condenser coil — cottonwood seed, leaves, grass clippings, or roof grit matted into the fins. The most common cause, the cheapest to confirm (look at the coil from outside the unit) and the cheapest to fix
  2. Condenser fan motor degradation — a failing motor or slipping mount turning the same blade slower, which moves less air across a coil that is clean
  3. Blade damage or obstruction — a bent or cracked blade, or something set down on the condenser guard; the split widens the same way as a fouled coil
  4. Hot-air recirculation from an adjacent rooftop unit discharging into this unit’s condenser intake. The coil is clean, the fan is fine, and the fix is sheet metal rather than service — indistinguishable from causes 1-3 on this signal, and the one to suspect when a cleaned coil does not clear the alarm
  5. Neither: an unshaded leaving-air probe, a biased outdoor-air sensor, or a baseline fitted while the coil was already dirty. Rule these out first — they cost nothing and the third one silences the rule permanently

Energy Impact

EFFICIENCY_LOSS, MEDIUM confidence, BASELINE_COMPARISON. The excess split the graph already computes is the estimator: waste_kw ≈ compressor_kw × 0.025 /K × relative_excess × cond_split_baseline — the share of compressor power spent lifting across resistance that should not be there. A unit 43% over a 14 K baseline carries 6 K of excess split, which the estimator reads as 6 K of extra lift and about 15% of compressor power — the top of the reference’s 5-15% band. MEDIUM because both steps are approximations: the sensitivity ratio is a thumb-rule borrowed from the chiller side, and air-side split stands in for refrigerant-side lift. Cooling-dominant, and worst on design days when the condenser is already working hardest.

Emissions Impact

Scope 2, PROXY_EMISSIONS, MEDIUM confidence; the same order as RTU-0002’s 300-2,000 kg CO₂e/yr, scaling with tonnage and cooling hours. Marginal operating emissions rate (MOER) is the avoided-emissions basis, and the timing works against the building twice over: a restricted condenser costs most on the hot afternoons when the grid dispatches its dirtiest generation and the unit runs longest.

Deviations

  • The deferral was resolved by the point dictionary, not by new blocks. faults/rtu/README.md carried this rule as deferred because its baseline is a function of two variables (stage and OAT) and the block set expresses no baseline curve. Publishing the fit as the host-derived point cond_split_baseline moves that curve out of the graph entirely, as CHW-0005 and HP-0004 do with derived saturation temperatures.
  • The stage dependence lives in the host fit, so comp_stage is not a point of this rule. The baseline the host publishes already answers “which stage”, and re-deriving the stage in-graph would add a binding and a block for information the rule cannot act on. The cost is real and declared in preconditions: a 1-to-2 stage-up switches the baseline instantly while the physical split takes minutes to follow, and nothing in-graph holds evaluation off across it.
  • comp_status is the runtime gate, not comp_stage > 0. The gate asks one question — is a compressor running — which comp_status answers directly. This rule may use an explicitly documented any-running aggregate while the integer route needs an Integers.GreaterThreshold to reach the same boolean. The dictionary’s OR-undercount warning is about counting starts (RTU-0001), not about whether anything is running, so it does not bite here.
  • The TrueDelay sits on the compressor condition, not on the fault condition. Settling is a property of the machine, not of the finding: a timer fed the AND would restart its clock every time the split dipped under the line, so a marginal unit could run all afternoon and never complete a window it had physically earned in the first 15 minutes. The consequence is that yFault has no debounce of its own.
  • No alarm_delay was invented to supply that debounce. The reference names two parameters and this card ships two. The arithmetic supports the choice: ±0.5 K of sensor error on a 14 K baseline is ~3.5% of the ratio against a 30% line, so the decision is not normally made on noise. A unit that genuinely oscillates across the line will chatter, which the vectors pin rather than hide, and the host’s alarm layer is where that hold-off belongs.
  • The division relies on the point contract instead of an in-graph guard. points/rtu.points.json requires the host to publish cond_split_baseline strictly positive, which it is by construction — an expected temperature rise across a working condenser. A host that publishes 0 anyway drives the quotient non-finite and the rule alarms permanently rather than going quiet, which zero_baseline_violates_the_point_contract_and_alarms pins: the survivable direction for a misconfiguration, but still a contract violation.
  • Strict > where the reference’s playbook is inclusive (“a 30% or greater increase indicates fouling”). CDL Reals has no GreaterEqual, so a split exactly 30% over baseline reads healthy. The disagreement is measure-zero on a real-valued signal and both sides are pinned, one of them bit-exact.
  • method: statistical describes where the baseline comes from, not what the graph does — RTU-0002’s stance, and more literally true here: the fit is regressed from this unit’s own clean-operation history rather than adopted from a population, which is also why estimation_method is BASELINE_COMPARISON rather than RTU-0002’s shipped constants.
  • The sensor half of the original deferral is not resolved, only declared. Most packaged units carry no condenser leaving-air sensor, so this rule is retrofit-gated in a way none of its RTU siblings are; the point dictionary says so and preconditions repeats it. A library that shipped this rule as broadly deployable would be overstating what a typical RTU can bind.
  • The energy estimator borrows a chiller thumb-rule. BEE 2006 §2.5.2’s 2-4% of compressor power per °C of lift is stated for water-cooled machines; the compressor does not care what fluid raised its condensing temperature, so the crossover is defensible as an approximation and runtime_estimation writes it as one. It is also the reason the estimator is only corroborating evidence for the reference’s 5-15% band rather than a second derivation of it.
  • The playbook’s resolution target is tighter than this rule’s alarm. Step 3.3 confirms the fix at the split returning within 20% of baseline while the rule alarms at 30%, so a coil cleaned back to 25% over clears the alarm without being fixed — the same trap RTU-0002 documents against its own playbook step. The playbook already carries this fault in its Applies-To row and its Steps 1.3, 2.3 and 3.3, so no playbook edit was needed.
  • clusters: [] and suppressed_by: [], both deliberate. CLU-10 (condenser-side degradation) is the water-side syndrome behind a shared cooling tower; an air-cooled packaged condenser shares no loop with it, so membership would be a false neighbour. And no rule in this family adjudicates oat or cond_leaving_temp — RTU-0003’s AFDD0 consistency check covers sat/mat — so there is nothing honest to be suppressed by. Both are the index owner’s to revisit.
  • The CXF namespace is urn:cxf-library:rtu-0007#. SCHEMA.md’s normative form, matching VAV-0010; the rest of the RTU family still carries pre-renumbering rtu-fc-0NN namespaces because renaming them would churn every recorded content_id for no gain.
  • runtimeOk.delayOnInit = true against the CDL default of false, the library’s standing choice, and load-bearing here: a compressor already running at load raises no edge for the timer to key on. Threshold hysteresis stays at the CDL default h = 0; a site whose split chatters should add hold-off at the host rather than widen the band.
  • No published test vectors exist. The reference supplies no cases for this test, so every scenario in vectors.json is authored from the equation and replayed against the pinned engine rev. Operating states and preconditions are declared in frontmatter for host enforcement rather than encoded in the graph. Severity 3 and method: statistical are the row faults/rtu/README.md has carried for this rule since the chapter was indexed; confidence: MEDIUM and estimation_method: BASELINE_COMPARISON are this card’s own, and match the chapter’s other baseline-referenced finding, RTU-0002.

Notes

Read yRuntimeOk first: on a short-cycling unit it never turns true, and that silence is RTU-0001’s finding rather than a clean coil. Then look at the coil, which is visible from outside the unit and is cause 1 in both the diagnosis list and the playbook. If washing it does not restore the split, take the fan next (motor speed, then the blade), and the neighbours last — hot-air recirculation is common on dense rooftops, invisible from the trend, and fixed with sheet metal rather than service. Trend the split against outdoor temperature for a week before scheduling any of it: a gap that grows with OAT points at the coil or the fan, while one flat and wide across the range points at recirculation or at a baseline fitted while the unit was already dirty.

Test Vectors

14 scenarios, clock step 60 s over 3600 s.

ScenarioDescription
clean_condenser_runningNO_FAULT: 50 °C off the coil against 35 °C outdoor air is a 15 K split where the host’s fit expects 14 K — 7% over, a coil worth hosing next spring and nothing this rule should say. yRuntimeOk turns true at exactly 900 s because delayOnInit holds the compressor condition from the first tick.
fouled_condenser_alarms_at_the_runtime_edgeFAULT: the same unit discharging 55 °C is a 20 K split against the same 14 K fit — 43% excess, well past the 30% line. The alarm lands on the tick the runtime gate arms, which is the whole timing story of this rule: excess is not debounced, arming is.
compressor_off_is_no_evalNO_EVAL: the fouled numbers with the compressor off. A condenser rejecting no heat holds whatever split the last cycle left in the sheet metal, so the reading is not evidence either way — yRuntimeOk false is what tells the host that the silence is not a clean coil.
excess_exactly_at_the_thresholdBoundary, bit-exact: 43.0 − 30.0 is exactly 13.0, minus a 10.0 baseline is exactly 3.0, and 3.0/10.0 is the same double the parameter 0.30 rounds to. Reals.GreaterThreshold is strict, so a unit sitting exactly on the line reads healthy by the strictness rather than by rounding.
excess_just_above_the_thresholdBoundary from above: 10 mK more off the coil puts the ratio at 0.3010 and the rule alarms on the normal schedule.
excess_just_below_the_thresholdBoundary from below: 10 mK less puts the ratio at 0.2990 and nothing fires, however long the compressor runs.
compressor_start_arms_the_ruleThe mid-run rising edge: a fouled unit whose compressor starts at t = 600 s. The gate arms at exactly 1500 s and the alarm lands on the same tick — T + delayTime, whether the condition arrives at init or mid-run.
short_cycling_unit_never_reaches_the_runtime_gateA fouled unit that also short-cycles: 10 min on, 10 min off, 10 min on. Each stop resets the delay, so the first window that completes 900 s of continuous running starts at 1200 s and arms at 2100 s. Below a 15-minute duty cycle this rule reports nothing at all and RTU-0001 is the finding.
condenser_cleaned_mid_runRecovery: the alarm asserts at 900 s and the coil is hosed off at t = 1800 s, dropping the split back to 15 K. yFault falls on that tick while yRuntimeOk stays true — the compressor never stopped, so the gate never re-armed.
compressor_stops_after_alarmThe evaluability release: an alarming unit stages off at t = 2400 s with the split unchanged. yFault and yRuntimeOk fall on the same tick, and only the pair distinguishes a cleaned coil from a unit that stopped cooling.
baseline_steps_under_a_running_ruleThe derived point moves: the split holds at 20 K while the host republishes the fit from 16 K to 14 K at t = 1800 s — a stage-up, or the OAT bin changing under the curve. The ratio crosses 0.30 on that tick and, with the gate already armed, so does yFault. Nothing in the graph waits for the physical split to catch up with the new baseline.
excess_flickering_across_the_threshold_chattersBlind spot, pinned: a unit whose relative excess alternates between 0.35 and 0.25 every 300 s — a condenser fan cycling on head pressure, or a marginal coil in gusty wind. yFault follows every crossing because the rule debounces arming, not excess. A host that alarms on the raw output gets one alarm per crossing.
outdoor_air_sensor_reading_high_hides_foulingThe other blind spot, pinned: a sun-baked outdoor-air sensor reads 40 °C on a 35 °C day while the coil is genuinely fouled at 55 °C leaving. The measured split shrinks to 15 K and the ratio lands on 0.0714 — the identical number the clean scenario produces. oat is on both sides of this test, the split and the fit that predicts it, and a bias on it is invisible to the graph in the false-negative direction.
zero_baseline_violates_the_point_contract_and_alarmsContract violation, pinned so the failure direction is on the record: a host publishes cond_split_baseline = 0 — an unfitted instance, or a default that leaked — against a real 20 K split. The graph has no divide guard, the quotient goes non-finite, and the strict comparison passes, so the rule alarms permanently instead of going quiet. Loud is the survivable direction for a misconfiguration, but the point dictionary requires a strictly positive baseline and this is what happens when that is not honoured.
vectors.json
{
  "schema": "cxf-library/vectors/v1",
  "clock": {
    "step_s": 60,
    "horizon_s": 3600
  },
  "scenarios": [
    {
      "name": "clean_condenser_running",
      "description": "NO_FAULT: 50 \u00b0C off the coil against 35 \u00b0C outdoor air is a 15 K split where the host's fit expects 14 K \u2014 7% over, a coil worth hosing next spring and nothing this rule should say. yRuntimeOk turns true at exactly 900 s because delayOnInit holds the compressor condition from the first tick.",
      "inputs": {
        "cond_leaving_temp": 50.0,
        "oat": 35.0,
        "cond_split_baseline": 14.0,
        "comp_status": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        },
        {
          "output": "yRuntimeOk",
          "from_s": 0,
          "to_s": 840,
          "equals": false
        },
        {
          "output": "yRuntimeOk",
          "from_s": 900,
          "to_s": 3600,
          "equals": true
        }
      ]
    },
    {
      "name": "fouled_condenser_alarms_at_the_runtime_edge",
      "description": "FAULT: the same unit discharging 55 \u00b0C is a 20 K split against the same 14 K fit \u2014 43% excess, well past the 30% line. The alarm lands on the tick the runtime gate arms, which is the whole timing story of this rule: excess is not debounced, arming is.",
      "inputs": {
        "cond_leaving_temp": 55.0,
        "oat": 35.0,
        "cond_split_baseline": 14.0,
        "comp_status": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 840,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 900,
          "to_s": 3600,
          "equals": true
        },
        {
          "output": "yRuntimeOk",
          "from_s": 0,
          "to_s": 840,
          "equals": false
        },
        {
          "output": "yRuntimeOk",
          "from_s": 900,
          "to_s": 3600,
          "equals": true
        }
      ]
    },
    {
      "name": "compressor_off_is_no_eval",
      "description": "NO_EVAL: the fouled numbers with the compressor off. A condenser rejecting no heat holds whatever split the last cycle left in the sheet metal, so the reading is not evidence either way \u2014 yRuntimeOk false is what tells the host that the silence is not a clean coil.",
      "inputs": {
        "cond_leaving_temp": 55.0,
        "oat": 35.0,
        "cond_split_baseline": 14.0,
        "comp_status": false
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        },
        {
          "output": "yRuntimeOk",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "excess_exactly_at_the_threshold",
      "description": "Boundary, bit-exact: 43.0 \u2212 30.0 is exactly 13.0, minus a 10.0 baseline is exactly 3.0, and 3.0/10.0 is the same double the parameter 0.30 rounds to. Reals.GreaterThreshold is strict, so a unit sitting exactly on the line reads healthy by the strictness rather than by rounding.",
      "inputs": {
        "cond_leaving_temp": 43.0,
        "oat": 30.0,
        "cond_split_baseline": 10.0,
        "comp_status": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        },
        {
          "output": "yRuntimeOk",
          "from_s": 0,
          "to_s": 840,
          "equals": false
        },
        {
          "output": "yRuntimeOk",
          "from_s": 900,
          "to_s": 3600,
          "equals": true
        }
      ]
    },
    {
      "name": "excess_just_above_the_threshold",
      "description": "Boundary from above: 10 mK more off the coil puts the ratio at 0.3010 and the rule alarms on the normal schedule.",
      "inputs": {
        "cond_leaving_temp": 43.01,
        "oat": 30.0,
        "cond_split_baseline": 10.0,
        "comp_status": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 840,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 900,
          "to_s": 3600,
          "equals": true
        },
        {
          "output": "yRuntimeOk",
          "from_s": 900,
          "to_s": 3600,
          "equals": true
        }
      ]
    },
    {
      "name": "excess_just_below_the_threshold",
      "description": "Boundary from below: 10 mK less puts the ratio at 0.2990 and nothing fires, however long the compressor runs.",
      "inputs": {
        "cond_leaving_temp": 42.99,
        "oat": 30.0,
        "cond_split_baseline": 10.0,
        "comp_status": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        },
        {
          "output": "yRuntimeOk",
          "from_s": 900,
          "to_s": 3600,
          "equals": true
        }
      ]
    },
    {
      "name": "compressor_start_arms_the_rule",
      "description": "The mid-run rising edge: a fouled unit whose compressor starts at t = 600 s. The gate arms at exactly 1500 s and the alarm lands on the same tick \u2014 T + delayTime, whether the condition arrives at init or mid-run.",
      "inputs": {
        "cond_leaving_temp": 55.0,
        "oat": 35.0,
        "cond_split_baseline": 14.0,
        "comp_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 600,
            "value": true
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1440,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 1500,
          "to_s": 3600,
          "equals": true
        },
        {
          "output": "yRuntimeOk",
          "from_s": 0,
          "to_s": 1440,
          "equals": false
        },
        {
          "output": "yRuntimeOk",
          "from_s": 1500,
          "to_s": 3600,
          "equals": true
        }
      ]
    },
    {
      "name": "short_cycling_unit_never_reaches_the_runtime_gate",
      "description": "A fouled unit that also short-cycles: 10 min on, 10 min off, 10 min on. Each stop resets the delay, so the first window that completes 900 s of continuous running starts at 1200 s and arms at 2100 s. Below a 15-minute duty cycle this rule reports nothing at all and RTU-0001 is the finding.",
      "inputs": {
        "cond_leaving_temp": 55.0,
        "oat": 35.0,
        "cond_split_baseline": 14.0,
        "comp_status": [
          {
            "t": 0,
            "value": true
          },
          {
            "t": 600,
            "value": false
          },
          {
            "t": 1200,
            "value": true
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2040,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2100,
          "to_s": 3600,
          "equals": true
        },
        {
          "output": "yRuntimeOk",
          "from_s": 0,
          "to_s": 2040,
          "equals": false
        },
        {
          "output": "yRuntimeOk",
          "from_s": 2100,
          "to_s": 3600,
          "equals": true
        }
      ]
    },
    {
      "name": "condenser_cleaned_mid_run",
      "description": "Recovery: the alarm asserts at 900 s and the coil is hosed off at t = 1800 s, dropping the split back to 15 K. yFault falls on that tick while yRuntimeOk stays true \u2014 the compressor never stopped, so the gate never re-armed.",
      "inputs": {
        "cond_leaving_temp": [
          {
            "t": 0,
            "value": 55.0
          },
          {
            "t": 1800,
            "value": 50.0
          }
        ],
        "oat": 35.0,
        "cond_split_baseline": 14.0,
        "comp_status": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 840,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 900,
          "to_s": 1740,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 1800,
          "to_s": 3600,
          "equals": false
        },
        {
          "output": "yRuntimeOk",
          "from_s": 900,
          "to_s": 3600,
          "equals": true
        }
      ]
    },
    {
      "name": "compressor_stops_after_alarm",
      "description": "The evaluability release: an alarming unit stages off at t = 2400 s with the split unchanged. yFault and yRuntimeOk fall on the same tick, and only the pair distinguishes a cleaned coil from a unit that stopped cooling.",
      "inputs": {
        "cond_leaving_temp": 55.0,
        "oat": 35.0,
        "cond_split_baseline": 14.0,
        "comp_status": [
          {
            "t": 0,
            "value": true
          },
          {
            "t": 2400,
            "value": false
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 840,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 900,
          "to_s": 2340,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 2400,
          "to_s": 3600,
          "equals": false
        },
        {
          "output": "yRuntimeOk",
          "from_s": 900,
          "to_s": 2340,
          "equals": true
        },
        {
          "output": "yRuntimeOk",
          "from_s": 2400,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "baseline_steps_under_a_running_rule",
      "description": "The derived point moves: the split holds at 20 K while the host republishes the fit from 16 K to 14 K at t = 1800 s \u2014 a stage-up, or the OAT bin changing under the curve. The ratio crosses 0.30 on that tick and, with the gate already armed, so does yFault. Nothing in the graph waits for the physical split to catch up with the new baseline.",
      "inputs": {
        "cond_leaving_temp": 55.0,
        "oat": 35.0,
        "cond_split_baseline": [
          {
            "t": 0,
            "value": 16.0
          },
          {
            "t": 1800,
            "value": 14.0
          }
        ],
        "comp_status": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1740,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 1800,
          "to_s": 3600,
          "equals": true
        },
        {
          "output": "yRuntimeOk",
          "from_s": 900,
          "to_s": 3600,
          "equals": true
        }
      ]
    },
    {
      "name": "excess_flickering_across_the_threshold_chatters",
      "description": "Blind spot, pinned: a unit whose relative excess alternates between 0.35 and 0.25 every 300 s \u2014 a condenser fan cycling on head pressure, or a marginal coil in gusty wind. yFault follows every crossing because the rule debounces arming, not excess. A host that alarms on the raw output gets one alarm per crossing.",
      "inputs": {
        "cond_leaving_temp": [
          {
            "t": 0,
            "value": 43.5
          },
          {
            "t": 1200,
            "value": 42.5
          },
          {
            "t": 1500,
            "value": 43.5
          },
          {
            "t": 1800,
            "value": 42.5
          },
          {
            "t": 2100,
            "value": 43.5
          }
        ],
        "oat": 30.0,
        "cond_split_baseline": 10.0,
        "comp_status": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 840,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 900,
          "to_s": 1140,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 1200,
          "to_s": 1440,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 1500,
          "to_s": 1740,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 1800,
          "to_s": 2040,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2100,
          "to_s": 3600,
          "equals": true
        },
        {
          "output": "yRuntimeOk",
          "from_s": 900,
          "to_s": 3600,
          "equals": true
        }
      ]
    },
    {
      "name": "outdoor_air_sensor_reading_high_hides_fouling",
      "description": "The other blind spot, pinned: a sun-baked outdoor-air sensor reads 40 \u00b0C on a 35 \u00b0C day while the coil is genuinely fouled at 55 \u00b0C leaving. The measured split shrinks to 15 K and the ratio lands on 0.0714 \u2014 the identical number the clean scenario produces. oat is on both sides of this test, the split and the fit that predicts it, and a bias on it is invisible to the graph in the false-negative direction.",
      "inputs": {
        "cond_leaving_temp": 55.0,
        "oat": 40.0,
        "cond_split_baseline": 14.0,
        "comp_status": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        },
        {
          "output": "yRuntimeOk",
          "from_s": 900,
          "to_s": 3600,
          "equals": true
        }
      ]
    },
    {
      "name": "zero_baseline_violates_the_point_contract_and_alarms",
      "description": "Contract violation, pinned so the failure direction is on the record: a host publishes cond_split_baseline = 0 \u2014 an unfitted instance, or a default that leaked \u2014 against a real 20 K split. The graph has no divide guard, the quotient goes non-finite, and the strict comparison passes, so the rule alarms permanently instead of going quiet. Loud is the survivable direction for a misconfiguration, but the point dictionary requires a strictly positive baseline and this is what happens when that is not honoured.",
      "inputs": {
        "cond_leaving_temp": 55.0,
        "oat": 35.0,
        "cond_split_baseline": 0.0,
        "comp_status": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 840,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 900,
          "to_s": 3600,
          "equals": true
        },
        {
          "output": "yRuntimeOk",
          "from_s": 900,
          "to_s": 3600,
          "equals": true
        }
      ]
    }
  ]
}

RTU-0008 — Refrigerant undercharge — superheat/subcooling divergence

Statusverified — engine e2ff2f8, cxf:fnv1a128:6185ddcde7a0e68b845e3af5bcea5b05, 2026-08-18
Severity3
Methodrule
Phase2
CategoryEFFICIENCY_LOSS
ConfidenceMEDIUM
EstimationPROXY_ESTIMATION
G36
Clusters
Suppresses
Suppressed by
RelatedHP-0004, RTU-0009, RTU-0007, RTU-0001, RTU-0010, RTU-0011
Playbooksrtu-compressor-refrigerant
SourceLibrary-authored — HVAC FDD Reference v1.0 §11 specifies no refrigerant-charge rule for packaged units; its playbook reaches charge only as a cause of short-cycling (Step 2.1.2); NIST SP 1087, Kim, Yoon, Payne & Domanski, Cooling Mode Fault Detection and Diagnosis Method for a Residential Heat Pump, NIST, October 2008: §5.4.3 Table 5.2 (undercharge fault-direction rows, zones A and B), §5.4.2 and §5.4.4 (the 0.5 °C upstream-subcooling test and its role in selecting a chart), §5.5.1 Figs. 5.16-5.17 (fault level versus EER degradation), §5.5.2 Table 5.16 (undercharge diagnosis results) — a residential heat-pump study restated here for unitary packaged equipment (see Deviations); Breuker & Braun 1998b and Rossi & Braun 1997, reproduced as SP 1087 Table 5.1(a) — the fixed-orifice refrigerant-leakage row, which is the half of the packaged population SP 1087’s own TXV rig does not represent; Li, H. & Braun, J.E. (2009), Decoupling features and virtual sensors for diagnosis of faults in vapor compression air conditioners, HVAC&R Research 15(1) — the virtual-refrigerant-charge sensor built from four surface-mounted temperatures and validated across seven unitary systems; the lineage for reading charge off exactly the temperatures this rule reads; Kim, W. & Braun, J.E. (2020), Energy and Buildings 225 — integrated virtual sensors demonstrated on rooftop units, the packaged-equipment continuation of that work; Hu, Y. et al. (2021), Energy and Buildings 248 — single-feature charge inference degrades when other faults are present; the simultaneous-fault caveat, carried in Deviations as on HP-0004; Sibling precedent: HP-0004 (the heat-pump original this card mirrors, including the sub-condition-flag resolution), RTU-0002 (fixed bands named as a simplification of a regressed baseline), RTU-0007 (settled-compressor and stage-change gating as host preconditions)
Operating statesmechanical cooling, compressor running and settled — one instance per refrigerant circuit. Cooling-only packaged equipment, so there is no mode split and no defrost state to exclude; a unit in economizer free cooling has its compressors off and is covered by the compressor gate.

Preconditions (host-enforced): The compressor must be running and must have held its current stage for min_runtime_for_eval (15 min). With the compressor off all four temperatures equalise and both differences collapse to zero; after a start or a stage change superheat overshoots for minutes while the metering device catches up, and on a multi-stage unit the whole refrigerant-side operating point moves (RTU-0007’s stage-change precondition, same reason). Short-cycling units may never present a settled window at all — that silence is RTU-0001’s finding, not a healthy charge. evap_sat_temp and cond_sat_temp are host-derived P-T lookups and each lookup MUST be configured for the refrigerant actually in the machine: a wrong refrigerant biases both differences at once and in opposite directions, which is this rule’s exact fault pattern. The suction and liquid probes must have good pipe contact and be insulated from ambient air — on a rooftop an unshaded, uninsulated liquid-line probe reads solar gain and fabricates collapsed subcooling. Head-pressure control must be at its normal control point; a unit deliberately flooding the condenser on a cool day moves subcooling by design. Read yTxvSaturated as diagnostic context, NOT as an evaluability gate: this rule has no in-graph NO_EVAL test and false never means healthy (see Deviations).

Points: suction_temp, evap_sat_temp, cond_sat_temp, liquid_temp

Outputs:

  • yFault — True while suction superheat has stayed above superheat_high_band and liquid subcooling below subcooling_low_band, both continuously for at least alarm_delay
  • yTxvSaturated — Sub-condition flag (NOT an evaluability flag; false never means NO_EVAL) — true when liquid subcooling has fallen below subcooling_two_phase_floor, meaning the liquid line is no longer measurably subcooled and the metering device is being fed two-phase refrigerant

Parameters:

NameDefaultUnitCXF pathDescription
superheat_high_band15.0°CshHigh.tSuction superheat above which the evaporator is judged starved. COMMISSIONING-SET PLACEHOLDER — the shipped 15.0 sits just above the no-fault compressor-suction superheats NIST SP 1087 reports for its test unit (10.2-13.7 °C). Set it from this unit’s own charging chart plus a tolerance, and on a fixed-orifice machine set it at the chart’s high-superheat corner — low indoor wet-bulb against a high outdoor drybulb (see Deviations); a suction-line probe reads higher than an evaporator-exit probe on the same machine.
subcooling_low_band3.0°CscLow.tLiquid subcooling below which the condenser is judged short of liquid. COMMISSIONING-SET PLACEHOLDER on the same terms — charging-chart targets run roughly 8-11 °C at design on a TXV unit and lower on a fixed-orifice one, and this band sits well under either so normal load swings do not reach it.
subcooling_two_phase_floor0.5°CtxvSat.tSubcooling below which the metering-device inlet is taken to be two-phase. Drives yTxvSaturated only. 0.5 °C is SP 1087’s own single-phase/two-phase test (§5.4.2), used there to pick which fault-direction chart applies; unlike the bands above it is a physical boundary, not a per-unit tuning.
alarm_delay1800.0spersist.delayTimeContinuous divergence required before the alarm asserts (30 min). Long enough to outlast a metering device hunting after a load step, short enough that a real charge loss is reported within the hour

Description

A machine short of refrigerant runs short of liquid. The metering device runs out of authority trying to keep the evaporator fed, so the evaporator starves while the condenser loses its liquid seal: superheat climbs and subcooling collapses at once. That divergence is the signature — capacity loss alone says nothing about cause, and a rooftop unit loses capacity quietly for a season before anyone calls it in. NIST SP 1087 imposed graded charge faults on a TXV-equipped R410A machine in cooling and recorded this pair once the valve saturated (§5.4.3, Table 5.2, zone B); Breuker & Braun’s fixed-orifice charts show it from the first pound lost. This rule forms both differences from four refrigerant-side temperatures and alarms when they sit past their commissioned bands for half an hour.

Detection Logic

suction_superheat = suction_temp  − evap_sat_temp
liquid_subcooling = cond_sat_temp − liquid_temp

yTxvSaturated = liquid_subcooling < subcooling_two_phase_floor
                (sub-condition flag; false does NOT mean NO_EVAL)

yFault        = suction_superheat > superheat_high_band
                AND liquid_subcooling < subcooling_low_band,
                sustained continuously for alarm_delay

Block graph (rule.cxf.jsonld):

RTU-0008 block graph

The conjunction is the diagnosis, not a noise filter. High superheat with subcooling high is the liquid-line or filter-drier restriction pattern, where refrigerant backs up ahead of the restriction; with subcooling merely normal it is a fixed-orifice unit at low indoor load, which is no fault at all. Low subcooling with superheat still normal is a valve compensating successfully. Only the two together indict the charge.

Both comparisons are strict, so a unit exactly on either band reads healthy, and both bands are per-unit commissioning values — the shipped defaults are placeholders, not thresholds anyone measured on the machine in front of you. persist requires 30 continuous minutes and clocks them from the conjunction, not from the first symptom; delayOnInit = true holds that window across a controller restart. yTxvSaturated reports whether the liquid line is still measurably subcooled; it does not gate the alarm, and the reason it must not is the card’s main deviation.

Possible Diagnoses

  1. Refrigerant leak — brazed joints, Schrader cores, service-valve packing and flare connections, in that order of prevalence; a top-up without a leak search buys months, not years
  2. The unit was charged short, at commissioning or after a rooftop repair that vented the circuit and was recharged by pressure rather than by weight
  3. Condenser airflow restriction (RTU-0007) — a fouled coil moves this pair the same way and separates only on condensing temperature, which rises rather than falls. This rule reads differences, not levels, so RTU-0007’s split is the discriminator and neither card suppresses the other
  4. Instrumentation: a P-T derivation configured for the wrong refrigerant, or a liquid-line probe with poor contact, missing insulation or sun on it. Each fabricates the pattern on a correctly charged machine

Energy Impact

EFFICIENCY_LOSS, MEDIUM confidence, PROXY_ESTIMATION. waste_kw = compressor_kw × d / (1 − d), the extra compressor runtime needed to deliver the same cooling at a degraded EER. NIST SP 1087 sizes d directly: every fault it tested except compressor leakage needed a fault level above 10% to cost 5% of EER, and a 20% charge shortfall cost 6.5-13% of EER, the largest hit in its Figure 5.17. Confidence is MEDIUM because the rule fires on a pattern, not a severity — it reports that the charge is low, not by how much, so d is a population number until the technician’s gauge set supplies a real one.

Emissions Impact

Scope 2, PROXY_EMISSIONS, MEDIUM confidence; typically 300-2,000 kg CO₂e/yr for a commercial packaged unit, the same order as RTU-0002 and RTU-0007 and all of it compressor electricity, so the avoided-emissions basis is the marginal operating emissions rate (MOER) and the waste peaks on the hot afternoons when the grid is dirtiest. A leaking circuit also vents refrigerant, and R410A carries a GWP near 2,000; that release is a scope 1 emission this card does not estimate, because the leak rate is not observable from any point the rule reads. Sites with refrigerant-tracking obligations should account for it separately.

Deviations

  • The 0.5 °C subcooling floor is a sub-condition flag, not an evaluability gate. SP 1087 uses it (§5.4.4) to select which fault-direction chart applies, never to suppress evaluation, and that chart still lists falling subcooling as an undercharge symptom. Gating yFault on it would silence the rule exactly when the liquid line has flashed to two-phase — severe undercharge, not missing data — so the flag is named yTxvSaturated, not y…Ok.
  • The flag keeps HP-0004’s name on a family where half the population has no TXV. Nothing saturates on a fixed-orifice unit, but the measurement is identical — the liquid line is no longer measurably subcooled — and outputs says so. A per-family rename would cost hosts binding both cards a shared signal name for one word of accuracy.
  • The rule does not know which expansion device it is watching, and does not need to. SP 1087 splits its charts on that device — Table 5.2 for TXV, Table 5.1(a) for fixed orifice — but both list superheat up with subcooling down for undercharge, so the pattern is common ground and no point or parameter records the device. The split moves into commissioning instead, and it lands on the superheat band: on a fixed-orifice unit superheat follows indoor wet-bulb and outdoor drybulb rather than being controlled (charging charts for that population are two-dimensional for that reason), so the band belongs at the chart’s high-superheat corner — low indoor wet-bulb against a high outdoor drybulb — or a dry-climate unit alarms on its hottest afternoons (fixed_orifice_unit_at_low_indoor_load pins the case).
  • Fixed bands replace SP 1087’s regressed no-fault baseline. SP 1087 compares each feature against a third-order regression on three variables; this library’s only regression primitive is a host-fitted line, so charging-chart nominal targets stand in — a real simplification, named as one on RTU-0002’s precedent, and the reason untouched defaults can alarm forever.
  • Sensitivity runs opposite between the two populations. On a TXV unit undercharge moves no superheat until the valve saturates (SP 1087’s zone A), so a mild loss the valve absorbs reads healthy here; earlier investigators reported difficulty detecting undercharge below roughly 40% charge loss (Breuker & Braun 1998b; Stylianou & Nikanpour 1996, via SP 1087 §2). A fixed-orifice unit is caught earlier and pays for it in false-alarm exposure at low load.
  • Two features, so condenser airflow restriction is not excluded. It shares the superheat-up/subcooling-down pair and separates on condensing temperature moving up rather than down — a level test needing a baseline, which RTU-0007 has and this rule does not. Diagnosis 3 names it and condenser_restriction_reads_as_undercharge pins that this rule fires on it; no suppression either way, because neither rule adjudicates the other’s evidence.
  • The grounding transfers from a residential heat pump to packaged equipment. A starved evaporator and an unsealed condenser are circuit-level physics, and Li & Braun (2009) validated charge inference from these same four surface temperatures across seven unitary systems, Kim & Braun (2020) carrying it onto rooftop units. What transfers is the pattern, not the numbers: no SP 1087 threshold is adopted except the physical two-phase floor.
  • Cooling-only, which is where this card stands on firmer ground than HP-0004. SP 1087 tested cooling exclusively, so the heat-pump reading has to carry a caveat about the coils swapping roles in heating. A cooling-only packaged unit has no reversing valve and no defrost cycle: the sensors keep their heat exchangers year-round, operating_states needs no mode split and preconditions no defrost gate. Bind HP-0004 for a heat-pump rooftop.
  • Compressor and steady-state gating stay host preconditions. The graph computes the fault given valid data, per SCHEMA.md; comp_status and time-since-stage-change are not among its inputs, and the 30-minute alarm_delay does not substitute — a post-start superheat overshoot starts the persistence timer rather than being excluded from it.
  • The pattern chart is a single-fault chart. SP 1087 imposed one fault at a time, and Hu et al. (2021) show single-feature charge inference degrading when other faults are present, their residuals superposing. Two faults at once can cancel this rule’s pattern or fake it; the diagnosis list is a ranking, not a verdict.
  • Strict > and < at both bands. CDL Reals has no GreaterEqual or LessEqual, so a unit sitting exactly on a band reads healthy. The disagreement is measure-zero on real-valued signals; both sides of both bands, and of the two-phase floor, are pinned bit-exactly by vectors.
  • The playbook covers the work but does not yet list this rule. Its Step 2.1.2 already sends the technician to superheat and subcooling against manufacturer specs, the measurement this rule automates; the Applies-To row and a charge-specific Step 1 entry are the index owner’s to add.
  • persist.delayOnInit = true (CDL default is false), the library’s standing choice. Severity 3 and category: EFFICIENCY_LOSS follow HP-0004 and RTU-0002; the namespace urn:cxf-library:rtu-0008# is SCHEMA.md’s normative form, as on RTU-0007 and VAV-0010. No reference card exists to inherit any of this from and none publishes test vectors, so every scenario in vectors.json is authored from the equation and replayed against the pinned engine rev.

Notes

Do not read a cleared alarm as a repaired machine: the host gates this rule on the compressor running, so every stop drops yFault for the same reason a recharge does, and a short-cycling unit may never complete a settled window at all. When yTxvSaturated is true, expect flash gas at the sight glass and weigh the recovered charge rather than trusting subcooling to confirm the fix. Check RTU-0007 before opening the gauges — a fouled condenser makes this same pair and costs a coil wash rather than a leak search. RTU-0009 is the overcharge branch, HP-0004 the heat-pump original, and rtu-compressor-refrigerant orders the on-site work.

Test Vectors

16 scenarios, clock step 60 s over 7200 s.

ScenarioDescription
correctly_charged_unitNominal mechanical cooling: 11 degC of suction superheat against a 15 degC band and 9 degC of subcooling against a 3 degC band. Neither conjunct holds and the liquid line is comfortably single-phase.
undercharge_with_liquid_still_subcooledThe signature: 20 degC superheat with subcooling collapsed to 1.5 degC. Both conjuncts hold from t=0, so delayOnInit puts the alarm exactly one alarm_delay in. Subcooling is still above the two-phase floor, so yTxvSaturated stays false and the fault does not depend on it.
high_superheat_with_high_subcooling_is_not_underchargeThe discriminator the subcooling conjunct exists for: 20 degC superheat with subcooling raised to 12 degC is the liquid-line-restriction pattern, where refrigerant backs up ahead of the restriction. Superheat alone would call this a charge fault; the AND does not.
fixed_orifice_unit_at_low_indoor_loadThe RTU false positive the conjunction blocks: a fixed-orifice machine at the high-superheat corner of its charging chart - low indoor wet-bulb against a hot outdoor day - runs 18 degC of superheat with its charge intact, because on that population superheat follows load rather than being controlled. Subcooling stays at a healthy 8 degC and nothing asserts.
low_subcooling_alone_while_the_valve_compensatesSubcooling down to 1 degC with superheat held at a normal 11 degC - a TXV still in control. This is the in-control-zone undercharge pattern and the rule deliberately does not fire on it (see Deviations); the superheat conjunct blocks alone.
superheat_exactly_at_the_bandThreshold edge: superheat is exactly superheat_high_band (20.0 - 5.0 = 15.0, binary-exact) with subcooling already low. The comparison is a strict >, so a unit sitting precisely on the band reads healthy.
superheat_just_above_the_bandThreshold edge from the other side: 15.25 degC of superheat, a quarter of a degree over the band, and the alarm matures at alarm_delay.
subcooling_exactly_at_the_bandThreshold edge: subcooling is exactly subcooling_low_band (33.0 - 30.0 = 3.0, binary-exact) with superheat well over its band. The comparison is a strict <, so a unit precisely on the band reads healthy.
subcooling_just_below_the_bandThreshold edge from the other side: 2.75 degC of subcooling clears the band and the alarm matures.
subcooling_exactly_at_the_two_phase_floorSub-condition-flag edge: subcooling is exactly subcooling_two_phase_floor (30.5 - 30.0 = 0.5, binary-exact). The floor test is a strict <, so yTxvSaturated is false - and the fault asserts anyway, because the flag is a sub-condition, not a gate.
subcooling_below_the_two_phase_floorThe card’s design point, and the other side of the floor edge: 0.25 degC of subcooling means the liquid line is no longer measurably subcooled and the metering device is being fed two-phase refrigerant - a severe charge loss. yTxvSaturated goes true and yFault still asserts; a host that treated the flag as an evaluability gate would go silent on the worst case this rule exists for.
condenser_restriction_reads_as_underchargeThe RTU-0007 interaction, pinned rather than hidden: a fouled condenser on a correctly charged unit also drives superheat up (18 degC) and subcooling down (2 degC), so this rule alarms. The one thing that separates the two - condensing temperature climbing to 50 degC rather than falling - is present in the inputs and invisible to the graph, which reads only the difference. Nothing suppresses either rule; RTU-0007’s condenser split is the discriminator and the two cards carry each other in related.
timer_starts_when_the_second_conjunct_arrivesSubcooling is low from t=0 but superheat only crosses its band at t=1200 s, as the charge loss deepens past the point where the valve can keep holding superheat. The persistence window runs from the conjunction, not from the first symptom, so the alarm lands at 3000 s.
transient_superheat_excursion_never_alarmsSuperheat spikes over its band for 1500 s - a load step, or an expansion valve hunting after a stage change - and settles back 300 s short of alarm_delay. The timer resets on the recovery and nothing is reported.
alarm_clears_after_the_charge_is_restoredRecovery: the divergence alarms at 1800 s, a technician weighs in the missing charge at t = 3600 s, and both readings return to nominal. yFault drops on that same tick - TrueDelay only delays the rising edge.
idle_unit_reads_healthy_and_the_flag_reads_saturatedWhy the compressor-running precondition is host-enforced rather than optional: with the unit off, all four temperatures equalise, superheat and subcooling both collapse to zero, and the graph reports no fault while yTxvSaturated reads true. Neither answer means anything about the refrigerant charge.
vectors.json
{
  "schema": "cxf-library/vectors/v1",
  "clock": {
    "step_s": 60,
    "horizon_s": 7200
  },
  "scenarios": [
    {
      "name": "correctly_charged_unit",
      "description": "Nominal mechanical cooling: 11 degC of suction superheat against a 15 degC band and 9 degC of subcooling against a 3 degC band. Neither conjunct holds and the liquid line is comfortably single-phase.",
      "inputs": {
        "suction_temp": 16.0,
        "evap_sat_temp": 5.0,
        "cond_sat_temp": 39.0,
        "liquid_temp": 30.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        },
        {
          "output": "yTxvSaturated",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        }
      ]
    },
    {
      "name": "undercharge_with_liquid_still_subcooled",
      "description": "The signature: 20 degC superheat with subcooling collapsed to 1.5 degC. Both conjuncts hold from t=0, so delayOnInit puts the alarm exactly one alarm_delay in. Subcooling is still above the two-phase floor, so yTxvSaturated stays false and the fault does not depend on it.",
      "inputs": {
        "suction_temp": 25.0,
        "evap_sat_temp": 5.0,
        "cond_sat_temp": 31.5,
        "liquid_temp": 30.0
      },
      "expect": [
        {
          "output": "yTxvSaturated",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1740,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 1800,
          "to_s": 7200,
          "equals": true
        }
      ]
    },
    {
      "name": "high_superheat_with_high_subcooling_is_not_undercharge",
      "description": "The discriminator the subcooling conjunct exists for: 20 degC superheat with subcooling raised to 12 degC is the liquid-line-restriction pattern, where refrigerant backs up ahead of the restriction. Superheat alone would call this a charge fault; the AND does not.",
      "inputs": {
        "suction_temp": 25.0,
        "evap_sat_temp": 5.0,
        "cond_sat_temp": 42.0,
        "liquid_temp": 30.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        },
        {
          "output": "yTxvSaturated",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        }
      ]
    },
    {
      "name": "fixed_orifice_unit_at_low_indoor_load",
      "description": "The RTU false positive the conjunction blocks: a fixed-orifice machine at the high-superheat corner of its charging chart - low indoor wet-bulb against a hot outdoor day - runs 18 degC of superheat with its charge intact, because on that population superheat follows load rather than being controlled. Subcooling stays at a healthy 8 degC and nothing asserts.",
      "inputs": {
        "suction_temp": 23.0,
        "evap_sat_temp": 5.0,
        "cond_sat_temp": 52.0,
        "liquid_temp": 44.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        },
        {
          "output": "yTxvSaturated",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        }
      ]
    },
    {
      "name": "low_subcooling_alone_while_the_valve_compensates",
      "description": "Subcooling down to 1 degC with superheat held at a normal 11 degC - a TXV still in control. This is the in-control-zone undercharge pattern and the rule deliberately does not fire on it (see Deviations); the superheat conjunct blocks alone.",
      "inputs": {
        "suction_temp": 16.0,
        "evap_sat_temp": 5.0,
        "cond_sat_temp": 31.0,
        "liquid_temp": 30.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        },
        {
          "output": "yTxvSaturated",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        }
      ]
    },
    {
      "name": "superheat_exactly_at_the_band",
      "description": "Threshold edge: superheat is exactly superheat_high_band (20.0 - 5.0 = 15.0, binary-exact) with subcooling already low. The comparison is a strict `>`, so a unit sitting precisely on the band reads healthy.",
      "inputs": {
        "suction_temp": 20.0,
        "evap_sat_temp": 5.0,
        "cond_sat_temp": 31.0,
        "liquid_temp": 30.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        }
      ]
    },
    {
      "name": "superheat_just_above_the_band",
      "description": "Threshold edge from the other side: 15.25 degC of superheat, a quarter of a degree over the band, and the alarm matures at alarm_delay.",
      "inputs": {
        "suction_temp": 20.25,
        "evap_sat_temp": 5.0,
        "cond_sat_temp": 31.0,
        "liquid_temp": 30.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1740,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 1800,
          "to_s": 7200,
          "equals": true
        }
      ]
    },
    {
      "name": "subcooling_exactly_at_the_band",
      "description": "Threshold edge: subcooling is exactly subcooling_low_band (33.0 - 30.0 = 3.0, binary-exact) with superheat well over its band. The comparison is a strict `<`, so a unit precisely on the band reads healthy.",
      "inputs": {
        "suction_temp": 25.0,
        "evap_sat_temp": 5.0,
        "cond_sat_temp": 33.0,
        "liquid_temp": 30.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        }
      ]
    },
    {
      "name": "subcooling_just_below_the_band",
      "description": "Threshold edge from the other side: 2.75 degC of subcooling clears the band and the alarm matures.",
      "inputs": {
        "suction_temp": 25.0,
        "evap_sat_temp": 5.0,
        "cond_sat_temp": 32.75,
        "liquid_temp": 30.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1740,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 1800,
          "to_s": 7200,
          "equals": true
        }
      ]
    },
    {
      "name": "subcooling_exactly_at_the_two_phase_floor",
      "description": "Sub-condition-flag edge: subcooling is exactly subcooling_two_phase_floor (30.5 - 30.0 = 0.5, binary-exact). The floor test is a strict `<`, so yTxvSaturated is false - and the fault asserts anyway, because the flag is a sub-condition, not a gate.",
      "inputs": {
        "suction_temp": 25.0,
        "evap_sat_temp": 5.0,
        "cond_sat_temp": 30.5,
        "liquid_temp": 30.0
      },
      "expect": [
        {
          "output": "yTxvSaturated",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1740,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 1800,
          "to_s": 7200,
          "equals": true
        }
      ]
    },
    {
      "name": "subcooling_below_the_two_phase_floor",
      "description": "The card's design point, and the other side of the floor edge: 0.25 degC of subcooling means the liquid line is no longer measurably subcooled and the metering device is being fed two-phase refrigerant - a severe charge loss. yTxvSaturated goes true and yFault still asserts; a host that treated the flag as an evaluability gate would go silent on the worst case this rule exists for.",
      "inputs": {
        "suction_temp": 25.0,
        "evap_sat_temp": 5.0,
        "cond_sat_temp": 30.25,
        "liquid_temp": 30.0
      },
      "expect": [
        {
          "output": "yTxvSaturated",
          "from_s": 0,
          "to_s": 7200,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1740,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 1800,
          "to_s": 7200,
          "equals": true
        }
      ]
    },
    {
      "name": "condenser_restriction_reads_as_undercharge",
      "description": "The RTU-0007 interaction, pinned rather than hidden: a fouled condenser on a correctly charged unit also drives superheat up (18 degC) and subcooling down (2 degC), so this rule alarms. The one thing that separates the two - condensing temperature climbing to 50 degC rather than falling - is present in the inputs and invisible to the graph, which reads only the difference. Nothing suppresses either rule; RTU-0007's condenser split is the discriminator and the two cards carry each other in `related`.",
      "inputs": {
        "suction_temp": 23.0,
        "evap_sat_temp": 5.0,
        "cond_sat_temp": 50.0,
        "liquid_temp": 48.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1740,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 1800,
          "to_s": 7200,
          "equals": true
        },
        {
          "output": "yTxvSaturated",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        }
      ]
    },
    {
      "name": "timer_starts_when_the_second_conjunct_arrives",
      "description": "Subcooling is low from t=0 but superheat only crosses its band at t=1200 s, as the charge loss deepens past the point where the valve can keep holding superheat. The persistence window runs from the conjunction, not from the first symptom, so the alarm lands at 3000 s.",
      "inputs": {
        "evap_sat_temp": 5.0,
        "cond_sat_temp": 31.0,
        "liquid_temp": 30.0,
        "suction_temp": [
          {
            "t": 0,
            "value": 16.0
          },
          {
            "t": 1200,
            "value": 25.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2940,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 3000,
          "to_s": 7200,
          "equals": true
        }
      ]
    },
    {
      "name": "transient_superheat_excursion_never_alarms",
      "description": "Superheat spikes over its band for 1500 s - a load step, or an expansion valve hunting after a stage change - and settles back 300 s short of alarm_delay. The timer resets on the recovery and nothing is reported.",
      "inputs": {
        "evap_sat_temp": 5.0,
        "cond_sat_temp": 31.0,
        "liquid_temp": 30.0,
        "suction_temp": [
          {
            "t": 0,
            "value": 16.0
          },
          {
            "t": 900,
            "value": 25.0
          },
          {
            "t": 2400,
            "value": 16.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        }
      ]
    },
    {
      "name": "alarm_clears_after_the_charge_is_restored",
      "description": "Recovery: the divergence alarms at 1800 s, a technician weighs in the missing charge at t = 3600 s, and both readings return to nominal. yFault drops on that same tick - TrueDelay only delays the rising edge.",
      "inputs": {
        "evap_sat_temp": 5.0,
        "liquid_temp": 30.0,
        "suction_temp": [
          {
            "t": 0,
            "value": 25.0
          },
          {
            "t": 3600,
            "value": 16.0
          }
        ],
        "cond_sat_temp": [
          {
            "t": 0,
            "value": 31.0
          },
          {
            "t": 3600,
            "value": 39.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1740,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 1800,
          "to_s": 3540,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 3600,
          "to_s": 7200,
          "equals": false
        }
      ]
    },
    {
      "name": "idle_unit_reads_healthy_and_the_flag_reads_saturated",
      "description": "Why the compressor-running precondition is host-enforced rather than optional: with the unit off, all four temperatures equalise, superheat and subcooling both collapse to zero, and the graph reports no fault while yTxvSaturated reads true. Neither answer means anything about the refrigerant charge.",
      "inputs": {
        "suction_temp": 22.0,
        "evap_sat_temp": 22.0,
        "cond_sat_temp": 22.0,
        "liquid_temp": 22.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        },
        {
          "output": "yTxvSaturated",
          "from_s": 0,
          "to_s": 7200,
          "equals": true
        }
      ]
    }
  ]
}

RTU-0009 — Refrigerant overcharge — high liquid subcooling

Statusverified — engine e2ff2f8, cxf:fnv1a128:6ae419eb306de9d41cfb42dd50a196d8, 2026-08-18
Severity3
Methodrule
Phase2
CategoryEFFICIENCY_LOSS
ConfidenceMEDIUM
EstimationPROXY_ESTIMATION
G36
Clusters
Suppresses
Suppressed by
RelatedHP-0005, RTU-0008, RTU-0007, RTU-0010, RTU-0011
Playbooksrtu-compressor-refrigerant
SourceNIST SP 1087, Kim, Yoon, Payne & Domanski, Cooling Mode Fault Detection and Diagnosis Method for a Residential Heat Pump (October 2008), Table 5.2 — for refrigerant overcharge on a TXV-equipped unit, subcooling, condensing temperature and discharge temperature all rise together while superheat does not move; NIST SP 1087 §5.4.2 — the 0.5 °C upstream-subcooling test that selects which fault chart applies; Figs. 5.16-5.17 and Table 5.17 — overcharge is the least EER-sensitive fault tested, and was diagnosed correctly at fault levels its own EER-degradation detector never flagged; Li, H. & Braun, J.E. (2009), Decoupling features and virtual sensors for diagnosis of faults in vapor compression air conditioners, HVAC&R Research 15(1) — the virtual-refrigerant-charge sensor built from surface-mounted temperatures and validated across unitary systems; the lineage for reading charge off the temperatures this card reads, cited for the measurement rather than for a threshold; Kim, W. & Braun, J.E. (2020), Energy and Buildings 225 — integrated virtual sensors demonstrated on rooftop units, the packaged-equipment continuation of that work; Hu, Y. et al. (2021), Energy and Buildings 248 — single-feature charge inference degrades when other faults are present; the simultaneous-fault caveat, which on a rooftop is the normal case; HVAC FDD Reference v1.0 §11 specifies no refrigerant-charge rule for this family; name, severity 3 and method: rule are argued on this card. Its Remediation Playbooks (pp. 168-169), carried in playbooks/rtu-compressor-refrigerant.md, touch charge once — Step 2.1.2, ‘check subcooling and superheat against manufacturer specs’, filed under short-cycling; points/rtu.points.json cond_sat_temp and liquid_temp — the host-derived P-T contract and the RTU-0008/RTU-0009 charge pair the dictionary notes name; Sibling precedent: HP-0005 (the heat-pump instance of this rule, whose graph this mirrors), RTU-0007 (condenser-side degradation, in-graph runtime gate), RTU-0002 (a fixed baseline shipped as a named simplification of a fitted model)
Operating statesmechanical cooling, compressor running and settled — the packaged-unit analogue of the source’s tested mode. One instance per refrigerant circuit; a two-circuit rooftop needs two, each with its own probes and its own band.

Preconditions (host-enforced): The host gates this rule end to end and there is no in-rule evaluability output: silence outside the gates is NO_EVAL by frontmatter, not a healthy charge. comp_status must have been true continuously for at least 10 min — subcooling on the source’s rig took roughly 5-8 min to settle after a start — and the host must hold evaluation off across a compressor STAGE change, which comp_status does not report: unloading leaves the condenser holding more liquid than the running stage needs. Evaluation must also be suspended while low-ambient head-pressure control is active — cycled or slowed condenser fans, or a flooded-condenser valve, hold liquid in the coil on purpose and produce this rule’s exact signature on a correctly charged unit. No point in this dictionary reports that state, so the gate can only be host-side. cond_sat_temp is host-derived through a refrigerant P-T lookup and the lookup must match the refrigerant actually in the machine; on rooftops replaced since the A2L transition that is often no longer the R-410A a site template assumes, and the wrong table offsets every subcooling reading by a near-constant the rule cannot distinguish from charge. liquid_temp must be sensed between the condenser outlet and the metering device, insulated from the condenser discharge airstream it sits in. subcooling_high_band must be commissioned against this unit’s own known-good subcooling before any verdict means anything (see Deviations).

Points: cond_sat_temp, liquid_temp

Outputs:

  • yFault — True while liquid subcooling (cond_sat_temp − liquid_temp) has stayed above subcooling_high_band continuously for at least alarm_delay

Parameters:

NameDefaultUnitCXF pathDescription
subcooling_high_band12.0°CscHigh.tLiquid subcooling above which the circuit is holding more refrigerant than it should. PER-UNIT COMMISSIONING VALUE — the shipped 12.0 is roughly double a typical commissioned packaged-unit target of 5-8 °C and catches gross overcharge only. Set it to this unit’s own known-good subcooling plus about 3 °C; on a fixed-orifice unit there is no published subcooling target to read it off (see Deviations)
alarm_delay900.0spersist.delayTimeContinuous excess subcooling required before the alarm asserts (15 min). Half HP-0005’s window because a rooftop compressor cycles: the alarm has to fit inside a run cycle or it never matures. Long enough to ride out a stage change or a load step, and overcharge itself does not come and go

Description

Overcharge is refrigerant the circuit has no room for. The surplus backs up into the outlet end of the condenser, turning surface that should be condensing into extra subcooling area, and the liquid line leaves colder relative to its own condensing saturation temperature — subcooling rises, which is what this rule reads. NIST SP 1087 imposed 10%, 20% and 30% overcharge on a TXV-equipped machine in cooling and measured subcooling, condensing temperature and discharge temperature all rising together while superheat did not move at all. On a packaged rooftop unit the fault is service-induced almost by definition: the machine leaves the factory charged, carries no field line set to justify an adder, and so any surplus arrived on a service visit. It is cheap in energy — 1.1-3.4% of EER at 20% overcharge — and expensive in compressor life.

Detection Logic

liquid_subcooling = cond_sat_temp − liquid_temp

yFault = liquid_subcooling > subcooling_high_band,
         sustained continuously for alarm_delay

Block graph (rule.cxf.jsonld):

RTU-0009 block graph

One subtraction, one comparison, one timer — HP-0005’s graph on packaged-unit points and a shorter window. liquid_subcooling is computed in-graph rather than bound as a point: the dictionary defines subcooling as exactly this difference and both operands are already boundary inputs.

There is deliberately no superheat term and no evaluability output. Compressor state, stage changes and head-pressure control are host preconditions, and the last of those is the one that bites on a rooftop: a low-ambient control holding liquid in the condenser reproduces this signature on a correctly charged unit.

The comparison is strict, so a unit exactly on the band reads healthy. persist requires 15 continuous minutes and delayOnInit = true holds that window across a controller restart. Everything here is a difference, so head pressure alone never fires it: high lift with normal subcooling is RTU-0007’s finding.

Possible Diagnoses

  1. Charge added on a no-cooling call whose real fault was air-side. The rooftop failure path: a loaded filter or a fouled condenser presents as a pressure complaint, gauges go on, gas goes in, and the original fault is still there underneath the overcharge. Read RTU-0002 and RTU-0007 over the same period before touching the charge
  2. Charged by pressure rather than by weight, or topped up for several summers with no leak ever found. A factory-charged packaged unit’s target is the nameplate weight, and with no field line set there is no length adder to justify a deviation from it — the split-system excuse does not exist here
  3. Liquid-line restriction: a plugged filter-drier, a kinked line, or a clogged metering-device screen backs refrigerant up ahead of the restriction and raises subcooling identically. The source separates the two by condensing temperature falling and superheat rising, neither of which this rule reads
  4. Non-condensable gas from a short or skipped evacuation — raises head pressure and subcooling together; confirm off-cycle against standstill pressure and ambient saturation, which is why the source excluded it from its online method
  5. Not a charge fault at all: a P-T lookup running the wrong refrigerant, an uninsulated liquid-line probe sitting in the condenser airstream, or low-ambient head-pressure control doing its job. Rule these out first — they cost nothing and one of them alarms every cool morning

Energy Impact

EFFICIENCY_LOSS, MEDIUM confidence, PROXY_ESTIMATION. Overcharge was the fault EER tolerated best of the six the source imposed: 20% too much refrigerant cost 1.1-3.4% of EER across four operating conditions, and a 5% hit took 32-42% overcharge. waste_kw ≈ eer_penalty × compressor_kw, with compressor_kw supplied by the host as RTU-0002 does for rtu_kw. PROXY and MEDIUM because the rule measures a temperature difference and borrows the penalty from lab results on a residential machine. The case for fixing it is only partly the meter: excess charge raises head pressure and discharge temperature and pushes liquid toward the compressor, a reliability cost this rule cannot size.

Emissions Impact

Scope 2, PROXY_EMISSIONS, MEDIUM confidence; on the order of 50-400 kg CO₂e/yr for a commercial packaged unit, scaling with tonnage and cooling hours — HP-0005’s basis, and an order below RTU-0002’s 300-2,000 kg because this fault’s efficiency penalty is small. The waste is compressor electricity, so the avoided-emissions basis is the marginal operating emissions rate (MOER). The larger climate term is off that meter: the surplus must be recovered rather than vented, and R-410A, still the bulk of the installed rooftop fleet, has a GWP above 2,000 against roughly 470-675 for the R-454B and R-32 machines replacing it.

Deviations

  • A fixed nominal band replaces the source’s regressed reference model — the central simplification, carried from HP-0005. The source predicts each feature from a third-order polynomial in outdoor drybulb, indoor drybulb and indoor dew point and tests the residual against a measured noise band. No CDL block expresses that regression, so this card tests an absolute subcooling the way charging practice states one. RTU-0002 names the same substitution.
  • subcooling_high_band is a commissioning placeholder, and on a packaged unit there may be no chart to read it off. Fixed-orifice and piston metering are still common on small rooftops, and their manufacturer charging procedure targets superheat, not subcooling — so the commissioner takes the band from this unit’s own known-good operation rather than from a published target. It fails silently in both directions: set low, every hot afternoon alarms; set high, nothing ever does.
  • No superheat conjunct, for a different reason than HP-0005’s. On a TXV the valve holds superheat against the fault and the source records it unchanged at every level tested. On a fixed-orifice unit overcharge does move superheat down — but metering type is not a point in this dictionary, so a superheat conjunct would make the rule fire on part of the fleet only. Subcooling rises under both, and RTU-0008 is where superheat carries the discriminating information.
  • alarm_delay is 900 s, half the 1800 s HP-0005 and RTU-0008 both use. The window has to fit inside a run cycle, and with the 10-minute settling gate ahead of it 15 minutes already demands 25 minutes of continuous compressor operation — on a cycling rooftop unit this is a loaded-afternoon detector. The measurand affords the shorter window where RTU-0008’s does not: subcooling responds to inventory and settles in minutes, while the superheat half of the undercharge pattern hunts with the metering device, which is what that card’s 30 minutes is spent outlasting. A host wanting coverage on shorter cycles lowers it further and buys transient exposure with the difference.
  • The compressor gate stays a host precondition, unlike RTU-0007’s in-graph yRuntimeOk, and the reason is the failure direction. Ungated readings on this measurement point away from the alarm: off-cycle the high side equalises and subcooling collapses toward zero, and just after a shutdown the falling saturation temperature under a still-hot liquid line drives it negative (ungated_off_cycle_stays_silent). A missed gate here costs coverage. On RTU-0007 it points the other way — a compressor still building head pressure reads as a restricted condenser — which is why that card had to hold its gate in the graph.
  • The gate that actually matters on a rooftop cannot be expressed at all. Low-ambient head-pressure control holds liquid in the condenser deliberately and sustains 15 K of subcooling on a correct charge; no point in this dictionary reports condenser fan state or head-pressure control mode, so the precondition is prose and the vectors pin the consequence (low_ambient_head_pressure_control_alarms_as_designed) rather than hide it.
  • No evaluability output. The source’s 0.5 °C two-phase test is satisfied by construction by any subcooling large enough to trip this rule, so the flag would be true whenever yFault is and false only where “not overcharged” is a sound verdict rather than NO_EVAL. Publishing it would invite hosts to discard a correct answer.
  • RTU-0007 is related, not suppressed_by, in either direction. A condenser restriction raises condensing pressure and can widen subcooling with it, so the two cards can fire together — but they are referenced differently: RTU-0007 measures an air-side split against that unit’s own per-stage, per-OAT fit, while this card reads an absolute refrigerant-side inventory. Neither may silence the other, for two reasons: RTU-0007 is retrofit-gated on a condenser leaving-air sensor most rooftops lack, so a suppression edge would break silently wherever it is unbound; and overcharge on a fouled condenser is a real and common combination, exactly the case Hu et al. (2021) warn single-fault-fitted diagnosis misreads. The pair is the diagnosis (see Notes).
  • Strict > at the band. CDL Reals has no GreaterEqual, so a unit exactly on the band reads healthy where a technician would call it high — measure-zero on a real-valued signal, and both sides are pinned, one of them bit-exact.
  • persist.delayOnInit = true against the CDL default false, the library’s standing choice: a unit already overcharged at controller start waits out the full 15 minutes rather than alarming on the first tick.
  • Single-fault provenance. The source’s chart is fit from singly imposed faults, and the virtual-charge lineage this card borrows its measurement from (Li & Braun 2009; Kim & Braun 2020) is calibrated the same way. One boolean on one feature inherits no ranking error, but read the diagnosis list as a family whenever a second fault is plausible.
  • The playbook carries no charge step for this fault yet. Its nearest content is Step 2.1.2 — check subcooling and superheat against manufacturer specs — filed under short-cycling, and its Applies-To row does not list this rule. Both edits belong to the playbook’s owner, not to this card; the Notes below state the field procedure in the meantime.
  • Severity 3, category: EFFICIENCY_LOSS and clusters: [] are authored — the reference chapter has no card for this fault. method: rule describes this graph, not the source, whose own diagnosis is a probabilistic classifier over regressed residuals. No published test vectors exist; every scenario in vectors.json is authored from the equation and replayed against the pinned engine rev.

Notes

Take the service history before the gauges: the surplus came from a visit, and the visit usually had another reason. Read RTU-0007 and RTU-0002 over the same window — if the condenser split is also wide, the coil is the trigger and the charge is what a previous tech did about it, so clean and re-measure before recovering anything. RTU-0007 silent with this card firing puts the surplus in the inventory: charge, a restriction, or non-condensables. RTU-0008 alongside is a contradiction rather than a machine both over- and undercharged — suspect the P-T lookup. Resolve to the nameplate weight with the recovered refrigerant weighed, and rule out the liquid-line restriction first: pulling charge out of a unit with a plugged drier makes it worse.

Test Vectors

11 scenarios, clock step 60 s over 7200 s.

ScenarioDescription
nominal_subcooling_healthyA correctly charged rooftop unit on a warm afternoon: 45 °C condensing saturation against a 37 °C liquid line is 8 K of subcooling, a normal packaged-unit reading and two thirds of the shipped band.
design_day_lift_same_subcooling_healthyBoth temperatures 13 K higher — a 40 °C roof, or a condenser the RTU-0007 test would already be reporting — and the same 8 K of subcooling. This rule reads the difference, not the level, so high head pressure on its own is not a charge verdict. Pairs with overcharged_liquid_backs_up: swap the operands and both flip.
overcharged_liquid_backs_upFAULT: excess refrigerant floods the lower condenser circuits and the liquid line leaves at 34 °C against a 50 °C condensing saturation — 16 K of subcooling. Both operands hold from t = 0, so delayOnInit puts the alarm exactly one alarm_delay in.
subcooling_exactly_at_the_bandThreshold edge: 47.0 − 35.0 is exactly subcooling_high_band (12.0 K) and both operands are binary-exact, so this is a true boundary pin rather than a rounding one. The comparison is strict, so a unit sitting precisely on the band reads healthy.
subcooling_just_above_the_bandThreshold edge from the other side: 47.2 − 35.0 is 12.2 K, two tenths past the band, and the alarm matures on the normal schedule.
two_phase_liquid_line_reads_healthy0.2 K of subcooling — a liquid line with no measurable liquid seal, which is where the charge question belongs to RTU-0008 rather than to this card. No evaluability flag is published for it because the verdict is sound: whatever this unit is, it is not overcharged.
ungated_off_cycle_stays_silentThe host’s compressor gate is the one precondition this rule can survive losing. Minutes after a stage drops out, the high side has equalised down to a 30 °C saturation while the liquid line is still holding 45 °C of stored heat: subcooling reads −15 K and the rule is silent. Off-cycle error on this measurement points away from the alarm, which is why the gate stays host-side (see Deviations).
stage_down_transient_never_alarmsA two-stage unit unloads to stage 1 at t = 600 s and the condenser is briefly holding more liquid than one compressor needs: 16 K of subcooling for 600 s, back to 8 K at t = 1200 s, 300 s short of alarm_delay. The timer resets on recovery and nothing is reported.
delay_runs_from_the_crossing_not_from_loadThe unit starts at 8 K and crosses the band at t = 1200 s, holding 14 K thereafter. The alarm lands at 1200 + alarm_delay, not at alarm_delay — the persistence window is measured from the crossing.
alarm_clears_after_charge_recovered_to_nameplateRecovery: 16 K of subcooling alarms at 900 s, the technician recovers the excess to the nameplate weight at t = 3600 s, and the liquid line returns to 8 K. yFault drops on that tick — TrueDelay only delays the rising edge.
low_ambient_head_pressure_control_alarms_as_designedThe known false positive, pinned rather than hidden: on a 5 °C morning the head-pressure control has cycled the condenser fans off and is deliberately holding liquid in the coil — 32 °C condensing saturation against a 17 °C liquid line, 15 K of subcooling, and the rule alarms on a correctly charged unit. The host precondition that suspends evaluation while that control is active is what prevents this, and there is no in-graph term that could.
vectors.json
{
  "schema": "cxf-library/vectors/v1",
  "clock": {
    "step_s": 60,
    "horizon_s": 7200
  },
  "scenarios": [
    {
      "name": "nominal_subcooling_healthy",
      "description": "A correctly charged rooftop unit on a warm afternoon: 45 \u00b0C condensing saturation against a 37 \u00b0C liquid line is 8 K of subcooling, a normal packaged-unit reading and two thirds of the shipped band.",
      "inputs": {
        "cond_sat_temp": 45.0,
        "liquid_temp": 37.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        }
      ]
    },
    {
      "name": "design_day_lift_same_subcooling_healthy",
      "description": "Both temperatures 13 K higher \u2014 a 40 \u00b0C roof, or a condenser the RTU-0007 test would already be reporting \u2014 and the same 8 K of subcooling. This rule reads the difference, not the level, so high head pressure on its own is not a charge verdict. Pairs with overcharged_liquid_backs_up: swap the operands and both flip.",
      "inputs": {
        "cond_sat_temp": 58.0,
        "liquid_temp": 50.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        }
      ]
    },
    {
      "name": "overcharged_liquid_backs_up",
      "description": "FAULT: excess refrigerant floods the lower condenser circuits and the liquid line leaves at 34 \u00b0C against a 50 \u00b0C condensing saturation \u2014 16 K of subcooling. Both operands hold from t = 0, so delayOnInit puts the alarm exactly one alarm_delay in.",
      "inputs": {
        "cond_sat_temp": 50.0,
        "liquid_temp": 34.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 840,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 900,
          "to_s": 7200,
          "equals": true
        }
      ]
    },
    {
      "name": "subcooling_exactly_at_the_band",
      "description": "Threshold edge: 47.0 \u2212 35.0 is exactly subcooling_high_band (12.0 K) and both operands are binary-exact, so this is a true boundary pin rather than a rounding one. The comparison is strict, so a unit sitting precisely on the band reads healthy.",
      "inputs": {
        "cond_sat_temp": 47.0,
        "liquid_temp": 35.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        }
      ]
    },
    {
      "name": "subcooling_just_above_the_band",
      "description": "Threshold edge from the other side: 47.2 \u2212 35.0 is 12.2 K, two tenths past the band, and the alarm matures on the normal schedule.",
      "inputs": {
        "cond_sat_temp": 47.2,
        "liquid_temp": 35.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 840,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 900,
          "to_s": 7200,
          "equals": true
        }
      ]
    },
    {
      "name": "two_phase_liquid_line_reads_healthy",
      "description": "0.2 K of subcooling \u2014 a liquid line with no measurable liquid seal, which is where the charge question belongs to RTU-0008 rather than to this card. No evaluability flag is published for it because the verdict is sound: whatever this unit is, it is not overcharged.",
      "inputs": {
        "cond_sat_temp": 45.0,
        "liquid_temp": 44.8
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        }
      ]
    },
    {
      "name": "ungated_off_cycle_stays_silent",
      "description": "The host's compressor gate is the one precondition this rule can survive losing. Minutes after a stage drops out, the high side has equalised down to a 30 \u00b0C saturation while the liquid line is still holding 45 \u00b0C of stored heat: subcooling reads \u221215 K and the rule is silent. Off-cycle error on this measurement points away from the alarm, which is why the gate stays host-side (see Deviations).",
      "inputs": {
        "cond_sat_temp": 30.0,
        "liquid_temp": 45.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        }
      ]
    },
    {
      "name": "stage_down_transient_never_alarms",
      "description": "A two-stage unit unloads to stage 1 at t = 600 s and the condenser is briefly holding more liquid than one compressor needs: 16 K of subcooling for 600 s, back to 8 K at t = 1200 s, 300 s short of alarm_delay. The timer resets on recovery and nothing is reported.",
      "inputs": {
        "cond_sat_temp": 45.0,
        "liquid_temp": [
          {
            "t": 0,
            "value": 37.0
          },
          {
            "t": 600,
            "value": 29.0
          },
          {
            "t": 1200,
            "value": 37.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        }
      ]
    },
    {
      "name": "delay_runs_from_the_crossing_not_from_load",
      "description": "The unit starts at 8 K and crosses the band at t = 1200 s, holding 14 K thereafter. The alarm lands at 1200 + alarm_delay, not at alarm_delay \u2014 the persistence window is measured from the crossing.",
      "inputs": {
        "cond_sat_temp": 45.0,
        "liquid_temp": [
          {
            "t": 0,
            "value": 37.0
          },
          {
            "t": 1200,
            "value": 31.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2040,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2100,
          "to_s": 7200,
          "equals": true
        }
      ]
    },
    {
      "name": "alarm_clears_after_charge_recovered_to_nameplate",
      "description": "Recovery: 16 K of subcooling alarms at 900 s, the technician recovers the excess to the nameplate weight at t = 3600 s, and the liquid line returns to 8 K. yFault drops on that tick \u2014 TrueDelay only delays the rising edge.",
      "inputs": {
        "cond_sat_temp": 50.0,
        "liquid_temp": [
          {
            "t": 0,
            "value": 34.0
          },
          {
            "t": 3600,
            "value": 42.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 840,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 900,
          "to_s": 3540,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 3660,
          "to_s": 7200,
          "equals": false
        }
      ]
    },
    {
      "name": "low_ambient_head_pressure_control_alarms_as_designed",
      "description": "The known false positive, pinned rather than hidden: on a 5 \u00b0C morning the head-pressure control has cycled the condenser fans off and is deliberately holding liquid in the coil \u2014 32 \u00b0C condensing saturation against a 17 \u00b0C liquid line, 15 K of subcooling, and the rule alarms on a correctly charged unit. The host precondition that suspends evaluation while that control is active is what prevents this, and there is no in-graph term that could.",
      "inputs": {
        "cond_sat_temp": 32.0,
        "liquid_temp": 17.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 840,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 900,
          "to_s": 7200,
          "equals": true
        }
      ]
    }
  ]
}

RTU-0010 — RTU supply-fan proof-of-operation failure

Statusverified — engine e2ff2f8, cxf:fnv1a128:9ce62aa58b24bd636ac88bdc9f0b6504, 2026-08-20
Severity2
Methodrule
Phase2
CategoryPROTECTIVE
ConfidenceHIGH
EstimationPROXY_ESTIMATION
G36
Clusters
Suppresses
Suppressed by
RelatedRTU-0001, RTU-0002, RTU-0003, RTU-0004, RTU-0005, RTU-0006, RTU-0008, RTU-0009, RTU-0011
Playbooksproof-of-operation
SourceLibrary proof-of-operation precedents AHU-0039, TOWER-0004, PMP-0003, and HW-0009 — final command versus independent status, separate direction timers, and initialization-safe persistence; ASHRAE Guideline 36-2021 section 5.1.6 — equipment is proven when its digital status matches the state set by its digital command; semantic grounding only, not a source for these shipped timer values; Library-authored RTU supply fan timing adaptation; no cited source publishes 60 s start and 120 s stop proof limits as portable values
Operating statesall states in which the final RTU supply-fan command and independent proof for that same fan are authoritative

Preconditions (host-enforced): Bind sf_cmd to the final supply-fan command after smoke, freeze, heat-exchanger, post-heat fan-delay, and safety logic; unit enable is valid only when it demonstrably is that final command. A commanded post-heat run must keep sf_cmd true. Bind sf_status to independent electrical, airflow, speed, rotation, or auxiliary-contact proof for the same fan. Exclude purge, smoke control, ventilation override, service, local hand mode, and any fan-delay state omitted from sf_cmd. The 120 s stop timer must exceed normal mechanical coast and proof dropout, not conceal an upstream command. Inputs must be fresh and aligned; unmet obligations are NO_EVAL, not healthy.

Points: sf_cmd, sf_status

Outputs:

  • yFault — True while either final command/status mismatch has matured through its own proof timer
  • yFailToStart — Diagnostic direction flag; true after a final start command remains without independent run proof for start_proof_time. False never means NO_EVAL
  • yUnexpectedRun — Diagnostic direction flag; true after independent operation continues without a final run command for stop_proof_time. False never means NO_EVAL

Parameters:

NameDefaultUnitCXF pathDescription
start_proof_time60.0sstartProof.delayTimeADOPTED_TUNABLE final-command-to-proof allowance. Commission above normal device response, acceleration, proof pickup, and telemetry latency.
stop_proof_time120.0sstopProof.delayTimeADOPTED_TUNABLE command-off-to-proof-dropout allowance. Commission above normal deceleration, coast-down, proof dropout, and telemetry latency; intentional sequence operation must remain represented in the final command.

Description

This rule checks whether the RTU supply fan did what its final Boolean command requested. Commanded on without independent proof is a fail-to-start; proven on without command is unexpected operation. The direction identifies the mismatch, not its cause, and neither diagnostic output is an evaluability gate.

Detection Logic

fail_to_start  = sf_cmd AND NOT sf_status
unexpected_run = NOT sf_cmd AND sf_status

yFailToStart   = fail_to_start sustained for start_proof_time
yUnexpectedRun = unexpected_run sustained for stop_proof_time
yFault         = yFailToStart OR yUnexpectedRun

RTU-0010 block graph

Each direction has its own TrueDelay(delayOnInit=true). Agreement clears both lanes immediately. A direct mismatch reversal clears the old diagnostic and starts the other timer from zero; elapsed time never transfers between lanes.

Possible Diagnoses

  1. Motor, contactor, belt, fan wheel, VFD, overload, or disconnect failure.
  2. Smoke, freeze, high-static, heat-exchanger, or OEM safety interlock.
  3. Failed or misconfigured current, airflow, speed, or auxiliary proof.
  4. Post-heat fan delay or purge omitted from the final command binding.
  5. Unauthorized local/manual operation, welded contactor, or second owner.

Energy Impact

The effect is direction-dependent. Unexpected operation can waste measured electrical energy during the mismatch. Fail-to-start is primarily availability, comfort, and diagnostic-coverage loss; these two booleans cannot price it.

Emissions Impact

Scope 2 is proxy-only for unexpected operation: multiply independently measured device kW by mismatch hours and an appropriate operating emissions factor. Do not claim avoided energy or emissions for fail-to-start without another model.

Deviations

  • Both timers are adopted commissioning values. No cited source establishes universal RTU supply fan proof windows. Configure them independently around the actual sequence, proof device, sampling, and network latency.
  • The command is final and device-scoped. An upstream enable, demand, or fleet request can disagree with status while downstream logic works correctly.
  • Status is independent proof. Command echo makes the graph tautological; proof type determines whether electrical operation, rotation, or delivery was actually demonstrated.
  • No whole-rule suppression is encoded. Fail-to-start can invalidate another rule’s running premise, but unexpected operation may leave that rule physically meaningful; current metadata cannot suppress by direction.
  • delayOnInit=true is explicit on both lanes. Evaluator restart into an existing mismatch must serve the full configured proof time.
  • No empirical FPR or TPR is claimed. Current simulation telemetry cannot provide both an independent final command and field-like proof for this device.
  • The stop allowance is 120 s, twice the 60 s start allowance, to accommodate mechanical coast and proof dropout. A controlled post-heat run must keep the final sf_cmd true; the timer does not legalize an upstream binding. Both values remain site-tuned placeholders, not portable source values.
  • A purge or smoke-control run is raw unexpected operation unless it is included in sf_cmd. The host excludes that state; the graph does not encode an upstream mode gate.

Notes

Fail-to-start contests the running or airflow premise of RTU-0001 through RTU-0006 and can distort RTU-0008/0009 refrigerant evidence. Unexpected run does not invalidate those rules automatically, so the relationship remains informational rather than a whole-rule suppression.

Test Vectors

26 scenarios, clock step 10 s over 2400 s.

ScenarioDescription
agreement_stoppedCommand and independent proof agree off; every direction remains clear.
agreement_runningCommand and independent proof agree on; every direction remains clear.
fail_to_start_at_initializationA standing commanded-on/unproved mismatch is clear one tick before 60 s, asserts exactly at 60 s, and remains true after the initialization-safe start timer.
unexpected_run_at_initializationA standing command-off/proven-on mismatch is clear one tick before 120 s, asserts exactly at 120 s, and remains true after the initialization-safe stop timer.
normal_delayed_start_and_stopStatus proves one tick inside both configured allowances, so ordinary start and stop latency never alarms.
start_mismatch_ends_one_tick_before_proofA start mismatch beginning at 300 s clears at 350 s, one 10 s evaluator step before maturity, and never alarms.
start_mismatch_ends_at_proof_boundaryThe start mismatch clears exactly 60 s after it begins. Agreement on the maturity tick wins and no alarm is asserted.
stop_mismatch_ends_one_tick_before_proofA stop mismatch beginning at 300 s clears at 410 s, one 10 s evaluator step before maturity, and never alarms.
stop_mismatch_ends_at_proof_boundaryThe stop mismatch clears exactly 120 s after it begins. Agreement on the maturity tick wins and no alarm is asserted.
mature_start_alarm_recoversA fail-to-start persists past its proof time, then clears immediately when independent proof arrives.
mature_stop_alarm_recoversAn unexpected run persists past its proof time, then clears immediately when proof drops.
pending_direction_reversal_starts_fresh_laneCommand and status swap while fail-to-start is pending. The old lane resets and the unexpected-run lane must serve its complete independent timer.
mature_direction_reversal_hands_offA mature fail-to-start reverses directly into unexpected-run. The old flag clears on the reversal tick, yFault has a real gap, and the opposite lane serves a fresh complete stop timer.
two_short_start_mismatches_do_not_accumulateTwo commanded-on/unproved episodes are separated by agreement; elapsed time cannot transfer across the reset.
two_short_stop_mismatches_do_not_accumulateTwo proven-on/uncommanded episodes are separated by agreement; elapsed time cannot transfer across the reset.
command_chatter_never_maturesThe final command chatters faster than either proof window while status is off; no continuous mismatch matures.
status_chatter_never_maturesIndependent proof chatters against a held-on command; every unproved interval clears before the start timer.
unexpected_run_chatter_never_maturesIndependent proof chatters on against a held-off command; every unexpected-run interval clears before the stop timer.
upstream_enable_raw_false_positiveAn upstream enable held true while downstream logic correctly withholds operation produces a raw fail-to-start alarm; this pins why only the final command is a valid binding.
command_echo_raw_blind_spotA status echo follows the command while the device is mechanically stopped. The pair agrees and the graph stays silent, pinning why proof must be independent.
manual_local_run_is_host_excludedA locally commanded device is proven on while the BAS final command is off. The raw unexpected-run alarm is correct; approved service or hand operation is host NO_EVAL.
occupied_continuous_fan_transitionA continuously commanded and proven fan remains in agreement across an occupancy transition; schedule state is intentionally not an input to this final-command proof rule.
valid_ninety_second_post_heat_runA true final command remains on through the 90 s post-heat run; command and proof drop together at 390 s, so the sequence stays in agreement.
proof_dropout_lingers_ninety_secondsThe final command drops at 300 s and electrical/airflow proof lingers for 90 s during mechanical coast or sensor dropout. The 120 s stop allowance accepts it without treating an active post-heat sequence as uncommanded.
proof_remains_on_beyond_stop_timerFan proof remains on beyond the 120 s coast/dropout allowance after final command-off: unexpected-run asserts exactly at 420 s and clears when proof drops.
purge_raw_behavior_is_host_excludedA purge or smoke-control owner runs the fan while an upstream BAS command stays off. The raw unexpected-run alarm pins the required final-command or host-gate obligation.
vectors.json
{
  "schema": "cxf-library/vectors/v1",
  "clock": {
    "step_s": 10,
    "horizon_s": 2400
  },
  "scenarios": [
    {
      "name": "agreement_stopped",
      "description": "Command and independent proof agree off; every direction remains clear.",
      "inputs": {
        "sf_cmd": false,
        "sf_status": false
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        }
      ]
    },
    {
      "name": "agreement_running",
      "description": "Command and independent proof agree on; every direction remains clear.",
      "inputs": {
        "sf_cmd": true,
        "sf_status": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        }
      ]
    },
    {
      "name": "fail_to_start_at_initialization",
      "description": "A standing commanded-on/unproved mismatch is clear one tick before 60 s, asserts exactly at 60 s, and remains true after the initialization-safe start timer.",
      "inputs": {
        "sf_cmd": true,
        "sf_status": false
      },
      "expect": [
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 50,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 60,
          "to_s": 2400,
          "equals": true
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 50,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 60,
          "to_s": 2400,
          "equals": true
        }
      ]
    },
    {
      "name": "unexpected_run_at_initialization",
      "description": "A standing command-off/proven-on mismatch is clear one tick before 120 s, asserts exactly at 120 s, and remains true after the initialization-safe stop timer.",
      "inputs": {
        "sf_cmd": false,
        "sf_status": true
      },
      "expect": [
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 110,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 120,
          "to_s": 2400,
          "equals": true
        },
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 110,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 120,
          "to_s": 2400,
          "equals": true
        }
      ]
    },
    {
      "name": "normal_delayed_start_and_stop",
      "description": "Status proves one tick inside both configured allowances, so ordinary start and stop latency never alarms.",
      "inputs": {
        "sf_cmd": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 300,
            "value": true
          },
          {
            "t": 1200,
            "value": false
          }
        ],
        "sf_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 350,
            "value": true
          },
          {
            "t": 1310,
            "value": false
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        }
      ]
    },
    {
      "name": "start_mismatch_ends_one_tick_before_proof",
      "description": "A start mismatch beginning at 300 s clears at 350 s, one 10 s evaluator step before maturity, and never alarms.",
      "inputs": {
        "sf_cmd": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 300,
            "value": true
          }
        ],
        "sf_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 350,
            "value": true
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        }
      ]
    },
    {
      "name": "start_mismatch_ends_at_proof_boundary",
      "description": "The start mismatch clears exactly 60 s after it begins. Agreement on the maturity tick wins and no alarm is asserted.",
      "inputs": {
        "sf_cmd": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 300,
            "value": true
          }
        ],
        "sf_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 360,
            "value": true
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        }
      ]
    },
    {
      "name": "stop_mismatch_ends_one_tick_before_proof",
      "description": "A stop mismatch beginning at 300 s clears at 410 s, one 10 s evaluator step before maturity, and never alarms.",
      "inputs": {
        "sf_cmd": [
          {
            "t": 0,
            "value": true
          },
          {
            "t": 300,
            "value": false
          }
        ],
        "sf_status": [
          {
            "t": 0,
            "value": true
          },
          {
            "t": 410,
            "value": false
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        }
      ]
    },
    {
      "name": "stop_mismatch_ends_at_proof_boundary",
      "description": "The stop mismatch clears exactly 120 s after it begins. Agreement on the maturity tick wins and no alarm is asserted.",
      "inputs": {
        "sf_cmd": [
          {
            "t": 0,
            "value": true
          },
          {
            "t": 300,
            "value": false
          }
        ],
        "sf_status": [
          {
            "t": 0,
            "value": true
          },
          {
            "t": 420,
            "value": false
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        }
      ]
    },
    {
      "name": "mature_start_alarm_recovers",
      "description": "A fail-to-start persists past its proof time, then clears immediately when independent proof arrives.",
      "inputs": {
        "sf_cmd": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 300,
            "value": true
          }
        ],
        "sf_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 460,
            "value": true
          }
        ]
      },
      "expect": [
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 350,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 360,
          "to_s": 450,
          "equals": true
        },
        {
          "output": "yFailToStart",
          "from_s": 460,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 350,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 360,
          "to_s": 450,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 460,
          "to_s": 2400,
          "equals": false
        }
      ]
    },
    {
      "name": "mature_stop_alarm_recovers",
      "description": "An unexpected run persists past its proof time, then clears immediately when proof drops.",
      "inputs": {
        "sf_cmd": [
          {
            "t": 0,
            "value": true
          },
          {
            "t": 300,
            "value": false
          }
        ],
        "sf_status": [
          {
            "t": 0,
            "value": true
          },
          {
            "t": 520,
            "value": false
          }
        ]
      },
      "expect": [
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 410,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 420,
          "to_s": 510,
          "equals": true
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 520,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 410,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 420,
          "to_s": 510,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 520,
          "to_s": 2400,
          "equals": false
        }
      ]
    },
    {
      "name": "pending_direction_reversal_starts_fresh_lane",
      "description": "Command and status swap while fail-to-start is pending. The old lane resets and the unexpected-run lane must serve its complete independent timer.",
      "inputs": {
        "sf_cmd": [
          {
            "t": 0,
            "value": true
          },
          {
            "t": 40,
            "value": false
          }
        ],
        "sf_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 40,
            "value": true
          }
        ]
      },
      "expect": [
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 150,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 160,
          "to_s": 2400,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 150,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 160,
          "to_s": 2400,
          "equals": true
        }
      ]
    },
    {
      "name": "mature_direction_reversal_hands_off",
      "description": "A mature fail-to-start reverses directly into unexpected-run. The old flag clears on the reversal tick, yFault has a real gap, and the opposite lane serves a fresh complete stop timer.",
      "inputs": {
        "sf_cmd": [
          {
            "t": 0,
            "value": true
          },
          {
            "t": 400,
            "value": false
          }
        ],
        "sf_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 400,
            "value": true
          }
        ]
      },
      "expect": [
        {
          "output": "yFailToStart",
          "from_s": 60,
          "to_s": 390,
          "equals": true
        },
        {
          "output": "yFailToStart",
          "from_s": 400,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 510,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 520,
          "to_s": 2400,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 60,
          "to_s": 390,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 400,
          "to_s": 510,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 520,
          "to_s": 2400,
          "equals": true
        }
      ]
    },
    {
      "name": "two_short_start_mismatches_do_not_accumulate",
      "description": "Two commanded-on/unproved episodes are separated by agreement; elapsed time cannot transfer across the reset.",
      "inputs": {
        "sf_cmd": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 300,
            "value": true
          },
          {
            "t": 340,
            "value": false
          },
          {
            "t": 900,
            "value": true
          },
          {
            "t": 940,
            "value": false
          }
        ],
        "sf_status": false
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        }
      ]
    },
    {
      "name": "two_short_stop_mismatches_do_not_accumulate",
      "description": "Two proven-on/uncommanded episodes are separated by agreement; elapsed time cannot transfer across the reset.",
      "inputs": {
        "sf_cmd": false,
        "sf_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 300,
            "value": true
          },
          {
            "t": 400,
            "value": false
          },
          {
            "t": 900,
            "value": true
          },
          {
            "t": 1000,
            "value": false
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        }
      ]
    },
    {
      "name": "command_chatter_never_matures",
      "description": "The final command chatters faster than either proof window while status is off; no continuous mismatch matures.",
      "inputs": {
        "sf_cmd": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 300,
            "value": true
          },
          {
            "t": 340,
            "value": false
          },
          {
            "t": 600,
            "value": true
          },
          {
            "t": 640,
            "value": false
          },
          {
            "t": 900,
            "value": true
          },
          {
            "t": 940,
            "value": false
          }
        ],
        "sf_status": false
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        }
      ]
    },
    {
      "name": "status_chatter_never_matures",
      "description": "Independent proof chatters against a held-on command; every unproved interval clears before the start timer.",
      "inputs": {
        "sf_cmd": true,
        "sf_status": [
          {
            "t": 0,
            "value": true
          },
          {
            "t": 300,
            "value": false
          },
          {
            "t": 340,
            "value": true
          },
          {
            "t": 600,
            "value": false
          },
          {
            "t": 640,
            "value": true
          },
          {
            "t": 900,
            "value": false
          },
          {
            "t": 940,
            "value": true
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        }
      ]
    },
    {
      "name": "unexpected_run_chatter_never_matures",
      "description": "Independent proof chatters on against a held-off command; every unexpected-run interval clears before the stop timer.",
      "inputs": {
        "sf_cmd": false,
        "sf_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 300,
            "value": true
          },
          {
            "t": 400,
            "value": false
          },
          {
            "t": 600,
            "value": true
          },
          {
            "t": 700,
            "value": false
          },
          {
            "t": 900,
            "value": true
          },
          {
            "t": 1000,
            "value": false
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        }
      ]
    },
    {
      "name": "upstream_enable_raw_false_positive",
      "description": "An upstream enable held true while downstream logic correctly withholds operation produces a raw fail-to-start alarm; this pins why only the final command is a valid binding.",
      "inputs": {
        "sf_cmd": true,
        "sf_status": false
      },
      "expect": [
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 50,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 60,
          "to_s": 2400,
          "equals": true
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 50,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 60,
          "to_s": 2400,
          "equals": true
        }
      ]
    },
    {
      "name": "command_echo_raw_blind_spot",
      "description": "A status echo follows the command while the device is mechanically stopped. The pair agrees and the graph stays silent, pinning why proof must be independent.",
      "inputs": {
        "sf_cmd": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 300,
            "value": true
          }
        ],
        "sf_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 300,
            "value": true
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        }
      ]
    },
    {
      "name": "manual_local_run_is_host_excluded",
      "description": "A locally commanded device is proven on while the BAS final command is off. The raw unexpected-run alarm is correct; approved service or hand operation is host NO_EVAL.",
      "inputs": {
        "sf_cmd": false,
        "sf_status": true
      },
      "expect": [
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 110,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 120,
          "to_s": 2400,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 110,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 120,
          "to_s": 2400,
          "equals": true
        }
      ]
    },
    {
      "name": "occupied_continuous_fan_transition",
      "description": "A continuously commanded and proven fan remains in agreement across an occupancy transition; schedule state is intentionally not an input to this final-command proof rule.",
      "inputs": {
        "sf_cmd": true,
        "sf_status": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        }
      ]
    },
    {
      "name": "valid_ninety_second_post_heat_run",
      "description": "A true final command remains on through the 90 s post-heat run; command and proof drop together at 390 s, so the sequence stays in agreement.",
      "inputs": {
        "sf_cmd": [
          {
            "t": 0,
            "value": true
          },
          {
            "t": 390,
            "value": false
          }
        ],
        "sf_status": [
          {
            "t": 0,
            "value": true
          },
          {
            "t": 390,
            "value": false
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        }
      ]
    },
    {
      "name": "proof_dropout_lingers_ninety_seconds",
      "description": "The final command drops at 300 s and electrical/airflow proof lingers for 90 s during mechanical coast or sensor dropout. The 120 s stop allowance accepts it without treating an active post-heat sequence as uncommanded.",
      "inputs": {
        "sf_cmd": [
          {
            "t": 0,
            "value": true
          },
          {
            "t": 300,
            "value": false
          }
        ],
        "sf_status": [
          {
            "t": 0,
            "value": true
          },
          {
            "t": 390,
            "value": false
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        }
      ]
    },
    {
      "name": "proof_remains_on_beyond_stop_timer",
      "description": "Fan proof remains on beyond the 120 s coast/dropout allowance after final command-off: unexpected-run asserts exactly at 420 s and clears when proof drops.",
      "inputs": {
        "sf_cmd": [
          {
            "t": 0,
            "value": true
          },
          {
            "t": 300,
            "value": false
          }
        ],
        "sf_status": [
          {
            "t": 0,
            "value": true
          },
          {
            "t": 600,
            "value": false
          }
        ]
      },
      "expect": [
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 410,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 420,
          "to_s": 590,
          "equals": true
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 600,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 410,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 420,
          "to_s": 590,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 600,
          "to_s": 2400,
          "equals": false
        }
      ]
    },
    {
      "name": "purge_raw_behavior_is_host_excluded",
      "description": "A purge or smoke-control owner runs the fan while an upstream BAS command stays off. The raw unexpected-run alarm pins the required final-command or host-gate obligation.",
      "inputs": {
        "sf_cmd": false,
        "sf_status": true
      },
      "expect": [
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 110,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 120,
          "to_s": 2400,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 110,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 120,
          "to_s": 2400,
          "equals": true
        }
      ]
    }
  ]
}

RTU-0011 — RTU supply-air temperature tracking failure

Statusverified — engine e2ff2f8, cxf:fnv1a128:0ae66df187840b9fde91f9796ee3082a, 2026-08-20
Severity3
Methodrule
Phase2
CategoryCOMFORT_ENERGY
ConfidenceMEDIUM
EstimationQUALITATIVE_ONLY
G36
Clusters
Suppresses
Suppressed by
RelatedRTU-0001, RTU-0002, RTU-0003, RTU-0004, RTU-0007, RTU-0008, RTU-0009, RTU-0010
Playbooksrtu-compressor-refrigerant
SourceVeronica, Automatically Detecting Faulty Regulation in HVAC Controls, HVAC&R Research 19(4), 2013, pp.412-422, DOI 10.1080/10789669.2013.789369 — regulated variables are checked against user allowance bands; semantic evidence, not this graph’s thresholds; Library CHW-0007 and HW-0010 — strict mirrored tracking-error topology with operating-premise gating and persistent alarm; Library-authored packaged-DX adaptation; no cited source publishes the shipped 2 K and 900 s combination as portable
Operating statesnormal automatic RTU heating or cooling after airflow and active mechanical delivery are established and the active SAT target has settled

Preconditions (host-enforced): sat and sat_sp must be the same RTU’s discharge temperature and final active mode-specific target, not an occupied default or cooling-only constant during heating. sf_status must prove stable supply airflow; comp_status or htg_status must represent active mechanical conditioning. Exclude startup, defrost, post-heat fan delay, mode/setpoint changes, demand response, low-ambient protection, and OEM capacity or safety limits. Suspend or smooth evaluation across normal staged/cycling-DX off intervals. Points must be healthy, fresh, aligned, and in degC. yConditioningActive false means NO_EVAL; true does not prove fan status or the other host obligations.

Points: sat, sat_sp, sf_status, comp_status, htg_status

Outputs:

  • yFault — True after proven fan operation and mechanical conditioning remain outside either side of the active SAT band for sustained_duration
  • yTooWarm — Diagnostic direction flag; true while evaluable SAT is strictly more than tracking_error above setpoint. False never means NO_EVAL
  • yTooCold — Diagnostic direction flag; true while evaluable SAT is strictly more than tracking_error below setpoint. False never means NO_EVAL
  • yConditioningActive — Evaluability subcondition; true when compressor or heating proof is active. False means NO_EVAL; true does not establish every host precondition

Parameters:

NameDefaultUnitCXF pathDescription
tracking_error2.0KtooWarmRaw.t, tooColdRaw.tADOPTED_TUNABLE symmetric settled SAT allowance; equality is clear. Commission above combined sensor uncertainty, cycling ripple, and normal deadband.
sustained_duration900.0spersist.delayTimeLIBRARY_PRECEDENT continuous out-of-band duration; commission above normal mode, staging, and setpoint-settling dynamics.

Description

This rule reports an RTU whose discharge temperature remains materially outside its final active target while the supply fan and mechanical heating or cooling are proven active. Warm/cold outputs identify the observed direction, not the failed component or commanded mode.

Detection Logic

error               = sat - sat_sp
conditioning_active = comp_status OR htg_status
running_conditioning = sf_status AND conditioning_active
too_warm            = error > tracking_error
too_cold            = -error > tracking_error

yConditioningActive = conditioning_active
yTooWarm = running_conditioning AND too_warm
yTooCold = running_conditioning AND too_cold
yFault = running_conditioning AND (too_warm OR too_cold),
         sustained for sustained_duration

RTU-0011 block graph

Comparisons are strict and direction flags are immediate. One TrueDelay(delayOnInit=true) follows the warm/cold OR, so a direct sampled direction handoff preserves age while any in-band or non-running tick resets it.

Possible Diagnoses

  1. Active setpoint misbound, stale, overridden, or not delivered locally
  2. Insufficient heating/cooling capacity, failed stage, or abnormal cycling
  3. Low supply airflow, dirty filter/coil, or failed fan proof
  4. Economizer or outdoor-air damper introducing the wrong air condition
  5. Refrigerant charge, condenser airflow, compressor, or heat-section fault
  6. Sensor bias or normal OEM limit/transition omitted from host gating

Energy Impact

A tracking fault may increase compressor, heating, or fan runtime and can miss temperature or humidity targets. The sign alone does not establish waste; no energy is inferred from temperature error without a causal model.

Emissions Impact

Scope 1 and/or 2, qualitative. Quantify only after isolating the cause and measuring affected fuel or electrical input against a defensible baseline.

Deviations

  • The 2 K / 900 s pair is adopted library logic. The sources support SAT tracking as a diagnostic, not portable packaged-unit thresholds.
  • Confidence is MEDIUM rather than the brief’s proposed HIGH. Cycling DX, active-setpoint semantics, airflow, OEM limits, and transitions require commissioned host gates before the same signature is causal.
  • Mechanical status is an OR, not a mode table. Heating-only, cooling-only, or both-active raw states are detectable; the host validates the actual mode.
  • Direction handoff preserves persistence. A sampled warm-to-cold transition remains continuously out of band; an actual in-band sample resets the timer.
  • Proof faults remain related, not suppressors. Current metadata cannot suppress tracking only for RTU-0010’s fail-to-start direction.
  • No empirical FPR or TPR is claimed. The current harness lacks a defensible final active RTU SAT setpoint plus aligned mechanical-status mapping.

Test Vectors

15 scenarios, clock step 60 s over 1500 s.

ScenarioDescription
fan_off_with_large_errorTemperature error without proven supply airflow is not evaluable.
fan_on_without_mechanical_conditioningA running fan alone does not establish the mechanical-delivery premise.
active_on_setpointStable mechanical operation at the active target is healthy.
cooling_status_too_warmCooling proof with SAT above its target drives the warm diagnostic and matures the alarm.
heating_status_too_coldHeating-only proof with SAT below target exercises the independent cold direction.
exact_warm_error_is_clearThe +2 K boundary is clear under the strict comparison.
exact_cold_error_is_clearThe -2 K boundary is clear under the mirrored strict comparison.
both_conditioning_statuses_active_raw_caseThe graph intentionally treats either or both mechanical statuses as active; host mode validation remains external.
normal_dx_off_cycle_resets_timerA normal compressor-off interval removes evaluability and resets an immature error episode.
violation_ends_one_step_before_persistenceAn error ending one evaluator step before 900 s never matures.
mature_warm_fault_recoversA mature alarm and its direction clear immediately when SAT returns to band.
two_short_error_episodes_resetTwo sub-duration violations separated by an in-band interval cannot accumulate persistence.
initialization_violation_serves_full_delaydelayOnInit requires the full tracking interval after evaluator startup.
sampled_direction_handoff_preserves_persistenceA direct sampled warm-to-cold handoff stays continuously outside the symmetric band and preserves timer age.
setpoint_step_raw_transientAn unexcluded active-setpoint step can create raw error, but this short case resets before alarm maturity.
vectors.json
{
  "schema": "cxf-library/vectors/v1",
  "clock": {
    "step_s": 60,
    "horizon_s": 1500
  },
  "scenarios": [
    {
      "name": "fan_off_with_large_error",
      "description": "Temperature error without proven supply airflow is not evaluable.",
      "inputs": {
        "sat": 20.0,
        "sat_sp": 15.0,
        "sf_status": false,
        "comp_status": true,
        "htg_status": false
      },
      "expect": [
        {
          "output": "yConditioningActive",
          "from_s": 0,
          "to_s": 1500,
          "equals": true
        },
        {
          "output": "yTooWarm",
          "from_s": 0,
          "to_s": 1500,
          "equals": false
        },
        {
          "output": "yTooCold",
          "from_s": 0,
          "to_s": 1500,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1500,
          "equals": false
        }
      ]
    },
    {
      "name": "fan_on_without_mechanical_conditioning",
      "description": "A running fan alone does not establish the mechanical-delivery premise.",
      "inputs": {
        "sat": 20.0,
        "sat_sp": 15.0,
        "sf_status": true,
        "comp_status": false,
        "htg_status": false
      },
      "expect": [
        {
          "output": "yConditioningActive",
          "from_s": 0,
          "to_s": 1500,
          "equals": false
        },
        {
          "output": "yTooWarm",
          "from_s": 0,
          "to_s": 1500,
          "equals": false
        },
        {
          "output": "yTooCold",
          "from_s": 0,
          "to_s": 1500,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1500,
          "equals": false
        }
      ]
    },
    {
      "name": "active_on_setpoint",
      "description": "Stable mechanical operation at the active target is healthy.",
      "inputs": {
        "sat": 15.0,
        "sat_sp": 15.0,
        "sf_status": true,
        "comp_status": true,
        "htg_status": false
      },
      "expect": [
        {
          "output": "yConditioningActive",
          "from_s": 0,
          "to_s": 1500,
          "equals": true
        },
        {
          "output": "yTooWarm",
          "from_s": 0,
          "to_s": 1500,
          "equals": false
        },
        {
          "output": "yTooCold",
          "from_s": 0,
          "to_s": 1500,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1500,
          "equals": false
        }
      ]
    },
    {
      "name": "cooling_status_too_warm",
      "description": "Cooling proof with SAT above its target drives the warm diagnostic and matures the alarm.",
      "inputs": {
        "sat": 17.1,
        "sat_sp": 15.0,
        "sf_status": true,
        "comp_status": true,
        "htg_status": false
      },
      "expect": [
        {
          "output": "yConditioningActive",
          "from_s": 0,
          "to_s": 1500,
          "equals": true
        },
        {
          "output": "yTooWarm",
          "from_s": 0,
          "to_s": 1500,
          "equals": true
        },
        {
          "output": "yTooCold",
          "from_s": 0,
          "to_s": 1500,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 840,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 960,
          "to_s": 1500,
          "equals": true
        }
      ]
    },
    {
      "name": "heating_status_too_cold",
      "description": "Heating-only proof with SAT below target exercises the independent cold direction.",
      "inputs": {
        "sat": 12.9,
        "sat_sp": 15.0,
        "sf_status": true,
        "comp_status": false,
        "htg_status": true
      },
      "expect": [
        {
          "output": "yConditioningActive",
          "from_s": 0,
          "to_s": 1500,
          "equals": true
        },
        {
          "output": "yTooWarm",
          "from_s": 0,
          "to_s": 1500,
          "equals": false
        },
        {
          "output": "yTooCold",
          "from_s": 0,
          "to_s": 1500,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 840,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 960,
          "to_s": 1500,
          "equals": true
        }
      ]
    },
    {
      "name": "exact_warm_error_is_clear",
      "description": "The +2 K boundary is clear under the strict comparison.",
      "inputs": {
        "sat": 17.0,
        "sat_sp": 15.0,
        "sf_status": true,
        "comp_status": true,
        "htg_status": false
      },
      "expect": [
        {
          "output": "yTooWarm",
          "from_s": 0,
          "to_s": 1500,
          "equals": false
        },
        {
          "output": "yTooCold",
          "from_s": 0,
          "to_s": 1500,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1500,
          "equals": false
        }
      ]
    },
    {
      "name": "exact_cold_error_is_clear",
      "description": "The -2 K boundary is clear under the mirrored strict comparison.",
      "inputs": {
        "sat": 13.0,
        "sat_sp": 15.0,
        "sf_status": true,
        "comp_status": true,
        "htg_status": false
      },
      "expect": [
        {
          "output": "yTooWarm",
          "from_s": 0,
          "to_s": 1500,
          "equals": false
        },
        {
          "output": "yTooCold",
          "from_s": 0,
          "to_s": 1500,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1500,
          "equals": false
        }
      ]
    },
    {
      "name": "both_conditioning_statuses_active_raw_case",
      "description": "The graph intentionally treats either or both mechanical statuses as active; host mode validation remains external.",
      "inputs": {
        "sat": 17.1,
        "sat_sp": 15.0,
        "sf_status": true,
        "comp_status": true,
        "htg_status": true
      },
      "expect": [
        {
          "output": "yConditioningActive",
          "from_s": 0,
          "to_s": 1500,
          "equals": true
        },
        {
          "output": "yTooWarm",
          "from_s": 0,
          "to_s": 1500,
          "equals": true
        },
        {
          "output": "yTooCold",
          "from_s": 0,
          "to_s": 1500,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 840,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 960,
          "to_s": 1500,
          "equals": true
        }
      ]
    },
    {
      "name": "normal_dx_off_cycle_resets_timer",
      "description": "A normal compressor-off interval removes evaluability and resets an immature error episode.",
      "inputs": {
        "sat": 18.0,
        "sat_sp": 15.0,
        "sf_status": true,
        "comp_status": [
          {
            "t": 0,
            "value": true
          },
          {
            "t": 600,
            "value": false
          },
          {
            "t": 900,
            "value": true
          }
        ],
        "htg_status": false
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1500,
          "equals": false
        },
        {
          "output": "yTooWarm",
          "from_s": 0,
          "to_s": 540,
          "equals": true
        },
        {
          "output": "yTooWarm",
          "from_s": 600,
          "to_s": 840,
          "equals": false
        },
        {
          "output": "yTooWarm",
          "from_s": 900,
          "to_s": 1500,
          "equals": true
        }
      ]
    },
    {
      "name": "violation_ends_one_step_before_persistence",
      "description": "An error ending one evaluator step before 900 s never matures.",
      "inputs": {
        "sat": [
          {
            "t": 0,
            "value": 18.0
          },
          {
            "t": 840,
            "value": 15.0
          }
        ],
        "sat_sp": 15.0,
        "sf_status": true,
        "comp_status": true,
        "htg_status": false
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1500,
          "equals": false
        },
        {
          "output": "yTooWarm",
          "from_s": 0,
          "to_s": 780,
          "equals": true
        },
        {
          "output": "yTooWarm",
          "from_s": 840,
          "to_s": 1500,
          "equals": false
        },
        {
          "output": "yTooCold",
          "from_s": 0,
          "to_s": 1500,
          "equals": false
        }
      ]
    },
    {
      "name": "mature_warm_fault_recovers",
      "description": "A mature alarm and its direction clear immediately when SAT returns to band.",
      "inputs": {
        "sat": [
          {
            "t": 0,
            "value": 18.0
          },
          {
            "t": 1080,
            "value": 15.0
          }
        ],
        "sat_sp": 15.0,
        "sf_status": true,
        "comp_status": true,
        "htg_status": false
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 840,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 960,
          "to_s": 1020,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 1080,
          "to_s": 1500,
          "equals": false
        },
        {
          "output": "yTooWarm",
          "from_s": 0,
          "to_s": 1020,
          "equals": true
        },
        {
          "output": "yTooWarm",
          "from_s": 1080,
          "to_s": 1500,
          "equals": false
        },
        {
          "output": "yTooCold",
          "from_s": 0,
          "to_s": 1500,
          "equals": false
        }
      ]
    },
    {
      "name": "two_short_error_episodes_reset",
      "description": "Two sub-duration violations separated by an in-band interval cannot accumulate persistence.",
      "inputs": {
        "sat": [
          {
            "t": 0,
            "value": 18.0
          },
          {
            "t": 480,
            "value": 15.0
          },
          {
            "t": 720,
            "value": 18.0
          },
          {
            "t": 1200,
            "value": 15.0
          }
        ],
        "sat_sp": 15.0,
        "sf_status": true,
        "comp_status": true,
        "htg_status": false
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1500,
          "equals": false
        }
      ]
    },
    {
      "name": "initialization_violation_serves_full_delay",
      "description": "delayOnInit requires the full tracking interval after evaluator startup.",
      "inputs": {
        "sat": 18.0,
        "sat_sp": 15.0,
        "sf_status": true,
        "comp_status": true,
        "htg_status": false
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 840,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 960,
          "to_s": 1500,
          "equals": true
        },
        {
          "output": "yTooWarm",
          "from_s": 0,
          "to_s": 1500,
          "equals": true
        },
        {
          "output": "yTooCold",
          "from_s": 0,
          "to_s": 1500,
          "equals": false
        }
      ]
    },
    {
      "name": "sampled_direction_handoff_preserves_persistence",
      "description": "A direct sampled warm-to-cold handoff stays continuously outside the symmetric band and preserves timer age.",
      "inputs": {
        "sat": [
          {
            "t": 0,
            "value": 18.0
          },
          {
            "t": 480,
            "value": 12.0
          }
        ],
        "sat_sp": 15.0,
        "sf_status": true,
        "comp_status": true,
        "htg_status": false
      },
      "expect": [
        {
          "output": "yTooWarm",
          "from_s": 0,
          "to_s": 420,
          "equals": true
        },
        {
          "output": "yTooWarm",
          "from_s": 480,
          "to_s": 1500,
          "equals": false
        },
        {
          "output": "yTooCold",
          "from_s": 0,
          "to_s": 420,
          "equals": false
        },
        {
          "output": "yTooCold",
          "from_s": 480,
          "to_s": 1500,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 840,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 960,
          "to_s": 1500,
          "equals": true
        }
      ]
    },
    {
      "name": "setpoint_step_raw_transient",
      "description": "An unexcluded active-setpoint step can create raw error, but this short case resets before alarm maturity.",
      "inputs": {
        "sat": 15.0,
        "sat_sp": [
          {
            "t": 0,
            "value": 15.0
          },
          {
            "t": 300,
            "value": 12.0
          },
          {
            "t": 720,
            "value": 15.0
          }
        ],
        "sf_status": true,
        "comp_status": true,
        "htg_status": false
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1500,
          "equals": false
        },
        {
          "output": "yTooWarm",
          "from_s": 0,
          "to_s": 240,
          "equals": false
        },
        {
          "output": "yTooWarm",
          "from_s": 300,
          "to_s": 660,
          "equals": true
        },
        {
          "output": "yTooWarm",
          "from_s": 720,
          "to_s": 1500,
          "equals": false
        },
        {
          "output": "yTooCold",
          "from_s": 0,
          "to_s": 1500,
          "equals": false
        }
      ]
    }
  ]
}

SYS Fault Rules

System-level and cross-equipment rules (SYS-*). Two populations share this chapter:

  • Reference ch.16 rules (SYS-0001–057): cross-equipment waste and schedule faults, fully specified in HVAC FDD Reference v1.0 ch.16 (pdf pages 138–147). All eight are verified — this completed the reference’s entire fully-specified fault set.
  • Sensor-health rules (SYS-0005 + SYS-0009/SYS-0010): the library’s cross-equipment sensor-integrity family per the accepted design (internal sensor-health design note, local-only). SYS-0005 is BOTH — the reference’s own paired-sensor cross-validation card is exactly the redundancy-pair bias rule, so it keeps its reference number (decided 2026-08-17; numbering is adjustable as the library grows).

These rules bind role points (points/sys.points.json) — the documented exception to the canonical-name convention: one graph, many bindings, with the host’s instance configuration recording each binding. That same record drives the adjudicates NO_EVAL fan-out (SCHEMA.md frontmatter table).

Point dictionary: points/sys.points.json.

Index

IDNameSevMethodStatus
SYS-0001CHW flow with no cooling demand3ruleverified
SYS-0002HW flow with no heating demand3ruleverified
SYS-0003Lighting on with no occupancy4ruleverified
SYS-0004Exhaust fan running during unoccupied hours3ruleverified
SYS-0005Sensor drift via cross-validation (paired sensors)3ruleverified
SYS-0006Virtual sensor drift detection3statisticalverified
SYS-0007Zone heating active during summer / warm weather3ruleverified
SYS-0008Exhaust fan schedule misalignment with AHU3ruleverified
SYS-0009Sensor flatline while equipment active3ruleverified
SYS-0010Sensor spike / rate-of-change violation3ruleverified

All severities/methods are re-verified against the chapter text (SYS-0003 is severity 4/info per the chapter, correcting the provisional row). SYS-0009/SYS-0010 are library-authored (design doc + public sources: Yang et al. 2008, Liao et al. 2021, Dey & Dong 2016). Naming note: the sys dictionary’s host-derived schedule boolean is occ_scheduled; the ahu dictionary spells the same concept occ_schedule — a known inconsistency, left in place because renaming a bound point churns verified content IDs.

Relationships

  • SYS-0005/SYS-0006/SYS-0009/SYS-0010 are meta-rules: their adjudicates frontmatter names the role point they judge, and hosts treat an active fault as NO_EVAL for every rule consuming the bound point. Spike detection builds on Discrete.UnitDelay (previous-tick comparison), flatline on Discrete.SamplerReals.Derivative is deliberately avoided at BAS tick rates (its k/T are input pins and it over-reads ramp rates by 1 + dt/T; design doc §4).
  • SYS-0005 is CLU-09’s (Sensor Integrity Failure) trigger with SYS-0006/SYS-0009/SYS-0010 as members; playbooks/sensor-drift.md is the family playbook. SYS-0006 adjudicates a single accused sensor (invalid_while_active); the pair rule SYS-0005 stays ambiguous.
  • SYS-0006 and SYS-0008 expose secondary boundary outputs that are sub-condition/direction flags (yBias/yNoise, yExhaustWithoutSupply/ySupplyWithoutExhaust), NOT the library’s usual y...Ok evaluability flags — false on these never means NO_EVAL (SCHEMA.md, outputs contract).
  • AHU-0028 / RTU-0003 are the precedent physical-plausibility rules the design doc §2 leans on: envelope checks that already ship without violating the fault-given-valid-data stance.

SYS-0001 — CHW flow with no cooling demand

Statusverified — engine e2ff2f8, cxf:fnv1a128:bce909255e3c56c02e9e03c9ecd80247, 2026-08-17
Severity3
Methodrule
Phase2
CategoryCRITICAL_WASTE
ConfidenceHIGH
EstimationDIRECT_MEASUREMENT
G36
ClustersCLU-07
Suppresses
Suppressed by
RelatedSYS-0002, CHW-0004, AHU-0014, AHU-0020, PMP-0002
Playbooksunnecessary-plant-operation, stuck-actuator
SourceHVAC FDD Reference v1.0 §16, SYS-0001 (pdf pp. 139-140) — equation, required points, all three tunables, the four diagnoses, and both impact profiles; The reference’s own provenance line for that card: PNNL AIRCx; Library precedent: CHW-0003 (chw_valve_max, the served-set valve aggregate this card mirrors), VAV-0001 and HP-0001 (parameters shipped as documented placeholders because the reference publishes a fitting rule rather than a number)
Operating statesall

Preconditions (host-enforced): ahu_clg_vlv_max must span every cooling load the loop serves, not the AHUs someone remembered. A maximum taken over a subset reads 0% while an unmonitored coil is wide open, which is exactly the case the fault claims to have excluded — and on a CHW loop the unmonitored load is rarely another AHU. Computer-room units, lab equipment, chilled beams, process heat exchangers and fan coils all hold legitimate demand that an AHU-only aggregate cannot see, and a plant serving them alarms here every hour it works correctly. Either extend the aggregate to those valves or do not bind the rule. no_demand_flow_threshold ships as a placeholder in L/s and MUST be fitted to roughly 10% of the loop’s design flow before any verdict means anything (see Deviations); the reference publishes the fitting rule, not the number. chw_flow must be in L/s — the rule converts nothing — and must read a true zero on a dead loop: a magnetic meter with a standing zero offset, or an ultrasonic meter reporting noise on an empty pipe, holds the flow conjunct true forever and turns this into a permanent alarm on a plant that is off. The loop must be variable-flow on modulating two-way valves. A three-way-valve loop circulates near design flow with every coil diverted to its bypass, so the rule fires continuously and means nothing; a loop whose valves are two-position has no meaningful maximum either. Windows where flow with no demand is the sequence working — a chiller pump-down, a scheduled proof-of-operation run, freeze or condensation protection — are the host’s to exclude, because the graph has no way to tell them from waste. When the aggregate is stale, partial, or missing the verdict is NO_EVAL, not healthy: there is no in-rule evaluability output, since a stale feed and a genuinely shut valve are the same number at the boundary.

Points: chw_flow, ahu_clg_vlv_max

Outputs:

  • yFault — True while the CHW distribution loop has carried more than no_demand_flow_threshold with every served cooling valve commanded below valve_closed_threshold, continuously for at least alarm_delay

Parameters:

NameDefaultUnitCXF pathDescription
no_demand_flow_threshold5.0L/sflowHigh.tDistribution flow above which the loop counts as circulating rather than resting. PER-LOOP SITE CONFIGURATION — the reference’s default is 10% of design, a commissioning-fitted quantity rather than a constant, and a CXF literal has to be one number in one unit. The shipped 5.0 L/s is 10% of a 50 L/s design loop (about 800 gpm, a mid-size plant at 2.4 gpm/ton); it is not a site value. Fit it from the loop’s design flow, and check it against what the meter actually reports with the pumps off.
valve_closed_threshold2.0%valvesShut.tCooling valve command at or below which a coil counts as closed (the reference’s own 2%). Applied to the served-set maximum, so it is the whole demand test. Sites whose valve commands park at a nonzero rest position must retune it above that position or accept a standing alarm.
alarm_delay900.0spersist.delayTimeContinuous flow-without-demand required before the alarm asserts (the reference’s AlarmDelay, 15 min). It is what separates the fault from the minutes after the last valve shuts, while the loop coasts down and the plant sequence runs.

Description

Water is moving and nothing is asking for it. Every cooling coil on the loop is commanded shut, so the chilled water comes back at the temperature it left and the pump energy that pushed it around the building turns into heat in the water it was supposed to cool; a chiller still enabled holds its evaporator and its controls alive for a load that is not there. Neither side can see the fault alone — the plant knows flow and nothing about demand, the AHUs know their valves are shut and nothing about the loop — which is why it is a cross-equipment rule. The waste is a base load with no comfort complaint and no plant alarm behind it: a 15 kW distribution pump left running through a shoulder season is around 65 MWh nobody notices.

Detection Logic

flow_high   = chw_flow > no_demand_flow_threshold
valves_shut = ahu_clg_vlv_max < valve_closed_threshold

yFault = (flow_high AND valves_shut) sustained continuously for alarm_delay

Block graph (rule.cxf.jsonld):

SYS-0001 block graph

The demand conjunct is where the reference and a block graph have to be reconciled. The reference writes all(clg_vlv_cmd <= valve_closed_threshold for ahu in served_ahus), a quantifier over a set whose width is a site property, and CXF has no variable-width input. The host supplies ahu_clg_vlv_max instead, and max < t is exactly all < t — an identity, not an approximation. What moves is the obligation to span every cooling load on the loop, which is what the preconditions spend their length on.

Both comparisons are single-sided and strict. flow_high is strict in the reference too; valves_shut is not — the reference writes <= and CDL Reals has no LessEqual — so a served-set maximum sitting at exactly 2.0% reads as demand rather than as closed, an error of one part in fifty of a valve command in the direction of silence.

persist is a TrueDelay asserting at exactly T + delayTime, so the realized test is “flow with no demand for strictly more than alarm_delay” at tick resolution, and any interruption discards the elapsed time rather than pausing it. delayOnInit = true (CDL default false) makes a loop already circulating at engine start wait out the full 15 minutes.

Possible Diagnoses

The reference’s four, in its order:

  1. CHW pump running unnecessarily — enabled by a schedule, a hand switch, or a start command nobody revoked
  2. Leaking cooling coil valve(s) — a valve commanded shut that does not seat passes water continuously; AHU-0014 sees the same defect from the air side
  3. Bypass valve stuck open — a minimum-flow or pressure-bypass valve that never closed, keeping the loop circulating whatever the coils do
  4. Control sequence not shutting down the CHW loop — no logic stops the pumps when demand goes away, so the plant runs whenever it is enabled

Energy Impact

CRITICAL_WASTE, HIGH confidence, DIRECT_MEASUREMENT — the reference’s profile. The affected subsystem is the distribution pump plus chiller standby, and the savings figure is 100% of both while the condition holds, because the load being served is zero by construction. waste_kw = chw_pump_kw + chiller_standby_kw is the reference’s runtime term and both quantities are the host’s; this rule reads a flow meter and a valve aggregate and never sees a kW, so DIRECT_MEASUREMENT holds only as far as the host’s pump metering does. Climate sensitivity is Both — a loop left circulating in winter wastes as much as one left circulating in summer.

Emissions Impact

Scope 2, DIRECT_EMISSIONS, HIGH confidence; the reference’s typical range is 1,500-10,000 kg CO₂e/yr for the pump plus chiller standby, on a marginal operating emissions rate (MOER) basis. All of it is electricity, and the marginal basis is the right one because the waste is dispatchable — it stops the moment someone stops the pump.

Deviations

  • The reference’s all(...) quantifier becomes one host-derived aggregate. max < t is exactly all < t, so the substitution is an identity; it is needed because the reference’s required points list a per-AHU clg_vlv_cmd and a CXF block has a fixed number of inputs. Precedent is CHW-0003’s chw_valve_max, and the point dictionary carries the same warning: a maximum over a subset of the served loads is worse than no rule.
  • <= becomes a strict <. CDL Reals has no LessEqual, so valve_closed_threshold is applied as LessThreshold with t = 2.0 and a served-set maximum of exactly 2.0% reads as demand where the reference would call it closed. The library’s standing convention is to pin the threshold at the boundary and take the strict form; the direction is the conservative one for a waste rule.
  • no_demand_flow_threshold ships as a placeholder, not a default. The reference gives 10% of design, a fitting rule rather than a number, and a CXF S231:value is one double in one unit. The shipped 5.0 L/s is 10% of a 50 L/s design loop and is not a site value: too low and the rule alarms on the leakage every loop has, too high and a pump at minimum speed never trips it. Same precedent as VAV-0001’s ventilation_requirement.
  • The valve aggregate is built from commands, not feedback, unlike CHW-0003’s. The question here is what the control system is requesting, and it is also what keeps diagnoses 2 and 3 visible: a leaking or stuck-open valve reads 0% on the command while it passes water; bind feedback and the same valve reads 20%, the demand conjunct blocks, and the rule goes quiet on the case it was written to catch.
  • No schedule or occupancy gate. The reference puts none in this equation and the omission is right — flow with no demand costs the same at 2 pm as at 2 am. SYS-0003 and SYS-0004 are the chapter’s schedule-gated rules.
  • AlarmDelay = 15 min becomes persist.delayTime = 900 s with delayOnInit = true (CDL default false), the library’s standing choice: a loop already circulating with no demand at controller restart waits out the full 15 minutes rather than alarming on the first tick.
  • TrueDelay asserts at exactly T + delayTime, verified against the engine at the pin rather than assumed, so the realized test is “strictly more than alarm_delay” at tick resolution.
  • Playbook binding. Primary is unnecessary-plant-operation, CLU-07’s declared slug; stuck-actuator stays bound as the secondary procedure for diagnoses 2 and 3.
  • Operating states and preconditions are declared in frontmatter for host enforcement rather than encoded in the block graph, per the library’s design stance.

Notes

Read the finding as a question about the pump before it is a question about a valve: if the pump is commanded on, the fault is diagnosis 1 or 4 and the work is in the BAS; if the pump is off and the meter still reads flow, diagnoses 2 and 3 are what remain. PMP-0002 (deadheading) often fires on the same hour.

SYS-0002 is the mirror, and a site with one usually has both — CLU-07 exists for that pairing, with this rule as the trigger. A plant tripping CHW-0004 at the same time is not showing two independent problems: flow with no load is the cleanest possible case of low delta-T syndrome.

Test Vectors

13 scenarios, clock step 60 s over 3600 s.

ScenarioDescription
plant_shut_down_with_no_demandThe healthy no-demand case: every cooling valve is shut and the CHW loop is shut down with it. Residual flow of 0.5 L/s is well under the no-demand threshold, so the flow conjunct blocks the fault on its own.
flow_with_real_cooling_demandThe other healthy case: 40 L/s circulating while the busiest coil valve sits at 65%. This is a plant doing its job, and the valve conjunct blocks the fault on its own no matter how much flow the loop carries.
flow_with_every_valve_shutThe fault: 12 L/s moving through the distribution loop while the maximum cooling valve command across every served AHU is 0%. Both conjuncts hold from the first tick, so persist matures at exactly 900 s.
flow_exactly_at_the_no_demand_thresholdBoundary from the reference’s own strict inequality: flow sitting at exactly no_demand_flow_threshold (5.0 L/s) is not above it. Reals.GreaterThreshold is strict, which is what the reference writes for this term, so the loop reads healthy.
flow_just_above_the_no_demand_thresholdThe same line from above: 5.1 L/s against a 5.0 L/s threshold with every valve shut, alarm at 900 s. Whether a real plant is faulted at 0.1 L/s of margin is a question about the fitted threshold, not about the rule.
valve_max_exactly_at_the_closed_thresholdThe strict-comparison deviation, pinned. The reference writes clg_vlv_cmd <= valve_closed_threshold; CDL Reals has no LessEqual, so the shipped test is a strict LessThreshold at 2.0 and a served set whose maximum sits at exactly 2.0% reads as demand rather than as closed. The pin is on the conservative side: no alarm.
valve_max_just_below_the_closed_thresholdThe same line from below: a maximum of 1.9% is inside the closed band, so the fault matures at 900 s. The gap between this scenario and the previous one is the whole cost of the strict pin.
one_open_valve_holds_the_aggregate_upThe reference’s all(…) quantifier written as a maximum: one AHU on the loop is trimming at 8% while every other coil valve is shut, so the aggregate never enters the closed band and the plant is serving a real load. This is the scenario the host’s aggregate has to get right — a maximum taken over a subset of the served AHUs would report 0% here and alarm on a plant that is working.
demand_stops_while_the_pump_runsThe transition the rule exists to catch: the building coasts out of its cooling load at t=600 s and the last valve shuts, but the CHW pump keeps 12 L/s moving. The delay starts on that tick, so the alarm lands at 1500 s rather than at 900 s.
demand_returns_before_the_delay_maturesA 14-minute lull in the cooling load is not a fault. A valve reopens to 30% at 840 s, one tick short of maturity, and shuts again at 1200 s. Continuous means continuous: the elapsed time is discarded rather than paused, so the alarm lands a full 900 s after the second shut, at 2100 s.
demand_returns_on_the_maturity_tickThe delay edge from below: a valve reopens at exactly 900 s. TrueDelay asserts at exactly T + delayTime and the falling edge passes straight through, so the two land on the same tick and the fault is never reported.
demand_returns_one_tick_after_maturityThe same edge from above: the valve reopens one tick later and leaves exactly one tick of alarm at 900 s. The realized test is therefore ‘no demand with flow for strictly more than alarm_delay’ at tick resolution.
pump_stops_and_the_fault_clearsRecovery: the alarm asserts at 900 s, someone stops the CHW pump at 1800 s, and flow falls to 0.5 L/s. TrueDelay delays the rising edge only, so the finding drops on that tick with no lag.
vectors.json
{
  "schema": "cxf-library/vectors/v1",
  "clock": {
    "step_s": 60,
    "horizon_s": 3600
  },
  "scenarios": [
    {
      "name": "plant_shut_down_with_no_demand",
      "description": "The healthy no-demand case: every cooling valve is shut and the CHW loop is shut down with it. Residual flow of 0.5 L/s is well under the no-demand threshold, so the flow conjunct blocks the fault on its own.",
      "inputs": {
        "chw_flow": 0.5,
        "ahu_clg_vlv_max": 0.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "flow_with_real_cooling_demand",
      "description": "The other healthy case: 40 L/s circulating while the busiest coil valve sits at 65%. This is a plant doing its job, and the valve conjunct blocks the fault on its own no matter how much flow the loop carries.",
      "inputs": {
        "chw_flow": 40.0,
        "ahu_clg_vlv_max": 65.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "flow_with_every_valve_shut",
      "description": "The fault: 12 L/s moving through the distribution loop while the maximum cooling valve command across every served AHU is 0%. Both conjuncts hold from the first tick, so persist matures at exactly 900 s.",
      "inputs": {
        "chw_flow": 12.0,
        "ahu_clg_vlv_max": 0.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 840,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 900,
          "to_s": 3600,
          "equals": true
        }
      ]
    },
    {
      "name": "flow_exactly_at_the_no_demand_threshold",
      "description": "Boundary from the reference's own strict inequality: flow sitting at exactly no_demand_flow_threshold (5.0 L/s) is not above it. Reals.GreaterThreshold is strict, which is what the reference writes for this term, so the loop reads healthy.",
      "inputs": {
        "chw_flow": 5.0,
        "ahu_clg_vlv_max": 0.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "flow_just_above_the_no_demand_threshold",
      "description": "The same line from above: 5.1 L/s against a 5.0 L/s threshold with every valve shut, alarm at 900 s. Whether a real plant is faulted at 0.1 L/s of margin is a question about the fitted threshold, not about the rule.",
      "inputs": {
        "chw_flow": 5.1,
        "ahu_clg_vlv_max": 0.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 840,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 900,
          "to_s": 3600,
          "equals": true
        }
      ]
    },
    {
      "name": "valve_max_exactly_at_the_closed_threshold",
      "description": "The strict-comparison deviation, pinned. The reference writes clg_vlv_cmd <= valve_closed_threshold; CDL Reals has no LessEqual, so the shipped test is a strict LessThreshold at 2.0 and a served set whose maximum sits at exactly 2.0% reads as demand rather than as closed. The pin is on the conservative side: no alarm.",
      "inputs": {
        "chw_flow": 12.0,
        "ahu_clg_vlv_max": 2.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "valve_max_just_below_the_closed_threshold",
      "description": "The same line from below: a maximum of 1.9% is inside the closed band, so the fault matures at 900 s. The gap between this scenario and the previous one is the whole cost of the strict pin.",
      "inputs": {
        "chw_flow": 12.0,
        "ahu_clg_vlv_max": 1.9
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 840,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 900,
          "to_s": 3600,
          "equals": true
        }
      ]
    },
    {
      "name": "one_open_valve_holds_the_aggregate_up",
      "description": "The reference's all(...) quantifier written as a maximum: one AHU on the loop is trimming at 8% while every other coil valve is shut, so the aggregate never enters the closed band and the plant is serving a real load. This is the scenario the host's aggregate has to get right \u2014 a maximum taken over a subset of the served AHUs would report 0% here and alarm on a plant that is working.",
      "inputs": {
        "chw_flow": 12.0,
        "ahu_clg_vlv_max": 8.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "demand_stops_while_the_pump_runs",
      "description": "The transition the rule exists to catch: the building coasts out of its cooling load at t=600 s and the last valve shuts, but the CHW pump keeps 12 L/s moving. The delay starts on that tick, so the alarm lands at 1500 s rather than at 900 s.",
      "inputs": {
        "chw_flow": 12.0,
        "ahu_clg_vlv_max": [
          {
            "t": 0,
            "value": 65.0
          },
          {
            "t": 600,
            "value": 0.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1440,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 1500,
          "to_s": 3600,
          "equals": true
        }
      ]
    },
    {
      "name": "demand_returns_before_the_delay_matures",
      "description": "A 14-minute lull in the cooling load is not a fault. A valve reopens to 30% at 840 s, one tick short of maturity, and shuts again at 1200 s. Continuous means continuous: the elapsed time is discarded rather than paused, so the alarm lands a full 900 s after the second shut, at 2100 s.",
      "inputs": {
        "chw_flow": 12.0,
        "ahu_clg_vlv_max": [
          {
            "t": 0,
            "value": 0.0
          },
          {
            "t": 840,
            "value": 30.0
          },
          {
            "t": 1200,
            "value": 0.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2040,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2100,
          "to_s": 3600,
          "equals": true
        }
      ]
    },
    {
      "name": "demand_returns_on_the_maturity_tick",
      "description": "The delay edge from below: a valve reopens at exactly 900 s. TrueDelay asserts at exactly T + delayTime and the falling edge passes straight through, so the two land on the same tick and the fault is never reported.",
      "inputs": {
        "chw_flow": 12.0,
        "ahu_clg_vlv_max": [
          {
            "t": 0,
            "value": 0.0
          },
          {
            "t": 900,
            "value": 30.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "demand_returns_one_tick_after_maturity",
      "description": "The same edge from above: the valve reopens one tick later and leaves exactly one tick of alarm at 900 s. The realized test is therefore 'no demand with flow for strictly more than alarm_delay' at tick resolution.",
      "inputs": {
        "chw_flow": 12.0,
        "ahu_clg_vlv_max": [
          {
            "t": 0,
            "value": 0.0
          },
          {
            "t": 960,
            "value": 30.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 840,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 900,
          "to_s": 900,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 960,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "pump_stops_and_the_fault_clears",
      "description": "Recovery: the alarm asserts at 900 s, someone stops the CHW pump at 1800 s, and flow falls to 0.5 L/s. TrueDelay delays the rising edge only, so the finding drops on that tick with no lag.",
      "inputs": {
        "chw_flow": [
          {
            "t": 0,
            "value": 12.0
          },
          {
            "t": 1800,
            "value": 0.5
          }
        ],
        "ahu_clg_vlv_max": 0.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 840,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 900,
          "to_s": 1740,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 1800,
          "to_s": 3600,
          "equals": false
        }
      ]
    }
  ]
}

SYS-0002 — HW flow with no heating demand

Statusverified — engine e2ff2f8, cxf:fnv1a128:893d380590509f14b9632129cdc3322b, 2026-08-17
Severity3
Methodrule
Phase2
CategoryCRITICAL_WASTE
ConfidenceHIGH
EstimationDIRECT_MEASUREMENT
G36
ClustersCLU-07
Suppresses
Suppressed by
RelatedSYS-0001, HW-0003, HW-0004, AHU-0015, VAV-0003
Playbooksunnecessary-plant-operation, stuck-actuator, hot-water-plant-faults
SourceHVAC FDD Reference v1.0 §16, SYS-0002 (pdf pp. 140-141) — equation, required points, the same structure as SYS-050 tunables row, the three diagnoses, and both impact profiles; The reference’s own provenance line for that card: PNNL AIRCx; SYS-0001, the CHW rule this one mirrors block for block and parameter for parameter; Library precedent: CHW-0003 (chw_valve_max, the served-set valve aggregate this card mirrors), VAV-0001 and HP-0001 (parameters shipped as documented placeholders because the reference publishes a fitting rule rather than a number)
Operating statesall

Preconditions (host-enforced): ahu_htg_vlv_max must span every heating load the loop serves. On a HW plant that usually means more than the air handlers: zone reheat valves, perimeter radiation, cabinet and unit heaters, and any preheat coil are all real demand, and a maximum taken over the AHU coils alone reads 0% while a hundred reheat valves modulate. That is the aggregate most likely to be built wrong, because the reheat valves live on zone controllers rather than on the plant. A plant that also makes domestic hot water must not be bound to this rule as it stands: a boiler holding 60 °C for service water circulates with every space-heating valve shut, and nothing in two points separates that from waste — bind a heating-only loop, gate host-side on the DHW load, or exclude the rule, the same decision HW-0003 and HW-0007 force. Loops that circulate on purpose with no call for heat are the other exclusion: freeze protection in an unconditioned space, a glycol run-around loop, and the pump exercise cycles some plants run weekly are all sequence working as designed, and the host owns those windows because the graph cannot see them. no_demand_flow_threshold ships as a placeholder in L/s and MUST be fitted to roughly 10% of the loop’s design flow before any verdict means anything (see Deviations); a HW loop moves far less water than a CHW loop of the same capacity, because it runs a design delta-T two to three times larger, so the CHW-derived shipped value is too high for most heating loops rather than too low. hw_flow must be in L/s — the rule converts nothing — and must read a true zero on a dead loop: a meter with a standing zero offset holds the flow conjunct true forever and turns this into a permanent alarm on a plant that is off. The loop must be variable-flow on modulating two-way valves; a three-way-valve loop circulates near design flow with every coil diverted to its bypass, and the rule fires continuously and means nothing on it. When the aggregate is stale, partial, or missing the verdict is NO_EVAL, not healthy: there is no in-rule evaluability output, since a stale feed and a genuinely shut valve are the same number at the boundary.

Points: hw_flow, ahu_htg_vlv_max

Outputs:

  • yFault — True while the HW distribution loop has carried more than no_demand_flow_threshold with every served heating valve commanded below valve_closed_threshold, continuously for at least alarm_delay

Parameters:

NameDefaultUnitCXF pathDescription
no_demand_flow_threshold5.0L/sflowHigh.tDistribution flow above which the loop counts as circulating rather than resting. PER-LOOP SITE CONFIGURATION — the reference’s default is 10% of design, a commissioning-fitted quantity rather than a constant, and a CXF literal has to be one number in one unit. The shipped 5.0 L/s is SYS-0001’s value, kept because the reference’s tunables row for this fault reads same structure as SYS-050; on a hot water loop it is generous, since a 1 MW plant at an 11 K design delta-T circulates about 22 L/s and 10% of that is 2.2 L/s. It is not a site value.
valve_closed_threshold2.0%valvesShut.tHeating valve command at or below which a coil counts as closed (the reference’s own 2%, inherited from SYS-0001). Applied to the served-set maximum, so it is the whole demand test. Sites whose valve commands park at a nonzero rest position must retune it above that position or accept a standing alarm.
alarm_delay900.0spersist.delayTimeContinuous flow-without-demand required before the alarm asserts (the reference’s AlarmDelay, 15 min). It is what separates the fault from the minutes after the last valve shuts, while the loop coasts down and the plant sequence runs.

Description

The heating loop is circulating and no coil is asking for heat. Hot water leaves the plant, travels the building, and returns at close to the temperature it left, so the pump energy moves water that delivers nothing and the distribution losses along the way are paid for out of fuel; a boiler still enabled holds a hot jacket and a set of controls alive for a load that does not exist. This is the heating mirror of SYS-0001, and the reference writes it that way — “same structure as SYS-050” in place of a tunables table. What differs is scale and season: a HW loop carries far less water for the same capacity (its design delta-T is two to three times a chilled loop’s), the standby term is fuel rather than electricity, and a heating plant left circulating through a summer produces no complaint, no alarm and no comfort signature until someone reads the flow meter.

Detection Logic

flow_high   = hw_flow > no_demand_flow_threshold
valves_shut = ahu_htg_vlv_max < valve_closed_threshold

yFault = (flow_high AND valves_shut) sustained continuously for alarm_delay

Block graph (rule.cxf.jsonld):

SYS-0002 block graph

ahu_htg_vlv_max carries the reference’s all(htg_vlv_cmd <= valve_closed_threshold for ahu in served_ahus) quantifier as a host-computed maximum, because CXF has no variable-width input and the served set is a site property. max < t is exactly all < t, so the substitution is an identity; what moves is the obligation. On a heating loop that obligation is heavier than on the CHW side, because the served set usually includes zone reheat valves the plant controller has never heard of — see preconditions.

valves_shut is strict where the reference writes <= (CDL Reals has no LessEqual), so a served-set maximum of exactly 2.0% reads as demand and blocks the fault. flow_high is strict in the reference too and needed no change.

persist is a TrueDelay asserting at exactly T + delayTime, so the realized test is “flow with no demand for strictly more than alarm_delay” at tick resolution, and a dip discards the elapsed time rather than pausing it. delayOnInit = true (CDL default false) makes a loop already circulating at engine start wait out the full 15 minutes.

Possible Diagnoses

The reference’s three, in its order:

  1. HW pump running unnecessarily — enabled by a schedule, a hand switch, or a start command nobody revoked; on a heating plant this is frequently seasonal
  2. Leaking heating coil valve(s) — a valve commanded shut that does not seat passes hot water continuously, putting heat into supply air that then has to be cooled back down; AHU-0015 sees it from the air side
  3. Bypass valve stuck open — a minimum-flow or pressure-bypass valve that never closed, keeping the loop circulating whatever the coils do

The reference lists no control-sequence item here, unlike its CHW card. In practice a loop with no logic to stop the pumps when demand goes away shows up under diagnosis 1, every hour, by design.

Energy Impact

CRITICAL_WASTE, HIGH confidence, DIRECT_MEASUREMENT — the reference’s profile. The affected subsystem is the distribution pump plus boiler standby, and the savings figure is 100% of both while the condition holds, because the load being served is zero by construction. waste_kw = hw_pump_kw + boiler_standby_kw is the reference’s runtime term and both quantities are the host’s. The halves are different kinds of energy: pump power is electricity, usually metered or reported by the drive; boiler standby is fuel — jacket and flue losses plus the short cycles that hold temperature — which on most plants is a nameplate-and-efficiency estimate, so DIRECT_MEASUREMENT holds only as far as the host’s instrumentation does. Climate sensitivity is Both, per the reference.

Emissions Impact

Scope 1 + 2, DIRECT_EMISSIONS, HIGH confidence; the reference’s typical range is 1,500-10,000 kg CO₂e/yr for the pump plus boiler standby, on a “Static Scope 1 + MOER” basis. The split follows the two subsystems: fuel burned to hold a boiler warm is Scope 1 on a static factor, pump electricity is Scope 2 on the marginal rate for the hour. An electric or heat-pump boiler moves the whole quantity into Scope 2 and onto MOER.

Deviations

  • The reference’s all(...) quantifier becomes one host-derived aggregate. max < t is exactly all < t, so the substitution is an identity; it is needed because the reference’s required points list a per-AHU htg_vlv_cmd and a CXF block has a fixed number of inputs. Precedent is CHW-0003’s chw_valve_max; the dictionary entry for ahu_htg_vlv_max carries the instruction that reheat valves belong in the set where the plant feeds them.
  • <= becomes a strict <. CDL Reals has no LessEqual, so valve_closed_threshold is applied as LessThreshold with t = 2.0 and a served-set maximum of exactly 2.0% reads as demand where the reference would call it closed. Standing library convention: pin the threshold at the boundary and take the strict form, which is the conservative direction for a waste rule.
  • no_demand_flow_threshold ships as a placeholder, and the mirror makes it worse here. The reference gives SYS-0001 10% of design and gives this fault “same structure as SYS-050”, so the tunable is inherited along with its problem. The shipped 5.0 L/s is a CHW-scale figure: a hot water loop at an 11 K design delta-T moves about 22 L/s per MW, so 5.0 L/s is 10% of design only near 2.3 MW and exceeds the entire design flow of a small plant — which fails silent. Fit it per loop before deployment. Precedent: VAV-0001’s ventilation_requirement.
  • The valve aggregate is built from commands, not feedback, following the reference’s own point (htg_vlv_cmd). The command states what the control system is asking for, which is what “no heating demand” means, and it is what keeps diagnoses 2 and 3 visible: a leaking or stuck-open valve reads 0% on the command while it passes water; bind feedback and it reads 20%, the demand conjunct blocks, and the rule goes quiet on two of its three diagnoses.
  • Three diagnoses, not SYS-0001’s four. The reference drops “control sequence not shutting down the loop” from this card’s list, and the list is transcribed rather than harmonised with the CHW card.
  • No schedule, occupancy, or OAT gate. The reference puts none in this equation, and the weather-based version of the fault is HW-0003 (plant operating above the OAT lockout), a separate rule with its own point and threshold. Nothing here consumes oat or occ_scheduled.
  • AlarmDelay = 15 min becomes persist.delayTime = 900 s with delayOnInit = true (CDL default false), the library’s standing choice: a loop already circulating with no demand at controller restart waits out the full 15 minutes rather than alarming on the first tick.
  • TrueDelay asserts at exactly T + delayTime, verified against the engine at the pin rather than assumed, so the realized test is “strictly more than alarm_delay” at tick resolution.
  • Playbook binding. Primary is unnecessary-plant-operation, CLU-07’s declared slug; stuck-actuator stays bound for the valve half of the diagnoses and hot-water-plant-faults for the plant half (boiler OAT lockout, DHW exclusion).
  • Operating states and preconditions are declared in frontmatter for host enforcement rather than encoded in the block graph, per the library’s design stance.

Notes

Settle the domestic hot water question before dispatching anything. On a combined plant this rule fires every summer hour, right about the numbers and wrong about the building, and the check is a drawing rather than a trend: does this loop feed a service water heat exchanger. If it does, the binding is the defect — a heating-only loop or a host-side gate, not a work order.

After that the finding is a question about the pump before it is a question about a valve. Pump commanded on is diagnosis 1 and BAS work, often a seasonal changeover nobody performed; pump off with flow on the meter leaves diagnoses 2 and 3. Pull HW-0003 alongside: it asks whether the plant is running above its OAT lockout, this rule asks whether anything is calling for heat, and a site that trips both has no demand-side shutdown at all. Check the chilled water side too — CLU-07’s trigger is SYS-0001, and the sequence gap is usually written once and copied.

Test Vectors

13 scenarios, clock step 60 s over 3600 s.

ScenarioDescription
plant_shut_down_with_no_demandThe healthy no-demand case: every heating valve is shut and the HW loop is shut down with it. Residual flow of 0.5 L/s is well under the no-demand threshold, so the flow conjunct blocks the fault on its own.
flow_with_real_heating_demandThe other healthy case: 40 L/s circulating while the busiest heating valve sits at 65%. One perimeter zone calling for reheat is enough to justify the whole loop, and the valve conjunct blocks the fault on its own.
flow_with_every_valve_shutThe fault: 12 L/s moving through the distribution loop while the maximum heating valve command across every served coil is 0%. Both conjuncts hold from the first tick, so persist matures at exactly 900 s.
flow_exactly_at_the_no_demand_thresholdBoundary from the reference’s own strict inequality: flow sitting at exactly no_demand_flow_threshold (5.0 L/s) is not above it. Reals.GreaterThreshold is strict, which is what the reference writes for this term, so the loop reads healthy.
flow_just_above_the_no_demand_thresholdThe same line from above: 5.1 L/s against a 5.0 L/s threshold with every valve shut, alarm at 900 s. Whether a real plant is faulted at 0.1 L/s of margin is a question about the fitted threshold, not about the rule.
valve_max_exactly_at_the_closed_thresholdThe strict-comparison deviation, pinned. The reference writes htg_vlv_cmd <= valve_closed_threshold; CDL Reals has no LessEqual, so the shipped test is a strict LessThreshold at 2.0 and a served set whose maximum sits at exactly 2.0% reads as demand rather than as closed. The pin is on the conservative side: no alarm.
valve_max_just_below_the_closed_thresholdThe same line from below: a maximum of 1.9% is inside the closed band, so the fault matures at 900 s. The gap between this scenario and the previous one is the whole cost of the strict pin.
one_open_valve_holds_the_aggregate_upThe reference’s all(…) quantifier written as a maximum: one zone reheat valve is trimming at 8% while every other heating valve is shut, so the aggregate never enters the closed band and the plant is serving a real load. On a HW loop that feeds VAV reheat this is the scenario the host’s aggregate most often gets wrong, because the reheat valves are counted in hundreds and live on the zone controllers rather than on the AHU.
demand_stops_while_the_pump_runsThe transition the rule exists to catch: the last zone comes off heat at t=600 s as the building warms up, but the HW pump keeps 12 L/s moving. The delay starts on that tick, so the alarm lands at 1500 s rather than at 900 s.
demand_returns_before_the_delay_maturesA 14-minute lull in the heating load is not a fault. A reheat valve reopens to 30% at 840 s, one tick short of maturity, and shuts again at 1200 s. Continuous means continuous: the elapsed time is discarded rather than paused, so the alarm lands a full 900 s after the second shut, at 2100 s.
demand_returns_on_the_maturity_tickThe delay edge from below: a valve reopens at exactly 900 s. TrueDelay asserts at exactly T + delayTime and the falling edge passes straight through, so the two land on the same tick and the fault is never reported.
demand_returns_one_tick_after_maturityThe same edge from above: the valve reopens one tick later and leaves exactly one tick of alarm at 900 s. The realized test is therefore ‘no demand with flow for strictly more than alarm_delay’ at tick resolution.
pump_stops_and_the_fault_clearsRecovery: the alarm asserts at 900 s, someone stops the HW pump at 1800 s, and flow falls to 0.5 L/s. TrueDelay delays the rising edge only, so the finding drops on that tick with no lag.
vectors.json
{
  "schema": "cxf-library/vectors/v1",
  "clock": {
    "step_s": 60,
    "horizon_s": 3600
  },
  "scenarios": [
    {
      "name": "plant_shut_down_with_no_demand",
      "description": "The healthy no-demand case: every heating valve is shut and the HW loop is shut down with it. Residual flow of 0.5 L/s is well under the no-demand threshold, so the flow conjunct blocks the fault on its own.",
      "inputs": {
        "hw_flow": 0.5,
        "ahu_htg_vlv_max": 0.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "flow_with_real_heating_demand",
      "description": "The other healthy case: 40 L/s circulating while the busiest heating valve sits at 65%. One perimeter zone calling for reheat is enough to justify the whole loop, and the valve conjunct blocks the fault on its own.",
      "inputs": {
        "hw_flow": 40.0,
        "ahu_htg_vlv_max": 65.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "flow_with_every_valve_shut",
      "description": "The fault: 12 L/s moving through the distribution loop while the maximum heating valve command across every served coil is 0%. Both conjuncts hold from the first tick, so persist matures at exactly 900 s.",
      "inputs": {
        "hw_flow": 12.0,
        "ahu_htg_vlv_max": 0.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 840,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 900,
          "to_s": 3600,
          "equals": true
        }
      ]
    },
    {
      "name": "flow_exactly_at_the_no_demand_threshold",
      "description": "Boundary from the reference's own strict inequality: flow sitting at exactly no_demand_flow_threshold (5.0 L/s) is not above it. Reals.GreaterThreshold is strict, which is what the reference writes for this term, so the loop reads healthy.",
      "inputs": {
        "hw_flow": 5.0,
        "ahu_htg_vlv_max": 0.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "flow_just_above_the_no_demand_threshold",
      "description": "The same line from above: 5.1 L/s against a 5.0 L/s threshold with every valve shut, alarm at 900 s. Whether a real plant is faulted at 0.1 L/s of margin is a question about the fitted threshold, not about the rule.",
      "inputs": {
        "hw_flow": 5.1,
        "ahu_htg_vlv_max": 0.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 840,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 900,
          "to_s": 3600,
          "equals": true
        }
      ]
    },
    {
      "name": "valve_max_exactly_at_the_closed_threshold",
      "description": "The strict-comparison deviation, pinned. The reference writes htg_vlv_cmd <= valve_closed_threshold; CDL Reals has no LessEqual, so the shipped test is a strict LessThreshold at 2.0 and a served set whose maximum sits at exactly 2.0% reads as demand rather than as closed. The pin is on the conservative side: no alarm.",
      "inputs": {
        "hw_flow": 12.0,
        "ahu_htg_vlv_max": 2.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "valve_max_just_below_the_closed_threshold",
      "description": "The same line from below: a maximum of 1.9% is inside the closed band, so the fault matures at 900 s. The gap between this scenario and the previous one is the whole cost of the strict pin.",
      "inputs": {
        "hw_flow": 12.0,
        "ahu_htg_vlv_max": 1.9
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 840,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 900,
          "to_s": 3600,
          "equals": true
        }
      ]
    },
    {
      "name": "one_open_valve_holds_the_aggregate_up",
      "description": "The reference's all(...) quantifier written as a maximum: one zone reheat valve is trimming at 8% while every other heating valve is shut, so the aggregate never enters the closed band and the plant is serving a real load. On a HW loop that feeds VAV reheat this is the scenario the host's aggregate most often gets wrong, because the reheat valves are counted in hundreds and live on the zone controllers rather than on the AHU.",
      "inputs": {
        "hw_flow": 12.0,
        "ahu_htg_vlv_max": 8.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "demand_stops_while_the_pump_runs",
      "description": "The transition the rule exists to catch: the last zone comes off heat at t=600 s as the building warms up, but the HW pump keeps 12 L/s moving. The delay starts on that tick, so the alarm lands at 1500 s rather than at 900 s.",
      "inputs": {
        "hw_flow": 12.0,
        "ahu_htg_vlv_max": [
          {
            "t": 0,
            "value": 65.0
          },
          {
            "t": 600,
            "value": 0.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1440,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 1500,
          "to_s": 3600,
          "equals": true
        }
      ]
    },
    {
      "name": "demand_returns_before_the_delay_matures",
      "description": "A 14-minute lull in the heating load is not a fault. A reheat valve reopens to 30% at 840 s, one tick short of maturity, and shuts again at 1200 s. Continuous means continuous: the elapsed time is discarded rather than paused, so the alarm lands a full 900 s after the second shut, at 2100 s.",
      "inputs": {
        "hw_flow": 12.0,
        "ahu_htg_vlv_max": [
          {
            "t": 0,
            "value": 0.0
          },
          {
            "t": 840,
            "value": 30.0
          },
          {
            "t": 1200,
            "value": 0.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2040,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2100,
          "to_s": 3600,
          "equals": true
        }
      ]
    },
    {
      "name": "demand_returns_on_the_maturity_tick",
      "description": "The delay edge from below: a valve reopens at exactly 900 s. TrueDelay asserts at exactly T + delayTime and the falling edge passes straight through, so the two land on the same tick and the fault is never reported.",
      "inputs": {
        "hw_flow": 12.0,
        "ahu_htg_vlv_max": [
          {
            "t": 0,
            "value": 0.0
          },
          {
            "t": 900,
            "value": 30.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "demand_returns_one_tick_after_maturity",
      "description": "The same edge from above: the valve reopens one tick later and leaves exactly one tick of alarm at 900 s. The realized test is therefore 'no demand with flow for strictly more than alarm_delay' at tick resolution.",
      "inputs": {
        "hw_flow": 12.0,
        "ahu_htg_vlv_max": [
          {
            "t": 0,
            "value": 0.0
          },
          {
            "t": 960,
            "value": 30.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 840,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 900,
          "to_s": 900,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 960,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "pump_stops_and_the_fault_clears",
      "description": "Recovery: the alarm asserts at 900 s, someone stops the HW pump at 1800 s, and flow falls to 0.5 L/s. TrueDelay delays the rising edge only, so the finding drops on that tick with no lag.",
      "inputs": {
        "hw_flow": [
          {
            "t": 0,
            "value": 12.0
          },
          {
            "t": 1800,
            "value": 0.5
          }
        ],
        "ahu_htg_vlv_max": 0.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 840,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 900,
          "to_s": 1740,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 1800,
          "to_s": 3600,
          "equals": false
        }
      ]
    }
  ]
}

SYS-0003 — Lighting on with no occupancy

Statusverified — engine e2ff2f8, cxf:fnv1a128:763feed2eb268c080053a6ce13419359, 2026-08-17
Severity4
Methodrule
Phase2
CategoryCRITICAL_WASTE
ConfidenceHIGH
EstimationDIRECT_MEASUREMENT
G36
ClustersCLU-04
Suppresses
Suppressed by
RelatedAHU-0018, SYS-0004, SYS-0008
Playbooksafter-hours-operation
SourceHVAC FDD Reference v1.0 §16, SYS-0003 (pdf pp. 140-141) — equation, AlarmDelay 30 min, severity 4 (info), the four diagnoses, and the whole impact profile; The reference’s own provenance line for that card: Mazzetto 2025 — 1,149 occurrences in 10 months at one facility; PNNL EEM-18 (lighting occupancy sensors) — the reference’s PNNL cross-reference for the 15-20% annual figure; Library precedent: AHU-0018 (the host-evaluated occupancy boolean, and the same Not/And/TrueDelay shape)
Operating statesall — the conjunction is self-gating, since two of its three terms are the unoccupied test

Preconditions (host-enforced): The occupancy sensor and the lighting circuit must cover the same space. This is a per-instance binding claim the graph cannot check, and getting it wrong is the rule’s main false-positive path: a corridor PIR paired with an open-plan circuit reports a fault every time the corridor empties. occ_scheduled is host-evaluated from the lighting schedule for THAT space, not the AHU’s — a building whose HVAC and lighting schedules differ needs the lighting one here, and schedule provenance that is stale or unknown is NO_EVAL rather than unoccupied. lighting_status should be a proven circuit status (current sensor, relay auxiliary contact, panel feedback) and not the command: a command point makes this rule an audit of the BAS’s intent, which is exactly the thing diagnosis 1 says has already failed. Occupancy-sensor timeout is the host’s to reconcile — a sensor whose timeout is long relative to alarm_delay delays the finding, and one that drops a stationary occupant produces a fault that is true given the data and wrong about the building.

Points: lighting_status, occ_sensor, occ_scheduled

Outputs:

  • yFault — True while the lighting circuit has been energized with the occupancy sensor unoccupied and the schedule closed, continuously for alarm_delay

Parameters:

NameDefaultUnitCXF pathDescription
alarm_delay1800.0spersist.delayTimeContinuous persistence required before the alarm asserts (30 min). The reference’s only tunable for this rule.

Description

Lights burning in an empty building after hours. The rule wants two independent witnesses before it says the space is empty — the occupancy sensor and the schedule — because either alone is wrong often enough to be useless: a schedule says nothing about the person working late, a PIR says nothing about the person sitting still. That is what makes a finding worth dispatching, and it is also why the rule under-reports: lights on all night in a room whose sensor has failed to a permanent “occupied” are invisible here. Prevalence is the reason the card exists — Mazzetto (2025) logged 1,149 occurrences in ten months at a single facility, and every hour of it is 100% waste. It is the only card in the library that leaves the mechanical plant, and it earns its place because the schedule that is wrong here is usually the master schedule AHU-0018 is failing on (hence CLU-04) and the fix is the same BAS work order.

Detection Logic

yFault = lighting_status
     AND NOT occ_sensor
     AND NOT occ_scheduled
     sustained continuously for alarm_delay

Block graph (rule.cxf.jsonld):

SYS-0003 block graph

Five blocks, no arithmetic and no thresholds — every input is already a boolean, so there is nothing to compare and nothing to tune but the delay.

Each of the three conjuncts blocks the fault by itself, and any of them going the other way drops a live alarm on the same tick: TrueDelay delays the rising edge only, so a schedule that opens mid-fault clears the finding immediately and a later unoccupied period starts a fresh 30 minutes.

persist asserts at exactly T + delayTime, so the realized test is “lit and unoccupied for strictly more than alarm_delay” at tick resolution. Continuous means continuous — someone crossing the room discards the elapsed time rather than pausing it, which is the intended trade: a full timer restart per PIR trip means an intermittent sensor produces silence rather than a stream of 30-minute findings. delayOnInit = true (CDL default false) makes a controller restart into an already-lit empty building wait out the full 30 minutes.

Possible Diagnoses

The reference’s four, in its order:

  1. Lighting control override active — a panel in HAND, or a BACnet priority-array entry holding the circuit on. Most common, cheapest to fix
  2. Occupancy sensor bypassed — disconnected, taped over, or decommissioned in software after nuisance-switching complaints
  3. Timer or photocell failure — a local astronomic timeclock that drifted or lost its battery, or a photocell reading a lit interior
  4. BAS schedule misconfiguration — the lighting schedule never edited from the default, or wrong holidays and time zone (the same root cause as AHU-0018)

Energy Impact

CRITICAL_WASTE, HIGH confidence, DIRECT_MEASUREMENT — the reference’s profile. While the fault is active the whole circuit is waste: waste_kw = lighting_circuit_kw, with no thermal term and no baseline to model, which is why confidence is HIGH on a rule this simple. EEM-18 puts occupancy-based lighting control at 15-20% of annual lighting energy. Climate-neutral: the waste scales with unoccupied hours, not weather. The reference’s severity 4 (info) against CRITICAL_WASTE answers a different question — every kilowatt-hour is unnecessary, but nothing breaks and nobody is uncomfortable, so it is a work order for the next scheduled visit rather than a callout.

Emissions Impact

Scope 2, DIRECT_EMISSIONS, HIGH confidence; the reference’s range is 500-5,000 kg CO₂e/yr, and its parenthetical is the point — “lighting waste, high MOER overnight.” Avoided-emissions basis MOER (marginal). The fault runs almost entirely when solar is off the grid and the marginal generator is gas or coal, so its emissions rank routinely beats its energy-cost rank in regions with cheap overnight power.

Deviations

  • Severity 4, from the chapter, against the family README’s 3. faults/sys/README.md lists this rule at severity 3 and its own note says the SYS-0001-057 rows are provisional transcriptions to be re-verified when each card is authored. The chapter says “Severity: 4 (info)” and wins; the README row needs updating by whoever owns that file.
  • occ_scheduled replaces the reference’s schedule-evaluation call. The reference writes NOT in_occupied_schedule(current_time, occ_schedule), a function over a calendar; the block graph has no clock, so the host evaluates the schedule (time zone, holidays, exceptions) and feeds the boolean, as AHU-0018 does. points/sys.points.json records it as a derived point.
  • The point is named occ_scheduled here and occ_schedule in points/ahu.points.json. One concept, two spellings. Cards bind by exact name within their own family dictionary so nothing breaks, but it is worth resolving library-wide.
  • One delay, not two. This card’s reference entry lists a single tunable, AlarmDelay = 30 min, so there is one TrueDelay and time-to-alarm is 30 minutes flat. AHU-0018 carries a grace_period because its own entry gives it one; none was invented here to match the sibling’s shape.
  • No thresholds, so the library’s strict-comparison deviation does not apply. Every input is a boolean and the graph contains no Reals block, so there is nothing to retune per binding.
  • delayOnInit = true (CDL default false), the library’s standing choice: a controller restart into an already-lit empty building waits out the full 30 minutes rather than alarming on the first tick.
  • TrueDelay asserts at exactly T + delayTime, so the realized test is “lit and unoccupied for strictly more than alarm_delay” at tick resolution.
  • The rule sees status, never power. lighting_circuit_kw in runtime_estimation is a host-side nameplate or metered value; no such point is bound and the graph produces a boolean. Accumulation is the host’s.
  • Operating states and preconditions are declared in frontmatter for host enforcement rather than encoded in the block graph. There is no NO_EVAL logic in the graph: it computes the fault given valid data.

Notes

Two failure paths lead to different trades. If occ_sensor and occ_scheduled disagree night after night, the schedule is the suspect and the fix is a BAS edit at $0. If they agree and the circuit stays on anyway, the suspect is downstream of the BAS — a HAND switch, a welded relay, a local timeclock — and the fix needs an electrician.

Where this fires alongside AHU-0018 on the same nights, treat it as one finding. The after-hours-operation playbook is shared for that reason, and CLU-04 exists to make the master schedule the thing that gets fixed rather than three symptoms of it.

Test Vectors

9 scenarios, clock step 60 s over 7200 s.

ScenarioDescription
lights_on_during_occupied_hoursThe normal case the rule must never touch: lights on, people detected, inside the occupied schedule.
lights_on_in_an_empty_room_during_scheduled_hoursThe schedule conjunct blocking on its own. Lights on and the sensor sees nobody, but the building is inside its occupied schedule — an empty conference room mid-morning is not this fault.
lights_on_after_hours_with_someone_presentThe sensor conjunct blocking on its own. Out of schedule, but the occupancy sensor sees someone — a cleaner or someone working late gets light, not a work order.
lights_off_after_hoursThe lighting conjunct blocking on its own: nobody there, nothing scheduled, and the circuit is off. Correct operation.
lights_on_with_no_occupancyThe reference condition. Circuit energized, sensor unoccupied, outside the schedule; TrueDelay asserts at exactly alarm_delay (1800 s) and holds.
lights_switch_off_on_the_alarm_tickThe delay edge from below: the circuit de-energizes at exactly 1800 s, so the condition is false on the tick the delay would have matured and nothing is ever reported.
lights_switch_off_one_tick_laterThe same edge from above: one tick of alarm at 1800 s, then clear. The realized test is ‘on and unoccupied for strictly more than alarm_delay’ at tick resolution.
occupancy_detected_restarts_the_clockContinuous means continuous. Someone walks through at 900 s and leaves at 1200 s; the elapsed 900 s is discarded rather than paused, so the alarm lands a full 1800 s after the room goes quiet again.
schedule_resumes_then_ends_againRecovery and re-arm on one timeline: the alarm asserts at 1800 s, the occupied schedule opens at 3000 s and drops it on that tick (TrueDelay delays the rising edge only), and the schedule closing again at 3600 s starts a fresh 1800 s to the second alarm at 5400 s.
vectors.json
{
  "schema": "cxf-library/vectors/v1",
  "clock": {
    "step_s": 60,
    "horizon_s": 7200
  },
  "scenarios": [
    {
      "name": "lights_on_during_occupied_hours",
      "description": "The normal case the rule must never touch: lights on, people detected, inside the occupied schedule.",
      "inputs": {
        "lighting_status": true,
        "occ_sensor": true,
        "occ_scheduled": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        }
      ]
    },
    {
      "name": "lights_on_in_an_empty_room_during_scheduled_hours",
      "description": "The schedule conjunct blocking on its own. Lights on and the sensor sees nobody, but the building is inside its occupied schedule \u2014 an empty conference room mid-morning is not this fault.",
      "inputs": {
        "lighting_status": true,
        "occ_sensor": false,
        "occ_scheduled": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        }
      ]
    },
    {
      "name": "lights_on_after_hours_with_someone_present",
      "description": "The sensor conjunct blocking on its own. Out of schedule, but the occupancy sensor sees someone \u2014 a cleaner or someone working late gets light, not a work order.",
      "inputs": {
        "lighting_status": true,
        "occ_sensor": true,
        "occ_scheduled": false
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        }
      ]
    },
    {
      "name": "lights_off_after_hours",
      "description": "The lighting conjunct blocking on its own: nobody there, nothing scheduled, and the circuit is off. Correct operation.",
      "inputs": {
        "lighting_status": false,
        "occ_sensor": false,
        "occ_scheduled": false
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        }
      ]
    },
    {
      "name": "lights_on_with_no_occupancy",
      "description": "The reference condition. Circuit energized, sensor unoccupied, outside the schedule; TrueDelay asserts at exactly alarm_delay (1800 s) and holds.",
      "inputs": {
        "lighting_status": true,
        "occ_sensor": false,
        "occ_scheduled": false
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1740,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 1800,
          "to_s": 7200,
          "equals": true
        }
      ]
    },
    {
      "name": "lights_switch_off_on_the_alarm_tick",
      "description": "The delay edge from below: the circuit de-energizes at exactly 1800 s, so the condition is false on the tick the delay would have matured and nothing is ever reported.",
      "inputs": {
        "lighting_status": [
          {
            "t": 0,
            "value": true
          },
          {
            "t": 1800,
            "value": false
          }
        ],
        "occ_sensor": false,
        "occ_scheduled": false
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        }
      ]
    },
    {
      "name": "lights_switch_off_one_tick_later",
      "description": "The same edge from above: one tick of alarm at 1800 s, then clear. The realized test is 'on and unoccupied for strictly more than alarm_delay' at tick resolution.",
      "inputs": {
        "lighting_status": [
          {
            "t": 0,
            "value": true
          },
          {
            "t": 1860,
            "value": false
          }
        ],
        "occ_sensor": false,
        "occ_scheduled": false
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1740,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 1800,
          "to_s": 1800,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 1860,
          "to_s": 7200,
          "equals": false
        }
      ]
    },
    {
      "name": "occupancy_detected_restarts_the_clock",
      "description": "Continuous means continuous. Someone walks through at 900 s and leaves at 1200 s; the elapsed 900 s is discarded rather than paused, so the alarm lands a full 1800 s after the room goes quiet again.",
      "inputs": {
        "lighting_status": true,
        "occ_sensor": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 900,
            "value": true
          },
          {
            "t": 1200,
            "value": false
          }
        ],
        "occ_scheduled": false
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2940,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 3000,
          "to_s": 7200,
          "equals": true
        }
      ]
    },
    {
      "name": "schedule_resumes_then_ends_again",
      "description": "Recovery and re-arm on one timeline: the alarm asserts at 1800 s, the occupied schedule opens at 3000 s and drops it on that tick (TrueDelay delays the rising edge only), and the schedule closing again at 3600 s starts a fresh 1800 s to the second alarm at 5400 s.",
      "inputs": {
        "lighting_status": true,
        "occ_sensor": false,
        "occ_scheduled": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 3000,
            "value": true
          },
          {
            "t": 3600,
            "value": false
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1740,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 1800,
          "to_s": 2940,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 3000,
          "to_s": 5340,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 5400,
          "to_s": 7200,
          "equals": true
        }
      ]
    }
  ]
}

SYS-0004 — Exhaust fan running during unoccupied hours

Statusverified — engine e2ff2f8, cxf:fnv1a128:bf696be4fccaa4a8f80be2adea0a2b4a, 2026-08-17
Severity3
Methodrule
Phase2
CategoryCRITICAL_WASTE
ConfidenceHIGH
EstimationDIRECT_MEASUREMENT
G36
ClustersCLU-04, CLU-08
Suppresses
Suppressed by
RelatedAHU-0018, SYS-0003, SYS-0008
Playbooksexhaust-fan-schedule-misalignment, after-hours-operation
SourceHVAC FDD Reference v1.0 §16, SYS-0004 (pdf pp. 141-142) — equation, AlarmDelay 15 min, severity 3 (warning), the four diagnoses, and the whole impact profile; The reference’s own provenance line for that card: Mazzetto 2025; PNNL EEM-07 (exhaust fan control); PNNL-25985 for the 0.5-3% site figure; PNNL RetuningOpps S08 for the ~35% prevalence; Library precedent: AHU-0018 (host-evaluated occupancy boolean, override conjunct, Not/And/TrueDelay shape)
Operating statesall — the conjunction is self-gating on unoccupied hours with no override

Preconditions (host-enforced): demand_override_active must aggregate EVERY legitimate hold on this fan before the rule is trusted: kitchen hood interlock, restroom or locker-room call, fume-hood or lab makeup, DCV purge, night flush, post-occupancy runout. The dictionary entry is a bare BAS flag with no Brick class behind it, so nothing checks that the host wired them all in, and each one missed is a nightly false positive with a real reason behind it. Fans that must run continuously by code — general lab exhaust, elevator machine rooms, battery and chemical storage — are not instances of this rule at all, and are excluded by not binding them rather than by anything the graph does. ef_status should be proven run status (current switch, differential pressure, VFD run feedback), not the start command, since diagnosis 3 is a relay that closed without being told to. occ_scheduled is host-evaluated from the schedule that governs THIS fan; where the exhaust fan follows its own schedule rather than the AHU’s, that is the one to feed, and unknown or stale schedule provenance is NO_EVAL rather than unoccupied.

Points: ef_status, occ_scheduled, demand_override_active

Outputs:

  • yFault — True while the exhaust fan has been proven running outside the occupied schedule with no demand override active, continuously for alarm_delay

Parameters:

NameDefaultUnitCXF pathDescription
alarm_delay900.0spersist.delayTimeContinuous persistence required before the alarm asserts (15 min). The reference’s only tunable for this rule.

Description

An exhaust fan running all night in an empty building. The fan’s own kilowatts are the small half of the bill: every cubic metre it throws away is replaced by outdoor air pulled through whatever the envelope offers, and that air gets conditioned — or it does not, and the building sits at negative pressure until morning with the heating plant chasing infiltration it was never sized for. That is why the reference calls a fan motor’s waste heating-dominant. It is also unusually common: PNNL’s retuning survey puts exhaust-fan schedule problems in roughly 35% of buildings, because exhaust fans are commissioned by a different trade than the AHU, often sit on their own timeclock, and appear on no graphic. The third term is where the engineering is — plenty of after-hours exhaust is correct, so the rule only accuses a fan running with nothing claiming responsibility for it.

Detection Logic

yFault = ef_status
     AND NOT occ_scheduled
     AND NOT demand_override_active
     sustained continuously for alarm_delay

Block graph (rule.cxf.jsonld):

SYS-0004 block graph

Five blocks, all boolean: no thresholds, nothing to retune but the delay.

Each conjunct blocks the fault by itself. An override that expires while the fan keeps running starts the clock from the expiry, not from the start of the run. The reverse edge is instant — TrueDelay delays only the rising edge, so a matured finding drops on the tick the occupied period opens.

persist asserts at exactly T + delayTime, so the realized test is “running unoccupied and unjustified for strictly more than alarm_delay” at tick resolution, and an interruption discards the elapsed time rather than pausing it. delayOnInit = true (CDL default false) makes a controller restarting at 02:00 into a running fan wait out the full 15 minutes.

Fifteen minutes is the reference’s number and it is short for this library (most rules hold for 30). The condition it guards is a discrete state rather than a noisy analog signal, so the delay buys immunity to short legitimate runs and to a status point that flickers at startup, and nothing else.

Possible Diagnoses

The reference’s four, in its order:

  1. Schedule misconfiguration — the fan’s schedule was never built, or is a copy of an occupied-hours-plus-buffer schedule nobody trimmed. Most common, $0
  2. Override stuck in the BAS — a manual hold or a BACnet priority-array entry from a service call that nobody released
  3. Fan relay stuck closed — the controller is commanding off and the fan runs anyway, the case that separates a proven ef_status from a command point
  4. Interlock with the AHU not configured — the fan has no relationship to the air handler it belongs with, which is SYS-0008’s subject

Energy Impact

CRITICAL_WASTE, HIGH confidence, DIRECT_MEASUREMENT — the reference’s profile. waste_kw = ef_rated_kw × (ef_speed/100)³ + conditioning penalty: the cube law on speed, plus the thermal cost of replacing what went out the roof. PNNL-25985 puts EEM-07 at 0.5-3% of site energy, modest per fan and additive because buildings have many of them and roughly a third have the problem. Climate sensitivity is heating-dominant: the fan does not care about the weather, the makeup air does, and an unbalanced building in January pays for every cubic metre twice — once to heat it and once in perimeter complaints next morning.

Emissions Impact

Scope 2, DIRECT_EMISSIONS, HIGH confidence; the reference’s range is 200-2,000 kg CO₂e/yr covering the fan plus the conditioning penalty. Avoided-emissions basis MOER (marginal) — like the other after-hours faults this one runs overnight when the marginal generator is dirtiest, so its emissions weight runs ahead of its energy cost. The conditioning half is Scope 1 wherever the makeup air is heated by a fuel-fired plant; the reference assigns the whole card Scope 2 and this transcribes that assignment rather than splitting it.

Deviations

  • occ_scheduled replaces the reference’s schedule-evaluation call. The reference writes NOT in_occupied_schedule(current_time, occ_schedule), a function over a calendar. The block graph has no clock, so the host evaluates the schedule and feeds the boolean, exactly as AHU-0018 does; points/sys.points.json records it as a derived point.
  • The point is named occ_scheduled here and occ_schedule in points/ahu.points.json. One concept, two spellings across dictionaries. Cards bind by exact name within their own family so nothing breaks, but it is worth resolving library-wide.
  • demand_override_active is a bare BAS flag with no ontology behind it. The dictionary entry carries brick: null, s223: null because Brick 1.4.4 models no override status. The consequence lands in preconditions rather than in the graph: the rule treats “no override” as “no justification,” and a site that has not wired every legitimate hold into that one boolean gets nightly false positives it will learn to ignore.
  • One delay, not two. The reference lists a single tunable for this rule, AlarmDelay = 15 min, so there is one TrueDelay and no grace period. The sibling AHU-0018 has a grace_period because its own entry gives it one; none was invented here to match.
  • No thresholds, so the library’s strict-comparison deviation does not apply. Every input is a boolean and the graph contains no Reals block.
  • delayOnInit = true (CDL default false), the library’s standing choice: a controller restarting at 02:00 into a running fan waits out the full 15 minutes rather than alarming on its first tick.
  • TrueDelay asserts at exactly T + delayTime, so the realized test is “running unoccupied and unjustified for strictly more than alarm_delay” at tick resolution.
  • Overlaps SYS-0008 and neither rule suppresses the other. A fan running unoccupied with its AHU off satisfies this rule and SYS-0008’s condition 1 at once; this one alarms at 900 s and that one at 2700 s. Both findings are true and they carry different fixes — turn the fan off after hours here, synchronize it with the AHU there — so suppresses stays empty both ways and CLU-08 groups them. Whether the cluster should promote one to trigger is a clusters/clusters.json question for whoever owns that file (CLU-08’s trigger today is AHU-0018).
  • The rule sees run status, never speed or power. ef_speed and ef_rated_kw in runtime_estimation are host-side; no such point is bound, and a VFD-driven fan idling at 20% trips this rule exactly as hard as one at full speed while wasting an eighth of the energy. Accumulation and ranking are the host’s.
  • Operating states and preconditions are declared in frontmatter for host enforcement rather than encoded in the block graph. There is no NO_EVAL logic in the graph: it computes the fault given valid data.

Notes

Trend the fan’s status against the AHU’s for a week before touching anything. Three patterns come out of that plot with different fixes: a fan running 24/7 has no schedule at all, a fan that stops hours after the AHU has a schedule copied and never trimmed, and a fan starting at odd hours is following an override or a local switch. Only the first two are $0.

Where the finding is a local timeclock or a wall switch outside the BAS, the fix is not a schedule edit. The exhaust-fan-schedule-misalignment playbook files that case under Step 2 “Remote fix,” and it is not one — someone has to stand at the panel. Bringing the fan under BAS control is a small capital job, worth saying so in the work order rather than discovering it on site.

Test Vectors

8 scenarios, clock step 60 s over 3600 s.

ScenarioDescription
fan_running_during_occupied_hoursThe normal case: the exhaust fan runs inside the occupied schedule.
fan_off_during_unoccupied_hoursThe fan conjunct blocking on its own: unoccupied and the fan is off, which is the outcome the rule exists to produce.
fan_running_unoccupied_with_no_overrideThe reference condition. Fan proven on, outside the schedule, nothing holding it; TrueDelay asserts at exactly alarm_delay (900 s).
demand_override_justifies_the_runThe override conjunct blocking on its own: same after-hours run, but a demand override (kitchen hood, restroom call, lab purge) is holding the fan on legitimately.
fan_stops_on_the_alarm_tickThe delay edge from below: the fan stops at exactly 900 s, so the condition is false on the tick the delay would have matured and nothing is reported.
fan_stops_one_tick_laterThe same edge from above: one tick of alarm at 900 s, then clear.
override_drops_and_starts_the_clockA legitimate override expires at 1200 s and the fan keeps running anyway. The 1200 s already elapsed does not count — the alarm lands 900 s after the justification disappears.
schedule_resumes_and_clears_the_alarmRecovery: the alarm asserts at 900 s and the occupied schedule opens at 1800 s, dropping it on that tick — TrueDelay delays the rising edge only.
vectors.json
{
  "schema": "cxf-library/vectors/v1",
  "clock": {
    "step_s": 60,
    "horizon_s": 3600
  },
  "scenarios": [
    {
      "name": "fan_running_during_occupied_hours",
      "description": "The normal case: the exhaust fan runs inside the occupied schedule.",
      "inputs": {
        "ef_status": true,
        "occ_scheduled": true,
        "demand_override_active": false
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "fan_off_during_unoccupied_hours",
      "description": "The fan conjunct blocking on its own: unoccupied and the fan is off, which is the outcome the rule exists to produce.",
      "inputs": {
        "ef_status": false,
        "occ_scheduled": false,
        "demand_override_active": false
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "fan_running_unoccupied_with_no_override",
      "description": "The reference condition. Fan proven on, outside the schedule, nothing holding it; TrueDelay asserts at exactly alarm_delay (900 s).",
      "inputs": {
        "ef_status": true,
        "occ_scheduled": false,
        "demand_override_active": false
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 840,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 900,
          "to_s": 3600,
          "equals": true
        }
      ]
    },
    {
      "name": "demand_override_justifies_the_run",
      "description": "The override conjunct blocking on its own: same after-hours run, but a demand override (kitchen hood, restroom call, lab purge) is holding the fan on legitimately.",
      "inputs": {
        "ef_status": true,
        "occ_scheduled": false,
        "demand_override_active": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "fan_stops_on_the_alarm_tick",
      "description": "The delay edge from below: the fan stops at exactly 900 s, so the condition is false on the tick the delay would have matured and nothing is reported.",
      "inputs": {
        "ef_status": [
          {
            "t": 0,
            "value": true
          },
          {
            "t": 900,
            "value": false
          }
        ],
        "occ_scheduled": false,
        "demand_override_active": false
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "fan_stops_one_tick_later",
      "description": "The same edge from above: one tick of alarm at 900 s, then clear.",
      "inputs": {
        "ef_status": [
          {
            "t": 0,
            "value": true
          },
          {
            "t": 960,
            "value": false
          }
        ],
        "occ_scheduled": false,
        "demand_override_active": false
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 840,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 900,
          "to_s": 900,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 960,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "override_drops_and_starts_the_clock",
      "description": "A legitimate override expires at 1200 s and the fan keeps running anyway. The 1200 s already elapsed does not count \u2014 the alarm lands 900 s after the justification disappears.",
      "inputs": {
        "ef_status": true,
        "occ_scheduled": false,
        "demand_override_active": [
          {
            "t": 0,
            "value": true
          },
          {
            "t": 1200,
            "value": false
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2040,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2100,
          "to_s": 3600,
          "equals": true
        }
      ]
    },
    {
      "name": "schedule_resumes_and_clears_the_alarm",
      "description": "Recovery: the alarm asserts at 900 s and the occupied schedule opens at 1800 s, dropping it on that tick \u2014 TrueDelay delays the rising edge only.",
      "inputs": {
        "ef_status": true,
        "occ_scheduled": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 1800,
            "value": true
          }
        ],
        "demand_override_active": false
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 840,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 900,
          "to_s": 1740,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 1800,
          "to_s": 3600,
          "equals": false
        }
      ]
    }
  ]
}

SYS-0005 — Sensor drift via cross-validation (paired sensors)

Statusverified — engine e2ff2f8, cxf:fnv1a128:0f6dd4de2df9cd68164f18828e25c4b9, 2026-08-17
Severity3
Methodrule
Phase2
CategoryCOMFORT_ENERGY
ConfidenceLOW
EstimationQUALITATIVE_ONLY
G36
ClustersCLU-09
Suppresses
Suppressed by
RelatedSYS-0006, SYS-0009, SYS-0010, AHU-0028, RTU-0003
Playbookssensor-drift
SourceHVAC FDD Reference v1.0 §16, SYS-0005 (pdf pp. 142-144) — equation, both delays, drift_threshold 2°C / 5%, the four diagnoses, and the whole impact profile; The reference’s own provenance line for that card: PNNL-27338 §3; G36 sensor calibration checks; Yang, H., Cho, S., Tae, C.-S., Zaheeruddin, M. (2008). Sequential rule based algorithms for temperature sensor fault detection in air handling units. Energy Conversion and Management 49(8), 2291-2306. doi:10.1016/j.enconman.2008.01.029 — the published grounding for pairwise sensor comparison, and the source of this card’s honesty about isolation; Accepted design: internal sensor-health design note (local-only, not distributed) (§2 stance, §3 ID scheme, §4.4 pair-bias vector strategy, §5 usable pairs); Library precedent: AHU-0028 and RTU-0003 (physical-plausibility rules that already ship), VFD-0001 (two published delays chained), VAV-0001 (per-binding placeholder parameter)
Operating statesall, within the binding’s own validity window — the two sensors must be measuring the same physical quantity at the moment of comparison, which for a stream-mixing pair is true only in particular damper or mode states (host-enforced; see preconditions)

Preconditions (host-enforced): sensor_value_a and sensor_value_b are ROLE points, not canonical names: the host’s instance configuration records which real point each is bound to, and that record is also what resolves this card’s adjudicates target. Both must be bound to the same quantity kind in the same units — the rule subtracts two numbers and converts nothing, so a pair trended in °C against °F reads as a permanent 30-unit divergence and alarms forever. drift_threshold ships as a temperature placeholder and MUST be retuned to the binding (see Deviations); a percent-quantity pair left at the shipped 2.0 gets a band the reference never intended. The pair must genuinely see the same quantity during evaluation, which is a per-binding claim the graph cannot check: erv_oa_entering_temp against oat holds whenever both are in the outdoor air stream, but mat against rat holds only with the outdoor air damper shut and mat against oat only at full economizer, so the host must gate those bindings on damper position and exclude the minutes after a changeover exactly as AHU-0028 does. Delivery quality is resolved before this rule runs, not by it: a value the host held over from twenty minutes ago reads as a divergence, and the rule is right about the number it was given and wrong about the sensor. Per the design doc’s normative constraint, no other card may list SYS-0005 in its suppresses — an equipment fault silencing the sensor rule that invalidates it is a cycle with a wrong answer at both ends.

Points: sensor_value_a, sensor_value_b

Outputs:

  • yFault — True while the two bound sensors have stayed more than drift_threshold apart, in the bound points’ units, continuously for drift_duration plus alarm_delay. Which member drifted is not determined — see adjudicates.verdict

Parameters:

NameDefaultUnitCXF pathDescription
drift_threshold2.0varies (the bound points’ own units)driftHigh.tMaximum divergence the pair’s combined accuracy explains. PER-BINDING SITE CONFIGURATION — the reference’s default is 2°C / 5%, one number per quantity kind, and a single CXF literal cannot be both. The shipped 2.0 is the temperature half; a percent-quantity binding (relative humidity, damper position, valve position) takes 5.0, and any other quantity takes a number nobody has published. Retune at binding.
drift_duration3600.0ssustained.delayTimeContinuous divergence required before it counts as drift rather than one sensor lagging the other through a transient (60 min). The reference’s own drift_duration.
alarm_delay1800.0spersist.delayTimeFurther persistence required after drift_duration before the alarm asserts (30 min). The reference’s own separate AlarmDelay; 90 min to alarm at the shipped defaults.

Description

Two sensors that should be reading the same number are not. A single transmitter has nothing to be checked against and every rule that reads one believes it; put a second in the same air stream, or take a pair the physics already constrains, and the disagreement is evidence no single-sensor rule can produce. The failure it catches is drift — slow, monotone, never still enough for a flatline test and never fast enough for a spike test, which is why it survives for years and why the reference puts its prevalence at 15%. What it costs is rarely its own energy: a biased outdoor-air sensor disables an economizer, a biased supply-air sensor drags a reset schedule with it, and what shows up on the report is some other rule firing for a reason that is not true.

The adjudicates contract. While yFault is active, both bound points are unfit to be believed: the host must return NO_EVAL for every rule on the same equipment instance that consumes either one, deriving that set from each card’s points list rather than from any list written here. The verdict is ambiguous because |a − b| says the pair disagrees and cannot say which member is wrong. adjudicates is card metadata; the block graph is unchanged and the engine never sees it.

That an in-graph rule may judge data at all is the accepted design’s argument: “data quality is the host’s” is about delivery — did a sample arrive, when, and what the field bus said about it — while this rule is about physical plausibility, computable from the signal, and a fault of a piece of equipment, because a sensor is equipment. AHU-0028 and RTU-0003 already ship that argument; this card is the same object with the equipment family taken out.

Detection Logic

yFault = |sensor_value_a − sensor_value_b| > drift_threshold
         sustained continuously for drift_duration,
         then held a further alarm_delay

Block graph (rule.cxf.jsonld):

SYS-0005 block graph

Five blocks and no gate. The sign discarded by absDiff is the rule’s defining property rather than an implementation detail: swap the two inputs and the output is identical on every tick, which is verdict: ambiguous written as arithmetic.

driftHigh is a strict Reals.GreaterThreshold — CDL Reals has no GreaterEqual — so a pair sitting exactly drift_threshold apart reads healthy. The threshold literal is in the bound points’ units, because the graph has none: bound to an ERV as erv_oa_entering_temp against oat (two thermometers in the same outdoor air stream, both °C, no host derivation and no mode gating), 2.0 means 2.0 K; bound to a pair of humidity transmitters the same literal means two points of relative humidity, which is not the reference’s number. Retune at binding.

sustained and persist are the reference’s two published delays chained rather than added, so 90 minutes to alarm at the defaults. Both assert at exactly T + delayTime, and continuous means continuous in both: a reconvergence discards the elapsed time rather than pausing it. delayOnInit = true on both (CDL default false) makes a pair already diverged at controller restart wait out the full 90 minutes.

Possible Diagnoses

The reference’s four, in its order:

  1. Sensor calibration drift — the intended target, fixed by the playbook’s Step 3 recalibration
  2. Sensor wiring fault — a long run picking up an offset, a loose terminal, or a 3-wire RTD lead-resistance error reading as a fixed bias
  3. Sensor placement — the two are not in the same air stream after all (a spare outdoor sensor on a sunlit wall, a probe downstream of a leak, a pair split across a mixing plane), which is a binding correction, not a work order
  4. Sensor failure — a transmitter drifting toward a rail, on its way to the flatline SYS-0009 will catch when it arrives

Every one of the four names a single sensor and this rule cannot say which of the two it is; the playbook’s Step 3.4 settles it by taking a reference instrument to both members.

Energy Impact

COMFORT_ENERGY, LOW confidence, QUALITATIVE_ONLY — the reference’s profile. There is no runtime waste term and this card does not invent one: a drifted sensor spends nothing by drifting. savings_range is EEM-01’s recalibration figure, 0-5% of site energy where the drift is causing downstream faults, and the conditional does the work — the amount belongs to the economizer or reset rule being misled, not to this one. Confidence is LOW for a specific reason: the rule is confident about the pair and silent about either member, and its false-positive rate is governed by whether the binding is sound. Diagnosis 3 is the standing false positive, and no threshold distinguishes it from real drift.

Emissions Impact

Scope 1 or 2 depending on what the mis-measurement drives, QUALITATIVE_EMISSIONS, LOW confidence, avoided-emissions basis N/A — the reference’s assignment. The ambiguity in scope is real: a drifted sensor biasing a boiler is Scope 1, the same sensor biasing a chiller or economizer is Scope 2, and the sensor itself emits nothing. The quantity is entirely cascade, which is why runtime_estimation is empty.

Deviations

  • First card in the library carrying adjudicates (contract above). No rule list appears on this card and none should: a hand-written list is correct the day it is written and silently incomplete the first time someone authors a rule reading the same point. AHU-0028’s hand-written thirteen-entry suppresses is the counter-example, and two of those thirteen do not exist yet.
  • The fan-out is larger than it looks. Bound to an ERV as erv_oa_entering_temp / oat, the closure is ERV-0001 and ERV-0002 — every rule the ERV family currently has, so the unit goes dark on one finding. Whether a host should then go silent or report at reduced confidence is an open question for the library owner; what this card owes is an honest declaration of scope, which is both points.
  • verdict: ambiguous rather than picking a victim in prose. The alternative was to declare sensor_value_b the reference (the reference’s required-points table calls it that) and adjudicate only sensor_value_a. Nothing in the graph distinguishes the two inputs and absDiff guarantees identical output under a swap, so naming the members primary and reference is a binding convention, not a property this rule can exploit.
  • sensor_value_a / sensor_value_b are role points, the documented exception to the canonical-name convention. The same graph deploys against many real points and the reference’s own required-points table says “varies by application” in the units column. The cost is named rather than hidden: the host’s instance configuration must record each binding — the same artifact adjudicates resolves against, so the design needs it either way.
  • drift_threshold ships as one number where the reference publishes two. The reference’s 2°C / 5% is it acknowledging that the threshold is per-quantity; a CXF S231:value is one double. The shipped 2.0 is the temperature half, the same value AHU-0028 uses for sensor_tolerance, and the 5% alternative is documented in params. Precedent for an explicitly per-binding placeholder: VAV-0001’s ventilation_requirement.
  • Two delays in series, not one. The reference lists drift_duration (60 min) and AlarmDelay (30 min) as separate tunables for one condition, so both are kept and chained, the VFD-0001 shape. A single 5400 s delay behaves identically as shipped; the chain is what lets a site keep a 30-minute drift window and a two-hour alarm hold, or the reverse, without re-authoring.
  • No activity gate, deliberately. SYS-0009 needs equip_active because a signal that is not moving on idle equipment is not evidence of anything; a bias test needs no such permission, since two thermometers in the same air disagree when one is wrong whether or not a fan runs. What this rule does need — that the pair is seeing the same quantity right now — is a per-binding claim about dampers and modes that no boolean expresses, so it is host-enforced preconditions, the call AHU-0028 made for its post-changeover exclusion.
  • No discrete blocks, so no startup artifact to mask. Subtract and Abs are combinational, and so is GreaterThreshold at the shipped h = 0 — it takes a state word only when hysteresis is enabled — so tick one compares two live readings and means it. Discrete.UnitDelay’s tick-one artifact and the ban on Reals.Derivative belong to SYS-0010; this card’s only state is the two timers.
  • TrueDelay asserts at exactly T + delayTime, verified against the engine at the pin rather than assumed: with delayOnInit the timer is zero on the first tick and accumulates dt from the second, so the realized test is “diverged for strictly more than drift_duration + alarm_delay”.
  • Common-mode drift is invisible. Two sensors from one calibration batch, on one supply voltage, with one wrong scaling constant drift together and the subtraction cancels it. The rules that could see it are SYS-0006 (a virtual sensor built from other points) and a fleet-comparison form that is not written.
  • Kept the reference’s number instead of taking a new one. At authoring time a consecutive SYS-FC-102 was offered; the reference’s own SYS-FC-054 won (decided 2026-08-17) because the reference’s card is this rule and the cluster and playbook files already used that ID. The 2026-08-18 renumbering into the general namespace made it SYS-0005; legacy IDs live in faults/registry.json.
  • clusters: [CLU-09] is a declaration, not an edit. CLU-09 already carries SYS-0005 as a member. That CLU-09’s trigger should arguably become one of the sensor rules, with AHU-0028 demoted to member, is a single-writer file and someone else’s edit — flagged here, not made.
  • suppresses: [], and it must stay that way in both directions. The NO_EVAL fan-out is adjudicates’ job, derived per instance. The stronger constraint belongs to whoever writes the next card: a card carrying adjudicates MUST NOT appear in any other card’s suppresses, because an equipment fault silencing the sensor rule that invalidates it is a cycle. The linter does not check this today.
  • category: COMFORT_ENERGY transcribed, not argued. PROTECTIVE is arguably more honest for an adjudicating rule, whose delivered value is avoided false alarms and preserved diagnostic coverage rather than energy, but the reference says COMFORT_ENERGY and so do AHU-0028 and RTU-0003; a category convention for sensor faults is library-wide. Severity 3 likewise.
  • Operating states and preconditions are declared in frontmatter for host enforcement rather than encoded in the block graph, per the library’s design stance.

Notes

Read the finding as a work order for two sensors, not one — Step 3.4 of the sensor-drift playbook says so, and it is the only procedure two points justify. A technician sent to a guessed member has a 50% chance of recalibrating a correct sensor against a drifted reference, which makes the pair agree and the building wrong.

Rule out diagnosis 3 from a trend first, because it is free and it is the most common false positive. Real drift opens slowly and does not close; a placement mismatch opens and closes with the weather, the schedule, or the damper, and the repair is a bracket rather than a calibration.

The family’s three members answer different questions: SYS-0009 catches the transmitter that has stopped moving, SYS-0010 the one that jumps further than the process can, and this one the one that is quietly wrong. A sensor that trips this rule and later trips SYS-0009 has finished failing.

Test Vectors

14 scenarios, clock step 60 s over 10800 s.

ScenarioDescription
pair_agreesBoth members of the pair read the same air within their combined accuracy: 15.0 against 14.6 is a 0.4-unit spread, well inside drift_threshold. Silent for three hours.
member_a_reads_highsensor_value_a sits 4.0 units above sensor_value_b. The divergence clears the threshold on the first tick, sustained matures at 3600 s and persist at 5400 s.
member_b_reads_highThe mirror of member_a_reads_high, and the vector-level statement of verdict: ambiguous. sensor_value_b is now the high one and every output tick is byte-identical to the previous scenario — Abs discards the sign, so the rule cannot name which member drifted and does not pretend to.
divergence_exactly_at_the_thresholdBoundary: 17.0 against 15.0 is a divergence of exactly drift_threshold (2.0). Reals.GreaterThreshold is strict and CDL Reals has no GreaterEqual, so exactly on the line reads healthy.
divergence_just_below_the_thresholdThe same line from below: 1.9 units apart, never reported.
divergence_just_above_the_thresholdThe same line from above: 2.1 units apart, alarm at 5400 s.
both_members_drift_togetherThe blind spot, pinned rather than left to be discovered on site: both sensors read 25.0 while the air they share is at 15. A common-mode error — one calibration batch, one supply voltage, one wrong scaling constant — cancels in the subtraction and this rule is silent through all of it.
slow_drift_crosses_the_thresholdThe reason the shape exists. sensor_value_a walks away from its partner over 90 minutes — 15.0, 16.0, 17.5, 19.0 — and crosses drift_threshold at t=3600 s, so the alarm lands at 9000 s. Neither a flatline test nor a spike test can see this: the signal is never still and never fast.
divergence_shorter_than_both_delaysA 75-minute divergence: long enough to mature sustained (60 min) and 15 of the further 30 minutes persist needs. No alarm. With the reference’s drift_duration alone — one combined delay instead of the chain — this fires at 3600 s, which is what makes it the regression test for the two-delay structure.
divergence_clears_on_the_maturity_tickThe delay edge from below: the pair reconverges at exactly 5400 s. TrueDelay asserts at T + delayTime and the falling edge passes through both delays with no lag, so the two land on the same tick and the fault is never reported.
divergence_clears_one_tick_laterThe same edge from above: reconvergence one tick later leaves exactly one tick of alarm at 5400 s. The realized test is therefore ‘diverged for strictly more than drift_duration + alarm_delay’ at tick resolution.
divergence_dips_and_restarts_the_clockContinuous means continuous. The pair converges for ten minutes at t=1800 s, which discards the elapsed time rather than pausing it, and the alarm lands a full 5400 s after the second crossing at 2400 s.
pair_reconverges_after_alarmRecovery: the alarm asserts at 5400 s and someone applies a BAS offset at 7200 s, bringing the members within 0.2 units. Both delays drop on that tick — TrueDelay delays the rising edge only.
percent_binding_at_the_shipped_defaultThe units warning made executable. Two humidity transmitters bound as the pair, 44.0 %RH against 40.0 %RH: the graph has no units, so the shipped 2.0 temperature placeholder alarms on a 4-point spread the reference’s own 5% default would have cleared. A binding that does not retune drift_threshold gets a rule it did not ask for.
vectors.json
{
  "schema": "cxf-library/vectors/v1",
  "clock": {
    "step_s": 60,
    "horizon_s": 10800
  },
  "scenarios": [
    {
      "name": "pair_agrees",
      "description": "Both members of the pair read the same air within their combined accuracy: 15.0 against 14.6 is a 0.4-unit spread, well inside drift_threshold. Silent for three hours.",
      "inputs": {
        "sensor_value_a": 15.0,
        "sensor_value_b": 14.6
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 10800,
          "equals": false
        }
      ]
    },
    {
      "name": "member_a_reads_high",
      "description": "sensor_value_a sits 4.0 units above sensor_value_b. The divergence clears the threshold on the first tick, sustained matures at 3600 s and persist at 5400 s.",
      "inputs": {
        "sensor_value_a": 19.0,
        "sensor_value_b": 15.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5340,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 5400,
          "to_s": 10800,
          "equals": true
        }
      ]
    },
    {
      "name": "member_b_reads_high",
      "description": "The mirror of member_a_reads_high, and the vector-level statement of verdict: ambiguous. sensor_value_b is now the high one and every output tick is byte-identical to the previous scenario \u2014 Abs discards the sign, so the rule cannot name which member drifted and does not pretend to.",
      "inputs": {
        "sensor_value_a": 15.0,
        "sensor_value_b": 19.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5340,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 5400,
          "to_s": 10800,
          "equals": true
        }
      ]
    },
    {
      "name": "divergence_exactly_at_the_threshold",
      "description": "Boundary: 17.0 against 15.0 is a divergence of exactly drift_threshold (2.0). Reals.GreaterThreshold is strict and CDL Reals has no GreaterEqual, so exactly on the line reads healthy.",
      "inputs": {
        "sensor_value_a": 17.0,
        "sensor_value_b": 15.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 10800,
          "equals": false
        }
      ]
    },
    {
      "name": "divergence_just_below_the_threshold",
      "description": "The same line from below: 1.9 units apart, never reported.",
      "inputs": {
        "sensor_value_a": 16.9,
        "sensor_value_b": 15.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 10800,
          "equals": false
        }
      ]
    },
    {
      "name": "divergence_just_above_the_threshold",
      "description": "The same line from above: 2.1 units apart, alarm at 5400 s.",
      "inputs": {
        "sensor_value_a": 17.1,
        "sensor_value_b": 15.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5340,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 5400,
          "to_s": 10800,
          "equals": true
        }
      ]
    },
    {
      "name": "both_members_drift_together",
      "description": "The blind spot, pinned rather than left to be discovered on site: both sensors read 25.0 while the air they share is at 15. A common-mode error \u2014 one calibration batch, one supply voltage, one wrong scaling constant \u2014 cancels in the subtraction and this rule is silent through all of it.",
      "inputs": {
        "sensor_value_a": 25.0,
        "sensor_value_b": 25.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 10800,
          "equals": false
        }
      ]
    },
    {
      "name": "slow_drift_crosses_the_threshold",
      "description": "The reason the shape exists. sensor_value_a walks away from its partner over 90 minutes \u2014 15.0, 16.0, 17.5, 19.0 \u2014 and crosses drift_threshold at t=3600 s, so the alarm lands at 9000 s. Neither a flatline test nor a spike test can see this: the signal is never still and never fast.",
      "inputs": {
        "sensor_value_a": [
          {
            "t": 0,
            "value": 15.0
          },
          {
            "t": 1800,
            "value": 16.0
          },
          {
            "t": 3600,
            "value": 17.5
          },
          {
            "t": 5400,
            "value": 19.0
          }
        ],
        "sensor_value_b": 15.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 8940,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 9000,
          "to_s": 10800,
          "equals": true
        }
      ]
    },
    {
      "name": "divergence_shorter_than_both_delays",
      "description": "A 75-minute divergence: long enough to mature sustained (60 min) and 15 of the further 30 minutes persist needs. No alarm. With the reference's drift_duration alone \u2014 one combined delay instead of the chain \u2014 this fires at 3600 s, which is what makes it the regression test for the two-delay structure.",
      "inputs": {
        "sensor_value_a": [
          {
            "t": 0,
            "value": 19.0
          },
          {
            "t": 4500,
            "value": 15.0
          }
        ],
        "sensor_value_b": 15.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 10800,
          "equals": false
        }
      ]
    },
    {
      "name": "divergence_clears_on_the_maturity_tick",
      "description": "The delay edge from below: the pair reconverges at exactly 5400 s. TrueDelay asserts at T + delayTime and the falling edge passes through both delays with no lag, so the two land on the same tick and the fault is never reported.",
      "inputs": {
        "sensor_value_a": [
          {
            "t": 0,
            "value": 19.0
          },
          {
            "t": 5400,
            "value": 15.0
          }
        ],
        "sensor_value_b": 15.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 10800,
          "equals": false
        }
      ]
    },
    {
      "name": "divergence_clears_one_tick_later",
      "description": "The same edge from above: reconvergence one tick later leaves exactly one tick of alarm at 5400 s. The realized test is therefore 'diverged for strictly more than drift_duration + alarm_delay' at tick resolution.",
      "inputs": {
        "sensor_value_a": [
          {
            "t": 0,
            "value": 19.0
          },
          {
            "t": 5460,
            "value": 15.0
          }
        ],
        "sensor_value_b": 15.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5340,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 5400,
          "to_s": 5400,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 5460,
          "to_s": 10800,
          "equals": false
        }
      ]
    },
    {
      "name": "divergence_dips_and_restarts_the_clock",
      "description": "Continuous means continuous. The pair converges for ten minutes at t=1800 s, which discards the elapsed time rather than pausing it, and the alarm lands a full 5400 s after the second crossing at 2400 s.",
      "inputs": {
        "sensor_value_a": [
          {
            "t": 0,
            "value": 19.0
          },
          {
            "t": 1800,
            "value": 15.0
          },
          {
            "t": 2400,
            "value": 19.0
          }
        ],
        "sensor_value_b": 15.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7740,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 7800,
          "to_s": 10800,
          "equals": true
        }
      ]
    },
    {
      "name": "pair_reconverges_after_alarm",
      "description": "Recovery: the alarm asserts at 5400 s and someone applies a BAS offset at 7200 s, bringing the members within 0.2 units. Both delays drop on that tick \u2014 TrueDelay delays the rising edge only.",
      "inputs": {
        "sensor_value_a": [
          {
            "t": 0,
            "value": 19.0
          },
          {
            "t": 7200,
            "value": 15.2
          }
        ],
        "sensor_value_b": 15.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5340,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 5400,
          "to_s": 7140,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 7200,
          "to_s": 10800,
          "equals": false
        }
      ]
    },
    {
      "name": "percent_binding_at_the_shipped_default",
      "description": "The units warning made executable. Two humidity transmitters bound as the pair, 44.0 %RH against 40.0 %RH: the graph has no units, so the shipped 2.0 temperature placeholder alarms on a 4-point spread the reference's own 5% default would have cleared. A binding that does not retune drift_threshold gets a rule it did not ask for.",
      "inputs": {
        "sensor_value_a": 44.0,
        "sensor_value_b": 40.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5340,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 5400,
          "to_s": 10800,
          "equals": true
        }
      ]
    }
  ]
}

SYS-0006 — Virtual sensor drift detection

Statusverified — engine e2ff2f8, cxf:fnv1a128:7b8d5126bff08106d7dae20ea85ecc3c, 2026-08-17
Severity3
Methodstatistical
Phase2
CategoryCOMFORT_ENERGY
ConfidenceLOW
EstimationQUALITATIVE_ONLY
G36
ClustersCLU-09
Suppresses
Suppressed by
RelatedSYS-0005, SYS-0009, SYS-0010, AHU-0028, RTU-0003
Playbookssensor-drift
SourceHVAC FDD Reference v1.0 §16, SYS-0006 (pdf pp. 144-145) — the residual equation, both published thresholds, the four diagnoses, the whole impact profile, and the Koo & Yoon note; The reference’s own provenance line for that card: Koo & Yoon 2022; Sun et al. 2024 (virtual sensor RMSE 0.30 °C, bias > 1 °C detected reliably); Accepted design: internal sensor-health design note (local-only, not distributed) (§2 stance, §2.3 the adjudicates contract, §4.3 the MovingAverage ring floor, §4.4 vector strategy); Library precedent: SYS-0005 (the pair form of the same question), SYS-0009/SYS-0010 (the role-point sensor family), AHU-0022 (Reals.MovingAverage at a 64-checkpoint ring), HP-0001 and VAV-0001 (host-fitted baselines consumed as ordinary points)
Operating statesall, within the operating envelope the virtual sensor was trained on. The graph has no gate and evaluates whenever the host publishes a prediction, so the envelope is the host’s to enforce: a Ridge model fitted over a summer learning period is extrapolating in January, and its extrapolation error arrives here as a residual indistinguishable from sensor drift. Where the host cannot vouch for the prediction it should stop publishing virtual_value rather than publish a guess.

Preconditions (host-enforced): physical_sensor and virtual_value are a ROLE PAIR, not canonical names: the host’s instance configuration records which real point physical_sensor is bound to, and that record is what resolves this card’s adjudicates target. Both thresholds are in the BOUND point’s units — the reference’s 1.5 and 3.0 are its temperature defaults and MUST be retuned for any other quantity kind. Four host obligations decide whether this rule means anything. (1) The model must never take the accused sensor as one of its own features: a regression that can see physical_sensor predicts it perfectly, the residual collapses to zero, and the rule goes permanently silent while reporting health. (2) The learning period must be known-good. A model trained while the sensor was already 2 K high learns the bias as truth, and the drift becomes invisible from the moment it is fitted — this rule cannot detect a fault that predates its own baseline. (3) Model health is a separate question with a separate rule: the reference’s META-FC-050 (statistical model confidence degradation) is what says the Ridge fit has stopped tracking, and a host running it should read a degraded model as NO_EVAL here rather than as sensor drift. (4) Delivery quality is resolved before this rule runs, not by it — a value held over from a dead subscription reads as a residual, and the rule is right about the number it was given and wrong about the sensor (design doc §2.2). Recommended: report NO_EVAL for the first window after load, where both statistics are computed over a partial window; the graph will still produce a verdict there, and bias_present_at_load pins what that verdict looks like.

Points: physical_sensor, virtual_value

Outputs:

  • yFault — True once the residual has broken either the bias band or the noise band continuously for alarm_delay. While true, the host treats physical_sensor as invalid for every rule bound to this equipment instance that consumes it (see adjudicates), weighed against diagnosis 4
  • yBias — Sub-condition flag, undelayed — the window mean of the residual is outside bias_threshold. True with yFault means diagnosis 1 (calibration drift). Not an evaluability output: a false yBias never means NO_EVAL
  • yNoise — Sub-condition flag, undelayed — the window variance of the residual is above noise_threshold squared. True with yFault means diagnosis 2 (intermittent failure). Not an evaluability output

Parameters:

NameDefaultUnitCXF pathDescription
bias_threshold1.5varies (the bound point’s own units)biasHigh.tHow far the window-mean residual may sit from zero before the sensor is accused of a calibration bias. The reference’s 1.5 °C, which sits above the ~1 °C floor Koo & Yoon report this method detects reliably and above the 0.30 °C RMSE of their model. PER-BINDING: it is a temperature number, and a pressure or humidity binding needs its own.
noise_threshold3.0varies (the bound point’s own units)noiseThr.kResidual standard deviation above which the sensor is accused of intermittent failure rather than bias. The reference’s 3.0 °C, published in natural units and kept that way — the graph squares it internally (see Deviations), so a host retunes noiseThr.k in degrees and never in degrees squared. PER-BINDING, same caveat as bias_threshold.
window3600.0smuResid.delta, muSq.deltaRolling window both statistics are computed over (60 min). LIBRARY-CHOSEN — the reference’s tunables line is truncated in the source pdf and publishes no window. One card parameter binds both MovingAverage instances and hosts must set them together; a host ticking faster than window/63 (57.1 s at the default) silently shortens the window through the block’s 64-checkpoint ring.
alarm_delay1800.0spersist.delayTimeContinuous persistence required after either branch trips before the alarm asserts (30 min). LIBRARY-CHOSEN for the same reason as window; 30 min is the AlarmDelay the reference publishes for its sibling sensor rule SYS-0005.

Description

Give a sensor a second opinion built from every other point that correlates with it, and the difference between the two is evidence the sensor cannot supply about itself. That second opinion is the virtual sensor: a Ridge regression the host fits over a learning period on three or more correlated features, publishing a prediction of the accused point every tick. Koo & Yoon (2022) report an RMSE of 0.30 °C for that kind of model and reliable detection of biases above about 1 °C, which is the basis for putting a 1.5 °C band around the residual and believing what crosses it. The graph does two statistics on that one signal: the window mean, which catches the slow monotone bias, and the window variance, which catches the transmitter that has started jumping — the reference’s diagnoses 1 and 2, kept separable in the output rather than merged.

The adjudicates contract. While yFault is active, physical_sensor is invalid: the host must return NO_EVAL for every rule on this equipment instance that consumes it, deriving that set by intersecting each card’s points with adjudicates.points. The verdict is invalid_while_active rather than SYS-0005’s ambiguous because the rule names one sensor — its two inputs are not interchangeable, one being a measurement and the other a computation — and it is weighed against diagnosis 4 before acting.

Detection Logic

residual = physical_sensor − virtual_value

yBias  = |MovingAverage(residual, window)| > bias_threshold
yNoise = MovingAverage(residual², window) − MovingAverage(residual, window)²
                                          > noise_threshold²
yFault = (yBias OR yNoise) held continuously for alarm_delay

Block graph (rule.cxf.jsonld):

SYS-0006 block graph

Thirteen blocks, one subtraction and two branches off it. The noise branch is the identity Var(r) = E[r²] − (E[r])² built from blocks that exist: sqResid squares the residual by feeding it into both ports of a Reals.Multiply, muSq averages that, meanSq squares the mean the bias branch already computed, and variance subtracts. noiseThr/thrSq do the same to the threshold, which is why the published tunable stays in the bound point’s units — retune noiseThr.k to 2.0 °C and the graph compares against 4.0.

Both comparisons are strict, as the reference writes them: a steady residual of exactly 1.5 reads healthy forever, and so does a square wave of amplitude exactly 3.0, whose variance is exactly 9.0.

The window is memory, and it cuts both ways. Reals.MovingAverage is a continuous-time integral mean, so the statistics migrate rather than jump: a 2.5 bias appearing after a clean hour takes 0.6 of a window (1.5/2.5) to drag the mean across the band, and the same arithmetic runs backwards after a repair. A host expecting the finding to disappear the moment the technician closes the ticket will conclude the repair failed.

The warmup convention is the exception to that patience: until the window fills, MovingAverage divides by elapsed time rather than by the window, so a bias already present when the controller starts is in the mean within one tick and the alarm lands one alarm_delay later. persist asserts at exactly T + delayTime and the disjunction must be held continuously — a branch that trips and drops resets the timer. delayOnInit = true (CDL default false) on persist.

Possible Diagnoses

The reference’s four, in its order:

  1. Target sensor calibration drift (bias error) — what yBias names: a transmitter reading a fixed amount high or low, with nothing about it that looks broken
  2. Target sensor intermittent failure (noise increase) — what yNoise names: the reading still averages correctly and has stopped being steady (a failing A/D channel, a loose terminal, an element on its way out)
  3. Target sensor wiring degradation — presents either way: a corroded splice adds a bias, a marginal connection adds noise, and one cable can do both
  4. Correlated sensors have drifted (cross-check) — the residual is a difference and this rule attributes all of it to the accused sensor, because that is the only point it was pointed at; a drifted model input produces an identical output with the accused transmitter in perfect health

Diagnosis 4 is the residual ambiguity to weigh against invalid_while_active. The cheap discriminator is the family: run SYS-0005, SYS-0009 and SYS-0010 on the model’s input sensors, and a clean bill on the features turns this rule’s finding from a suspicion into an accusation. The expensive one is a reference instrument, which is where the playbook ends up anyway.

Energy Impact

COMFORT_ENERGY, LOW confidence, QUALITATIVE_ONLY — the reference’s profile. There is no runtime waste term and this card does not invent one: a drifted sensor spends nothing by drifting. The cost is entirely cascade, through EEM-01 (sensor recalibration) and the downstream faults a believed-but-wrong reading causes or hides, and it is accounted for by the rules this one adjudicates. Confidence is LOW for a structural reason: the statistic is sound and its detection floor is published (near 1 °C of bias against a 0.30 °C model RMSE), but the verdict rests on a model that rests on other sensors and on a learning period nobody in the graph can audit. Diagnosis 4 is a standing false positive with no in-rule remedy; a model fitted on an already-drifted sensor is a standing false negative.

Emissions Impact

Scope 1 or 2 depending on what the mis-measurement drives, QUALITATIVE_EMISSIONS, LOW confidence, avoided-emissions basis N/A — the reference’s assignment. The ambiguity in scope is real: a drifted sensor biasing a boiler is Scope 1, the same sensor biasing a chiller or economizer is Scope 2, and the sensor itself emits nothing. The quantity is entirely cascade, which is why runtime_estimation is empty.

Deviations

  • The Ridge regression is host-side and never enters the graph. The reference’s equation begins virtual_value = regression_model.predict(correlated_features), none of which is expressible in CDL elementary blocks; the prediction arrives as the derived role point virtual_value, the same way HP-0001 and VAV-0001 consume host-fitted baselines. The consequence is that the correlated features are invisible here — the rule binds two points where the reference names four or more, which is exactly why diagnosis 4 is invisible to it.
  • rolling_std becomes variance against the squared threshold. The engine has no standard-deviation block, but Var(r) = E[r²] − (E[r])² is buildable and variance > threshold² is equivalent to std > threshold for non-negative quantities. Publishing noise_threshold_sq = 9.0 and skipping two blocks was rejected: the reference publishes 3.0 °C, and a tunable in degrees-squared is hostile to whoever retunes it on site.
  • Sibling note, not an edit: AHU-0022 states that squaring inside a moving average is not expressible in this block set and substitutes mean absolute deviation. This card shows the variance form is expressible; whoever owns AHU-0022 should reconcile the two, though the MAD substitution is defensible there because its ratio test is scale-invariant.
  • The absolute value on the bias branch is an addition. The reference writes rolling_mean(residual, window) > bias_threshold with no | |, which as written detects only sensors reading high — a transmitter drifting 3 K low produces a mean of −3 and passes. absMu fixes that, matching the treatment the reference gives its own paired-sensor rule SYS-0005. The finding does not name the direction; the trend does.
  • window and alarm_delay are library-chosen, because the source publishes neither — the reference’s tunables line for this card is truncated in the source pdf. window = 3600 s is long enough that a process transient averages out and short enough that a drifted sensor is named inside a shift, and it matches the 60 min drift_duration the reference publishes for SYS-0005; alarm_delay = 1800 s is SYS-0005’s published AlarmDelay. A site with a slower trend interval should raise window before anything else.
  • Reals.MovingAverage’s 64-checkpoint ring sets a minimum tick interval. The block stores one checkpoint per tick and drops the oldest in-window checkpoint beyond 64, so the tick interval must be at least window/63 — 57.1 s at the shipped window. A host ticking every 30 s gets a silently truncated window: the rule still detects, against half the history the card claims. AHU-0022 hit the same floor; window binds both MovingAverage instances and hosts must move them together.
  • No window-fill gate in the graph, because the block divides by elapsed time until its window fills — a residual present at load is in the mean within one tick. That is the honest behaviour of a statistic computed over the data available, but it is less data, so preconditions recommends the host report NO_EVAL for the first window after a restart, the call AHU-0022 makes for its own warmup. Unlike AHU-0022 this rule has no ratio test, so a short window cannot manufacture a fault; it can only be noisier.
  • virtual_value is consumed and deliberately not adjudicated. It is not a sensor, nothing downstream consumes it, and its health is a model question the reference gives its own rule (META-FC-050); a host running that rule should read a degraded model as NO_EVAL here rather than as sensor drift. The adjudicates fan-out is not enumerated on this card by design, so it stays complete as rules are added, and this card must not appear in any other card’s suppresses.
  • Two extra boundary outputs, sub-conditions rather than evaluability signals. yBias and yNoise exist because diagnoses 1 and 2 are different failures with different repairs and the Or destroys the distinction. They are read with yFault, never instead of it: both are undelayed, so either can flicker during a transient without the alarm moving, and a false yBias never means NO_EVAL — this rule has no evaluability output, because its evaluability question (is the model still fit?) lives outside the graph.
  • Role points, and the thresholds are in the bound point’s units. Same documented exception as SYS-0005 and SYS-0009 (SCHEMA.md points contract): one graph deploys against many real points, so the host’s instance configuration records each binding. Both published thresholds are the reference’s temperature numbers; a humidity or pressure binding left at 1.5 and 3.0 is comparing against a band nobody chose.
  • Both comparisons are strict, matching the reference’s own operators, so the library’s standing >=> deviation does not apply here.
  • persist.delayOnInit = true (CDL default false), the library’s standing choice: a residual already outside the band at controller restart waits out the full 30 minutes rather than alarming on the first tick. TrueDelay asserts at exactly T + delayTime, so the realized test is “either branch held for strictly more than alarm_delay” at tick resolution.
  • clusters: [CLU-09] is a declaration, not an edit. CLU-09 already lists SYS-0006 as a member with SYS-0005 as trigger, and playbooks/sensor-drift.md already names this rule in its Applies-To row and in step 1.2. Both files predate this card and neither needs an edit.
  • category: COMFORT_ENERGY transcribed, not argued. SYS-0009 departs to PROTECTIVE on the grounds that a sensor gate delivers avoided false alarms rather than energy, and the argument applies word for word here; it is not taken because the reference publishes a profile for this card and it says COMFORT_ENERGY. The family is now split between the two labels, and making it consistent is a library-wide call. Severity 3 and method: statistical likewise.
  • Operating states and preconditions are declared in frontmatter for host enforcement rather than encoded in the block graph, per the library’s design stance.

Notes

Reference note, transcribed: “Koo & Yoon (2022): virtual sensor RMSE of 0.30 °C, bias > 1 °C reliably”.

Read yBias and yNoise before dispatching, because they change the work order. A bias finding is a calibration — the transmitter is fine, its zero is not. A noise finding is rarely a calibration at all; it is a connection, a channel, or an element that has begun to fail, and recalibrating it wastes a visit. A sensor showing both is usually the second one.

Check the model’s inputs before believing the model: diagnosis 4 costs nothing to rule out from a desk. A residual that starts the day a chiller was rebalanced or an air handler changed sequence is a stale model, not a drifted sensor, and META-FC-050 is the rule that says so.

Retraining resets what this rule can see, so build it into the procedure. A model refitted while the accused sensor is drifting learns the drift as truth and the residual returns to zero — the finding disappears, nothing was repaired, and the sensor is permanently invisible to the rule. Retrain after the calibration, never before, and treat a fault that cleared without a work order as a retraining event to be explained.

The family’s members answer different questions about the same transmitter: SYS-0009 catches the sensor that has stopped moving, SYS-0010 the one that jumps further than the process can, SYS-0005 the one that disagrees with a partner in the same air stream, and this one the one that disagrees with everything else at once. The sensor-drift playbook covers the verification and service workflow for all four.

Test Vectors

14 scenarios, clock step 60 s over 21600 s.

ScenarioDescription
virtual_and_physical_agreeThe healthy case: the Ridge model predicts the sensor exactly, so the residual is zero, both statistics are zero and nothing is reported across six hours.
residual_within_the_bias_bandOrdinary model error: the physical sensor sits 1.0 above the prediction, which is inside bias_threshold and well inside the noise band. Koo & Yoon report an RMSE of 0.30 degC for this kind of model, so a steady 1.0 is a mediocre fit rather than a drifted sensor, and the bias conjunct blocks the fault on its own.
both_track_a_moving_processThe rule is about the residual, not the level. Sensor and prediction walk 15 -> 22 -> 18 -> 25 degC together; the process moves 10 K and the residual never leaves zero.
calibration_bias_after_a_clean_windowThe flagship bias case, run the honest way: a full clean window first, then the sensor steps 2.5 above the prediction at 3600 s. The window mean migrates linearly rather than jumping, crosses bias_threshold at 5760 s (0.6 of a window after the step, which is 1.5/2.5) and the alarm lands one alarm_delay later at 7560 s. The noise branch stays down throughout — a step contributes at most 6.25/4 to the variance, nowhere near noise_threshold squared — so this is bias-only.
bias_present_at_loadThe same 2.5 bias, already present when the controller starts. MovingAverage divides by elapsed time until its window fills, so the mean is the live residual within one tick rather than ramping in over an hour: yBias asserts at 60 s and yFault at 1860 s. This is the TrueDelay boundary pinned explicitly — the branch has been true since 60 s, yFault is still false at 1800 s and asserts at exactly 60 + alarm_delay.
bias_exactly_at_the_thresholdBoundary: a steady residual of exactly bias_threshold (1.5). Reals.GreaterThreshold is strict and CDL Reals has no GreaterEqual, so a sensor sitting exactly on the published band reads healthy forever.
bias_just_above_the_thresholdThe same line from above: a residual of 1.6 asserts the branch at 60 s and the alarm at 1860 s. Koo & Yoon put the reliable detection floor for this method at about 1 degC of bias, and the shipped 1.5 sits above it.
noise_burst_without_biasNoise-only, and the reason the second branch exists. After a clean window the sensor starts swinging plus and minus 4 around the prediction on a 30-minute square wave: the mean residual stays near zero so the bias branch never moves, while the variance climbs past noise_threshold squared at 5640 s and the alarm lands at 7440 s. This is diagnosis 2 (intermittent failure) rather than diagnosis 1, and the branch outputs are what tell the two apart.
noise_exactly_at_the_thresholdBoundary on the noise side, and the vector that pins the squaring. A square wave of amplitude exactly noise_threshold (3.0) has variance exactly noise_threshold squared, the comparison is strict, and the rule stays silent through eighteen thousand seconds of it. Amplitude reads directly as standard deviation for a square wave, which is why this waveform is the one the vectors use.
noise_just_above_the_thresholdThe same line from above: amplitude 3.2 gives a variance of 10.24 against a threshold of 9.0, the noise branch asserts at 6780 s and the alarm at 8580 s. The margin over the 3.0 case is 7% in amplitude and 14% in the compared quantity, which is what squaring the threshold does to the sensitivity near the line.
bias_reverses_before_the_alarm_maturesThe alarm needs the disjunction held continuously. A residual of +3 for the first ten minutes takes the bias branch true at 60 s; the residual then reverses to -1 and drags the window mean back through the band at 900 s, well short of the 1860 s the timer needed. The branch drops, the timer resets, and no alarm is ever reported.
residual_cleared_after_the_alarmRecovery, and the vector that shows the statistic’s memory. The 2.5 bias alarms at 1860 s and a technician recalibrates at 7200 s, taking the residual straight to zero. The alarm does not clear with it: the window still holds the biased hour, the mean decays linearly, and yFault does not drop until 8580 s — 23 minutes after the sensor was fixed. A host that watches for the fault to clear immediately after a repair will conclude the repair failed.
virtual_model_drifts_insteadDiagnosis 4 written as arithmetic. The physical sensor is perfect at 20.0 and the model’s prediction has fallen to 17.5 because one of its correlated input sensors drifted. Every output tick is identical to bias_present_at_load, where the physical sensor was the faulty one: the rule sees a residual and names the point it was pointed at. This is the residual ambiguity the host has to weigh, and no arrangement of one sensor against one model resolves it.
physical_sensor_reads_lowSign symmetry: the sensor reads 2.5 BELOW the prediction. Abs throws the sign away, so a transmitter drifting low is detected on the same schedule as one drifting high, and the finding does not say which direction without looking at the trend.
vectors.json
{
  "schema": "cxf-library/vectors/v1",
  "clock": {
    "step_s": 60,
    "horizon_s": 21600
  },
  "scenarios": [
    {
      "name": "virtual_and_physical_agree",
      "description": "The healthy case: the Ridge model predicts the sensor exactly, so the residual is zero, both statistics are zero and nothing is reported across six hours.",
      "inputs": {
        "physical_sensor": 20.0,
        "virtual_value": 20.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 21600,
          "equals": false
        },
        {
          "output": "yBias",
          "from_s": 0,
          "to_s": 21600,
          "equals": false
        },
        {
          "output": "yNoise",
          "from_s": 0,
          "to_s": 21600,
          "equals": false
        }
      ]
    },
    {
      "name": "residual_within_the_bias_band",
      "description": "Ordinary model error: the physical sensor sits 1.0 above the prediction, which is inside bias_threshold and well inside the noise band. Koo & Yoon report an RMSE of 0.30 degC for this kind of model, so a steady 1.0 is a mediocre fit rather than a drifted sensor, and the bias conjunct blocks the fault on its own.",
      "inputs": {
        "physical_sensor": 21.0,
        "virtual_value": 20.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 21600,
          "equals": false
        },
        {
          "output": "yBias",
          "from_s": 0,
          "to_s": 21600,
          "equals": false
        },
        {
          "output": "yNoise",
          "from_s": 0,
          "to_s": 21600,
          "equals": false
        }
      ]
    },
    {
      "name": "both_track_a_moving_process",
      "description": "The rule is about the residual, not the level. Sensor and prediction walk 15 -> 22 -> 18 -> 25 degC together; the process moves 10 K and the residual never leaves zero.",
      "inputs": {
        "physical_sensor": [
          {
            "t": 0,
            "value": 15.0
          },
          {
            "t": 5400,
            "value": 22.0
          },
          {
            "t": 10800,
            "value": 18.0
          },
          {
            "t": 16200,
            "value": 25.0
          }
        ],
        "virtual_value": [
          {
            "t": 0,
            "value": 15.0
          },
          {
            "t": 5400,
            "value": 22.0
          },
          {
            "t": 10800,
            "value": 18.0
          },
          {
            "t": 16200,
            "value": 25.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 21600,
          "equals": false
        },
        {
          "output": "yBias",
          "from_s": 0,
          "to_s": 21600,
          "equals": false
        },
        {
          "output": "yNoise",
          "from_s": 0,
          "to_s": 21600,
          "equals": false
        }
      ]
    },
    {
      "name": "calibration_bias_after_a_clean_window",
      "description": "The flagship bias case, run the honest way: a full clean window first, then the sensor steps 2.5 above the prediction at 3600 s. The window mean migrates linearly rather than jumping, crosses bias_threshold at 5760 s (0.6 of a window after the step, which is 1.5/2.5) and the alarm lands one alarm_delay later at 7560 s. The noise branch stays down throughout \u2014 a step contributes at most 6.25/4 to the variance, nowhere near noise_threshold squared \u2014 so this is bias-only.",
      "inputs": {
        "physical_sensor": [
          {
            "t": 0,
            "value": 20.0
          },
          {
            "t": 3600,
            "value": 22.5
          }
        ],
        "virtual_value": 20.0
      },
      "expect": [
        {
          "output": "yBias",
          "from_s": 0,
          "to_s": 5700,
          "equals": false
        },
        {
          "output": "yBias",
          "from_s": 5760,
          "to_s": 21600,
          "equals": true
        },
        {
          "output": "yNoise",
          "from_s": 0,
          "to_s": 21600,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7500,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 7560,
          "to_s": 21600,
          "equals": true
        }
      ]
    },
    {
      "name": "bias_present_at_load",
      "description": "The same 2.5 bias, already present when the controller starts. MovingAverage divides by elapsed time until its window fills, so the mean is the live residual within one tick rather than ramping in over an hour: yBias asserts at 60 s and yFault at 1860 s. This is the TrueDelay boundary pinned explicitly \u2014 the branch has been true since 60 s, yFault is still false at 1800 s and asserts at exactly 60 + alarm_delay.",
      "inputs": {
        "physical_sensor": 22.5,
        "virtual_value": 20.0
      },
      "expect": [
        {
          "output": "yBias",
          "from_s": 0,
          "to_s": 0,
          "equals": false
        },
        {
          "output": "yBias",
          "from_s": 60,
          "to_s": 21600,
          "equals": true
        },
        {
          "output": "yNoise",
          "from_s": 0,
          "to_s": 21600,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 1860,
          "to_s": 21600,
          "equals": true
        }
      ]
    },
    {
      "name": "bias_exactly_at_the_threshold",
      "description": "Boundary: a steady residual of exactly bias_threshold (1.5). Reals.GreaterThreshold is strict and CDL Reals has no GreaterEqual, so a sensor sitting exactly on the published band reads healthy forever.",
      "inputs": {
        "physical_sensor": 21.5,
        "virtual_value": 20.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 21600,
          "equals": false
        },
        {
          "output": "yBias",
          "from_s": 0,
          "to_s": 21600,
          "equals": false
        },
        {
          "output": "yNoise",
          "from_s": 0,
          "to_s": 21600,
          "equals": false
        }
      ]
    },
    {
      "name": "bias_just_above_the_threshold",
      "description": "The same line from above: a residual of 1.6 asserts the branch at 60 s and the alarm at 1860 s. Koo & Yoon put the reliable detection floor for this method at about 1 degC of bias, and the shipped 1.5 sits above it.",
      "inputs": {
        "physical_sensor": 21.6,
        "virtual_value": 20.0
      },
      "expect": [
        {
          "output": "yBias",
          "from_s": 60,
          "to_s": 21600,
          "equals": true
        },
        {
          "output": "yNoise",
          "from_s": 0,
          "to_s": 21600,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 1860,
          "to_s": 21600,
          "equals": true
        }
      ]
    },
    {
      "name": "noise_burst_without_bias",
      "description": "Noise-only, and the reason the second branch exists. After a clean window the sensor starts swinging plus and minus 4 around the prediction on a 30-minute square wave: the mean residual stays near zero so the bias branch never moves, while the variance climbs past noise_threshold squared at 5640 s and the alarm lands at 7440 s. This is diagnosis 2 (intermittent failure) rather than diagnosis 1, and the branch outputs are what tell the two apart.",
      "inputs": {
        "physical_sensor": [
          {
            "t": 0,
            "value": 20.0
          },
          {
            "t": 3600,
            "value": 24.0
          },
          {
            "t": 4500,
            "value": 16.0
          },
          {
            "t": 5400,
            "value": 24.0
          },
          {
            "t": 6300,
            "value": 16.0
          },
          {
            "t": 7200,
            "value": 24.0
          },
          {
            "t": 8100,
            "value": 16.0
          },
          {
            "t": 9000,
            "value": 24.0
          },
          {
            "t": 9900,
            "value": 16.0
          },
          {
            "t": 10800,
            "value": 24.0
          },
          {
            "t": 11700,
            "value": 16.0
          },
          {
            "t": 12600,
            "value": 24.0
          },
          {
            "t": 13500,
            "value": 16.0
          },
          {
            "t": 14400,
            "value": 24.0
          },
          {
            "t": 15300,
            "value": 16.0
          },
          {
            "t": 16200,
            "value": 24.0
          },
          {
            "t": 17100,
            "value": 16.0
          },
          {
            "t": 18000,
            "value": 24.0
          },
          {
            "t": 18900,
            "value": 16.0
          },
          {
            "t": 19800,
            "value": 24.0
          },
          {
            "t": 20700,
            "value": 16.0
          }
        ],
        "virtual_value": 20.0
      },
      "expect": [
        {
          "output": "yBias",
          "from_s": 0,
          "to_s": 21600,
          "equals": false
        },
        {
          "output": "yNoise",
          "from_s": 0,
          "to_s": 5580,
          "equals": false
        },
        {
          "output": "yNoise",
          "from_s": 5640,
          "to_s": 21600,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7380,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 7440,
          "to_s": 21600,
          "equals": true
        }
      ]
    },
    {
      "name": "noise_exactly_at_the_threshold",
      "description": "Boundary on the noise side, and the vector that pins the squaring. A square wave of amplitude exactly noise_threshold (3.0) has variance exactly noise_threshold squared, the comparison is strict, and the rule stays silent through eighteen thousand seconds of it. Amplitude reads directly as standard deviation for a square wave, which is why this waveform is the one the vectors use.",
      "inputs": {
        "physical_sensor": [
          {
            "t": 0,
            "value": 20.0
          },
          {
            "t": 3600,
            "value": 23.0
          },
          {
            "t": 4500,
            "value": 17.0
          },
          {
            "t": 5400,
            "value": 23.0
          },
          {
            "t": 6300,
            "value": 17.0
          },
          {
            "t": 7200,
            "value": 23.0
          },
          {
            "t": 8100,
            "value": 17.0
          },
          {
            "t": 9000,
            "value": 23.0
          },
          {
            "t": 9900,
            "value": 17.0
          },
          {
            "t": 10800,
            "value": 23.0
          },
          {
            "t": 11700,
            "value": 17.0
          },
          {
            "t": 12600,
            "value": 23.0
          },
          {
            "t": 13500,
            "value": 17.0
          },
          {
            "t": 14400,
            "value": 23.0
          },
          {
            "t": 15300,
            "value": 17.0
          },
          {
            "t": 16200,
            "value": 23.0
          },
          {
            "t": 17100,
            "value": 17.0
          },
          {
            "t": 18000,
            "value": 23.0
          },
          {
            "t": 18900,
            "value": 17.0
          },
          {
            "t": 19800,
            "value": 23.0
          },
          {
            "t": 20700,
            "value": 17.0
          }
        ],
        "virtual_value": 20.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 21600,
          "equals": false
        },
        {
          "output": "yBias",
          "from_s": 0,
          "to_s": 21600,
          "equals": false
        },
        {
          "output": "yNoise",
          "from_s": 0,
          "to_s": 21600,
          "equals": false
        }
      ]
    },
    {
      "name": "noise_just_above_the_threshold",
      "description": "The same line from above: amplitude 3.2 gives a variance of 10.24 against a threshold of 9.0, the noise branch asserts at 6780 s and the alarm at 8580 s. The margin over the 3.0 case is 7% in amplitude and 14% in the compared quantity, which is what squaring the threshold does to the sensitivity near the line.",
      "inputs": {
        "physical_sensor": [
          {
            "t": 0,
            "value": 20.0
          },
          {
            "t": 3600,
            "value": 23.2
          },
          {
            "t": 4500,
            "value": 16.8
          },
          {
            "t": 5400,
            "value": 23.2
          },
          {
            "t": 6300,
            "value": 16.8
          },
          {
            "t": 7200,
            "value": 23.2
          },
          {
            "t": 8100,
            "value": 16.8
          },
          {
            "t": 9000,
            "value": 23.2
          },
          {
            "t": 9900,
            "value": 16.8
          },
          {
            "t": 10800,
            "value": 23.2
          },
          {
            "t": 11700,
            "value": 16.8
          },
          {
            "t": 12600,
            "value": 23.2
          },
          {
            "t": 13500,
            "value": 16.8
          },
          {
            "t": 14400,
            "value": 23.2
          },
          {
            "t": 15300,
            "value": 16.8
          },
          {
            "t": 16200,
            "value": 23.2
          },
          {
            "t": 17100,
            "value": 16.8
          },
          {
            "t": 18000,
            "value": 23.2
          },
          {
            "t": 18900,
            "value": 16.8
          },
          {
            "t": 19800,
            "value": 23.2
          },
          {
            "t": 20700,
            "value": 16.8
          }
        ],
        "virtual_value": 20.0
      },
      "expect": [
        {
          "output": "yBias",
          "from_s": 0,
          "to_s": 21600,
          "equals": false
        },
        {
          "output": "yNoise",
          "from_s": 0,
          "to_s": 6720,
          "equals": false
        },
        {
          "output": "yNoise",
          "from_s": 6780,
          "to_s": 21600,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 8520,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 8580,
          "to_s": 21600,
          "equals": true
        }
      ]
    },
    {
      "name": "bias_reverses_before_the_alarm_matures",
      "description": "The alarm needs the disjunction held continuously. A residual of +3 for the first ten minutes takes the bias branch true at 60 s; the residual then reverses to -1 and drags the window mean back through the band at 900 s, well short of the 1860 s the timer needed. The branch drops, the timer resets, and no alarm is ever reported.",
      "inputs": {
        "physical_sensor": [
          {
            "t": 0,
            "value": 23.0
          },
          {
            "t": 600,
            "value": 19.0
          }
        ],
        "virtual_value": 20.0
      },
      "expect": [
        {
          "output": "yBias",
          "from_s": 60,
          "to_s": 840,
          "equals": true
        },
        {
          "output": "yBias",
          "from_s": 900,
          "to_s": 21600,
          "equals": false
        },
        {
          "output": "yNoise",
          "from_s": 0,
          "to_s": 21600,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 21600,
          "equals": false
        }
      ]
    },
    {
      "name": "residual_cleared_after_the_alarm",
      "description": "Recovery, and the vector that shows the statistic's memory. The 2.5 bias alarms at 1860 s and a technician recalibrates at 7200 s, taking the residual straight to zero. The alarm does not clear with it: the window still holds the biased hour, the mean decays linearly, and yFault does not drop until 8580 s \u2014 23 minutes after the sensor was fixed. A host that watches for the fault to clear immediately after a repair will conclude the repair failed.",
      "inputs": {
        "physical_sensor": [
          {
            "t": 0,
            "value": 22.5
          },
          {
            "t": 7200,
            "value": 20.0
          }
        ],
        "virtual_value": 20.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 1860,
          "to_s": 8520,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 8580,
          "to_s": 21600,
          "equals": false
        },
        {
          "output": "yBias",
          "from_s": 60,
          "to_s": 8520,
          "equals": true
        },
        {
          "output": "yBias",
          "from_s": 8580,
          "to_s": 21600,
          "equals": false
        }
      ]
    },
    {
      "name": "virtual_model_drifts_instead",
      "description": "Diagnosis 4 written as arithmetic. The physical sensor is perfect at 20.0 and the model's prediction has fallen to 17.5 because one of its correlated input sensors drifted. Every output tick is identical to bias_present_at_load, where the physical sensor was the faulty one: the rule sees a residual and names the point it was pointed at. This is the residual ambiguity the host has to weigh, and no arrangement of one sensor against one model resolves it.",
      "inputs": {
        "physical_sensor": 20.0,
        "virtual_value": 17.5
      },
      "expect": [
        {
          "output": "yBias",
          "from_s": 60,
          "to_s": 21600,
          "equals": true
        },
        {
          "output": "yNoise",
          "from_s": 0,
          "to_s": 21600,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 1860,
          "to_s": 21600,
          "equals": true
        }
      ]
    },
    {
      "name": "physical_sensor_reads_low",
      "description": "Sign symmetry: the sensor reads 2.5 BELOW the prediction. Abs throws the sign away, so a transmitter drifting low is detected on the same schedule as one drifting high, and the finding does not say which direction without looking at the trend.",
      "inputs": {
        "physical_sensor": 17.5,
        "virtual_value": 20.0
      },
      "expect": [
        {
          "output": "yBias",
          "from_s": 60,
          "to_s": 21600,
          "equals": true
        },
        {
          "output": "yNoise",
          "from_s": 0,
          "to_s": 21600,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 1860,
          "to_s": 21600,
          "equals": true
        }
      ]
    }
  ]
}

SYS-0007 — Zone heating active during summer / warm weather

Statusverified — engine e2ff2f8, cxf:fnv1a128:81170d88e808d51ed3e4980ee1e99faa, 2026-08-17
Severity3
Methodrule
Phase2
CategoryCRITICAL_WASTE
ConfidenceHIGH
EstimationDIRECT_MEASUREMENT
G36
ClustersCLU-05
Suppresses
Suppressed by
RelatedVAV-0006, VAV-0003, VAV-0001, AHU-0016, SYS-0002
Playbooksvav-min-flow-reheat
SourceHVAC FDD Reference v1.0 §16, SYS-0007 (pdf pp. 145-146) — equation, all three tunables, the four diagnoses, the whole impact profile, and the truncated notes line; The reference’s own provenance line for that card: PNNL RetuningOpps Z01; ~20% prevalence (PNNL 151-building study); Library precedent: VAV-0006 (the zone-level reheat rule this one overlaps and does not duplicate — see Deviations), VAV-0003, AHU-0016
Operating statesall. There is no occupied gate and no mode gate, because the reference specifies none and because the fault is defined by the weather rather than by the schedule: a zone reheating at 26 degC outdoors is waste at 03:00 as surely as at 15:00, and a night-setback heating cycle at that outdoor temperature is itself the finding.

Preconditions (host-enforced): Instantiated PER ZONE. rht_vlv_cmd is one VAV box’s or one FCU’s reheat command, not a building aggregate, and the deployment runs one instance per zone (the reference files this rule under SYS and scopes it to VAV and FCU equipment). rht_vlv_cmd must be the modulating command in percent of travel; a two-position coil that reports only on/off should be bound as 0/100 and the threshold left where it is, and a coil whose command is in some other unit needs reheat_active_threshold retuned to it. oat must be a fresh, shaded site outdoor air temperature: this rule’s standing false positive is a sun-baked wall sensor reading 23 degC on a 17 degC afternoon, and the sensor-health rules (SYS-0005/SYS-0009/SYS-0010) are what adjudicate that before the finding is believed. Where a zone has a legitimate reason to heat above the lockout — a freezer anteroom, a pool hall, a humidity-controlled space with reheat downstream of a dehumidification coil — the binding is wrong rather than the rule, and that zone should not be instantiated.

Points: rht_vlv_cmd, oat

Outputs:

  • yFault — True while this zone’s reheat command has stayed above reheat_active_threshold with outdoor air above zone_heating_lockout_temp, continuously for alarm_delay

Parameters:

NameDefaultUnitCXF pathDescription
reheat_active_threshold10.0%rhtOn.tReheat command above which the coil counts as actively consuming energy rather than leaking through a shut valve (the reference’s own 10%, and the same number VAV-0006 uses for the same test)
zone_heating_lockout_temp21.0°CwarmOut.tOutdoor air temperature above which no zone should be calling for heat — the temperature an OAT heating lockout would be programmed at (the reference’s own 21 °C / 70 °F). Note this is 3 K above VAV-0006’s cooling_season_oat and asks a different question; see Deviations
alarm_delay1800.0spersist.delayTimeContinuous persistence required before the alarm asserts (30 min). This is the reference’s published AlarmDelay and also stands in for the lockout_check_duration its equation names but never publishes — one knob, see Deviations

Description

A reheat coil is heating a zone while it is 26 degC outside. Nothing about the building needs that heat, and whatever the coil delivers the plant paid to cool the air first — simultaneous heating and cooling seen from the zone end, which PNNL found in about a fifth of the buildings it retuned. The usual cause is the absence of a control decision rather than the failure of one: most zone sequences call for reheat any time the space falls under its heating setpoint and nothing in that logic knows what the weather is doing, so a zone under an overcooled supply duct in July asks for heat all afternoon and gets it. The fix is a line of programming — lock the reheat valve out above an outdoor air temperature — which is why one instance usually means the whole zone family is affected. Instantiated per zone: rht_vlv_cmd is one box’s valve.

Detection Logic

yFault = rht_vlv_cmd > reheat_active_threshold
     AND oat > zone_heating_lockout_temp
     sustained continuously for alarm_delay

Block graph (rule.cxf.jsonld):

SYS-0007 block graph

Four blocks, and either conjunct blocks the fault on its own.

Both comparisons are strict, which is what the reference writes (> in both terms) rather than a reinterpretation of it, so a valve reported at exactly 10.0% and an outdoor air temperature of exactly 21.0 degC each fail their term. Those exact values matter more than usual here, because 10% and 21 degC are round numbers a retuned site will park on deliberately.

persist starts on the crossing, not at midnight, so a zone already reheating when the weather crosses the lockout alarms 30 minutes after the crossing. Sustained means continuous — a ten-minute dip below the lockout discards the elapsed time rather than pausing it — and the fault clears on the tick the valve shuts, because TrueDelay delays the rising edge only. It asserts at exactly T + delayTime, and delayOnInit = true (CDL default false) makes a zone already reheating at controller restart wait out the full 30 minutes.

Possible Diagnoses

The reference’s four, in its order:

  1. Zone heating lockout not programmed by OAT — the sequence has no weather term at all. The common case, the cheap fix, and why findings arrive in batches
  2. Reheat valve stuck open — mechanical, and distinguishable from the trend: a stuck valve reads the same command all day and ignores a commanded close
  3. Zone controller demanding heat due to sensor error — a zone temperature sensor reading low makes the box genuinely believe the space is cold, and every part of the control chain then behaves correctly
  4. Perimeter heating operating independently of BAS — baseboard or radiant perimeter on its own thermostat or reset curve, which the BAS neither commands nor sees

Energy Impact

CRITICAL_WASTE, HIGH confidence, DIRECT_MEASUREMENT — the reference’s profile. The waste term needs no counterfactual: waste_kw = rht_vlv_cmd/100 × vav_rht_capacity_kw for every hour the condition holds, all of it pure loss because the heat is applied to air the plant just paid to cool. The reference puts it at 100% of reheat energy while active and up to 20% of the zone’s annual energy, at ~20% prevalence across the PNNL 151-building study. Cooling-dominant by climate, but the multiplier is what matters: the defect is nearly always systemic, so the site number is the per-zone number times the count of zones sharing the sequence.

Emissions Impact

Scope 1, DIRECT_EMISSIONS, HIGH confidence; the reference gives 500-4,000 kg CO₂e/yr for reheat waste during warm weather, on a static Scope 1 factor. That assumes hot-water reheat from a gas-fired boiler, the common case. Electric reheat, or hot water from a heat pump or district loop, moves the same kilowatts into Scope 2 — the quantity is unchanged and the inventory line is not, so hosts should follow the actual heating source (the same caveat VAV-0006 carries).

Deviations

  • lockout_check_duration and AlarmDelay are treated as one knob. The reference’s equation ends “sustained for lockout_check_duration” but its tunables table publishes three parameters, none named that, including AlarmDelay = 30 min. Both names describe how long the condition must hold and only one has a published number, so the graph carries a single TrueDelay at 30 minutes. This is the opposite call from VAV-0006 and SYS-0005, which chain two timers because their references publish two numbers; inventing a second here would put an unsupported duration in the card.
  • Both comparisons are strict, matching the reference’s own operators, so the library’s standing >=> deviation does not apply on this card.
  • Filed under SYS, instantiated per VAV or FCU zone. The reference’s header reads Equipment: VAV, FCU while the ID is SYS-0007 — a tension in the source, not in this card. Deployment is one instance per zone with a host-side rollup, and rht_vlv_cmd and oat are duplicated into points/sys.points.json with matched groundings, because lint resolves a card’s points against its own family dictionary.
  • No cross-zone aggregation in the graph, and the count is where the real diagnosis lives. One zone reheating in summer is a zone problem; half the zones on an air handler is a supply-air-temperature problem, and the vav-min-flow-reheat playbook’s step 1.4 turns that ratio into the discriminator. The graph cannot express a variable-width zone vector, so the rollup is the host’s.
  • Overlaps VAV-0006 deliberately, and the two ask different questions. VAV-0006 is reheat at minimum flow during the cooling season: three terms, an 18 degC season threshold, and a damper term that separates waste from a zone answering a genuine load. This card has no damper term and a 21 degC threshold, so it fires on a zone that is genuinely cold and genuinely being heated — because above 21 degC outdoors the reference’s claim is that no zone should be heating at all. A box tripping this one alone is reheating with its damper open, which points at diagnosis 3 or an overcooled supply duct. Both are CLU-05 members with VAV-0006 as trigger, so the cluster encodes the fix order.
  • No occupancy gate, no mode gate, no supply-fan gate. The reference specifies none, and adding one would change the fault: unoccupied reheat above 21 degC outdoors is a worse instance of it, not an exception. The one defensible gate — a reheat coil downstream of a dehumidification coil, where warm-weather reheat is design intent — is a binding decision, so it lives in preconditions.
  • oat drift is the standing false positive and this card does not solve it. A sun-baked or drifted outdoor sensor reading 3 K high manufactures the fault across every zone at once, which is also the tell. SYS-0005, SYS-0009 and SYS-0010 adjudicate oat directly; where a host runs them, an active sensor finding on the bound oat makes this rule NO_EVAL through the adjudicates fan-out.
  • clusters: [CLU-05] is a declaration, not an edit. CLU-05 already lists SYS-0007 as a member with VAV-0006 as trigger. playbooks/vav-min-flow-reheat.md already names SYS-0007 in its step 2.3 text but not in its Applies-To row; adding the ID there is the playbook owner’s edit, flagged rather than made.
  • The reference’s Notes block is truncated mid-sentence in the source document — “Found in ~20% of buildings. In perimeter zones this is especially” — and is quoted below as far as the source runs. The perimeter-zone claim is not recoverable from the chapter text and has not been reconstructed.
  • Severity 3, method: rule, phase 2 and the whole impact profile are the chapter card’s, matching the provisional row in faults/sys/README.md; the reference’s §5.8.1 index carries no severity column, so the chapter card governs.
  • Operating states and preconditions are declared in frontmatter for host enforcement rather than encoded in the block graph, per the library’s design stance.

Notes

Reference note, quoted as far as the source runs: “Found in ~20% of buildings. In perimeter zones this is especially”.

Count the zones before dispatching anyone. This fault is systemic far more often than mechanical: diagnosis 1 is a missing line of sequence logic nobody wrote for any box, so the normal shape is dozens of zones alarming on the same warm afternoon and clearing together when one lockout is programmed. A single zone alarming while its neighbours stay quiet points at diagnoses 2, 3 and 4.

The remote fix is the vav-min-flow-reheat playbook’s step 2.3: a summer reheat lockout above an outdoor air temperature, applied in batch, at no cost. Set it at the reference’s 21 degC before arguing about the number — lower and the site fights genuine morning heating loads in the shoulder seasons, higher and it pays the difference in reheat.

Check the outdoor air sensor first, and check it once rather than per zone. It is the single input every instance shares, a 3 K error moves every finding at the site together, and verifying it against a hand-held reference takes ten minutes.

Test Vectors

13 scenarios, clock step 60 s over 5400 s.

ScenarioDescription
winter_reheat_normalReheat doing its job in cold weather: the valve is 60% open at 5 degC outdoors. The heating conjunct is satisfied and the weather conjunct is not, so nothing is reported.
warm_weather_reheat_activeThe fault: 45% reheat at 26 degC outdoors. Both conjuncts hold from the first tick and the alarm lands at exactly alarm_delay.
warm_weather_valve_closedThe reheat conjunct blocking on its own: 26 degC outdoors with the reheat valve shut. Warm weather is not a fault; reheat during warm weather is.
cold_weather_reheat_wide_openThe weather conjunct blocking on its own: the valve is 100% open at -5 degC. This is the zone working, and the mirror of warm_weather_valve_closed.
reheat_exactly_at_the_thresholdBoundary: the valve sits on exactly reheat_active_threshold (10.0%) in warm weather. Reals.GreaterThreshold is strict and CDL Reals has no GreaterEqual, so exactly on the line reads as closed and the rule stays silent.
reheat_just_above_the_thresholdThe same line from above: 10.1% of valve travel at 26 degC alarms at 1800 s. Leakage past a shut valve is not this rule’s business, and 10% is where the reference draws that line.
oat_exactly_at_the_lockoutBoundary on the weather side: OAT sits on exactly zone_heating_lockout_temp (21.0 degC) with the valve at 45%. Strict again, so 21.0 is not yet warm weather.
oat_just_above_the_lockoutThe same line from above: 21.1 degC alarms at 1800 s.
morning_warmup_into_a_hot_afternoonThe realistic trace: the zone is reheating at 18 degC outdoors and the weather crosses the lockout at 600 s. The timer starts on that crossing, not at t=0, so the alarm lands at 2400 s.
valve_closes_on_the_maturity_tickThe delay edge from below: the valve shuts at exactly 1800 s. TrueDelay asserts at T + delayTime and drops a falling edge with no lag, so the two land on the same tick and the fault is never reported.
valve_closes_one_tick_laterThe same edge from above: the valve shuts one tick after maturity, leaving exactly one tick of alarm at 1800 s. The realized test is ‘both conditions held for strictly more than alarm_delay’ at tick resolution.
cloud_cover_restarts_the_clockSustained means continuous. OAT drops back under the lockout for ten minutes at 900 s, which discards the elapsed time rather than pausing it, and the alarm lands a full 1800 s after the second crossing at 1500 s.
lockout_programmed_after_the_alarmRecovery: the alarm asserts at 1800 s, someone adds the OAT lockout to the zone sequence at 3600 s and the valve drives shut. The fault clears on that tick — TrueDelay delays the rising edge only.
vectors.json
{
  "schema": "cxf-library/vectors/v1",
  "clock": {
    "step_s": 60,
    "horizon_s": 5400
  },
  "scenarios": [
    {
      "name": "winter_reheat_normal",
      "description": "Reheat doing its job in cold weather: the valve is 60% open at 5 degC outdoors. The heating conjunct is satisfied and the weather conjunct is not, so nothing is reported.",
      "inputs": {
        "rht_vlv_cmd": 60.0,
        "oat": 5.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        }
      ]
    },
    {
      "name": "warm_weather_reheat_active",
      "description": "The fault: 45% reheat at 26 degC outdoors. Both conjuncts hold from the first tick and the alarm lands at exactly alarm_delay.",
      "inputs": {
        "rht_vlv_cmd": 45.0,
        "oat": 26.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1740,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 1800,
          "to_s": 5400,
          "equals": true
        }
      ]
    },
    {
      "name": "warm_weather_valve_closed",
      "description": "The reheat conjunct blocking on its own: 26 degC outdoors with the reheat valve shut. Warm weather is not a fault; reheat during warm weather is.",
      "inputs": {
        "rht_vlv_cmd": 0.0,
        "oat": 26.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        }
      ]
    },
    {
      "name": "cold_weather_reheat_wide_open",
      "description": "The weather conjunct blocking on its own: the valve is 100% open at -5 degC. This is the zone working, and the mirror of warm_weather_valve_closed.",
      "inputs": {
        "rht_vlv_cmd": 100.0,
        "oat": -5.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        }
      ]
    },
    {
      "name": "reheat_exactly_at_the_threshold",
      "description": "Boundary: the valve sits on exactly reheat_active_threshold (10.0%) in warm weather. Reals.GreaterThreshold is strict and CDL Reals has no GreaterEqual, so exactly on the line reads as closed and the rule stays silent.",
      "inputs": {
        "rht_vlv_cmd": 10.0,
        "oat": 26.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        }
      ]
    },
    {
      "name": "reheat_just_above_the_threshold",
      "description": "The same line from above: 10.1% of valve travel at 26 degC alarms at 1800 s. Leakage past a shut valve is not this rule's business, and 10% is where the reference draws that line.",
      "inputs": {
        "rht_vlv_cmd": 10.1,
        "oat": 26.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1740,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 1800,
          "to_s": 5400,
          "equals": true
        }
      ]
    },
    {
      "name": "oat_exactly_at_the_lockout",
      "description": "Boundary on the weather side: OAT sits on exactly zone_heating_lockout_temp (21.0 degC) with the valve at 45%. Strict again, so 21.0 is not yet warm weather.",
      "inputs": {
        "rht_vlv_cmd": 45.0,
        "oat": 21.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        }
      ]
    },
    {
      "name": "oat_just_above_the_lockout",
      "description": "The same line from above: 21.1 degC alarms at 1800 s.",
      "inputs": {
        "rht_vlv_cmd": 45.0,
        "oat": 21.1
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1740,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 1800,
          "to_s": 5400,
          "equals": true
        }
      ]
    },
    {
      "name": "morning_warmup_into_a_hot_afternoon",
      "description": "The realistic trace: the zone is reheating at 18 degC outdoors and the weather crosses the lockout at 600 s. The timer starts on that crossing, not at t=0, so the alarm lands at 2400 s.",
      "inputs": {
        "rht_vlv_cmd": 45.0,
        "oat": [
          {
            "t": 0,
            "value": 18.0
          },
          {
            "t": 600,
            "value": 25.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2340,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2400,
          "to_s": 5400,
          "equals": true
        }
      ]
    },
    {
      "name": "valve_closes_on_the_maturity_tick",
      "description": "The delay edge from below: the valve shuts at exactly 1800 s. TrueDelay asserts at T + delayTime and drops a falling edge with no lag, so the two land on the same tick and the fault is never reported.",
      "inputs": {
        "rht_vlv_cmd": [
          {
            "t": 0,
            "value": 45.0
          },
          {
            "t": 1800,
            "value": 0.0
          }
        ],
        "oat": 26.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        }
      ]
    },
    {
      "name": "valve_closes_one_tick_later",
      "description": "The same edge from above: the valve shuts one tick after maturity, leaving exactly one tick of alarm at 1800 s. The realized test is 'both conditions held for strictly more than alarm_delay' at tick resolution.",
      "inputs": {
        "rht_vlv_cmd": [
          {
            "t": 0,
            "value": 45.0
          },
          {
            "t": 1860,
            "value": 0.0
          }
        ],
        "oat": 26.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1740,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 1800,
          "to_s": 1800,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 1860,
          "to_s": 5400,
          "equals": false
        }
      ]
    },
    {
      "name": "cloud_cover_restarts_the_clock",
      "description": "Sustained means continuous. OAT drops back under the lockout for ten minutes at 900 s, which discards the elapsed time rather than pausing it, and the alarm lands a full 1800 s after the second crossing at 1500 s.",
      "inputs": {
        "rht_vlv_cmd": 45.0,
        "oat": [
          {
            "t": 0,
            "value": 26.0
          },
          {
            "t": 900,
            "value": 19.0
          },
          {
            "t": 1500,
            "value": 26.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3240,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 3300,
          "to_s": 5400,
          "equals": true
        }
      ]
    },
    {
      "name": "lockout_programmed_after_the_alarm",
      "description": "Recovery: the alarm asserts at 1800 s, someone adds the OAT lockout to the zone sequence at 3600 s and the valve drives shut. The fault clears on that tick \u2014 TrueDelay delays the rising edge only.",
      "inputs": {
        "rht_vlv_cmd": [
          {
            "t": 0,
            "value": 45.0
          },
          {
            "t": 3600,
            "value": 0.0
          }
        ],
        "oat": 26.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1740,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 1800,
          "to_s": 3540,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 3600,
          "to_s": 5400,
          "equals": false
        }
      ]
    }
  ]
}

SYS-0008 — Exhaust fan schedule misalignment with AHU

Statusverified — engine e2ff2f8, cxf:fnv1a128:763b463070b0a7478aca2cc03d4d225e, 2026-08-17
Severity3
Methodrule
Phase2
CategoryCRITICAL_WASTE
ConfidenceHIGH
EstimationDIRECT_MEASUREMENT
G36
ClustersCLU-08
Suppresses
Suppressed by
RelatedSYS-0004, AHU-0018, SYS-0003
Playbooksexhaust-fan-schedule-misalignment
SourceHVAC FDD Reference v1.0 §16, SYS-0008 (pdf pp. 146-148) — both conditions, misalignment_duration 30 min, AlarmDelay 15 min, severity 3 (warning), the five diagnoses, the published 4-row test-vector table, and the whole impact profile; The reference’s own provenance line for that card: PNNL RetuningOpps S08 (~35% prevalence); PNNL-25985; PNNL EEM-07 (exhaust fan control) — the reference’s PNNL cross-reference; Library precedent: SYS-0005 and VFD-0001 (two published delays chained rather than summed); AHU-0018 (host-evaluated occupancy boolean)
Operating statesall — the rule judges alignment between the two fans in every hour, and the occupied qualifier applies to condition 2 only

Preconditions (host-enforced): One instance per exhaust-fan/AHU pair, and the pairing is a site claim the graph cannot check: sf_status must be the supply fan that pressurizes the space this exhaust fan draws from. A toilet exhaust bound to the wrong air handler on a multi-AHU floor reports misalignment forever, correctly by its own arithmetic and about nothing. Fans that are legitimately independent of any AHU — continuous code-required exhaust, elevator machine rooms, dedicated process exhaust, garage CO-driven fans — are excluded by not instantiating the rule against them, since no conjunct in the graph can exempt them. Both statuses should be proven run status (current switch, differential pressure, VFD run feedback) rather than start commands: diagnosis 5 is a fan running on a VFD fault, which a command point cannot see. occ_scheduled is host-evaluated for the space the pair serves. Where a site runs its exhaust on purpose during unoccupied hours (night flush, a scheduled purge), that intent is invisible here and condition 1 will report it — SYS-0004 carries demand_override_active for exactly that case and this rule has no equivalent input, because the reference gives it none.

Points: ef_status, sf_status, occ_scheduled

Outputs:

  • yFault — True while either misalignment condition has been sustained for misalignment_duration and then held a further alarm_delay
  • yExhaustWithoutSupply — Condition 1 sustained: the exhaust fan has been running with the supply fan off for misalignment_duration. Diagnostic direction flag — the building is being depressurized. Not an evaluability flag
  • ySupplyWithoutExhaust — Condition 2 sustained: the supply fan has been running with the exhaust fan off, during occupied hours, for misalignment_duration. Diagnostic direction flag — the building is over-pressurized and under-ventilated. Not an evaluability flag

Parameters:

NameDefaultUnitCXF pathDescription
misalignment_duration1800.0sefOnlyHeld.delayTime, sfOnlyHeld.delayTimeContinuous misalignment each condition must show before it counts (30 min). The reference’s own misalignment_duration, applied per condition; hosts must set both paths together.
alarm_delay900.0spersist.delayTimeFurther persistence required after a condition matures before the alarm asserts (15 min). The reference’s own separate AlarmDelay; 45 min to alarm at the shipped defaults.

Description

Exhaust and supply are supposed to move together. When they do not, the building stops being a balanced system and becomes a pump: exhaust without supply pulls the floor negative and drags unconditioned air in through every door and window frame, and supply without exhaust pushes it positive and leaves the spaces that need extraction sharing their air with everyone else. Neither shows up on a temperature trend; both show up as draughty entrances, doors that will not latch, and a heating bill nobody can explain. The reference gives it ~35% prevalence, one of the highest numbers in the document, and the cause is organisational: exhaust fans are installed by a different trade, commissioned at a different time, and often run from a local timeclock no BAS point touches. The rule is deliberately two-sided and the sides are not symmetric — exhaust running with the supply fan off is a fault at any hour, while supply running with the exhaust off is a fault only during occupied hours, because an AHU cycling overnight for setback with the toilet exhaust properly shut down is correct.

Detection Logic

C1 = ef_status AND NOT sf_status                        sustained misalignment_duration
C2 = sf_status AND NOT ef_status AND occ_scheduled      sustained misalignment_duration

yFault = (C1 held OR C2 held) sustained a further alarm_delay

Block graph (rule.cxf.jsonld):

SYS-0008 block graph

Nine blocks in two branches and a join, each branch carrying its own misalignment_duration sustain before the shared persist.

Timing. The two delays are chained, not summed, and chained TrueDelays on one steady signal add exactly: a misalignment that starts at T asserts its direction flag at T + 1800 s and yFault at T + 2700 s — 45 minutes at the shipped defaults, which is what the reference’s two published tunables come to when both are kept. Each stage asserts at exactly T + delayTime, so every realized test is “strictly more than” its delay at tick resolution, and delayOnInit = true on all three (CDL default false) makes a restart into an already-misaligned pair wait out the full 45 minutes.

Per-condition sustain is not the same rule as sustaining the Or. A pair misaligned on every tick of a two-hour run that flips direction every twenty minutes matures neither branch and is never reported; under a single TrueDelay(1800) on the Or it would alarm at 2700 s. The reference’s wording — each condition “sustained for duration” — picks the first reading, and that is what ships.

The two direction flags are diagnostic, not evaluability flags. Both are false when the rule is healthy, and whichever is true alongside yFault says which way the building is being pushed. They mature 900 s before yFault, so a host wanting an early warning has one. Unlike the y…Ok outputs elsewhere in this library, false does not mean NO_EVAL.

Possible Diagnoses

The reference’s five, in its order:

  1. Exhaust fan schedule not synchronized with the AHU — the ordinary case, a $0 BAS edit
  2. Exhaust fan on an independent timer or switch — not on the BAS at all, so the finding is real and the remote fix will not work (see Notes)
  3. Exhaust fan override left active — a manual hold from a service call
  4. BAS programming error — the interlock was written and is wrong: inverted logic, wrong AHU referenced, or a start/stop pair missing the exhaust side
  5. Exhaust fan VFD fault keeping the fan running — the drive lost its command and runs on a local reference or fault-state default

Read the direction flags against that list: yExhaustWithoutSupply points at 2, 3 and 5, ySupplyWithoutExhaust at 1 and 4.

Energy Impact

CRITICAL_WASTE, HIGH confidence, DIRECT_MEASUREMENT — the reference’s profile. The fan term is direct: waste_kw = ef_rated_kw × (ef_speed/100)³ for every hour the exhaust runs alone. The pressurization term is the larger and looser one, which the reference puts at 1-3% of site energy: infiltration through an unbalanced envelope, conditioned in whichever direction the season demands, which is why climate sensitivity is “both” rather than heating-dominant like SYS-0004. The supply-without-exhaust half wastes little fan energy and is mostly a ventilation-compliance and comfort finding; it shares the card and the severity because the reference put it there and the fix is the same work order.

Emissions Impact

Scope 2, DIRECT_EMISSIONS, HIGH confidence; the reference’s range is 200-2,000 kg CO₂e/yr for the fan plus the pressurization penalty. Avoided-emissions basis MOER (marginal). Where the infiltration penalty is met by a fuel-fired heating plant the honest scope is 1 + 2; the reference assigns the card Scope 2 and this transcribes that assignment rather than splitting it.

Deviations

  • Two delays in series, not one. The reference lists misalignment_duration (30 min) and a separate AlarmDelay (15 min) for one rule and does not say how they compose. Both are kept and chained — the SYS-0005 and VFD-0001 shape — so a steady single-branch misalignment alarms at T + 2700 s. A single 2700 s delay behaves identically as shipped; the chain is what lets a site keep a 30-minute misalignment window and a two-hour alarm hold, or the reverse.
  • misalignment_duration binds two CXF paths. One card parameter, one delay per branch, and SCHEMA.md’s list form for params.*.cxf requires hosts to set both together. Retuning one branch alone would make the rule quietly asymmetric in a way the reference never describes.
  • Per-condition sustain, so continuous misalignment that alternates direction is not caught. This follows the reference’s wording and is a real blind spot rather than an implementation artifact; a fan pair oscillating that way is a controls problem the reference has no rule for.
  • Two extra boundary outputs, and they are not the library’s usual y…Ok evaluability flags. SCHEMA.md allows additional outputs for sub-condition flags and these are that: both are false in the healthy case and true means the named condition has matured. Hosts that treat every non-yFault output as an evaluability gate will get this exactly backwards.
  • occ_scheduled replaces the reference’s occ_schedule schedule object. The block graph has no clock or calendar, so the host evaluates the schedule and feeds the boolean, as AHU-0018 does. The same concept is spelled occ_schedule in points/ahu.points.json — one concept, two dictionary names, worth resolving library-wide.
  • The asymmetry between the two conditions is the reference’s, transcribed. Condition 1 has no occupancy qualifier and condition 2 does, so exhaust running with the supply fan off is a fault at 03:00 while the mirror case is not. The engineering reason is in the Description; the authority is the reference.
  • No override input, unlike SYS-0004. That card carries demand_override_active because its reference entry names the point; this one does not, so a legitimate scheduled night purge trips condition 1. Adding an override conjunct would be an invention, so the exclusion lives in preconditions — exclude those fans at binding.
  • No thresholds, so the library’s strict-comparison deviation does not apply. Every input is a boolean and the graph contains no Reals block.
  • The reference’s four published test vectors are scenarios 1-4 of vectors.json, transcribed with its own column values; the rest are authored.
  • Overlaps SYS-0004 and neither rule suppresses the other. An exhaust fan running unoccupied with its AHU off satisfies SYS-0004 at 900 s and this rule’s condition 1 at 2700 s. Both are true and their fixes differ, so suppresses stays empty in both directions; CLU-08 groups them.
  • The rule sees run status, never speed or power. ef_rated_kw and ef_speed in runtime_estimation are host-side, and the pressurization term is not computable from these three booleans at all. Accumulation is the host’s.
  • Operating states and preconditions are declared in frontmatter for host enforcement rather than encoded in the block graph. There is no NO_EVAL logic in the graph: it computes the fault given valid data.

Notes

Establish first whether the BAS can actually stop this fan, because diagnosis 2 changes what the work order costs. The cheapest test is to command it off and watch ef_status. A fan on a local timeclock or a janitor’s wall switch does not answer, and the exhaust-fan-schedule-misalignment playbook files reprogramming that timer under Step 2 “Remote fix” — which it is not. Its own better answer is in the same step: an interlock relay that makes the exhaust follow supply status, a small capital job worth naming in the work order.

Where this rule and SYS-0004 both fire on the same fan they are one problem with two views — SYS-0004 says the fan runs when the building is empty, this one says it runs when its air handler is not — and fixing the interlock usually clears both, which is what CLU-08 expresses.

Building pressure is the confirmation measurement and hardly anyone has the sensor. If the site has one, an unbalanced pair shows up as a sustained offset tracking the misalignment window exactly. If not, the door test costs nothing: a lobby door that pulls hard against you at 07:00 and swings freely at noon is the same finding in physical form.

Test Vectors

13 scenarios, clock step 60 s over 7200 s.

ScenarioDescription
both_on_occupiedReference test-vector row 1 (sf ON, ef ON, occupied): aligned, no fault.
both_off_unoccupiedReference test-vector row 2 (sf OFF, ef OFF, unoccupied): aligned, no fault.
exhaust_on_supply_offReference test-vector row 3 (sf OFF, ef ON, unoccupied): condition 1. The branch sustains at 1800 s and the alarm follows 900 s later at 2700 s — the chained delays sum exactly.
supply_on_exhaust_off_occupiedReference test-vector row 4 (sf ON, ef OFF, occupied): condition 2, same 1800 + 900 s timing on the mirror branch. The two flags are mutually exclusive by construction.
supply_on_exhaust_off_unoccupiedThe occupancy conjunct blocking on its own — the asymmetry between the two conditions. Supply running without exhaust is only a fault during occupied hours; unoccupied it is a normal night-cycle or warmup state.
both_off_during_occupied_hoursBoth fans down inside the occupied schedule. That is somebody else’s fault (AHU-0026 and friends) — this rule judges alignment, not whether the equipment should be running at all.
exhaust_stops_on_the_sustain_tickThe misalignment_duration edge from below: the exhaust fan stops at exactly 1800 s, so the sustain never matures and neither does anything downstream of it.
exhaust_stops_one_tick_after_the_sustainThe regression test for keeping both published delays: a 31-minute misalignment matures misalignment_duration (one tick of yExhaustWithoutSupply at 1800 s) and dies immediately, so the 900 s alarm hold never runs. A rule that used misalignment_duration alone, with no separate alarm hold, would have alarmed here.
misalignment_clears_one_tick_before_the_alarmThe alarm edge from below: the exhaust fan stops at 2640 s, one tick short of the 2700 s the chain needs, and the fault is never reported.
misalignment_clears_one_tick_after_the_alarmThe same edge from above: one tick of yFault at 2700 s, then clear.
supply_fan_starts_and_realignsRecovery: the exhaust-only alarm asserts at 2700 s and the AHU starts at 3600 s. Both fans running is aligned, so every output drops on that tick — TrueDelay delays the rising edge only.
occupancy_starts_the_supply_only_clockThe supply-only branch is armed by the schedule, not by the fans. An AHU running with its exhaust off is silent overnight and starts counting when the occupied period opens at 1800 s: sustain at 3600 s, alarm at 4500 s.
direction_alternates_so_neither_branch_sustainsThe consequence of sustaining each condition separately rather than sustaining their Or. The pair is misaligned on every single tick of this two-hour run, but it flips direction every 20 minutes, so neither branch ever reaches misalignment_duration and nothing is reported. A TrueDelay(1800) placed on Or(C1, C2) instead would have alarmed at 2700 s.
vectors.json
{
  "schema": "cxf-library/vectors/v1",
  "clock": {
    "step_s": 60,
    "horizon_s": 7200
  },
  "scenarios": [
    {
      "name": "both_on_occupied",
      "description": "Reference test-vector row 1 (sf ON, ef ON, occupied): aligned, no fault.",
      "inputs": {
        "sf_status": true,
        "ef_status": true,
        "occ_scheduled": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        },
        {
          "output": "yExhaustWithoutSupply",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        },
        {
          "output": "ySupplyWithoutExhaust",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        }
      ]
    },
    {
      "name": "both_off_unoccupied",
      "description": "Reference test-vector row 2 (sf OFF, ef OFF, unoccupied): aligned, no fault.",
      "inputs": {
        "sf_status": false,
        "ef_status": false,
        "occ_scheduled": false
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        },
        {
          "output": "yExhaustWithoutSupply",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        },
        {
          "output": "ySupplyWithoutExhaust",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        }
      ]
    },
    {
      "name": "exhaust_on_supply_off",
      "description": "Reference test-vector row 3 (sf OFF, ef ON, unoccupied): condition 1. The branch sustains at 1800 s and the alarm follows 900 s later at 2700 s \u2014 the chained delays sum exactly.",
      "inputs": {
        "sf_status": false,
        "ef_status": true,
        "occ_scheduled": false
      },
      "expect": [
        {
          "output": "yExhaustWithoutSupply",
          "from_s": 0,
          "to_s": 1740,
          "equals": false
        },
        {
          "output": "yExhaustWithoutSupply",
          "from_s": 1800,
          "to_s": 7200,
          "equals": true
        },
        {
          "output": "ySupplyWithoutExhaust",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2640,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2700,
          "to_s": 7200,
          "equals": true
        }
      ]
    },
    {
      "name": "supply_on_exhaust_off_occupied",
      "description": "Reference test-vector row 4 (sf ON, ef OFF, occupied): condition 2, same 1800 + 900 s timing on the mirror branch. The two flags are mutually exclusive by construction.",
      "inputs": {
        "sf_status": true,
        "ef_status": false,
        "occ_scheduled": true
      },
      "expect": [
        {
          "output": "ySupplyWithoutExhaust",
          "from_s": 0,
          "to_s": 1740,
          "equals": false
        },
        {
          "output": "ySupplyWithoutExhaust",
          "from_s": 1800,
          "to_s": 7200,
          "equals": true
        },
        {
          "output": "yExhaustWithoutSupply",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2640,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2700,
          "to_s": 7200,
          "equals": true
        }
      ]
    },
    {
      "name": "supply_on_exhaust_off_unoccupied",
      "description": "The occupancy conjunct blocking on its own \u2014 the asymmetry between the two conditions. Supply running without exhaust is only a fault during occupied hours; unoccupied it is a normal night-cycle or warmup state.",
      "inputs": {
        "sf_status": true,
        "ef_status": false,
        "occ_scheduled": false
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        },
        {
          "output": "yExhaustWithoutSupply",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        },
        {
          "output": "ySupplyWithoutExhaust",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        }
      ]
    },
    {
      "name": "both_off_during_occupied_hours",
      "description": "Both fans down inside the occupied schedule. That is somebody else's fault (AHU-0026 and friends) \u2014 this rule judges alignment, not whether the equipment should be running at all.",
      "inputs": {
        "sf_status": false,
        "ef_status": false,
        "occ_scheduled": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        },
        {
          "output": "yExhaustWithoutSupply",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        },
        {
          "output": "ySupplyWithoutExhaust",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        }
      ]
    },
    {
      "name": "exhaust_stops_on_the_sustain_tick",
      "description": "The misalignment_duration edge from below: the exhaust fan stops at exactly 1800 s, so the sustain never matures and neither does anything downstream of it.",
      "inputs": {
        "sf_status": false,
        "ef_status": [
          {
            "t": 0,
            "value": true
          },
          {
            "t": 1800,
            "value": false
          }
        ],
        "occ_scheduled": false
      },
      "expect": [
        {
          "output": "yExhaustWithoutSupply",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        }
      ]
    },
    {
      "name": "exhaust_stops_one_tick_after_the_sustain",
      "description": "The regression test for keeping both published delays: a 31-minute misalignment matures misalignment_duration (one tick of yExhaustWithoutSupply at 1800 s) and dies immediately, so the 900 s alarm hold never runs. A rule that used misalignment_duration alone, with no separate alarm hold, would have alarmed here.",
      "inputs": {
        "sf_status": false,
        "ef_status": [
          {
            "t": 0,
            "value": true
          },
          {
            "t": 1860,
            "value": false
          }
        ],
        "occ_scheduled": false
      },
      "expect": [
        {
          "output": "yExhaustWithoutSupply",
          "from_s": 0,
          "to_s": 1740,
          "equals": false
        },
        {
          "output": "yExhaustWithoutSupply",
          "from_s": 1800,
          "to_s": 1800,
          "equals": true
        },
        {
          "output": "yExhaustWithoutSupply",
          "from_s": 1860,
          "to_s": 7200,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        }
      ]
    },
    {
      "name": "misalignment_clears_one_tick_before_the_alarm",
      "description": "The alarm edge from below: the exhaust fan stops at 2640 s, one tick short of the 2700 s the chain needs, and the fault is never reported.",
      "inputs": {
        "sf_status": false,
        "ef_status": [
          {
            "t": 0,
            "value": true
          },
          {
            "t": 2640,
            "value": false
          }
        ],
        "occ_scheduled": false
      },
      "expect": [
        {
          "output": "yExhaustWithoutSupply",
          "from_s": 1800,
          "to_s": 2580,
          "equals": true
        },
        {
          "output": "yExhaustWithoutSupply",
          "from_s": 2640,
          "to_s": 7200,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        }
      ]
    },
    {
      "name": "misalignment_clears_one_tick_after_the_alarm",
      "description": "The same edge from above: one tick of yFault at 2700 s, then clear.",
      "inputs": {
        "sf_status": false,
        "ef_status": [
          {
            "t": 0,
            "value": true
          },
          {
            "t": 2760,
            "value": false
          }
        ],
        "occ_scheduled": false
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2640,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2700,
          "to_s": 2700,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 2760,
          "to_s": 7200,
          "equals": false
        }
      ]
    },
    {
      "name": "supply_fan_starts_and_realigns",
      "description": "Recovery: the exhaust-only alarm asserts at 2700 s and the AHU starts at 3600 s. Both fans running is aligned, so every output drops on that tick \u2014 TrueDelay delays the rising edge only.",
      "inputs": {
        "ef_status": true,
        "sf_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 3600,
            "value": true
          }
        ],
        "occ_scheduled": false
      },
      "expect": [
        {
          "output": "yExhaustWithoutSupply",
          "from_s": 1800,
          "to_s": 3540,
          "equals": true
        },
        {
          "output": "yExhaustWithoutSupply",
          "from_s": 3600,
          "to_s": 7200,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2700,
          "to_s": 3540,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 3600,
          "to_s": 7200,
          "equals": false
        }
      ]
    },
    {
      "name": "occupancy_starts_the_supply_only_clock",
      "description": "The supply-only branch is armed by the schedule, not by the fans. An AHU running with its exhaust off is silent overnight and starts counting when the occupied period opens at 1800 s: sustain at 3600 s, alarm at 4500 s.",
      "inputs": {
        "sf_status": true,
        "ef_status": false,
        "occ_scheduled": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 1800,
            "value": true
          }
        ]
      },
      "expect": [
        {
          "output": "ySupplyWithoutExhaust",
          "from_s": 0,
          "to_s": 3540,
          "equals": false
        },
        {
          "output": "ySupplyWithoutExhaust",
          "from_s": 3600,
          "to_s": 7200,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 4440,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 4500,
          "to_s": 7200,
          "equals": true
        }
      ]
    },
    {
      "name": "direction_alternates_so_neither_branch_sustains",
      "description": "The consequence of sustaining each condition separately rather than sustaining their Or. The pair is misaligned on every single tick of this two-hour run, but it flips direction every 20 minutes, so neither branch ever reaches misalignment_duration and nothing is reported. A TrueDelay(1800) placed on Or(C1, C2) instead would have alarmed at 2700 s.",
      "inputs": {
        "occ_scheduled": true,
        "ef_status": [
          {
            "t": 0,
            "value": true
          },
          {
            "t": 1200,
            "value": false
          },
          {
            "t": 2400,
            "value": true
          },
          {
            "t": 3600,
            "value": false
          },
          {
            "t": 4800,
            "value": true
          },
          {
            "t": 6000,
            "value": false
          }
        ],
        "sf_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 1200,
            "value": true
          },
          {
            "t": 2400,
            "value": false
          },
          {
            "t": 3600,
            "value": true
          },
          {
            "t": 4800,
            "value": false
          },
          {
            "t": 6000,
            "value": true
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        },
        {
          "output": "yExhaustWithoutSupply",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        },
        {
          "output": "ySupplyWithoutExhaust",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        }
      ]
    }
  ]
}

SYS-0009 — Sensor flatline while equipment active

Statusverified — engine e2ff2f8, cxf:fnv1a128:e9b6eca9434e06c98d3fbdf289ffa6c6, 2026-08-17
Severity3
Methodrule
Phase3
CategoryPROTECTIVE
ConfidenceLOW
EstimationQUALITATIVE_ONLY
G36
ClustersCLU-09
Suppresses
Suppressed by
RelatedSYS-0005, SYS-0010, AHU-0028, RTU-0003
Playbookssensor-drift
SourceAccepted design: internal sensor-health design note (local-only, not distributed) — §1 (the flatline shape and its activity gate), §2 (why a physical-plausibility rule does not break the fault-given-valid-data stance), §2.3 (the adjudicates contract), §4.3 (Discrete.Sampler mechanics and the MovingAverage rejection), §4.4 (the flatline vector strategy this card’s scenarios follow); Yang, H., Cho, S., Tae, C.-S., Zaheeruddin, M. (2008). Sequential rule based algorithms for temperature sensor fault detection in air handling units. Energy Conversion and Management 49(8), 2291-2306. doi:10.1016/j.enconman.2008.01.029 — rule-based temperature-sensor FDD validated on real AHUs; the source of the isolation argument this card’s adjudicates verdict rests on; Liao, H., Cai, W., Cheng, F., Dubey, S., Rajesh, P. B. (2021). An Online Data-Driven Fault Diagnosis Method for Air Handling Units by Rule and Convolutional Neural Networks. Sensors 21(13), 4358. doi:10.3390/s21134358 — published evidence that a cheap deterministic sensor layer under a heavier diagnostic layer works in the field; Dey, D., Dong, B. (2016). A probabilistic approach to diagnose faults of air handling units in buildings. Energy and Buildings 130, 177-187. doi:10.1016/j.enbuild.2016.08.017 — the motivation stated negatively: a satisfied equipment rule cannot separate a real fault from a sensor fault after the fact; Library-authored: the HVAC FDD Reference v1.0 specifies no flatline rule in any chapter. Name, severity 3 and method: rule are faults/sys/README.md’s; everything else is argued on this card; Sibling precedent: AHU-0023 (the Discrete.Sampler baseline plus flatness dwell this graph reuses), AHU-0028 and RTU-0003 (physical-plausibility rules the library already ships), VAV-0001 (the per-binding placeholder parameter convention)
Operating statesall — the rule evaluates only while equip_active is true, and its own yWindowOk reports whether a complete window of running time has accumulated

Preconditions (host-enforced): Host delivery quality must be resolved before this rule runs, and this is the load-bearing precondition rather than boilerplate: a value the host is re-serving from cache because the subscription died presents to the graph as a perfectly frozen sensor, and the rule will report flatline and be right about the number it was given and wrong about the transmitter. Freshness, PointStatus and gap handling stay where the reference’s ch.4 puts them — host-side, ahead of this rule (design doc §2.2). sensor_value must be bound to a live measurement: a setpoint, a configured constant, a schedule output or a host-derived aggregate that only refreshes hourly will all read as flatline and none of them is a sensor. equip_active must be bound to the run status of the equipment whose process actually drives the bound sensor — sf_status for an AHU supply-air temperature, comp_status for a suction line, pump_status for a loop reading. A VAV box has no run status of its own; bind the parent AHU’s fan status or zone_airflow > 0 and record which. Both flatline_band and flatline_window are per-binding site configuration in the bound point’s units and MUST be set for this instance before the rule is trusted; the shipped defaults are a worked example for a supply-air temperature, not a site value. Where yWindowOk is false the verdict is NO_EVAL, not a healthy sensor.

Points: sensor_value, equip_active

Outputs:

  • yFault — True while the bound sensor has stayed within flatline_band of its window baseline continuously for flatline_window of running time, and has then held that condition for a further alarm_delay. While true, the host treats sensor_value as invalid for every rule bound to this equipment instance that consumes it (see adjudicates)
  • yWindowOk — Evaluability signal — true once equip_active has been continuously true for a full flatline_window, which is the shortest run the rule can form any verdict from. False means NO_EVAL, never a healthy sensor

Parameters:

NameDefaultUnitCXF pathDescription
flatline_band0.25varies (the bound point’s units)stillBand.tHow far the reading may travel from its window baseline and still count as not moving. PER-BINDING SITE CONFIGURATION, in the units of whatever real point sensor_value is bound to — the VAV-0001 ventilation_requirement convention. The shipped 0.25 is the worked example for sensor_value := sat on an AHU (0.25 °C: above the 0.1 °C quantum most BAS report a duct temperature at, well below the 1-3 °C a controlled supply-air temperature travels in two hours). It is meaningless on any other quantity — see Deviations for the duct-static counter-example and for which direction each mis-tuning fails in.
flatline_window7200.0ssensRef.samplePeriod, stillHeld.delayTime, windowOk.delayTimeHow long the reading must stay inside the band, measured in running time, before the rule believes the sensor rather than the process. ADOPTED at 2 h and argued in Deviations; tune within roughly 1-4 h per binding. One card parameter binds three block parameters — the sampler that produces the baseline and both dwell timers — and hosts must set all three together.
alarm_delay900.0spersist.delayTimeFurther continuous persistence after the window completes, before the alarm asserts (15 min). The retuning knob for alarm hygiene, kept separate from flatline_window because that parameter also sets the baseline’s re-arm period and cannot be moved for reporting reasons alone.

Description

A transmitter that has stopped reporting change is the quietest failure in a building. Nothing alarms, no zone complains, the trend line is beautifully smooth, and every rule downstream keeps producing verdicts from a number that stopped being a measurement weeks ago. A frozen sensing element, a controller holding last-known-good, a point re-served from cache after a dead subscription — all three present identically: zero variance under a load that should be producing some. The reason to write the rule is Dey & Dong (2016): a Bayesian layer sits over APAR precisely because a satisfied equipment rule cannot separate coil fouling from a temperature-sensor bias after the fact, and the cheaper answer is to detect the sensor case directly and drop it from the candidate set first. It is library-authored — the HVAC FDD Reference specifies no flatline rule in any chapter — and it binds role points, whose real identities live in the host’s instance configuration.

The adjudicates contract. While yFault is active, sensor_value is invalid: the host must return NO_EVAL for every rule on this equipment instance that consumes it, deriving that set by intersecting each card’s points with adjudicates.points. equip_active is consumed but deliberately not adjudicated. yWindowOk is a separate gate: while it is false the verdict is NO_EVAL, never a healthy sensor.

Detection Logic

baseline   = Sampler(sensor_value, flatline_window)   (re-arms once per window)
still      = |sensor_value − baseline| < flatline_band

yWindowOk  = equip_active held continuously for flatline_window
                                            (false ⇒ host reports NO_EVAL)
yFault     = (still AND equip_active) held continuously for flatline_window,
             then sustained a further alarm_delay

Block graph (rule.cxf.jsonld):

SYS-0009 block graph

Eight blocks. sensRef is a Discrete.Sampler on the window period, so the comparison is always against where the reading sat at the start of the current window; stillBand is strict, so a deviation of exactly flatline_band reads as moving. The activity gate sits inside the dwell rather than after it — equip_active is one leg of stillAndActive, so flatline_window is a window of running time and a plant that stops discards the elapsed time rather than pausing it. windowOk is not an echo of that input: it asserts only once the equipment has run continuously for a full window, the shortest run this rule can form any verdict from, so a true yFault implies a true yWindowOk and not the reverse.

Both thresholds are per-binding and the shipped pair is a worked example for sensor_value := sat, equip_active := sf_status: 0.25 °C sits above the 0.1 °C quantum most BAS report a duct temperature at and well below the one to three degrees a controlled supply-air temperature travels in two hours, and 7200 s fits inside a single occupied period. Every other binding needs its own two numbers — Deviations works the arithmetic on which direction each mis-tuning fails.

persist (15 min) is the only knob a host should touch for reporting reasons; flatline_window binds the sampler period and both dwell timers at once, because they express one quantity. All three TrueDelays assert at exactly T + delayTime and carry delayOnInit = true (CDL default false).

Possible Diagnoses

Library-authored; no published diagnosis list exists for this shape.

  1. Failed sensing element — a thermistor or RTD opened or shorted into a fixed reading, a transducer whose diaphragm has seized. The only repair that is a part
  2. Failed or saturated A/D channel — the element is fine, the input is not; distinguishable on site by moving the sensor to a spare input
  3. A controller holding last-known-good — many BAS substitute the previous value on a read failure rather than flagging the point
  4. A point re-served from cache after a lost subscription — the host is supposed to catch this as a delivery fault before the rule sees it
  5. A manual override or hand mode left on the point — costs nothing to check and is the most common finding on a new deployment
  6. A sensor installed where nothing happens — a duct probe in a dead leg, a well without paste, a space sensor behind a closed door. The repair is relocation
  7. Genuinely stable process — the false-positive case: a tight loop on a light load can sit still for two hours. Raise flatline_window, never flatline_band

Energy Impact

PROTECTIVE, LOW confidence, QUALITATIVE_ONLY. There is no waste term — a frozen transmitter burns nothing — and the cost is the diagnostic coverage of every rule that reads it, realized as energy only through the faults it hides. AHU-0028 set the convention this card follows: the value of a sensor gate belongs to the rules it gates. The fan-out is the number that matters and it is per-instance, computed by the host from the points lists every card already carries. Confidence is LOW for two honest reasons: diagnoses 6 and 7 cannot be separated from a stuck transmitter by this graph, and a deployment that has not retuned the thresholds is comparing against a supply-air temperature’s numbers.

Emissions Impact

Scope 1 or 2 depending on what the adjudicated point serves, QUALITATIVE_EMISSIONS, LOW confidence. Same argument as the energy claim: no direct term, and the avoided emissions belong to whichever rules were restored to service when the sensor was repaired. AHU-0028’s 1|2 assignment is the precedent, for the same reason — a bad temperature can be hiding a gas-fired heating fault or an electric cooling one, and the rule does not know which.

Deviations

  • Library-authored, not a transcription. No chapter of the reference specifies a flatline rule, so there is no algorithm to deviate from. The ID, name, severity 3 and method: rule are faults/sys/README.md’s; everything else is argued here on the accepted design plus Yang et al. (2008) and Liao et al. (2021).
  • The stance, in brief. Delivery quality — arrival, PointStatus, gaps — stays host-side and is untouched by this rule; physical plausibility (the sample arrived clean and is wrong) is a property of the signal and a fault of a piece of equipment, because a sensor is equipment. AHU-0028 and RTU-0003 are the precedent.
  • The rule depends on host delivery quality and cannot substitute for it. This keeps the layering acyclic and is the one way a deployment can make the card lie: fed a value held over from a dead subscription, the rule reports flatline — right about the number it was given, wrong about the transmitter.
  • adjudicates: {points: [sensor_value], verdict: invalid_while_active}. The verdict names one sensor rather than SYS-0005’s pair. equip_active is not adjudicated: the rule says nothing about whether the run status is telling the truth. The fan-out is deliberately not enumerated, so it stays complete as rules are added — AHU-0028’s hand-written suppresses list is the counter-example that motivated the field.
  • suppresses: [] and suppressed_by: [], and the second is normative. An equipment fault silencing the sensor rule that invalidates it is a cycle with a wrong answer at both ends. The two fields are not interchangeable: suppresses says the silenced verdict is true but redundant, adjudicates says the consuming verdicts are meaningless and their rolling state should be reset. Whose job that reset is remains open.
  • The evaluability output is a dwell rather than an echo. A passthrough of a boolean boundary input carries no information the host does not have (HW-0005’s yMildWeather deviation is explicit), so yWindowOk is equip_active held for a full flatline_window.
  • Both thresholds ship as per-binding placeholders in the bound point’s units, the VAV-0001 convention. The counter-example that makes it bite: bound to a duct static pressure, 0.25 Pa is below the noise floor of every transducer on the market, so still is never true and the rule sits silent forever — that binding wants something nearer 5 Pa. The failure directions are not symmetric: a band too small produces silent misses, a band too large turns a slowly moving healthy sensor into a flatline finding, and only the second is visible from the alarm list.
  • flatline_window = 7200 s is ADOPTED, and short by this family’s standards. AHU-0023 uses seven days for the same sampler-and-dwell structure; copying it would be wrong because the window is continuous running time and a scheduled air handler never runs for a week, because every unreported hour blinds everything downstream, and because above roughly four hours the window exceeds the uninterrupted run of ordinary scheduled plant. The floor is diagnosis 7 at about an hour.
  • Discrete.Sampler, not Reals.MovingAverage, and not Reals.Derivative. Engine-verified at the pin. MovingAverage’s fixed 64-checkpoint ring imposes a minimum tick of window/64 (112.5 s here) and degrades silently when a deployment ticks faster; AHU-0023 rejected it for the same reason. Reals.Derivative is forbidden in this family outright — its k and T are input pins, and its implicit-Euler discretisation biases a ramp by (1 + dt/T), reporting twice the actual slope at a 300 s tick with T = 300 s.
  • Discrete.Sampler emits the live input on its first tick, so there is no startup artifact of the kind Discrete.UnitDelay produces with y_start = 0 (that is SYS-0010’s problem). This is pinned by an arrival time rather than asserted: the alarm lands at flatline_window + alarm_delay from t = 0, which is only reachable if the first baseline was the live reading.
  • The sampler grid is anchored to absolute model time, not to controller start: the first sample instant is floor(t_start/period)·period, so a rule loaded at t = 137 s with a two-hour period takes its first grid instant at t = 0. It does not change the verdict, but a deployment’s tick must divide flatline_window.
  • The baseline re-arms every window, with two consequences. At each sample instant the block emits the live input, so the deviation is exactly zero on that tick however hard the signal is moving — a burst of apparent stillness that cannot mature, since the dwell needs a full window. And a sensor that freezes partway through a window is not detected until the next re-arm re-baselines onto its stuck value, so worst-case time to alarm from the moment of failure is 2 × flatline_window + alarm_delay.
  • A slow drift reads as a flatline. Any reading moving slower than flatline_band per flatline_window satisfies the test. That is a sensor finding either way, but the name is wrong and this rule cannot supply the right one — naming drift is SYS-0005’s job, and a host running both should read a simultaneous SYS-0009 and SYS-0005 as drift, not two faults.
  • Strict < on the band. CDL Reals has no LessEqual and the disagreement is measure-zero on a real-valued signal, so the comparison errs toward silence. Edge cases use dyadic values (14.0 against 14.25) so the difference is exactly 0.25 in IEEE-754: 14.2 − 14.0 is not 0.2 in double precision, and a naively written edge test would pin the wrong side of its own threshold.
  • delayOnInit = true on all three TrueDelays (CDL default false), the library’s standing choice: a restart onto an already-frozen sensor waits out the full window, and windowOk refuses to claim a window it did not observe. Each asserts at exactly T + delayTime, so every realized test is “strictly more than” its delay at tick resolution.
  • alarm_delay is a separate parameter from flatline_window on purpose. The two answer different questions: flatline_window is a physical claim about how long the bound process can legitimately sit still and it also sets the baseline’s re-arm period, so it cannot be shortened for alarm hygiene without changing what the rule measures. alarm_delay is the knob that moves freely.
  • clusters: [], flagged not edited. CLU-09 (Sensor Integrity Failure) is where this card belongs, and if the FC-100 family lands its trigger is arguably one of these rules with AHU-0028 demoted to member. clusters/clusters.json is a single-writer file, as is playbooks/sensor-drift.md, whose four steps already apply to this finding.
  • category: PROTECTIVE, departing from the two precedent sensor gates. AHU-0028 and RTU-0003 are both COMFORT_ENERGY and consistency is the honest counter-argument; it is declined because the alternative misdescribes the card — there is no comfort effect and no energy term, and what the rule delivers is avoided false findings and preserved diagnostic coverage. Severity stays 3: the fan-out is the host’s consequence of the verdict, not a property of the finding.
  • Operating states and preconditions are declared in frontmatter for host enforcement rather than encoded in the block graph, per the library’s design stance.

Notes

Read yWindowOk before yFault, and read adjudicates before doing anything with either. A true yFault is not an instruction to dispatch a technician and stop — it is an instruction to stop believing every other finding on that equipment that touches the bound point, and the second half is worth more.

Check the cheap causes first: diagnoses 3, 4 and 5 account for most of what this rule finds on a new deployment, cost nothing to check from a desk, and are configuration rather than hardware. Then look at where the sensor is before assuming it is broken — diagnosis 6 survives recalibration, because a probe in a dead leg reports a real temperature of a place where nothing happens and passes every bench test. The tell is a reading that is plausible and static while its neighbours move.

Tune the window, never the band, when the rule is too talkative. Raising flatline_band pulls genuinely drifting sensors into the finding, making the report noisier and less specific at once; raising flatline_window costs only detection latency, and this family has latency to spare. See the sensor-drift playbook for the verification and service workflow.

Test Vectors

14 scenarios, clock step 300 s over 21600 s.

ScenarioDescription
frozen_sensor_with_equipment_runningThe plain fault: the reading never moves while the equipment runs. The alarm lands at 8100 s = flatline_window + alarm_delay measured from t = 0, and that arrival time is itself the pin for Discrete.Sampler emitting the LIVE input on its first tick. A UnitDelay-style y_start = 0 artifact would have made the first baseline 0.0, held stillBand false until the t = 7200 s re-arm, and pushed the alarm out to 15300 s.
flat_signal_on_idle_equipment_is_not_a_faultThe gate’s whole purpose: an identical frozen reading on stopped equipment is normal, not a sensor fault. yWindowOk never rises, so the host reads NO_EVAL rather than a healthy sensor.
equipment_stop_restarts_the_windowThe gate sits inside the dwell, so the window is a window of RUNNING time: a 600 s stop at 5400 s discards the elapsed 5400 s rather than pausing it. The clock restarts at 6000 s, stillHeld matures at 13200 s and the alarm lands at 14100 s.
wobble_inside_the_band_still_assertsThe band is not decorative. A reading stepping 0.15 units either side of its baseline - quantisation and transmitter noise, not process movement - still reads as flatline and alarms on the same 8100 s schedule as a bit-frozen signal. The wobble period divides flatline_window, so every re-arm lands on the low value and the baseline is stable across windows.
band_edge_exactly_on_the_linestillBand is a strict Reals.LessThreshold: a deviation of exactly flatline_band reads as MOVING, so the rule stays silent. Both values are dyadic, so 14.25 - 14.0 is exactly 0.25 in IEEE-754 and the comparison really does sit on the line rather than near it.
band_edge_a_hair_under0.001 below the line: 14.249 against a 14.0 baseline is inside the band, so the same waveform that stayed silent at exactly 0.25 now alarms at 8100 s.
band_edge_a_hair_over0.001 above the line: 14.251 clears the band, stillBand drops on every high half-cycle and the dwell never completes.
healthy_sensor_tracking_the_processThe ordinary healthy case - a reading swinging 2.0 units as the process moves under control. Never silent for a full window, never alarms, and yWindowOk is true throughout the run so the silence is a verdict rather than an unasked question.
signal_moves_on_the_maturity_tickDelay edge from below: the reading is frozen from t = 0 and jumps 6.0 units at exactly 8100 s, the tick persist matures on. The input is false when the timer comes due, so a full window plus alarm_delay of stillness is never reported.
signal_moves_one_tick_after_maturityDelay edge from above: the same freeze held 300 s longer asserts at exactly 8100 s and clears at 8400 s. The pair makes the T + delayTime boundary observable rather than assumed. After the jump the rule is blind until the next re-arm at 14400 s re-baselines onto 20.0, and the fresh dwell cannot mature before the horizon.
slow_drift_reads_as_flatlineA documented limit, pinned rather than left for a site to find: a reading climbing 0.05 units every 1800 s never travels more than 0.15 from its baseline inside one window, so the rule calls it flatline and alarms at 8100 s. SYS-0009 cannot separate a frozen transmitter from one moving slower than flatline_band per flatline_window; both are sensor findings, and naming which is the redundancy-pair rule’s job.
rearm_stillness_never_maturesThe Sampler re-arm artifact: at every sample instant the block emits the live input, so the deviation is exactly zero and stillBand reads true no matter how hard the signal is moving. Here the reading climbs 6.0 units every 1800 s and still shows a burst of apparent stillness after each re-arm; the burst is bounded by the sample grid, the dwell needs a full window, and yFault never rises.
equipment_stops_on_the_window_tickWindow delay edge from below, on the evaluability output: the equipment stops at exactly 7200 s, the tick windowOk matures on. The input is false when the timer comes due, so the rule never claims to have had a complete window of running time.
equipment_stops_one_tick_after_the_window_tickWindow delay edge from above: the same run held 300 s longer sets yWindowOk for exactly one tick at 7200 s. yFault stays false because persist still owed 900 s when the gate dropped, which also pins that a true yFault implies a true yWindowOk and never the reverse.
vectors.json
{
  "schema": "cxf-library/vectors/v1",
  "clock": {
    "step_s": 300,
    "horizon_s": 21600
  },
  "scenarios": [
    {
      "name": "frozen_sensor_with_equipment_running",
      "description": "The plain fault: the reading never moves while the equipment runs. The alarm lands at 8100 s = flatline_window + alarm_delay measured from t = 0, and that arrival time is itself the pin for Discrete.Sampler emitting the LIVE input on its first tick. A UnitDelay-style y_start = 0 artifact would have made the first baseline 0.0, held stillBand false until the t = 7200 s re-arm, and pushed the alarm out to 15300 s.",
      "inputs": {
        "sensor_value": 14.0,
        "equip_active": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7800,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 8400,
          "to_s": 21600,
          "equals": true
        },
        {
          "output": "yWindowOk",
          "from_s": 0,
          "to_s": 6900,
          "equals": false
        },
        {
          "output": "yWindowOk",
          "from_s": 7500,
          "to_s": 21600,
          "equals": true
        }
      ]
    },
    {
      "name": "flat_signal_on_idle_equipment_is_not_a_fault",
      "description": "The gate's whole purpose: an identical frozen reading on stopped equipment is normal, not a sensor fault. yWindowOk never rises, so the host reads NO_EVAL rather than a healthy sensor.",
      "inputs": {
        "sensor_value": 14.0,
        "equip_active": false
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 21600,
          "equals": false
        },
        {
          "output": "yWindowOk",
          "from_s": 0,
          "to_s": 21600,
          "equals": false
        }
      ]
    },
    {
      "name": "equipment_stop_restarts_the_window",
      "description": "The gate sits inside the dwell, so the window is a window of RUNNING time: a 600 s stop at 5400 s discards the elapsed 5400 s rather than pausing it. The clock restarts at 6000 s, stillHeld matures at 13200 s and the alarm lands at 14100 s.",
      "inputs": {
        "sensor_value": 14.0,
        "equip_active": [
          {
            "t": 0,
            "value": true
          },
          {
            "t": 5400,
            "value": false
          },
          {
            "t": 6000,
            "value": true
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 13800,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 14400,
          "to_s": 21600,
          "equals": true
        },
        {
          "output": "yWindowOk",
          "from_s": 0,
          "to_s": 12900,
          "equals": false
        },
        {
          "output": "yWindowOk",
          "from_s": 13500,
          "to_s": 21600,
          "equals": true
        }
      ]
    },
    {
      "name": "wobble_inside_the_band_still_asserts",
      "description": "The band is not decorative. A reading stepping 0.15 units either side of its baseline - quantisation and transmitter noise, not process movement - still reads as flatline and alarms on the same 8100 s schedule as a bit-frozen signal. The wobble period divides flatline_window, so every re-arm lands on the low value and the baseline is stable across windows.",
      "inputs": {
        "sensor_value": [
          {
            "t": 0,
            "value": 14.0
          },
          {
            "t": 1800,
            "value": 14.15
          },
          {
            "t": 3600,
            "value": 14.0
          },
          {
            "t": 5400,
            "value": 14.15
          },
          {
            "t": 7200,
            "value": 14.0
          },
          {
            "t": 9000,
            "value": 14.15
          },
          {
            "t": 10800,
            "value": 14.0
          },
          {
            "t": 12600,
            "value": 14.15
          },
          {
            "t": 14400,
            "value": 14.0
          },
          {
            "t": 16200,
            "value": 14.15
          },
          {
            "t": 18000,
            "value": 14.0
          },
          {
            "t": 19800,
            "value": 14.15
          },
          {
            "t": 21600,
            "value": 14.0
          }
        ],
        "equip_active": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7800,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 8400,
          "to_s": 21600,
          "equals": true
        },
        {
          "output": "yWindowOk",
          "from_s": 0,
          "to_s": 6900,
          "equals": false
        },
        {
          "output": "yWindowOk",
          "from_s": 7500,
          "to_s": 21600,
          "equals": true
        }
      ]
    },
    {
      "name": "band_edge_exactly_on_the_line",
      "description": "stillBand is a strict Reals.LessThreshold: a deviation of exactly flatline_band reads as MOVING, so the rule stays silent. Both values are dyadic, so 14.25 - 14.0 is exactly 0.25 in IEEE-754 and the comparison really does sit on the line rather than near it.",
      "inputs": {
        "sensor_value": [
          {
            "t": 0,
            "value": 14.0
          },
          {
            "t": 1800,
            "value": 14.25
          },
          {
            "t": 3600,
            "value": 14.0
          },
          {
            "t": 5400,
            "value": 14.25
          },
          {
            "t": 7200,
            "value": 14.0
          },
          {
            "t": 9000,
            "value": 14.25
          },
          {
            "t": 10800,
            "value": 14.0
          },
          {
            "t": 12600,
            "value": 14.25
          },
          {
            "t": 14400,
            "value": 14.0
          },
          {
            "t": 16200,
            "value": 14.25
          },
          {
            "t": 18000,
            "value": 14.0
          },
          {
            "t": 19800,
            "value": 14.25
          },
          {
            "t": 21600,
            "value": 14.0
          }
        ],
        "equip_active": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 21600,
          "equals": false
        },
        {
          "output": "yWindowOk",
          "from_s": 0,
          "to_s": 6900,
          "equals": false
        },
        {
          "output": "yWindowOk",
          "from_s": 7500,
          "to_s": 21600,
          "equals": true
        }
      ]
    },
    {
      "name": "band_edge_a_hair_under",
      "description": "0.001 below the line: 14.249 against a 14.0 baseline is inside the band, so the same waveform that stayed silent at exactly 0.25 now alarms at 8100 s.",
      "inputs": {
        "sensor_value": [
          {
            "t": 0,
            "value": 14.0
          },
          {
            "t": 1800,
            "value": 14.249
          },
          {
            "t": 3600,
            "value": 14.0
          },
          {
            "t": 5400,
            "value": 14.249
          },
          {
            "t": 7200,
            "value": 14.0
          },
          {
            "t": 9000,
            "value": 14.249
          },
          {
            "t": 10800,
            "value": 14.0
          },
          {
            "t": 12600,
            "value": 14.249
          },
          {
            "t": 14400,
            "value": 14.0
          },
          {
            "t": 16200,
            "value": 14.249
          },
          {
            "t": 18000,
            "value": 14.0
          },
          {
            "t": 19800,
            "value": 14.249
          },
          {
            "t": 21600,
            "value": 14.0
          }
        ],
        "equip_active": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7800,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 8400,
          "to_s": 21600,
          "equals": true
        },
        {
          "output": "yWindowOk",
          "from_s": 0,
          "to_s": 6900,
          "equals": false
        },
        {
          "output": "yWindowOk",
          "from_s": 7500,
          "to_s": 21600,
          "equals": true
        }
      ]
    },
    {
      "name": "band_edge_a_hair_over",
      "description": "0.001 above the line: 14.251 clears the band, stillBand drops on every high half-cycle and the dwell never completes.",
      "inputs": {
        "sensor_value": [
          {
            "t": 0,
            "value": 14.0
          },
          {
            "t": 1800,
            "value": 14.251
          },
          {
            "t": 3600,
            "value": 14.0
          },
          {
            "t": 5400,
            "value": 14.251
          },
          {
            "t": 7200,
            "value": 14.0
          },
          {
            "t": 9000,
            "value": 14.251
          },
          {
            "t": 10800,
            "value": 14.0
          },
          {
            "t": 12600,
            "value": 14.251
          },
          {
            "t": 14400,
            "value": 14.0
          },
          {
            "t": 16200,
            "value": 14.251
          },
          {
            "t": 18000,
            "value": 14.0
          },
          {
            "t": 19800,
            "value": 14.251
          },
          {
            "t": 21600,
            "value": 14.0
          }
        ],
        "equip_active": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 21600,
          "equals": false
        },
        {
          "output": "yWindowOk",
          "from_s": 0,
          "to_s": 6900,
          "equals": false
        },
        {
          "output": "yWindowOk",
          "from_s": 7500,
          "to_s": 21600,
          "equals": true
        }
      ]
    },
    {
      "name": "healthy_sensor_tracking_the_process",
      "description": "The ordinary healthy case - a reading swinging 2.0 units as the process moves under control. Never silent for a full window, never alarms, and yWindowOk is true throughout the run so the silence is a verdict rather than an unasked question.",
      "inputs": {
        "sensor_value": [
          {
            "t": 0,
            "value": 14.0
          },
          {
            "t": 1800,
            "value": 16.0
          },
          {
            "t": 3600,
            "value": 14.0
          },
          {
            "t": 5400,
            "value": 16.0
          },
          {
            "t": 7200,
            "value": 14.0
          },
          {
            "t": 9000,
            "value": 16.0
          },
          {
            "t": 10800,
            "value": 14.0
          },
          {
            "t": 12600,
            "value": 16.0
          },
          {
            "t": 14400,
            "value": 14.0
          },
          {
            "t": 16200,
            "value": 16.0
          },
          {
            "t": 18000,
            "value": 14.0
          },
          {
            "t": 19800,
            "value": 16.0
          },
          {
            "t": 21600,
            "value": 14.0
          }
        ],
        "equip_active": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 21600,
          "equals": false
        },
        {
          "output": "yWindowOk",
          "from_s": 0,
          "to_s": 6900,
          "equals": false
        },
        {
          "output": "yWindowOk",
          "from_s": 7500,
          "to_s": 21600,
          "equals": true
        }
      ]
    },
    {
      "name": "signal_moves_on_the_maturity_tick",
      "description": "Delay edge from below: the reading is frozen from t = 0 and jumps 6.0 units at exactly 8100 s, the tick persist matures on. The input is false when the timer comes due, so a full window plus alarm_delay of stillness is never reported.",
      "inputs": {
        "sensor_value": [
          {
            "t": 0,
            "value": 14.0
          },
          {
            "t": 8100,
            "value": 20.0
          }
        ],
        "equip_active": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 21600,
          "equals": false
        },
        {
          "output": "yWindowOk",
          "from_s": 0,
          "to_s": 6900,
          "equals": false
        },
        {
          "output": "yWindowOk",
          "from_s": 7500,
          "to_s": 21600,
          "equals": true
        }
      ]
    },
    {
      "name": "signal_moves_one_tick_after_maturity",
      "description": "Delay edge from above: the same freeze held 300 s longer asserts at exactly 8100 s and clears at 8400 s. The pair makes the T + delayTime boundary observable rather than assumed. After the jump the rule is blind until the next re-arm at 14400 s re-baselines onto 20.0, and the fresh dwell cannot mature before the horizon.",
      "inputs": {
        "sensor_value": [
          {
            "t": 0,
            "value": 14.0
          },
          {
            "t": 8400,
            "value": 20.0
          }
        ],
        "equip_active": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7800,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 8100,
          "to_s": 8100,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 8700,
          "to_s": 21600,
          "equals": false
        },
        {
          "output": "yWindowOk",
          "from_s": 0,
          "to_s": 6900,
          "equals": false
        },
        {
          "output": "yWindowOk",
          "from_s": 7500,
          "to_s": 21600,
          "equals": true
        }
      ]
    },
    {
      "name": "slow_drift_reads_as_flatline",
      "description": "A documented limit, pinned rather than left for a site to find: a reading climbing 0.05 units every 1800 s never travels more than 0.15 from its baseline inside one window, so the rule calls it flatline and alarms at 8100 s. SYS-0009 cannot separate a frozen transmitter from one moving slower than flatline_band per flatline_window; both are sensor findings, and naming which is the redundancy-pair rule's job.",
      "inputs": {
        "sensor_value": [
          {
            "t": 0,
            "value": 14.0
          },
          {
            "t": 1800,
            "value": 14.05
          },
          {
            "t": 3600,
            "value": 14.1
          },
          {
            "t": 5400,
            "value": 14.15
          },
          {
            "t": 7200,
            "value": 14.2
          },
          {
            "t": 9000,
            "value": 14.25
          },
          {
            "t": 10800,
            "value": 14.3
          },
          {
            "t": 12600,
            "value": 14.35
          },
          {
            "t": 14400,
            "value": 14.4
          },
          {
            "t": 16200,
            "value": 14.45
          },
          {
            "t": 18000,
            "value": 14.5
          },
          {
            "t": 19800,
            "value": 14.55
          },
          {
            "t": 21600,
            "value": 14.6
          }
        ],
        "equip_active": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7800,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 8400,
          "to_s": 21600,
          "equals": true
        },
        {
          "output": "yWindowOk",
          "from_s": 0,
          "to_s": 6900,
          "equals": false
        },
        {
          "output": "yWindowOk",
          "from_s": 7500,
          "to_s": 21600,
          "equals": true
        }
      ]
    },
    {
      "name": "rearm_stillness_never_matures",
      "description": "The Sampler re-arm artifact: at every sample instant the block emits the live input, so the deviation is exactly zero and stillBand reads true no matter how hard the signal is moving. Here the reading climbs 6.0 units every 1800 s and still shows a burst of apparent stillness after each re-arm; the burst is bounded by the sample grid, the dwell needs a full window, and yFault never rises.",
      "inputs": {
        "sensor_value": [
          {
            "t": 0,
            "value": 14.0
          },
          {
            "t": 1800,
            "value": 20.0
          },
          {
            "t": 3600,
            "value": 26.0
          },
          {
            "t": 5400,
            "value": 32.0
          },
          {
            "t": 7200,
            "value": 38.0
          },
          {
            "t": 9000,
            "value": 44.0
          },
          {
            "t": 10800,
            "value": 50.0
          },
          {
            "t": 12600,
            "value": 56.0
          },
          {
            "t": 14400,
            "value": 62.0
          },
          {
            "t": 16200,
            "value": 68.0
          },
          {
            "t": 18000,
            "value": 74.0
          },
          {
            "t": 19800,
            "value": 80.0
          },
          {
            "t": 21600,
            "value": 86.0
          }
        ],
        "equip_active": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 21600,
          "equals": false
        },
        {
          "output": "yWindowOk",
          "from_s": 0,
          "to_s": 6900,
          "equals": false
        },
        {
          "output": "yWindowOk",
          "from_s": 7500,
          "to_s": 21600,
          "equals": true
        }
      ]
    },
    {
      "name": "equipment_stops_on_the_window_tick",
      "description": "Window delay edge from below, on the evaluability output: the equipment stops at exactly 7200 s, the tick windowOk matures on. The input is false when the timer comes due, so the rule never claims to have had a complete window of running time.",
      "inputs": {
        "sensor_value": 14.0,
        "equip_active": [
          {
            "t": 0,
            "value": true
          },
          {
            "t": 7200,
            "value": false
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 21600,
          "equals": false
        },
        {
          "output": "yWindowOk",
          "from_s": 0,
          "to_s": 21600,
          "equals": false
        }
      ]
    },
    {
      "name": "equipment_stops_one_tick_after_the_window_tick",
      "description": "Window delay edge from above: the same run held 300 s longer sets yWindowOk for exactly one tick at 7200 s. yFault stays false because persist still owed 900 s when the gate dropped, which also pins that a true yFault implies a true yWindowOk and never the reverse.",
      "inputs": {
        "sensor_value": 14.0,
        "equip_active": [
          {
            "t": 0,
            "value": true
          },
          {
            "t": 7500,
            "value": false
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 21600,
          "equals": false
        },
        {
          "output": "yWindowOk",
          "from_s": 0,
          "to_s": 6900,
          "equals": false
        },
        {
          "output": "yWindowOk",
          "from_s": 7200,
          "to_s": 7200,
          "equals": true
        },
        {
          "output": "yWindowOk",
          "from_s": 7800,
          "to_s": 21600,
          "equals": false
        }
      ]
    }
  ]
}

SYS-0010 — Sensor spike / rate-of-change violation

Statusverified — engine e2ff2f8, cxf:fnv1a128:188c9fa8f8976ddf90245592e44abb9c, 2026-08-17
Severity3
Methodrule
Phase3
CategoryCOMFORT_ENERGY
ConfidenceMEDIUM
EstimationQUALITATIVE_ONLY
G36
ClustersCLU-09
Suppresses
Suppressed by
RelatedSYS-0009, SYS-0005, AHU-0028, RTU-0003
Playbookssensor-drift
SourceLibrary-authored per the accepted internal sensor-health design note (local-only, not distributed; §2 stance, §4.1-4.2 block choice, §4.4 vector strategy); Yang, Cho, Tae, Zaheeruddin 2008, Energy Conversion and Management 49(8) 2291-2306, doi:10.1016/j.enconman.2008.01.029 — sequential rule-based temperature sensor fault detection in AHUs; Liao, Cai, Cheng, Dubey, Rajesh 2021, Sensors 21(13) 4358, doi:10.3390/s21134358 — a cheap deterministic sensor-rule layer under a heavier diagnostic layer; Dey & Dong 2016, Energy and Buildings 130 177-187, doi:10.1016/j.enbuild.2016.08.017 — the sensor-bias ambiguity this family removes before the equipment rules are read; Sibling precedent: AHU-0028 and RTU-0003 (physical-plausibility rules already shipping under the same stance), AHU-0023 (discrete baseline plus Subtract/Abs), VAV-0001 (per-binding placeholder parameter); Engine pin e2ff2f8: crates/oce-blocks/src/discrete.rs (UnitDelay warmup and sample grid), logical_timing.rs (TrueDelay init branch)
Operating statesall — a step larger than the measured process can produce is implausible whether the equipment is running, idling, or off, and the rule takes no run-state conjunct (see Deviations)

Preconditions (host-enforced): Delivery quality is resolved host-side before this rule runs, and this rule is unusually exposed to it: a value re-served from cache after a comms outage, a gap the host interpolated badly, and a poll interval that slipped all present as a sample-to-sample step that no graph reading one point can tell from a failing transmitter. The reference’s ch.4 gap handling runs first and this rule sits on top of it — it is right about the number it was given and says nothing about how the number arrived. sample_period MUST equal the host’s tick interval: the rule is written for, pinned at, and only means what it looks like it means at one sample of lookback (see Deviations). history_warmup MUST be set to exactly twice sample_period and re-set with it; it is not an independent tunable. max_step_per_sample is a per-binding number in the bound point’s units, and the shipped default is a worked example for sensor_value := sat at a 60 s tick, not a site value — deployed unretuned on a pressure, flow, humidity or CO2 point it is comparing against an arbitrary number. Read yHistoryOk before yFault: while it is false the delay line still holds its y_start seed, and the verdict is NO_EVAL rather than a healthy sensor.

Points: sensor_value

Outputs:

  • yFault — True on each tick where the reading moved further from the previous sample than max_step_per_sample, once the startup inhibit has expired. Momentary by construction — a single-sample outlier asserts for two ticks, a level shift for one — and the host owns any dwell it wants on top
  • yHistoryOk — Evaluability signal — false for the first history_warmup after load, the worst-case window in which Discrete.UnitDelay is still emitting its y_start seed and the computed step is a fabrication. False means NO_EVAL and the host must ignore yFault

Parameters:

NameDefaultUnitCXF pathDescription
max_step_per_sample10.0varies — the bound point’s unitstooFast.tLargest change the measured process can physically produce between two samples one sample_period apart. PER-BINDING SITE CONFIGURATION in the units of whatever sensor_value is bound to: the shipped 10.0 is the worked example for sensor_value := sat at a 60 s tick (argued in Detection Logic) and carries no authority on a Pa, L/s, %RH or ppm point. Re-derive it, never scale it, when sample_period changes.
sample_period60.0sprev.samplePeriodThe rule’s lookback — one sample of Discrete.UnitDelay history. MUST equal the host’s tick interval; at any other ratio the delayed sample is between one and two periods old and the threshold stops meaning a sample-to-sample step (see Deviations).
history_warmup120.0shistoryOk.delayTimeWidth of the startup inhibit that masks the UnitDelay y_start seed, and the window in which yHistoryOk is false. MUST be set to exactly 2 × sample_period and re-set whenever sample_period changes: a rule loaded between sample instants keeps emitting the seed until its second instant, so the seed can survive two full periods. Shorter lets a fabricated spike out; longer buys nothing.

Description

Every measured process in a building has mass behind it. Air is dragged past a probe that takes half a minute to come to temperature, water carries the thermal inertia of the pipe, a zone’s CO₂ concentration is the integral of people breathing into a volume. Each puts a ceiling on how far a reading can move between two samples, and that ceiling belongs to the physics rather than to the equipment: it does not change when the fan stops. A reading that clears it did not come from the process — it came from a failing element, an intermittent termination, a transmitter that changed range, or a point now serving a different number than it used to. It is the only rule in the family whose fault is momentary: flatline accuses a sensor of standing still for hours and pair bias accuses two of disagreeing for a shift, while a spike happens on one sample and is gone. That fact drives every timing decision below, and it is why this is the one card in the library with no persistence timer on its fault path. The stance is AHU-0028’s and RTU-0003’s: delivery quality stays host-owned, physical plausibility is a fault of a piece of equipment, because a sensor is equipment.

The adjudicates contract. While yFault is true, sensor_value is invalid: the host must return NO_EVAL for every rule on this equipment instance that consumes it, deriving that set from the other cards’ own points lists so it stays complete as rules are added. The verdict is invalid_while_active rather than ambiguous because attribution is not in doubt — one point went somewhere it could not have gone. Read yHistoryOk first: while it is false the verdict is NO_EVAL, never a healthy sensor.

Detection Logic

step        = |sensor_value − UnitDelay(sensor_value, sample_period)|
yHistoryOk  = true from history_warmup after load    (false ⇒ host reports NO_EVAL)
yFault      = step > max_step_per_sample  AND  yHistoryOk

Block graph (rule.cxf.jsonld):

SYS-0010 block graph

Seven blocks in two strands. The upper strand is the measurement, ending in a strict Reals.GreaterThreshold: a step landing exactly on max_step_per_sample reads clear. The lower strand is a startup inhibit — a constant true through a TrueDelay with delayOnInit = true, false for the first history_warmup after load and true forever after — which is also the yHistoryOk output.

The delay line’s warmup is a fabricated spike, masked deliberately. Discrete.UnitDelay seeds its state from y_start and emits it until the second sample instant, so at the CDL default of 0.0 the first computed step on a 22 °C duct is |22 − 0| = 22 — more than twice the bound, on every load and every restart. The gate is two sample periods wide because that is the longest the seed can survive; it costs one real sample of blindness when a load happens to be grid-aligned.

yFault is one or two ticks wide by design. A single-sample outlier violates the bound twice, going out and coming back; a level shift that stays — a units change, a rescaled transmitter — violates it exactly once, because the next sample is compared against the shifted value. There is no latch and no accumulated timer: each event is judged on its own tick, and any dwell is the host’s to add.

The worked example: sensor_value := sat at a 60 s tick, which is the shipped max_step_per_sample = 10.0 and no other binding. Coil-leaving air can fall 8-10 K within a minute of a large stage cutting in, and a duct probe in a 2.5 m/s stream filters that with a 30-60 s time constant, so one 60 s sample carries 60-85% of a true step: the largest genuine one-minute excursion reaching the reading is of order 6-8 K. Ten sits above every transient the plant can produce and below the 15 K per minute that is not a temperature at all. Nothing about that argument survives a change of binding or of sample_period — a 300 s sample admits the entire stage transient — so re-derive the bound from the physics rather than multiplying this one.

Possible Diagnoses

Library-authored; nothing published fixes a cause list for a step bound.

  1. A failing sensing element — an intermittent thermistor or RTD, a cracked lead, a probe that opens when the duct vibrates. Isolated outliers between long stretches of good data, which is the two-tick case
  2. Loose, corroded or wet terminations, or a signal pair sharing conduit with a switching inductive load — the spikes correlate with that equipment’s cycles rather than with the sensed process
  3. A transmitter or A/D channel that changed range or scaling — a 4-20 mA transmitter re-ranged during service, a jumper moved, an input reconfigured from 10 k to 1 k. A level shift, one tick wide
  4. A units change nobody announced — a point re-served in °F on a °C-declared binding. Also one tick wide, and this is the only rule in the library that notices
  5. A point rebound to a different physical sensor by a controller download or a BAS integration edit — the reading is honest and is no longer the sensor the rest of the rules think they are reading
  6. A host-side delivery artifact in the costume of a sensor fault — a value re-served from cache when comms came back, a bad interpolation, a slipped poll. Not a sensor fault at all
  7. A correct reading and a wrong threshold — diagnose this first if the alarms cluster on stage changes and mode transitions

Energy Impact

COMFORT_ENERGY, MEDIUM confidence, QUALITATIVE_ONLY. There is no direct waste term: a spiking sensor burns nothing, and its cost is entirely in what reads it — a controller that takes one 40 K outlier as real and slams a valve, an economizer that changes mode on a phantom reading, the diagnostic rules that then miss a real fault or invent one. PNNL EEM-01 (sensor recalibration) puts the recoverable range at 0-5% of site energy across an entire sensor population at roughly 15% prevalence, which is the honest ceiling for the whole family. Confidence is MEDIUM rather than LOW, a deliberate departure from AHU-0028 and RTU-0003: those compare several sensors and cannot say which is wrong, while this one reads a single point and its finding is unambiguous by construction. It is not HIGH because the threshold ships as a placeholder and because the host’s delivery layer can manufacture the identical step (diagnosis 6).

Emissions Impact

QUALITATIVE_EMISSIONS, MEDIUM confidence; no direct emissions, because a reading neither burns fuel nor draws power. Scope is 1|2 for the same reason AHU-0028 records it that way: it depends on which subsystem the bad number distorts. A spiking supply-air temperature that provokes preheat lands in Scope 1; one that provokes mechanical cooling or drops an economizer out of service lands in Scope 2, and the same sensor can do both in different seasons. Avoided-emissions basis: N/A.

Deviations

  • No persistence timer on the fault path, and that decision is the card. Every other rule document here drives yFault from a TrueDelay, because they detect conditions that persist. A spike does not, and the timer would delete findings rather than merely be redundant: at the pin TrueDelay emits false on a rising edge and true on the next tick the input still holds, so any positive delayTime up to one tick means “two consecutive violating samples” — keeping the single-sample outlier and silently deleting the entire level-shift class (units change, re-ranged transmitter, rebound point), which between them is the most common thing this rule finds. Zero is not available either: positive_duration clamps at 0 and the init branch honours delayOnInit only when delayTime > 0, so a zero-delay timer would pass the warmup artifact straight through. The timer therefore moves to the startup inhibit. What the library gives up is debounce, and that is host policy.
  • The warmup gate is two sample periods wide because the seed can survive two. Discrete.UnitDelay promotes staged→held only at sample instants and its grid is anchored to absolute model time, so a load landing between instants stages nothing, the next instant promotes seed→seed, and the first real sample does not appear until the instant after that. A one-period gate is correct only for grid-aligned loads and lets a fabricated 22 K step through on every other one — a fault the rule invents on a restart nobody chose the timing of. The cost when the load is aligned is one real sample of blindness.
  • y_start is left at the CDL default of 0.0. Seeding it to a plausible mid-scale reading would also hide the artifact, but it buries a block-level fact instead of documenting it and turns a graph constant into a per-point site value that buys no diagnostic power.
  • sample_period and history_warmup are two card parameters carrying one decision, and the linter cannot enforce the ratio. The multi-path cxf: [...] form AHU-0023 uses is unavailable, because the two block parameters take different values (samplePeriod and 2 × samplePeriod) and that form sets every listed path to the same number. So the invariant history_warmup = 2 × sample_period is stated in both descriptions, in preconditions, and here: a host that retunes the sample period and forgets the gate gets a rule that fabricates a spike on some fraction of its restarts. Same class of coupled-parameter hazard as HP-0001’s slope and intercept.
  • sample_period must equal the host’s tick interval. UnitDelay’s grid is anchored to absolute model time and it holds between instants, so the emitted value is between one and two periods old — the lookback age runs over [P, 2P). When P equals the tick that collapses to exactly one tick and the rule means what the equation looks like; when it does not, the rule means “an excursion exceeding the bound somewhere within one to two sample periods”, a defensible test but a different one, with a per-window magnitude for a threshold and a warmup gate 2P/tick ticks wide. The arithmetic is read from the engine at the pin and deliberately not vector-pinned, since pinning it would require shipping the configuration this card tells hosts not to use.
  • Reals.Derivative is not used, and could not be. It is the block the equation suggests and it is wrong on the engine’s own arithmetic: its k and T are RealInput connectors rather than parameters, its filter discretizes to k·s·(1 + dt/T) for a ramp of slope s (twice the actual slope at a 300 s tick with T = 300 s; holding the error under 5% needs T ≥ 20·dt, a hundred minutes of lag in a spike detector), and its step response ignores dt, so the same 5 K jump reads identically whether it took 60 s or 600 s.
  • Strict >. CDL Reals has no GreaterEqual, so a step of exactly max_step_per_sample reads clear, which errs toward silence. Sites whose BAS quantises the bound point coarsely will land on the boundary often enough to notice, and should set the threshold between two quantisation levels.
  • Reals.Abs discards the sign and the card does not try to recover it. A signal-conditioning failure that rails low and one that rails high are the same finding here; splitting them would double the graph to distinguish two cases that lead to the same work order.
  • No equip_active conjunct, which is where this rule parts company with SYS-0009. Flatline needs the gate, because a still reading on idle equipment is the correct answer. A spike needs none for the mirror reason: the ceiling is set by the mass of the process, not by whether anything drives it, and an idle system moves slower, so the running case is the permissive one. A gate would also cost a second binding obligation on a family whose binding cost is already the main argument against it.
  • max_step_per_sample ships as a per-binding placeholder, the VAV-0001 arrangement in a stronger form: here even the dimension is unknown until binding. The number is defended above for one binding and no other. Set it 3× too high and the rule never fires; 3× too low and it alarms on every stage change the plant makes. points/sys.points.json states this in sensor_value’s own notes.
  • The rule cannot see drift, ramps, or anything that stays under the bound. Five consecutive 5 K steps carry a reading from 22 to 47 °C — physically absurd in a duct, and silent, because each individual step is legal. A step bound sees steps. Slow monotone drift is structurally invisible to this rule and to SYS-0009 alike, which is the argument for SYS-0005 being a third shape rather than a variation on these two.
  • adjudicates cards must not be suppressible, so suppressed_by is empty and must stay empty: if an equipment fault could silence the sensor rule that invalidates it, the suppression graph has a cycle with a wrong answer at both ends. suppresses is empty for a different reason — the fan-out of an adjudicated point is derived by the host from other cards’ points lists, not hand-written here, which is the entire argument for keying the field to the point. AHU-0028’s fourteen-entry suppresses list is the counter-example: correct today, silently incomplete the first time someone authors an AHU rule that reads mat.
  • Severity 3 and COMFORT_ENERGY follow AHU-0028 and RTU-0003 rather than the fan-out argument. Severity 3 is faults/sys/README.md’s index value and is not this card’s to change. PROTECTIVE is declined on evidence: in this library it means avoided physical damage (PMP-0001’s dry-running seal, RTU-0001’s short-cycled compressor), and stretching it to cover avoided false alarms would make one category mean two unrelated things. Note SYS-0009 takes the opposite call, so the family is split and reconciling it is library-wide.
  • clusters: [], and CLU-09 is the open question. clusters/clusters.json gives CLU-09 the trigger AHU-0028 and lists SYS-0005 and SYS-0006 as members; with the FC-100 family landing, the trigger is arguably one of the sys rules and 062 becomes a member. That is a single-writer file, so this card declares nothing and this bullet is the flag.
  • sensor-drift is the right playbook and does not yet name this rule; adding SYS-0009 and SYS-0010 to its Applies-To row is the playbook owner’s edit. Step 1’s portable-reference comparison and step 3’s recalibrate-or-replace both transfer. Step 2’s BAS offset does not: an offset corrects a bias and does nothing for a sensor that jumps.
  • Role-point binding breaks the library’s canonical-name convention, on purpose. The host’s instance configuration records which real point sensor_value is, and that same record resolves adjudicates.points, so the binding is a required artifact of the design rather than an extra one. SCHEMA.md’s points contract carries the exception; the alternative — thirty copies of this graph, one per point-and-family pair — is thirty chances to drift apart with no cross-card diff check to catch it.
  • Operating states and preconditions are declared in frontmatter for host enforcement rather than encoded in the block graph, per the library’s design stance.

Notes

Read yHistoryOk first. It is false for two sample periods after the rule loads, and in that window a false yFault is silence rather than a clean bill of health. On a host that reloads rules on every configuration change that window recurs.

When this rule fires, look at the trend before dispatching anyone, because the shape of the event names the cause faster than any test does. Isolated single samples between long stretches of clean data are an intermittent connection or a failing element — chase terminations, and note whether the spikes line up with a compressor or a lighting contactor rather than with the sensed process. A single step that never comes back is usually not a sensor failure at all: it is a re-ranged transmitter, a units change, or a point rebound during a controller download, and the fix is a configuration edit. Check what changed in the BAS that day before touching the sensor.

Then check the tick. Alarms that cluster on stage changes and mode transitions rather than falling at random usually mean max_step_per_sample was inherited from another binding or survived a change to sample_period untouched. The threshold is a physical claim about one sensor measuring one process at one sample interval, and it does not travel — not between points, not between equipment types, not between tick rates.

Know what this rule cannot do. It sees steps, so it is blind to the drift most sensor work is actually about: a transmitter losing a degree a month never violates a step bound. SYS-0009 covers the opposite extreme, a reading that stopped moving at all. The middle needs a second sensor to compare against, which is SYS-0005’s job and the reason this family has three shapes.

Test Vectors

12 scenarios, clock step 60 s over 1800 s.

ScenarioDescription
steady_reading_and_the_unit_delay_warmup_artifactA still 22 degC reading, and the block-level artifact this rule is built around. UnitDelay seeds both state words from y_start (0.0, the CDL default), so at t = 0 the graph computes
identical_step_after_the_warmup_gate_opensThe pair for the scenario above: the same 22 degC step arrives at t = 120, the tick history_warmup expires, where the delay line is guaranteed to hold a real sample. Identical arithmetic, opposite verdict - which is what proves the gate masks a fabrication rather than swallowing findings. The alarm is exactly one tick wide because the reading stays at its new value.
genuine_step_inside_the_warmup_gate_is_not_reportedThe price of the gate, pinned rather than hidden: these vectors start on a sample boundary, so the delay line already holds a real sample at t = 60 and this 22 degC step is genuine - and it is suppressed anyway, because history_warmup covers the worst case (a rule loaded between sample instants, where the seed survives two full periods) rather than the best. One sample of blindness per load, in exchange for never fabricating one.
single_sample_outlier_reported_on_both_edgesThe classic spike: one bad sample of 60 degC at t = 600 in an otherwise still 22 degC signal. It violates the bound twice - on the way out and on the way back - so the alarm is two ticks wide. A rule with a persistence timer on the fault path would read the first edge as noise and never assert.
units_change_is_one_tick_wideThe level-shift archetype, and the reason this rule carries no persistence timer: a 12.8 degC supply reading is re-served as the 55 that was its degF value, and stays there. The bound is violated on exactly ONE tick, because the sample after the shift is compared against the shifted value. Any TrueDelay on the fault path erases this whole class of fault - units changes, rescaled transmitters, points rebound by a controller download.
step_exactly_on_the_bound_reads_clearThreshold edge, on the line: 22.0 -> 32.0 degC is a step of exactly max_step_per_sample. Reals.GreaterThreshold is strict, so a step landing on the bound is not above it and nothing is reported.
step_just_under_the_bound_reads_clearThreshold edge from below: 22.0 -> 31.9 degC, a tenth of a kelvin short of the bound, still clear.
step_just_over_the_bound_is_reportedThreshold edge from above: 22.0 -> 32.1 degC asserts for the one tick that carries the step. All three sides of the bound are pinned so the strictness cannot change silently.
downward_step_is_reported_the_same_as_an_upward_oneReals.Abs throws the sign away: 22.0 -> 4.0 degC is an 18 K collapse, reported exactly as an 18 K jump would be. A sensor whose signal conditioning fails low reads as clearly as one that rails high, and the output cannot say which happened.
ramp_under_the_bound_every_tick_is_invisibleThe blind spot, pinned rather than left for a site to discover: five consecutive 5 K steps carry the reading from 22 to 47 degC in five minutes - two and a half times the bound in total, and physically impossible in a duct - and every individual step is under max_step_per_sample, so the rule is silent. A step bound sees steps. Drift and ramps belong to SYS-0005.
two_outliers_report_independentlyRecovery: the rule holds no latch and accumulates no timer, so a second outlier fifteen minutes after the first is reported on its own merits and the quiet stretch between them reads clean. The host owns any dwell it wants on top - the graph reports the ticks the physics was violated and nothing else.
small_magnitude_binding_has_no_warmup_artifact_to_maskWhether the y_start seed fabricates a spike at all depends on the bound point’s magnitude: a signal that lives near zero - a differential pressure at rest, a valve position, a flow at no load - starts within the bound and would never have alarmed on tick one. The gate does not care, and that is the point: masking is unconditional, so the card does not have to reason about every binding’s zero.
vectors.json
{
  "schema": "cxf-library/vectors/v1",
  "clock": {
    "step_s": 60,
    "horizon_s": 1800
  },
  "scenarios": [
    {
      "name": "steady_reading_and_the_unit_delay_warmup_artifact",
      "description": "A still 22 degC reading, and the block-level artifact this rule is built around. UnitDelay seeds both state words from y_start (0.0, the CDL default), so at t = 0 the graph computes |22 - 0| = 22 degC - more than twice max_step_per_sample, a fabricated spike on every load. yHistoryOk is false across the whole seed window and yFault never asserts.",
      "inputs": {
        "sensor_value": 22.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yHistoryOk",
          "from_s": 0,
          "to_s": 60,
          "equals": false
        },
        {
          "output": "yHistoryOk",
          "from_s": 120,
          "to_s": 1800,
          "equals": true
        }
      ]
    },
    {
      "name": "identical_step_after_the_warmup_gate_opens",
      "description": "The pair for the scenario above: the same 22 degC step arrives at t = 120, the tick history_warmup expires, where the delay line is guaranteed to hold a real sample. Identical arithmetic, opposite verdict - which is what proves the gate masks a fabrication rather than swallowing findings. The alarm is exactly one tick wide because the reading stays at its new value.",
      "inputs": {
        "sensor_value": [
          {
            "t": 0,
            "value": 22.0
          },
          {
            "t": 120,
            "value": 44.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 60,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 120,
          "to_s": 120,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 180,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yHistoryOk",
          "from_s": 0,
          "to_s": 60,
          "equals": false
        },
        {
          "output": "yHistoryOk",
          "from_s": 120,
          "to_s": 1800,
          "equals": true
        }
      ]
    },
    {
      "name": "genuine_step_inside_the_warmup_gate_is_not_reported",
      "description": "The price of the gate, pinned rather than hidden: these vectors start on a sample boundary, so the delay line already holds a real sample at t = 60 and this 22 degC step is genuine - and it is suppressed anyway, because history_warmup covers the worst case (a rule loaded between sample instants, where the seed survives two full periods) rather than the best. One sample of blindness per load, in exchange for never fabricating one.",
      "inputs": {
        "sensor_value": [
          {
            "t": 0,
            "value": 22.0
          },
          {
            "t": 60,
            "value": 44.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yHistoryOk",
          "from_s": 0,
          "to_s": 60,
          "equals": false
        },
        {
          "output": "yHistoryOk",
          "from_s": 120,
          "to_s": 1800,
          "equals": true
        }
      ]
    },
    {
      "name": "single_sample_outlier_reported_on_both_edges",
      "description": "The classic spike: one bad sample of 60 degC at t = 600 in an otherwise still 22 degC signal. It violates the bound twice - on the way out and on the way back - so the alarm is two ticks wide. A rule with a persistence timer on the fault path would read the first edge as noise and never assert.",
      "inputs": {
        "sensor_value": [
          {
            "t": 0,
            "value": 22.0
          },
          {
            "t": 600,
            "value": 60.0
          },
          {
            "t": 660,
            "value": 22.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 540,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 600,
          "to_s": 660,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 720,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yHistoryOk",
          "from_s": 120,
          "to_s": 1800,
          "equals": true
        }
      ]
    },
    {
      "name": "units_change_is_one_tick_wide",
      "description": "The level-shift archetype, and the reason this rule carries no persistence timer: a 12.8 degC supply reading is re-served as the 55 that was its degF value, and stays there. The bound is violated on exactly ONE tick, because the sample after the shift is compared against the shifted value. Any TrueDelay on the fault path erases this whole class of fault - units changes, rescaled transmitters, points rebound by a controller download.",
      "inputs": {
        "sensor_value": [
          {
            "t": 0,
            "value": 12.8
          },
          {
            "t": 600,
            "value": 55.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 540,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 600,
          "to_s": 600,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 660,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yHistoryOk",
          "from_s": 0,
          "to_s": 60,
          "equals": false
        },
        {
          "output": "yHistoryOk",
          "from_s": 120,
          "to_s": 1800,
          "equals": true
        }
      ]
    },
    {
      "name": "step_exactly_on_the_bound_reads_clear",
      "description": "Threshold edge, on the line: 22.0 -> 32.0 degC is a step of exactly max_step_per_sample. Reals.GreaterThreshold is strict, so a step landing on the bound is not above it and nothing is reported.",
      "inputs": {
        "sensor_value": [
          {
            "t": 0,
            "value": 22.0
          },
          {
            "t": 600,
            "value": 32.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yHistoryOk",
          "from_s": 120,
          "to_s": 1800,
          "equals": true
        }
      ]
    },
    {
      "name": "step_just_under_the_bound_reads_clear",
      "description": "Threshold edge from below: 22.0 -> 31.9 degC, a tenth of a kelvin short of the bound, still clear.",
      "inputs": {
        "sensor_value": [
          {
            "t": 0,
            "value": 22.0
          },
          {
            "t": 600,
            "value": 31.9
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yHistoryOk",
          "from_s": 120,
          "to_s": 1800,
          "equals": true
        }
      ]
    },
    {
      "name": "step_just_over_the_bound_is_reported",
      "description": "Threshold edge from above: 22.0 -> 32.1 degC asserts for the one tick that carries the step. All three sides of the bound are pinned so the strictness cannot change silently.",
      "inputs": {
        "sensor_value": [
          {
            "t": 0,
            "value": 22.0
          },
          {
            "t": 600,
            "value": 32.1
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 540,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 600,
          "to_s": 600,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 660,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yHistoryOk",
          "from_s": 120,
          "to_s": 1800,
          "equals": true
        }
      ]
    },
    {
      "name": "downward_step_is_reported_the_same_as_an_upward_one",
      "description": "Reals.Abs throws the sign away: 22.0 -> 4.0 degC is an 18 K collapse, reported exactly as an 18 K jump would be. A sensor whose signal conditioning fails low reads as clearly as one that rails high, and the output cannot say which happened.",
      "inputs": {
        "sensor_value": [
          {
            "t": 0,
            "value": 22.0
          },
          {
            "t": 600,
            "value": 4.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 540,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 600,
          "to_s": 600,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 660,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yHistoryOk",
          "from_s": 120,
          "to_s": 1800,
          "equals": true
        }
      ]
    },
    {
      "name": "ramp_under_the_bound_every_tick_is_invisible",
      "description": "The blind spot, pinned rather than left for a site to discover: five consecutive 5 K steps carry the reading from 22 to 47 degC in five minutes - two and a half times the bound in total, and physically impossible in a duct - and every individual step is under max_step_per_sample, so the rule is silent. A step bound sees steps. Drift and ramps belong to SYS-0005.",
      "inputs": {
        "sensor_value": [
          {
            "t": 0,
            "value": 22.0
          },
          {
            "t": 600,
            "value": 27.0
          },
          {
            "t": 660,
            "value": 32.0
          },
          {
            "t": 720,
            "value": 37.0
          },
          {
            "t": 780,
            "value": 42.0
          },
          {
            "t": 840,
            "value": 47.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yHistoryOk",
          "from_s": 120,
          "to_s": 1800,
          "equals": true
        }
      ]
    },
    {
      "name": "two_outliers_report_independently",
      "description": "Recovery: the rule holds no latch and accumulates no timer, so a second outlier fifteen minutes after the first is reported on its own merits and the quiet stretch between them reads clean. The host owns any dwell it wants on top - the graph reports the ticks the physics was violated and nothing else.",
      "inputs": {
        "sensor_value": [
          {
            "t": 0,
            "value": 22.0
          },
          {
            "t": 300,
            "value": 60.0
          },
          {
            "t": 360,
            "value": 22.0
          },
          {
            "t": 1200,
            "value": 60.0
          },
          {
            "t": 1260,
            "value": 22.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 240,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 300,
          "to_s": 360,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 420,
          "to_s": 1140,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 1200,
          "to_s": 1260,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 1320,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yHistoryOk",
          "from_s": 120,
          "to_s": 1800,
          "equals": true
        }
      ]
    },
    {
      "name": "small_magnitude_binding_has_no_warmup_artifact_to_mask",
      "description": "Whether the y_start seed fabricates a spike at all depends on the bound point's magnitude: a signal that lives near zero - a differential pressure at rest, a valve position, a flow at no load - starts within the bound and would never have alarmed on tick one. The gate does not care, and that is the point: masking is unconditional, so the card does not have to reason about every binding's zero.",
      "inputs": {
        "sensor_value": 5.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yHistoryOk",
          "from_s": 0,
          "to_s": 60,
          "equals": false
        },
        {
          "output": "yHistoryOk",
          "from_s": 120,
          "to_s": 1800,
          "equals": true
        }
      ]
    }
  ]
}

TOWER Fault Rules

Cooling tower rules (TOWER-*) are fully library-authored: no HVAC FDD Reference chapter covers towers. BEE, DOE/PNNL, NREL, ASHRAE, SPX, and EVAPCO sources corroborate the mechanisms and safety constraints, but they do not publish portable executable limits for approach/range degradation, loaded-fan overcooling, proof timing, or basin thermal response. Each card therefore labels adopted values and site/OEM adoption blockers explicitly.

The first batch’s quantitative grounding remains the committed 4-climate simulation study (tools/simharness README, “Tower groundwork”): healthy approach spans 1.6–13.3 °C un-gated purely on VFD fan modulation, which is why TOWER-0001 is fan-at-capacity gated, while range holds a stable healthy band. TOWER-0003’s 4–5 starts/hour mechanism is literature-backed. TOWER-0004..0006 add per-fan proof, loaded-fan overcooling, and an explicitly site/OEM-governed wet-basin freeze watchdog.

Point dictionary: points/tower.points.json — note the loop-side semantics (tower-leaving = cold = entering condenser water), the oa_wetbulb host psychrometric obligation, and provisional 223 topology for basin points.

Index

IDNameSevMethodStatus
TOWER-0001Tower approach high at fan capacity3ruleverified
TOWER-0002Tower range collapse3ruleverified
TOWER-0003Tower fan short-cycling3ruleverified
TOWER-0004Tower fan proof-of-operation failure2ruleverified
TOWER-0005Condenser water overcooling with fan energy3ruleverified
TOWER-0006Cooling-tower basin freeze-protection failure2ruleverified

Relationships

  • CLU-10 (Condenser-Side Degradation): TOWER-0001 is the trigger, TOWER-0002 and CHW-0005 (chiller condenser approach) the members; playbooks/cooling-tower-performance.md is the family playbook. CHW-0005 is the tube-side vs fill-side discriminator: tower approach normal + condenser approach high → clean the tubes, not the fill.
  • TOWER-0003 stays outside CLU-10 — a drive/control fault, not the degradation syndrome; it shares the playbook’s control-side steps.
  • TOWER-0004 and TOWER-0005 stay outside CLU-10. Proof disagreement and overcooling control waste do not share the cluster’s degradation repair contract. TOWER-0004 is direction-sensitive proof context for TOWER-0005; no static whole-rule suppression is safe.
  • TOWER-0006 stays independent and synthetic-only. It applies only to a wet, filled basin with monitored heater/equivalent protection and configured site/OEM limits. A basin heater is not protection for external piping.
  • The pending primary sources when they are acquired: a CTI/ASHRAE tower chapter for approach/range fault bands, ASHRAE RP-1043 for the chiller condenser-approach threshold.

TOWER-0001 — Tower approach high at fan capacity

Statusverified — engine e2ff2f8, cxf:fnv1a128:31a206719e4e4a6f656dca426369d9d2, 2026-08-18
Severity3
Methodrule
Phase2
CategoryEFFICIENCY_LOSS
ConfidenceLOW
EstimationPROXY_ESTIMATION
G36
ClustersCLU-10
Suppresses
Suppressed by
RelatedTOWER-0002, TOWER-0003, TOWER-0004, TOWER-0005, CHW-0005
Playbookscooling-tower-performance
SourceLibrary-authored: no reference chapter covers cooling towers — the TOWER family opens with this card (see faults/tower/README.md); cxf-library simulation study — tools/simharness/README.md, ‘Tower groundwork (condenser-loop stats)’: 4-climate healthy-operation approach envelope; the fan-at-capacity gate is that study’s design result and the band’s only quantitative grounding; BEE Best Practice Manual: HVAC Chillers (2006) §2.8 — approach and range defined and ranked, one worked design point (26 °C wet-bulb, 3 °C approach); design guidance, no fault band (paraphrased); DOE FEMP/PNNL, O&M Best Practices Guide Release 3.0 §9.5 and PNNL-13890 §7.5 — causes of poor tower performance (scale, clogged nozzles, poor airflow); fouling’s symptom named as a larger water-to-refrigerant temperature difference, with no magnitude attached (paraphrased); Sibling precedent: HP-0001 (commissioning-placeholder parameters, y…Ok evaluability output), HW-0004 (library-extension framing)
Operating statesCooling season, tower rejecting heat: condenser water pumps running and the fan cell(s) serving the sensed water commanded on. The at-capacity half of that state is in-rule (tower_fan_speed); the tower-on half is host-enforced, and a tower that has just started needs its loop to turn over before approach means anything.

Preconditions (host-enforced): tower_leaving_temp must be the COLD water leaving the tower basin, the water that goes on to enter the chiller condenser — which is why the dictionary grounds it as brick:Entering_Condenser_Water_Temperature_Sensor. Bind the warm return instead (tower_entering_temp, or the chw dictionary’s cwst, the same physical stream) and approach reads several degrees high forever: a permanently faulted tower that looks like a working rule. oa_wetbulb is the weak input. Almost no site has a wet-bulb sensor, so it is a host psychrometric derivation from dry-bulb, humidity and station pressure — a multivariable calculation, least accurate near saturation, which is exactly when a tower matters most. Its error passes into approach one-for-one against a 5 K band, so the host must document the routine and its accuracy, and must not resample stale humidity into a live derivation. tower_fan_speed must be a real variable-speed feedback for the cell(s) whose water is being measured: a single- or two-speed fan reads 100% whenever it runs, the gate then admits every part-load hour, and the rule degenerates into the un-gated form the simulation study rejects. On a multi-cell tower sensed only at the common headers, all cells must be staged together and the speed bound must be the least-loaded cell’s — one cell off or ramping makes the header approach a mixture that belongs to no tower. approach_high_band must be set from this tower’s commissioned design approach before any verdict means anything; the shipped 5.0 K is a placeholder (see Deviations). Finally, check what the fans are chasing: a condenser-water setpoint below wet-bulb plus design approach pins the fans at capacity against a target no tower can reach, and this rule fires correctly on a tower that is not degraded. Evaluability of the wet-bulb input is signalled in-rule by yWetBulbOk; when it is false the verdict is NO_EVAL, not a healthy tower.

Points: tower_leaving_temp, oa_wetbulb, tower_fan_speed

Outputs:

  • yFault — True while the tower’s approach has stayed above approach_high_band with the fan above fan_capacity_threshold, continuously for at least alarm_delay
  • yWetBulbOk — Evaluability signal — true while the computed approach is positive, the thermodynamic floor evaporative cooling cannot cross. False means the wet-bulb derivation (or a water temperature sensor) is wrong and the verdict is NO_EVAL; the host must ignore yFault

Parameters:

NameDefaultUnitCXF pathDescription
approach_high_band5.0°CappHigh.tApproach above which a tower at capacity is judged degraded. PER-TOWER SITE CONFIGURATION AND A COMMISSIONING PLACEHOLDER — no literature fault band exists (see Deviations). Retune to roughly 2 × this tower’s commissioned design approach, measured at full fan with the design flow; the shipped 5.0 K is 2 × the 2.5 K class of design approach a modern tower is selected for, and sits above the ~2.3 K healthy p95 the simulation study observed at design-like loaded conditions.
fan_capacity_threshold95.0%fanMax.tFan speed above which the tower is treated as out of capacity, so approach becomes diagnostic. 95% leaves a little headroom for a drive that never quite commands 100%; lower it toward 90% for a drive that tops out short, and never set it low enough to admit ordinary part-load operation — that is the whole design of the rule.
alarm_delay1800.0spersist.delayTimeContinuous high approach at capacity required before the alarm asserts (30 min). Shorter than the library’s usual hour because the fan gate already excludes transients and the at-capacity window itself may only last an hour or two on a shoulder-season peak.

Description

A cooling tower pushes its leaving water down toward the ambient wet-bulb, and how close it gets — the approach — measures whether the fill, the water distribution and the airflow still do what they were bought to do. Scale, clogged spray nozzles, blocked air inlets and recirculated discharge air all read the same way: more air needed than before for the same water. A variable-speed fan hides every bit of that. At part load the drive backs off and approach rides wherever the control loop is satisfied, so a large approach usually means a lightly loaded tower rather than a dirty one. This rule asks the question only when the fan has nothing left to give: approach above the band while the fan is at capacity is the tower failing to deliver, and the chiller pays for it.

Detection Logic

approach = tower_leaving_temp − oa_wetbulb

yWetBulbOk = approach > 0                            (false ⇒ host reports NO_EVAL)
yFault     = approach > approach_high_band
             AND tower_fan_speed > fan_capacity_threshold,
             sustained continuously for alarm_delay

Block graph (rule.cxf.jsonld):

TOWER-0001 block graph

The fan conjunct is not a data-quality gate but the rule’s premise: the study behind this card measured healthy approach spanning 1.6–13.3 K across four climates on nothing but fan modulation (tools/simharness README, “Tower groundwork”). An ungated threshold inside that span alarms on healthy towers in half the fleet, and one above it never fires anywhere. Both conjuncts compare strictly, so a tower exactly on the band, or a drive exactly at 95%, reads healthy.

wbOk tests the sign of the same difference: a tower cannot make water colder than the wet-bulb, so a non-positive approach means the psychrometric derivation or a water sensor is wrong and the silence underneath is NO_EVAL. It is a boundary output only — it cannot change yFault, since a non-positive approach already fails the high test.

persist requires 30 continuous minutes and carries delayOnInit = true; its falling edge is immediate, and fans backing off at the end of the day is the ordinary way this alarm clears with nothing fixed.

Possible Diagnoses

  1. Scale or biological fouling on the fill — the classic cause, and the one the O&M guides name first; usually accompanied by a water-treatment record that stopped being kept
  2. Clogged or broken spray nozzles, or a distribution basin flooding to one side — the fill only works where the water actually falls
  3. Air-side blockage: plugged inlet louvers, collapsed drift eliminators, debris screens, or a new structure that has put the tower into its own discharge
  4. Fan or drive not delivering the airflow the command implies — slipping belt, worn gearbox, blade pitch drifted, or a motor running backwards after service
  5. Condenser water flow above design, which raises approach as it lowers range (TOWER-0002 reads the other half of that pair, and flow is the first thing to check when both fire)
  6. A tower now undersized for the load on it — added chiller capacity, a changed process, or a derate the selection never carried
  7. Control chasing an unreachable condenser-water setpoint, which pins the fans at capacity on a perfectly clean tower (playbook step 2.2 — the fix is the setpoint, not the tower)
  8. A wet-bulb derivation reading low, which inflates approach with no physical change at all; yWetBulbOk only catches the opposite error

Energy Impact

EFFICIENCY_LOSS, LOW confidence, PROXY_ESTIMATION. The fan is already at capacity, so the fault costs nothing extra on the tower side — the whole bill is the chiller’s, which sees warmer condenser water and lifts against it at roughly 2–4% more power per K (BEE 2006; the DOE/PNNL O&M guides’ chiller chapter gives 1.2–1.7%/°F split by compressor type). A tower 3 K off its commissioned approach therefore costs something like 6–12% of chiller power for as long as it stays at capacity, which is the hottest and most expensive hours of the year. Confidence is LOW for the same reason the band is a placeholder: the sensitivity ratio is well corroborated, but nothing in the literature says how much approach rise a given degree of fouling produces, so the trigger point of the estimate is a commissioning number rather than a published one.

Emissions Impact

Scope 2, PROXY_EMISSIONS, LOW confidence. All of it is chiller electricity, so the basis is the marginal operating emissions rate, and the fault concentrates in exactly the hours a summer-peaking grid is dirtiest — the avoided emissions are worth more than the annual-average kWh figure implies. No published emissions range exists for tower degradation; the estimate is the host’s chiller kW times its own factors.

Deviations

  • The band is a commissioning placeholder and its fault-side corroboration is pending. Three sources were read for a degraded-approach magnitude — BEE 2006, PNNL-13890, and DOE/PNNL O&M Best Practices 3.0 — and all three are silent: they give design approach bands and named causes of poor performance, never a number at which approach becomes a fault. The only quantitative grounding under the shipped 5.0 K is this library’s own 4-climate simulation envelope (tools/simharness/README.md, “Tower groundwork”), which measures healthy operation, not faulted. A CTI or ASHRAE tower-chapter source is the outstanding gap; until it is read, treat approach_high_band the way HP-0001 asks its baseline coefficients to be treated — as a value the site must set, not a value the library has established.
  • The fan-at-capacity conjunct is the study’s design result, not a convenience. Healthy approach ran 1.6–13.3 K across Miami, Atlanta, Tucson and Buffalo purely from VFD modulation, so no fixed ungated threshold is defensible; at design-like loaded conditions healthy p95 was ~2.3 K, which is what makes the gated form thresholdable at all. The vector part_load_high_approach_stays_silent pins an 8 K healthy approach staying silent at 60% fan.
  • fan_capacity_threshold = 95% is adopted, and it is what a two-speed tower breaks. The simulation ran variable-speed cells; the number is a judgment about drive headroom rather than a measured line. A single- or two-speed fan reads full speed whenever it runs, which silently restores the ungated rule — that limit is preconditions text because no block can see it.
  • yWetBulbOk is an evaluability output that is deliberately not wired into the conjunction. With any positive band, a non-positive approach already fails the high test, so an And term would add a block and change no verdict. What the flag buys is the distinction a host cannot otherwise make: a quiet rule because the tower is fine, versus a quiet rule because the psychrometric input is nonsense.
  • That sign test only catches one direction of wet-bulb error. A derivation reading too high shrinks approach and, far enough, inverts it — caught. A derivation reading too low inflates approach and produces a false fault that looks exactly like fouling — not caught by anything in the graph, and the reason the derivation’s accuracy is a precondition rather than a footnote.
  • The validity floor is 0 K and is not exposed as a card parameter. It is the thermodynamic limit of evaporative cooling, not a tunable, and it is CDL’s own GreaterThreshold default, which SCHEMA.md says to leave unwritten. A small negative tolerance for sensor noise would read better on a real site but would need a negative parameter, which this library ships only where a fitted sign demands it (HP-0001’s regression slope).
  • alarm_delay = 1800 s departs from the hour its siblings use. CHW-0004 and HW-0004 require 60 minutes because their gates admit a lot of ordinary operation; here the fan gate has already excluded everything transient, and a full-capacity window on a shoulder-season afternoon can be shorter than the alarm delay itself. Thirty minutes still covers a staging transition and a load step.
  • There is no range or flow conjunct, so a flow fault reads as a tower fault. Condenser flow above design raises approach without any tower degradation (diagnosis 5), and the tower dictionary carries no flow point to test it with. TOWER-0002 measures the range half of that signature and the playbook orders flow before fill; folding both into one card would have produced a rule that alarms on neither cleanly.
  • Severity 3, method: rule and phase: 2 are library choices. No reference chapter or index covers cooling towers — there is nothing to transcribe and nothing whose severity column to follow. The classification follows the sibling condenser-side cards: a degradation that costs money continuously and threatens nothing.
  • clusters: [CLU-10]. A condenser-side syndrome (this rule, TOWER-0002 and CHW-0005 all describe one plant lifting harder than it should) is a reasonable cluster and is the cluster owner’s edit, not this card’s.
  • persist.delayOnInit = true (CDL default false), the library’s standing choice: a tower already over the band at controller restart waits out the full 30 minutes rather than alarming on the first tick.
  • No published test vectors exist for this fault — there is no published algorithm — so every scenario in vectors.json is authored from the equation and replayed against the pinned engine rev.
  • Operating states and preconditions are declared in frontmatter for host enforcement rather than encoded in the block graph, per the library’s design stance.

Notes

Read yWetBulbOk before yFault, and read the fan speed before either: this alarm ends every evening when the load falls away and the fans back off, and nothing about that is a repair. Trend approach against wet-bulb over a week of full-capacity hours before dispatching anyone — a tower whose approach has drifted up season over season at matched wet-bulb is fouling, while one that only ever reads high at a particular setpoint is a controls finding.

Where CHW-0005 fires and this card does not, the fouling is on the condenser tubes rather than in the tower; where both fire, do the water treatment first, since the same water made both deposits.

Test Vectors

13 scenarios, clock step 60 s over 5400 s.

ScenarioDescription
healthy_tower_at_full_fanDesign-like conditions: 24 °C wet-bulb, 26 °C leaving water, fans wide open. Approach 2.0 °C sits inside the loaded healthy envelope the simulation study observed (Miami July p95 ~2.3 °C), so the fan conjunct is true and the approach conjunct is what holds the alarm down.
fouled_fill_at_full_fanThe fault: same 24 °C wet-bulb, but the tower can only make 30 °C leaving water with the fans at 100%. Approach 6.0 °C is past the band and the fan has nothing left to give, so the alarm matures one alarm_delay in.
part_load_high_approach_stays_silentThe design result the fan gate exists for: approach 8.0 °C at 60% fan speed. That is a bigger approach than the fault scenario carries and it is healthy — the study found un-gated healthy approach spanning 1.6-13.3 °C, driven by nothing but VFD modulation at part load. The approach conjunct is true here and the fan conjunct alone blocks the alarm.
approach_exactly_at_the_bandThreshold edge: 29.0 − 24.0 is exactly 5.0, the shipped band, and the comparison is a strict >, so a tower sitting precisely on the band reads healthy. Both operands and the difference are binary-exact, so this is a true boundary pin.
approach_just_past_the_bandThreshold edge from the other side: a tenth of a degree more leaving-water temperature puts approach at 5.1 °C and the alarm matures.
fan_exactly_at_the_capacity_thresholdFan-gate edge: approach is 6.0 °C and clearly faulted, but the drive reads exactly fan_capacity_threshold (95.0%). The gate is a strict >, so the rule stays silent — a tower with a drive still holding a hair of headroom is not at capacity.
fan_just_past_the_capacity_thresholdFan-gate edge from the other side: 95.1% clears the gate and the identical approach now alarms. The two scenarios differ by a tenth of a percent of fan speed and disagree completely.
brief_peak_never_alarmsA load step, not a fault: approach jumps to 6.0 °C at t = 600 s with the fans already at 100% and recovers at t = 1800 s, 20 minutes short of alarm_delay. The timer resets on the recovery and nothing is reported.
capacity_reached_late_starts_the_clockDelay edge: approach is 6.0 °C from t = 0 but the fan only reaches capacity at t = 1200 s, so the alarm lands at 3000 s — alarm_delay is measured from the conjunction’s rising edge, not from when the approach went high.
alarm_clears_when_the_fans_back_offRecovery has two readings and this is the one that is not a repair: the tower is still making 30 °C water, but the load falls away at t = 3600 s and the fans throttle to 70%. yFault drops on that same tick — TrueDelay only delays the rising edge — and the tower is no more clean than it was a minute earlier.
negative_approach_is_no_evalThe wet-bulb input fails its own sanity check: 24 °C wet-bulb against 22 °C leaving water is an approach of −2 °C, which evaporative cooling cannot produce. yWetBulbOk goes false and the host must read NO_EVAL rather than a well-performing tower.
approach_exactly_zero_is_no_evalValidity-floor edge: leaving water exactly at the wet-bulb. The floor is a strict >, so a 0.0 °C approach reads NO_EVAL — it is the thermodynamic limit, not an achievable steady state, and a tower reporting it is reporting a derivation or a sensor.
approach_just_above_zero_is_evaluableValidity-floor edge from the other side: a tenth of a degree of approach is physically extraordinary but not impossible, so the rule is willing to issue a verdict — and the verdict is healthy, since 0.1 °C is far below the band.
vectors.json
{
  "schema": "cxf-library/vectors/v1",
  "clock": {
    "step_s": 60,
    "horizon_s": 5400
  },
  "scenarios": [
    {
      "name": "healthy_tower_at_full_fan",
      "description": "Design-like conditions: 24 \u00b0C wet-bulb, 26 \u00b0C leaving water, fans wide open. Approach 2.0 \u00b0C sits inside the loaded healthy envelope the simulation study observed (Miami July p95 ~2.3 \u00b0C), so the fan conjunct is true and the approach conjunct is what holds the alarm down.",
      "inputs": {
        "tower_leaving_temp": 26.0,
        "oa_wetbulb": 24.0,
        "tower_fan_speed": 100.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        },
        {
          "output": "yWetBulbOk",
          "from_s": 0,
          "to_s": 5400,
          "equals": true
        }
      ]
    },
    {
      "name": "fouled_fill_at_full_fan",
      "description": "The fault: same 24 \u00b0C wet-bulb, but the tower can only make 30 \u00b0C leaving water with the fans at 100%. Approach 6.0 \u00b0C is past the band and the fan has nothing left to give, so the alarm matures one alarm_delay in.",
      "inputs": {
        "tower_leaving_temp": 30.0,
        "oa_wetbulb": 24.0,
        "tower_fan_speed": 100.0
      },
      "expect": [
        {
          "output": "yWetBulbOk",
          "from_s": 0,
          "to_s": 5400,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1740,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 1860,
          "to_s": 5400,
          "equals": true
        }
      ]
    },
    {
      "name": "part_load_high_approach_stays_silent",
      "description": "The design result the fan gate exists for: approach 8.0 \u00b0C at 60% fan speed. That is a bigger approach than the fault scenario carries and it is healthy \u2014 the study found un-gated healthy approach spanning 1.6-13.3 \u00b0C, driven by nothing but VFD modulation at part load. The approach conjunct is true here and the fan conjunct alone blocks the alarm.",
      "inputs": {
        "tower_leaving_temp": 32.0,
        "oa_wetbulb": 24.0,
        "tower_fan_speed": 60.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        },
        {
          "output": "yWetBulbOk",
          "from_s": 0,
          "to_s": 5400,
          "equals": true
        }
      ]
    },
    {
      "name": "approach_exactly_at_the_band",
      "description": "Threshold edge: 29.0 \u2212 24.0 is exactly 5.0, the shipped band, and the comparison is a strict `>`, so a tower sitting precisely on the band reads healthy. Both operands and the difference are binary-exact, so this is a true boundary pin.",
      "inputs": {
        "tower_leaving_temp": 29.0,
        "oa_wetbulb": 24.0,
        "tower_fan_speed": 100.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        }
      ]
    },
    {
      "name": "approach_just_past_the_band",
      "description": "Threshold edge from the other side: a tenth of a degree more leaving-water temperature puts approach at 5.1 \u00b0C and the alarm matures.",
      "inputs": {
        "tower_leaving_temp": 29.1,
        "oa_wetbulb": 24.0,
        "tower_fan_speed": 100.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1740,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 1860,
          "to_s": 5400,
          "equals": true
        }
      ]
    },
    {
      "name": "fan_exactly_at_the_capacity_threshold",
      "description": "Fan-gate edge: approach is 6.0 \u00b0C and clearly faulted, but the drive reads exactly fan_capacity_threshold (95.0%). The gate is a strict `>`, so the rule stays silent \u2014 a tower with a drive still holding a hair of headroom is not at capacity.",
      "inputs": {
        "tower_leaving_temp": 30.0,
        "oa_wetbulb": 24.0,
        "tower_fan_speed": 95.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        }
      ]
    },
    {
      "name": "fan_just_past_the_capacity_threshold",
      "description": "Fan-gate edge from the other side: 95.1% clears the gate and the identical approach now alarms. The two scenarios differ by a tenth of a percent of fan speed and disagree completely.",
      "inputs": {
        "tower_leaving_temp": 30.0,
        "oa_wetbulb": 24.0,
        "tower_fan_speed": 95.1
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1740,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 1860,
          "to_s": 5400,
          "equals": true
        }
      ]
    },
    {
      "name": "brief_peak_never_alarms",
      "description": "A load step, not a fault: approach jumps to 6.0 \u00b0C at t = 600 s with the fans already at 100% and recovers at t = 1800 s, 20 minutes short of alarm_delay. The timer resets on the recovery and nothing is reported.",
      "inputs": {
        "oa_wetbulb": 24.0,
        "tower_fan_speed": 100.0,
        "tower_leaving_temp": [
          {
            "t": 0,
            "value": 26.0
          },
          {
            "t": 600,
            "value": 30.0
          },
          {
            "t": 1800,
            "value": 26.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        }
      ]
    },
    {
      "name": "capacity_reached_late_starts_the_clock",
      "description": "Delay edge: approach is 6.0 \u00b0C from t = 0 but the fan only reaches capacity at t = 1200 s, so the alarm lands at 3000 s \u2014 alarm_delay is measured from the conjunction's rising edge, not from when the approach went high.",
      "inputs": {
        "tower_leaving_temp": 30.0,
        "oa_wetbulb": 24.0,
        "tower_fan_speed": [
          {
            "t": 0,
            "value": 80.0
          },
          {
            "t": 1200,
            "value": 100.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2940,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 3060,
          "to_s": 5400,
          "equals": true
        }
      ]
    },
    {
      "name": "alarm_clears_when_the_fans_back_off",
      "description": "Recovery has two readings and this is the one that is not a repair: the tower is still making 30 \u00b0C water, but the load falls away at t = 3600 s and the fans throttle to 70%. yFault drops on that same tick \u2014 TrueDelay only delays the rising edge \u2014 and the tower is no more clean than it was a minute earlier.",
      "inputs": {
        "tower_leaving_temp": 30.0,
        "oa_wetbulb": 24.0,
        "tower_fan_speed": [
          {
            "t": 0,
            "value": 100.0
          },
          {
            "t": 3600,
            "value": 70.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1740,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 1860,
          "to_s": 3540,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 3660,
          "to_s": 5400,
          "equals": false
        }
      ]
    },
    {
      "name": "negative_approach_is_no_eval",
      "description": "The wet-bulb input fails its own sanity check: 24 \u00b0C wet-bulb against 22 \u00b0C leaving water is an approach of \u22122 \u00b0C, which evaporative cooling cannot produce. yWetBulbOk goes false and the host must read NO_EVAL rather than a well-performing tower.",
      "inputs": {
        "tower_leaving_temp": 22.0,
        "oa_wetbulb": 24.0,
        "tower_fan_speed": 100.0
      },
      "expect": [
        {
          "output": "yWetBulbOk",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        }
      ]
    },
    {
      "name": "approach_exactly_zero_is_no_eval",
      "description": "Validity-floor edge: leaving water exactly at the wet-bulb. The floor is a strict `>`, so a 0.0 \u00b0C approach reads NO_EVAL \u2014 it is the thermodynamic limit, not an achievable steady state, and a tower reporting it is reporting a derivation or a sensor.",
      "inputs": {
        "tower_leaving_temp": 24.0,
        "oa_wetbulb": 24.0,
        "tower_fan_speed": 100.0
      },
      "expect": [
        {
          "output": "yWetBulbOk",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        }
      ]
    },
    {
      "name": "approach_just_above_zero_is_evaluable",
      "description": "Validity-floor edge from the other side: a tenth of a degree of approach is physically extraordinary but not impossible, so the rule is willing to issue a verdict \u2014 and the verdict is healthy, since 0.1 \u00b0C is far below the band.",
      "inputs": {
        "tower_leaving_temp": 24.1,
        "oa_wetbulb": 24.0,
        "tower_fan_speed": 100.0
      },
      "expect": [
        {
          "output": "yWetBulbOk",
          "from_s": 0,
          "to_s": 5400,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        }
      ]
    }
  ]
}

TOWER-0002 — Tower range collapse

Statusverified — engine e2ff2f8, cxf:fnv1a128:e7ae89b2fdf66c58a01579412c64b6f5, 2026-08-18
Severity3
Methodrule
Phase2
CategoryEXCESS_CONSUMPTION
ConfidenceLOW
EstimationPROXY_ESTIMATION
G36
ClustersCLU-10
Suppresses
Suppressed by
RelatedTOWER-0001, TOWER-0003, TOWER-0005, CHW-0004
Playbookscooling-tower-performance
SourceLibrary extension: the HVAC FDD Reference v1.0 has no cooling-tower chapter — the TOWER family is library-authored; cxf-library simulation study, tools/simharness/README.md ‘Tower groundwork’ — 4-climate healthy-operation envelope; range p50 2.2-3.2 °C across all six runs. THE ONLY quantitative grounding for this card’s band, and it measures healthy operation, not the fault side; DOE/PNNL O&M Best Practices Guide Release 3.0 §9.5 and PNNL-13890 §7.5 — cooling-tower poor-performance causes (scale, clogged nozzles, poor airflow, poor pump performance); SILENT on any range magnitude; BEE Best Practice Manual: HVAC Chillers (2006) — condenser-approach mechanism and design bands; SILENT on any range fault magnitude; Sibling precedent: CHW-0004 and HW-0004 (delta-T graph shape, evaluability output), HP-0001 (commissioning-placeholder contract)
Operating statesCondenser loop rejecting heat — a chiller loaded, condenser water circulating, and the tower enabled with its fan above min_fan_speed_for_eval. yFanOk covers the fan half of that state; the chiller-loaded half is the host’s to enforce, and the rule is wrong without it (see Deviations).

Preconditions (host-enforced): tower_entering_temp and tower_leaving_temp must describe the same tower cell (or the same common header) at the same moment, and the loop-side binding must follow points/tower.points.json: LEAVING is the cold basin outlet that feeds the chiller condenser, ENTERING is the warm chiller-leaving water. Bound the other way round the rule reports a permanent fault on a healthy tower (pinned by loop_side_semantics_inverted). Condenser water flow is not a point of this rule and cannot be: range = heat rejected / (flow x cp), so a flow increase collapses range with no tower degradation whatever — a second condenser pump staged on, a VFD forced to 100%, a balancing valve opened. A finding therefore names the pair {flow, heat rejected}, never the tower alone, and the cooling-tower playbook’s step 1.3 checks flow first. The loop must actually be rejecting heat: a tower circulating with the chiller off equalises entering and leaving and alarms permanently, so gate host-side on chiller or condenser-pump status. Bind tower_fan_speed from VFD FEEDBACK where the point exists — where only the command is available, a fan tripped, in hand, or locked out reads a healthy speed while moving no air, and the host must add tower_fan_status to the gate. On a multi-cell tower bind per cell where the cells are sensed individually; common-header temperatures mix a starved cell with a working one and dilute the range of both. Both temperatures must be in °C (the rule converts nothing), and range_low_band must be commissioned from this tower’s own full-load range before any verdict means anything — the shipped 1.0 K is a placeholder (see Deviations). Evaluability is signalled in-rule by yFanOk; when it is false the verdict is NO_EVAL, not a healthy tower.

Points: tower_entering_temp, tower_leaving_temp, tower_fan_speed

Outputs:

  • yFault — True while the tower range has stayed below range_low_band with the fan above min_fan_speed_for_eval, continuously for at least alarm_delay
  • yFanOk — Evaluability signal — true when tower_fan_speed is above min_fan_speed_for_eval, the speed below which so little air is moving that range says nothing about the condenser loop. False means NO_EVAL and the host must ignore yFault

Parameters:

NameDefaultUnitCXF pathDescription
range_low_band1.0°CrangeLow.tRange below which the condenser loop is faulted. COMMISSIONING-SET PLACEHOLDER — no published fault-side range band exists for cooling towers (three sources silent; see Deviations). 1.0 K sits well below the 2.2-3.2 K healthy p50 the 4-climate simulation study measured across every climate it ran. Commission it from this tower’s own full-load range.
min_fan_speed_for_eval30.0%fanOk.tTower fan speed below which range is not evaluated. ADOPTED — no source supplies a floor; 30% sits above the 20-25% minimum common in tower VFD sequences, so the gate excludes a tower idling at its drive floor rather than one working. Retune to this drive’s minimum plus a margin.
alarm_delay3600.0spersist.delayTimeContinuous range collapse at fan load required before the alarm asserts (60 min). ADOPTED from CHW-0004 — no tower source specifies a persistence, and condenser-loop thermal mass plus chiller staging make anything shorter noise.

Description

Range is what the condenser loop takes out of the water: how far the tower drops it between the chiller’s discharge and the basin. It is the one tower quantity with a stable healthy band — a 4-climate simulation of a large office plant put the median between 2.2 and 3.2 K from Miami in July to Tucson in January, while approach over the same runs spread from 1.6 to 13.3 K. A range collapsed to a fraction of that says the loop is moving far more water than the heat it carries needs, or that the water and the air are not meeting. Neither finding is about tower capability: range = heat rejected / (flow × cp), and flow is not a point this rule can see. What the rule reports is that the pair has come apart; the playbook checks flow first.

Detection Logic

range  = tower_entering_temp − tower_leaving_temp   (warm chiller-leaving minus cold basin outlet)

yFanOk = tower_fan_speed > min_fan_speed_for_eval   (false ⇒ host reports NO_EVAL)
yFault = range < range_low_band AND yFanOk,
         sustained continuously for alarm_delay

Block graph (rule.cxf.jsonld):

TOWER-0002 block graph

The operand order is the trap. The tower dictionary grounds tower_leaving_temp as entering condenser water — the cold basin outlet on its way to the chiller — so the warm side is tower_entering_temp and it goes on u1. Subtract the other way and every healthy tower reports a permanent fault while looking like a rule that works.

rangeLow is strict, so a tower sitting exactly on the band reads healthy, and the boundary is bit-exact: 30.0 − 29.0 is precisely 1.0. fanOk is the evaluability story — a fan barely turning rejects little heat, and the range underneath it is small for a reason that has nothing to do with a fault. Fan speed is the only load-shaped signal in the tower dictionary; Deviations records what that substitution costs. persist requires 60 continuous minutes and carries delayOnInit = true: a collapsed range is a loop condition, not an event.

Possible Diagnoses

Library-authored — no source lists range-collapse causes, so this is the mass-balance read of range = heat rejected / (flow × cp):

  1. Condenser water flow above design — a second pump staged on, a VFD forced to full, a balancing valve opened after a service call. The commonest cause and the one this rule cannot separate from any other; check it first
  2. Tower bypass valve open or leaking, the winter freeze-protection valve left in hand being the classic — water reaches the basin without crossing the fill
  3. Water short-circuiting inside the tower: cracked distribution basin, lifted hot-water deck covers, or collapsed and missing fill letting water fall past the air stream
  4. Flow through an idle cell on a multi-cell tower — the idle cell returns water near its entering temperature and the common header mixes the range away
  5. A chiller unloaded further than the fan gate excludes: less heat to reject at unchanged flow. The gate is a fan-speed proxy, not a load measurement
  6. Water-side sensor error — a 0.5 K offset is half the shipped band. An entering sensor reading low, or a leaving sensor reading high, both bias toward the alarm
  7. Sensors bound to different cells, or across a bypass. This alarms hardest of all and has nothing to do with the tower

Energy Impact

EXCESS_CONSUMPTION, LOW confidence, PROXY_ESTIMATION. Where the collapse is flow-driven — the usual case — the waste is condenser pumping: excess_cond_pump_kw ≈ cond_pump_kw × (design_range − actual_range) / design_range, CHW-0004’s estimator on the condenser loop. Where it is a bypass or a short-circuit the pumping term understates the cost, because the water returning to the chiller is warmer than the tower could have made it and the chiller pays for the lift (~2-4% of chiller power per °C, BEE 2006). Confidence is LOW for a reason no tuning fixes: the trip band has no literature behind it, and the rule cannot tell a flow increase from a tower defect.

Emissions Impact

Scope 2, PROXY_EMISSIONS, LOW confidence. Both terms are purchased electricity — condenser pump kWh and chiller kWh — so the avoided-emissions basis is the marginal operating emissions rate. The lift term peaks on hot afternoons when the grid is dirtiest, so a bypass found in July is worth more than its annual kWh figure suggests. No published emissions range exists for this fault; the estimate is the host’s own pump and chiller factors.

Deviations

  • The band is a commissioning-set placeholder, and its only quantitative grounding is simulation. The HVAC FDD Reference has no cooling-tower chapter, and all three sources deep-read for this family (PNNL-13890 §7.5, DOE/PNNL O&M Best Practices 3.0 §9.5, BEE 2006) are silent on any range fault magnitude — they name causes of poor tower performance and attach no number to one. range_low_band = 1.0 K is placed under the 2.2-3.2 K healthy p50 measured by this library’s own 4-climate study (tools/simharness/README.md, “Tower groundwork”), which describes healthy operation, not the fault side. CTI/ASHRAE fault-side corroboration is pending. Until a site commissions the band from its own full-load range this card is runnable but not calibrated — HP-0001’s contract, and the reason confidence: LOW.
  • One absolute band, not a design × fraction pair. CHW-0004 and HW-0004 assemble their trip lines from a design delta-T times a fraction because PNNL-27338 supplies both halves. No tower source supplies a fraction, and the simulation gives an absolute healthy band rather than a ratio to design, so a two-parameter form would dress a placeholder up as a derivation.
  • Range is not a tower-capability measurement, and the flow confound cannot be fixed in-rule. range = Q / (flow × cp): a flow increase collapses it with the tower untouched. No condenser-flow point exists in the tower dictionary and adding one would not help — it would turn the rule into a heat-balance calculation whose answer is still ambiguous without design flow. The confound lives in preconditions, in diagnosis 1, and in the playbook’s step 1.3. Tower capability degradation is TOWER-0001’s approach test, not this one.
  • The fan-speed floor is entirely adopted. No tower source gates a range or approach reading on anything. Shipping an ungated range test would alarm through every mild night and every unloaded chiller hour, which is the same failure HW-0004 records for PNNL-27338 §4.6. 30% is chosen against typical tower VFD minimum speeds (20-25%), not against a citation.
  • tower_fan_speed gates the rule, tower_fan_status does not. The dictionary carries both. Status answers “is the fan running”; speed answers “is the tower working”, which is the question a range reading needs, and a threshold on a real is what SCHEMA.md asks an evaluability output to be. The cost is a fan that is tripped or in hand while its speed command still reads 60% — a NO_EVAL the rule will miss, carried in preconditions.
  • There is no chiller-on conjunct, and that is a real blind spot. A condenser loop circulating with no heat input equalises, range goes to zero, and the rule alarms at full confidence on a plant with no tower defect. Adding a chiller status would import a plant-level condition into a tower rule and silence it through the off-cycles of a normally staging plant; HW-0004 rejected the same edge for the same reasons. The honest placement is operating_states plus a host gate.
  • Nothing guards against a negative range. Sensors bound the wrong way round, or a reverse-flow path, give a negative difference that is below any positive band and alarms permanently (pinned by loop_side_semantics_inverted). A comparison against zero could suppress it, but reverse flow through a bypass is itself a real hydraulic fault, so the guard would hide a plant problem to hide a binding one. Commissioning check: watch the sign once, before trusting the rule.
  • Strict < at the band and strict > at the floor. CDL Reals has no LessEqual or GreaterEqual. A tower at exactly 1.0 K range reads healthy and a fan at exactly 30.0% reads NO_EVAL; both disagreements are measure-zero and both err toward silence.
  • yFanOk is an evaluability flag, not a sub-condition flag. False means NO_EVAL and the host must not read the yFault = false underneath it as a healthy tower. Same stance as CHW-0004’s yLoadOk and HP-0001’s yPowerOk.
  • alarm_delay = 3600 s is adopted from CHW-0004, not sourced. No tower source specifies a persistence at all. An hour matches the chilled-water sibling and rides out a chiller stage change, a pump changeover, and a cell rotation. Persistence is not averaging: a range alternating either side of the band every 20 minutes never alarms (intermittent_collapse_never_alarms), even though a loop spending half its day collapsed is a genuine finding.
  • persist.delayOnInit = true (CDL default false), the library’s standing choice: a tower already below the band at controller restart waits out the full hour rather than alarming on the first tick.
  • Severity 3 and method: rule are library judgements. No reference index exists for the TOWER family to carry them, and the fault is a waste finding with no comfort or protection consequence — a collapsed range costs pump and chiller energy and nothing else fails.
  • clusters: [CLU-10]. clusters/clusters.json has no condenser-side cluster; CLU-06 is chilled water by name and membership. A tower syndrome (this rule, TOWER-0001 and CHW-0005 all describing one condenser loop giving away energy) is a reasonable future cluster and the cluster owner’s edit.
  • suppresses and suppressed_by are both empty. TOWER-0001 is the closest candidate, but approach and range answer different questions — a tower can fail both, either, or neither — and both findings stay separately actionable. Suppression edges must be declared on both cards in any case.
  • No published test vectors exist for this rule. Nothing in the literature specifies a range-collapse case, so every scenario in vectors.json is authored from the equation and replayed against the pinned engine rev.
  • Operating states and preconditions are declared in frontmatter for host enforcement rather than encoded in the block graph, per the library’s design stance.

Notes

Read yFanOk before yFault. A tower coasting at 20% fan on a mild morning holds it false for hours, and every yFault = false underneath means “not evaluated” rather than “range is fine”.

Check condenser flow before anyone climbs the tower. Trend range against the number of condenser pumps running and against pump speed: a range that steps down when a second pump starts is diagnosis 1 and needs no tower work at all. A range that is low across every flow state points at the bypass valve, then at the fill and the distribution basin. Where TOWER-0001 fires as well, the approach finding is the tower’s and this one is still probably the loop’s.

Test Vectors

13 scenarios, clock step 60 s over 7200 s.

ScenarioDescription
healthy_rangeEntering 32.0 °C, leaving 29.0 °C — a 3.0 K range, inside the 2.2-3.2 K healthy p50 band the 4-climate simulation study found, with the fan modulating at 60%. Evaluated and silent.
range_collapsedEntering 30.0 °C, leaving 29.2 °C — a 0.8 K range against the 1.0 K band, with the fan at 60%. The alarm lands at exactly alarm_delay because delayOnInit holds the condition from the first tick.
fan_below_eval_floorThe same collapsed 0.8 K range with the fan idling at 20%. A tower whose fan is barely turning rejects little heat and its range says nothing about the condenser loop, which is what yFanOk = false tells the host: NO_EVAL, not a healthy tower.
range_exactly_at_the_bandBoundary, bit-exact: 30.0 − 29.0 is exactly 1.0 in IEEE-754 and range_low_band is exactly 1.0. Reals.LessThreshold is strict, so exactly-on-the-band reads healthy.
range_just_below_the_bandBoundary from below: 30.0 − 29.01 = 0.99 K clears the strict comparison by 10 mK and alarms on the normal schedule.
range_just_above_the_bandBoundary from above: 30.0 − 28.99 = 1.01 K never alarms, however marginal the loop is. Ten milli-kelvin is an order of magnitude inside any condenser-water sensor’s error, which is the point of the placeholder warning on the band.
fan_speed_exactly_at_the_floorBoundary on the other conjunct: the fan sits at exactly min_fan_speed_for_eval (30%) with the range collapsed. Reals.GreaterThreshold is strict, so exactly-at-the-floor is NO_EVAL and the rule stays silent.
fan_speed_just_above_the_floor30.1% with the same collapsed range: evaluable, and the alarm lands at exactly alarm_delay. One tenth of a percent of fan speed is the whole difference between this scenario and the previous one.
range_collapses_mid_runA tower holding a healthy 3.0 K range loses it at t = 1800 s — a second condenser pump starting, or a bypass valve opening. The alarm lands at exactly 5400 s: the mid-run rising edge carries the same T + delayTime arithmetic as the init case.
range_recovers_after_alarmRecovery: the alarm asserts at 3600 s and the range comes back at t = 5400 s when the extra pump stops. TrueDelay passes the falling edge with no delay, so yFault drops on that tick.
fan_slows_after_alarmThe evaluability release: an alarming tower drops to 15% fan at t = 5400 s with the collapsed range unchanged. yFault and yFanOk fall on the same tick, and only the pair tells the host that the tower unloaded rather than that the range recovered.
intermittent_collapse_never_alarmsRange alternating between 0.8 K and 3.0 K every 1200 s — a condenser pump staging on and off, or a chiller cycling. No single episode reaches alarm_delay, so nothing fires. Persistence is not averaging: a loop spending half its day at 0.8 K range is a real finding this rule cannot make.
loop_side_semantics_invertedBlind spot, pinned: the two water sensors are bound the wrong way round (entering 29.0 °C, leaving 32.0 °C), so range reads −3.0 K. Nothing in the graph knows a negative range is impossible, and the rule reports the strongest possible collapse on a tower that may be running perfectly. The tower dictionary’s loop-side note is the check: leaving is the COLD side.
vectors.json
{
  "schema": "cxf-library/vectors/v1",
  "clock": {
    "step_s": 60,
    "horizon_s": 7200
  },
  "scenarios": [
    {
      "name": "healthy_range",
      "description": "Entering 32.0 \u00b0C, leaving 29.0 \u00b0C \u2014 a 3.0 K range, inside the 2.2-3.2 K healthy p50 band the 4-climate simulation study found, with the fan modulating at 60%. Evaluated and silent.",
      "inputs": {
        "tower_entering_temp": 32.0,
        "tower_leaving_temp": 29.0,
        "tower_fan_speed": 60.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        },
        {
          "output": "yFanOk",
          "from_s": 0,
          "to_s": 7200,
          "equals": true
        }
      ]
    },
    {
      "name": "range_collapsed",
      "description": "Entering 30.0 \u00b0C, leaving 29.2 \u00b0C \u2014 a 0.8 K range against the 1.0 K band, with the fan at 60%. The alarm lands at exactly alarm_delay because delayOnInit holds the condition from the first tick.",
      "inputs": {
        "tower_entering_temp": 30.0,
        "tower_leaving_temp": 29.2,
        "tower_fan_speed": 60.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3540,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 3600,
          "to_s": 7200,
          "equals": true
        },
        {
          "output": "yFanOk",
          "from_s": 0,
          "to_s": 7200,
          "equals": true
        }
      ]
    },
    {
      "name": "fan_below_eval_floor",
      "description": "The same collapsed 0.8 K range with the fan idling at 20%. A tower whose fan is barely turning rejects little heat and its range says nothing about the condenser loop, which is what yFanOk = false tells the host: NO_EVAL, not a healthy tower.",
      "inputs": {
        "tower_entering_temp": 30.0,
        "tower_leaving_temp": 29.2,
        "tower_fan_speed": 20.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        },
        {
          "output": "yFanOk",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        }
      ]
    },
    {
      "name": "range_exactly_at_the_band",
      "description": "Boundary, bit-exact: 30.0 \u2212 29.0 is exactly 1.0 in IEEE-754 and range_low_band is exactly 1.0. Reals.LessThreshold is strict, so exactly-on-the-band reads healthy.",
      "inputs": {
        "tower_entering_temp": 30.0,
        "tower_leaving_temp": 29.0,
        "tower_fan_speed": 60.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        },
        {
          "output": "yFanOk",
          "from_s": 0,
          "to_s": 7200,
          "equals": true
        }
      ]
    },
    {
      "name": "range_just_below_the_band",
      "description": "Boundary from below: 30.0 \u2212 29.01 = 0.99 K clears the strict comparison by 10 mK and alarms on the normal schedule.",
      "inputs": {
        "tower_entering_temp": 30.0,
        "tower_leaving_temp": 29.01,
        "tower_fan_speed": 60.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3540,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 3600,
          "to_s": 7200,
          "equals": true
        },
        {
          "output": "yFanOk",
          "from_s": 0,
          "to_s": 7200,
          "equals": true
        }
      ]
    },
    {
      "name": "range_just_above_the_band",
      "description": "Boundary from above: 30.0 \u2212 28.99 = 1.01 K never alarms, however marginal the loop is. Ten milli-kelvin is an order of magnitude inside any condenser-water sensor's error, which is the point of the placeholder warning on the band.",
      "inputs": {
        "tower_entering_temp": 30.0,
        "tower_leaving_temp": 28.99,
        "tower_fan_speed": 60.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        },
        {
          "output": "yFanOk",
          "from_s": 0,
          "to_s": 7200,
          "equals": true
        }
      ]
    },
    {
      "name": "fan_speed_exactly_at_the_floor",
      "description": "Boundary on the other conjunct: the fan sits at exactly min_fan_speed_for_eval (30%) with the range collapsed. Reals.GreaterThreshold is strict, so exactly-at-the-floor is NO_EVAL and the rule stays silent.",
      "inputs": {
        "tower_entering_temp": 30.0,
        "tower_leaving_temp": 29.2,
        "tower_fan_speed": 30.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        },
        {
          "output": "yFanOk",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        }
      ]
    },
    {
      "name": "fan_speed_just_above_the_floor",
      "description": "30.1% with the same collapsed range: evaluable, and the alarm lands at exactly alarm_delay. One tenth of a percent of fan speed is the whole difference between this scenario and the previous one.",
      "inputs": {
        "tower_entering_temp": 30.0,
        "tower_leaving_temp": 29.2,
        "tower_fan_speed": 30.1
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3540,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 3600,
          "to_s": 7200,
          "equals": true
        },
        {
          "output": "yFanOk",
          "from_s": 0,
          "to_s": 7200,
          "equals": true
        }
      ]
    },
    {
      "name": "range_collapses_mid_run",
      "description": "A tower holding a healthy 3.0 K range loses it at t = 1800 s \u2014 a second condenser pump starting, or a bypass valve opening. The alarm lands at exactly 5400 s: the mid-run rising edge carries the same T + delayTime arithmetic as the init case.",
      "inputs": {
        "tower_entering_temp": 32.0,
        "tower_leaving_temp": [
          {
            "t": 0,
            "value": 29.0
          },
          {
            "t": 1800,
            "value": 31.2
          }
        ],
        "tower_fan_speed": 60.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5340,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 5400,
          "to_s": 7200,
          "equals": true
        },
        {
          "output": "yFanOk",
          "from_s": 0,
          "to_s": 7200,
          "equals": true
        }
      ]
    },
    {
      "name": "range_recovers_after_alarm",
      "description": "Recovery: the alarm asserts at 3600 s and the range comes back at t = 5400 s when the extra pump stops. TrueDelay passes the falling edge with no delay, so yFault drops on that tick.",
      "inputs": {
        "tower_entering_temp": 32.0,
        "tower_leaving_temp": [
          {
            "t": 0,
            "value": 31.2
          },
          {
            "t": 5400,
            "value": 29.0
          }
        ],
        "tower_fan_speed": 60.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3540,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 3600,
          "to_s": 5340,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 5400,
          "to_s": 7200,
          "equals": false
        },
        {
          "output": "yFanOk",
          "from_s": 0,
          "to_s": 7200,
          "equals": true
        }
      ]
    },
    {
      "name": "fan_slows_after_alarm",
      "description": "The evaluability release: an alarming tower drops to 15% fan at t = 5400 s with the collapsed range unchanged. yFault and yFanOk fall on the same tick, and only the pair tells the host that the tower unloaded rather than that the range recovered.",
      "inputs": {
        "tower_entering_temp": 30.0,
        "tower_leaving_temp": 29.2,
        "tower_fan_speed": [
          {
            "t": 0,
            "value": 60.0
          },
          {
            "t": 5400,
            "value": 15.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3540,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 3600,
          "to_s": 5340,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 5400,
          "to_s": 7200,
          "equals": false
        },
        {
          "output": "yFanOk",
          "from_s": 0,
          "to_s": 5340,
          "equals": true
        },
        {
          "output": "yFanOk",
          "from_s": 5400,
          "to_s": 7200,
          "equals": false
        }
      ]
    },
    {
      "name": "intermittent_collapse_never_alarms",
      "description": "Range alternating between 0.8 K and 3.0 K every 1200 s \u2014 a condenser pump staging on and off, or a chiller cycling. No single episode reaches alarm_delay, so nothing fires. Persistence is not averaging: a loop spending half its day at 0.8 K range is a real finding this rule cannot make.",
      "inputs": {
        "tower_entering_temp": 32.0,
        "tower_leaving_temp": [
          {
            "t": 0,
            "value": 31.2
          },
          {
            "t": 1200,
            "value": 29.0
          },
          {
            "t": 2400,
            "value": 31.2
          },
          {
            "t": 3600,
            "value": 29.0
          },
          {
            "t": 4800,
            "value": 31.2
          },
          {
            "t": 6000,
            "value": 29.0
          }
        ],
        "tower_fan_speed": 60.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        },
        {
          "output": "yFanOk",
          "from_s": 0,
          "to_s": 7200,
          "equals": true
        }
      ]
    },
    {
      "name": "loop_side_semantics_inverted",
      "description": "Blind spot, pinned: the two water sensors are bound the wrong way round (entering 29.0 \u00b0C, leaving 32.0 \u00b0C), so range reads \u22123.0 K. Nothing in the graph knows a negative range is impossible, and the rule reports the strongest possible collapse on a tower that may be running perfectly. The tower dictionary's loop-side note is the check: leaving is the COLD side.",
      "inputs": {
        "tower_entering_temp": 29.0,
        "tower_leaving_temp": 32.0,
        "tower_fan_speed": 60.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3540,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 3600,
          "to_s": 7200,
          "equals": true
        },
        {
          "output": "yFanOk",
          "from_s": 0,
          "to_s": 7200,
          "equals": true
        }
      ]
    }
  ]
}

TOWER-0003 — Tower fan short-cycling

Statusverified — engine e2ff2f8, cxf:fnv1a128:ce00b61ddcba13408da191e530c6f390, 2026-08-18
Severity3
Methodrule
Phase2
CategoryPROTECTIVE
ConfidenceMEDIUM
EstimationQUALITATIVE_ONLY
G36
Clusters
Suppresses
Suppressed by
RelatedTOWER-0001, TOWER-0002, TOWER-0004
Playbookscooling-tower-performance
SourceDOE/PNNL O&M Best Practices Guide Release 3.0 §9.5.6 and PNNL-13890 §7.5.6 — the tower fan motor must start and stop no more frequently than four to five times per hour to prevent motor overheating. Identical wording in both editions; Library extension: the HVAC FDD Reference v1.0 has no cooling-tower chapter — the TOWER family is library-authored; cxf-library simulation study, tools/simharness/README.md ‘Tower groundwork’ — the 4-climate healthy envelope that grounds this family’s approach and range bands. It has no bearing on this card’s threshold, which is literature-backed; Sibling precedent: RTU-0001 (edge-counter idiom, tick-band arithmetic, warm-up NO_EVAL contract)
Operating statesall states in which the tower is enabled. A tower locked out for the season produces no starts and reads clear, which is the right answer rather than a gap

Preconditions (host-enforced): The host must report NO_EVAL for the first count_window (1 h) after engine start: while the moving average’s window fills, its divisor is elapsed time, so the output is an extrapolated pace rather than a completed-hour count. The gate is load-bearing — alarm_delay (15 min) is shorter than count_window, so delayOnInit does not cover the warm-up window and two starts inside the first few minutes can reach a verdict (warmup_rate_asserts pins it). tower_fan_status must be bound per fan motor: one instance per cell, and one per winding on a two-speed motor, because the low-to-high transition is itself a motor start and motor starts are what the source counts. An OR across cells hides every start that happens while another cell is already running and undercounts each of them. The point must be a run PROOF — a current switch or auxiliary contact — not the enable command: a command chattering against a locked-out motor counts starts that never happened, and a motor cycling on its own thermal overload while the command stays true counts none of them. Host tick interval must lie in [57.2 s, 360 s) with count_scale set to match (see Deviations); 60 s is recommended and the only tick these vectors exercise. A VFD-driven fan that modulates instead of cycling produces zero starts and reads clear — correctly; this rule counts starts, it does not detect speed hunting. When any gate is unmet the verdict is NO_EVAL, not healthy.

Points: tower_fan_status

Outputs:

  • yFault — True while the number of tower fan starts in the trailing count_window has stayed above max_starts_per_hour for at least alarm_delay

Parameters:

NameDefaultUnitCXF pathDescription
max_starts_per_hour5.01/hcntHigh.tStarts per hour above which cycling counts as short-cycling rather than load-following. 5.0 is the permissive end of the DOE/PNNL O&M guides’ four-to-five-starts-per-hour motor-protection ceiling; with the strict comparison a tower starting six times an hour alarms and five reads clear. Retune to 4.0 for the strict reading of the source, and lower still for a large fan motor whose nameplate permits fewer starts.
count_window3600.0srate.deltaTrailing window the starts are counted over (1 h). It also fixes the units of max_starts_per_hour; a host that shortens it must retune count_scale with it and read max_starts_per_hour as starts per window rather than per hour.
count_scale60.01count.kRescales the moving average of the one-tick pulse train back into a start count: k = count_window / host tick interval in seconds. The default 60.0 is 3600/60, correct only at a 60 s tick; a host on a different tick MUST retune this or every count is wrong by the ratio of the two intervals.
alarm_delay900.0spersist.delayTimeContinuous fault persistence required before the alarm asserts (15 min). ADOPTED from RTU-0001 — the source states a starts-per-hour ceiling, not an alarm persistence.

Description

A tower fan motor is a large, high-inertia, across-the-line load turning a gear reducer and a fan whose blades are still windmilling when the contactor closes again. Each start pulls locked-rotor current through windings that have not cooled, and the DOE/PNNL O&M guides put a hard number on how often that is survivable: no more than four to five starts an hour. This is the only fault-side number the cooling-tower literature supplies — the family’s approach and range bands are commissioning placeholders, and this one is not. Short-cycling is a symptom, not a root cause; the rule reports that the fan is being asked to start too often and the service call decides why. Most of the time the answer is a control deadband and the fix is remote.

Detection Logic

start  = rising edge of tower_fan_status                     one tick wide
count  = MovingAverage(start, count_window) × count_scale     starts in the trailing hour
yFault = count > max_starts_per_hour, sustained continuously for alarm_delay

Block graph (rule.cxf.jsonld):

TOWER-0003 block graph

Logical.Edge emits u ∧ ¬pre(u), one tick wide, on every OFF→ON transition — stops and run durations are not counted. Reals.MovingAverage is a continuous-time integral mean, so a one-tick pulse of height 1.0 encloses one tick interval of area and n starts inside the window give rate = n · dt / count_window; multiplying by count_scale = count_window / dt recovers n. That makes count_scale a function of the host’s tick interval, and it is the first thing to check before deploying — see the first two Deviations. The comparison is strict, so exactly five starts an hour reads clear and six alarms; the boundary is exact in IEEE-754, since 60.0 × (5 × 60 / 3600) evaluates to precisely 5.0. persist then requires the count to stay above the ceiling for 15 minutes, which rides out the burst around a chiller stage change without leaving a motor restarting all afternoon. delayOnInit = true holds that window across a controller restart.

Possible Diagnoses

  1. Condenser-water or basin temperature deadband set too narrow — the fan satisfies the setpoint in a minute and restarts as the water drifts back. The commonest cause, and the one remote fix worth trying before a truck roll
  2. Condenser water setpoint below what the wet-bulb allows: the fan runs to capacity, overshoots when the load steps, and cycles against an unreachable target
  3. Cell staging with no minimum on/off timers, or several cells sharing one setpoint and hunting against each other
  4. A single-speed fan on a tower whose load needs modulation — cycling is the only capacity control it has, and no setting fixes it
  5. Cell oversized for the load: at low load one cell’s minimum output already exceeds what the loop needs
  6. Vibration switch or motor overload tripping and auto-resetting — the protection is doing the cycling and the underlying fault is mechanical
  7. A VFD faulting and restarting on undervoltage or start overcurrent, which reads identically from the status point

Energy Impact

PROTECTIVE, MEDIUM confidence, QUALITATIVE_ONLY. The rule sees one boolean and cannot price a start, so no waste term is computable from its inputs and the cost is asset life: a tower fan motor and gear reducer worn out early, plus the cell’s time out of service on a plant that may have no spare capacity in July. MEDIUM confidence rather than HIGH because the threshold, while stated plainly in two editions of the same DOE/PNNL guide, is a maintenance rule of thumb with no study behind it and no dependence on motor size — a large tower fan is often rated for fewer starts than four an hour, and a small one tolerates more.

Emissions Impact

Scope 2, QUALITATIVE_EMISSIONS, MEDIUM confidence. AHU-0001’s convention gives a PROTECTIVE fault with no emitting stream scope “N/A”; this card does not qualify, because a cycling fan spends every restart accelerating a high-inertia load while delivering little airflow, and the condenser water it fails to cool costs the chiller lift. Both terms are electricity the plant actually draws, neither is computable from a run status, and the larger emissions term is indirect: a motor and gear reducer replaced years early carry their embodied carbon. Avoided-emissions basis: N/A.

Deviations

  • The threshold ships at the permissive end of the source band. The DOE/PNNL guides state four to five starts per hour; max_starts_per_hour = 5.0 with a strict > alarms at six and clears at five, so nothing fires while the tower is anywhere inside the range the source calls acceptable. A site reading the requirement strictly sets 4.0; a site with a large fan motor should read its nameplate instead, since permissible starts fall with motor size and the source makes no such distinction.
  • This card’s number is literature-backed, and the family’s others are not. TOWER-0001 and TOWER-0002 ship commissioning-set placeholder bands whose only quantitative grounding is this library’s 4-climate simulation envelope (tools/simharness/README.md, “Tower groundwork”), with CTI/ASHRAE fault-side corroboration still pending. A starts-per-hour count is orthogonal to approach and range: it needs no fan-speed gate, no wet-bulb, and no thermal band, which is why it survives the gap that parks the other two at LOW confidence.
  • The tick interval is constrained at both ends, and the failure at the top end is Nyquist. A start is visible only if the fan is seen OFF on one tick and ON on the next, so the most this rule can ever observe is count_window/(2·dt) starts per window — 1800/dt per hour. The threshold must sit strictly below that ceiling: 5.0 < 1800/dt gives dt < 360 s. The moving average’s fixed 64-checkpoint ring sets the floor: the retained window holds count_window/dt + 1 checkpoints, so dt ≥ 3600/6357.2 s. Legal band [57.2 s, 360 s), 60 s recommended and the only tick these vectors exercise. Practical advice beyond the legal band: at 300 s the ceiling is exactly 6 starts/h, so six is the only count above the threshold the rule can represent — keep dt ≤ 120 s (ceiling 15/h) for usable headroom. Retuning the threshold to 4.0 relaxes the top of the band to 450 s.
  • count_scale is coupled to the host’s tick interval and the failure is silent. k = count_window / dt, so a host ticking every 120 s must set count_scale to 30.0; left at 60.0 it reports double the true count and alarms on three starts an hour. RTU-0001’s deployment constraint verbatim.
  • Rolling count built from a moving average, because the block set has no windowed counter. Integers.OnCounter counts monotonically from a reset, so a trailing-hour count would need a host-driven hourly reset — a tumbling count whose verdict depends on where the hour boundary fell. RTU-0001’s trade, taken again.
  • Startup artifact (a): a spurious first-tick pulse, which costs nothing. Logical.Edge compares u against pre_u_start on the first tick, so a fan already running when the rule loads registers a start at t = 0. It encloses no area (dt is zero on the first tick) and never reaches the count, so pre_u_start is written explicitly as false and not exposed as a card parameter.
  • Startup artifact (b): the first hour reads as a pace, and here it can reach a verdict. While t < count_window the moving average divides by elapsed time, so two starts in the first three minutes read as 40/h — the pace, extrapolated. alarm_delay (15 min) is shorter than the window, so that pace can assert (warmup_rate_asserts pins it). The host NO_EVAL precondition for the first count_window is not optional.
  • Strict > on a discrete count. Exactly five starts an hour is clear and six alarms, the source’s ceiling read literally. The boundary is exact in IEEE-754: 60.0 × (5 × 60 / 3600) evaluates to precisely 5.0, so the five-start case is a real pin and not a near-miss.
  • The counting window is half-open. rate compares the accumulated integral now against its value one count_window ago, so a start exactly count_window old has just left the window. The source is silent; it matters only on the threshold and it errs toward silence.
  • How long a crossing survives is count_window minus the span of the starts that caused it. Six starts inside ten minutes hold cntHigh for nearly an hour and always alarm; six spread across fifty minutes hold it for 600 s and never do — same starts per hour, opposite verdicts, and the difference is invisible in the printed equation (six_starts_per_hour_trips against spread_burst_clears_before_delay pins both). What the rule reports is cycling sustained above the ceiling, not every excursion through it.
  • Severity 3, where RTU-0001’s compressor sibling is severity 2. Same detection shape, cheaper asset: a tower fan motor and gear reducer cost a fraction of a compressor, a multi-cell tower has redundancy a packaged rooftop does not, and no space goes uncooled while the repair is scheduled. No reference index exists for the TOWER family to carry a severity, so this is a library judgement recorded rather than inherited.
  • alarm_delay = 900 s is adopted from RTU-0001. The source specifies a starts-per-hour ceiling and no persistence. Fifteen minutes is one to two more starts on a tower already cycling hard — enough to ride out the burst around a chiller stage change or a one-off drive trip.
  • persist.delayOnInit = true (CDL default false), the library’s standing choice: a fan already above the ceiling at controller start waits out the full 15 minutes instead of alarming on the first tick after a restart.
  • clusters: []. CLU-10 (Condenser-Side Degradation) was created at batch-18 closeout; this fault would not belong to one anyway: fan cycling is a control or mechanical defect on one cell, not a symptom of the plant-wide heat-rejection syndrome TOWER-0001 and TOWER-0002 describe. Any future tower cluster is the cluster owner’s edit.
  • No published test vectors exist. The source states a threshold, not cases, so every scenario in vectors.json is authored from the equation and replayed against the pinned engine rev.
  • Operating states and preconditions are declared in frontmatter for host enforcement rather than encoded in the block graph, per the library’s design stance.

Notes

Bind tower_fan_status per cell and deploy one instance per fan, as the tower point dictionary requires. An OR across a two-cell tower undercounts: a lag start while the lead is already running never moves the signal, so a plant whose lead cell runs continuously can cycle its lag cell all afternoon and read healthy.

Remediation follows the cooling-tower-performance playbook — deadband and staging first, then the setpoint against wet-bulb, then the drive and the mechanical inspection. Its step 1.4 is the one check worth doing before the truck roll: confirm the starts are sustained and rule out an aggressive leaving-temperature deadband. Firing alongside TOWER-0001 points at the setpoint (diagnosis 2) — a fan chasing an unreachable target both runs at capacity and overshoots.

Test Vectors

10 scenarios, clock step 60 s over 14400 s.

ScenarioDescription
three_starts_per_hourNormal staging: the tower cell is idle for the first 20 minutes, then runs 8 minutes and rests 12 — a 20-minute cycle, three starts an hour against a 5/h ceiling. The rolling count holds at 3.0 for the whole run, including the warm-up window, where three starts an hour also happens to be the extrapolated pace.
single_long_runOne start, one long run: the fan starts at t = 1200 s and runs to the horizon. count peaks at 3.0 (the warm-up extrapolation of one start in 20 minutes) and decays from there; one start can never reach the 5/h ceiling.
startup_pulse_is_inertStartup artifact: tower_fan_status is already true when the rule loads, so Logical.Edge sees u true against pre_u_start = false and emits a rising edge on tick 0. It costs nothing — MovingAverage integrates u*dt and dt is zero on the first tick, so the pulse encloses no area and count stays at 0.0 for the whole run.
short_cycling_ten_per_hourThe fault: the fan is off for the first hour (so the verdict rests on a completed-window count, not on the warm-up extrapolation), then runs 2 minutes and rests 4 — a 6-minute cycle, ten starts an hour. The sixth start enters the trailing hour at t = 5400 s and count steps to 6.0; yFault follows one alarm_delay later at t = 6300 s, and the count settles at exactly 10.0.
five_starts_per_hour_stays_clearBoundary, low side. After an idle first hour the fan runs 2 minutes every 12 — exactly five starts an hour, the top of the sources’ 4-5/h band met exactly. count lands on exactly 5.0 (60.0 x 5 x 60 / 3600 is exact in IEEE-754) and max_starts_per_hour is a strict >, so five starts an hour reads clear.
six_starts_per_hour_tripsBoundary, high side. Same shape with one more start folded into each hour: 2 minutes on, 8 off, six starts an hour repeating on a 3600 s pattern so every trailing hour holds exactly six. count steps to 6.0 at t = 6600 s and yFault asserts one alarm_delay later at t = 7500 s.
cycling_stops_alarm_clearsRecovery. Ten short cycles between t = 3600 s and t = 6840 s push count to 10.0 and yFault asserts at t = 6300 s; then the fan settles into one long run. The count does not drop at once — it decays as starts age out of the trailing hour, one per 360 s — and reaches 5.0 at t = 8640 s, where the strict threshold goes false and the alarm clears on that same tick, since TrueDelay has no off-delay.
warmup_rate_assertsStartup artifact, and the reason the host must report NO_EVAL for the first count_window: two starts two minutes apart read as a 40/h pace, because the growing-window divisor is elapsed time rather than count_window. cntHigh is true from t = 60 s and alarm_delay (900 s) expires long before the window fills, so yFault asserts at t = 960 s on the strength of two starts. The reading decays as the window grows and falls below 5.0 at t = 1440 s, clearing the alarm.
startup_spike_clearsTransient shorter than alarm_delay. A single start one tick after load extrapolates to a 60/h pace, so cntHigh is true from t = 60 s, but the reading decays with the growing window and drops below 5.0 at t = 720 s. cntHigh held for 600 s against a 900 s alarm_delay, so nothing asserts — the same single-start history as single_long_run, seen from inside the warm-up window.
spread_burst_clears_before_delayTransient in the steady-state regime, and the contrast case for six_starts_per_hour_trips: the same six starts 600 s apart, but the burst stops after the sixth. count reaches 6.0 at t = 6600 s and falls back to 5.0 at t = 7200 s when the first start leaves the trailing hour, so cntHigh held 540 s against a 900 s alarm_delay and nothing asserts. How long a crossing survives is count_window minus the span of the starts that caused it.
vectors.json
{
  "schema": "cxf-library/vectors/v1",
  "clock": {
    "step_s": 60,
    "horizon_s": 14400
  },
  "scenarios": [
    {
      "name": "three_starts_per_hour",
      "description": "Normal staging: the tower cell is idle for the first 20 minutes, then runs 8 minutes and rests 12 \u2014 a 20-minute cycle, three starts an hour against a 5/h ceiling. The rolling count holds at 3.0 for the whole run, including the warm-up window, where three starts an hour also happens to be the extrapolated pace.",
      "inputs": {
        "tower_fan_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 1200,
            "value": true
          },
          {
            "t": 1680,
            "value": false
          },
          {
            "t": 2400,
            "value": true
          },
          {
            "t": 2880,
            "value": false
          },
          {
            "t": 3600,
            "value": true
          },
          {
            "t": 4080,
            "value": false
          },
          {
            "t": 4800,
            "value": true
          },
          {
            "t": 5280,
            "value": false
          },
          {
            "t": 6000,
            "value": true
          },
          {
            "t": 6480,
            "value": false
          },
          {
            "t": 7200,
            "value": true
          },
          {
            "t": 7680,
            "value": false
          },
          {
            "t": 8400,
            "value": true
          },
          {
            "t": 8880,
            "value": false
          },
          {
            "t": 9600,
            "value": true
          },
          {
            "t": 10080,
            "value": false
          },
          {
            "t": 10800,
            "value": true
          },
          {
            "t": 11280,
            "value": false
          },
          {
            "t": 12000,
            "value": true
          },
          {
            "t": 12480,
            "value": false
          },
          {
            "t": 13200,
            "value": true
          },
          {
            "t": 13680,
            "value": false
          },
          {
            "t": 14400,
            "value": true
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 14400,
          "equals": false
        }
      ]
    },
    {
      "name": "single_long_run",
      "description": "One start, one long run: the fan starts at t = 1200 s and runs to the horizon. count peaks at 3.0 (the warm-up extrapolation of one start in 20 minutes) and decays from there; one start can never reach the 5/h ceiling.",
      "inputs": {
        "tower_fan_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 1200,
            "value": true
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 14400,
          "equals": false
        }
      ]
    },
    {
      "name": "startup_pulse_is_inert",
      "description": "Startup artifact: tower_fan_status is already true when the rule loads, so Logical.Edge sees u true against pre_u_start = false and emits a rising edge on tick 0. It costs nothing \u2014 MovingAverage integrates u*dt and dt is zero on the first tick, so the pulse encloses no area and count stays at 0.0 for the whole run.",
      "inputs": {
        "tower_fan_status": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 14400,
          "equals": false
        }
      ]
    },
    {
      "name": "short_cycling_ten_per_hour",
      "description": "The fault: the fan is off for the first hour (so the verdict rests on a completed-window count, not on the warm-up extrapolation), then runs 2 minutes and rests 4 \u2014 a 6-minute cycle, ten starts an hour. The sixth start enters the trailing hour at t = 5400 s and count steps to 6.0; yFault follows one alarm_delay later at t = 6300 s, and the count settles at exactly 10.0.",
      "inputs": {
        "tower_fan_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 3600,
            "value": true
          },
          {
            "t": 3720,
            "value": false
          },
          {
            "t": 3960,
            "value": true
          },
          {
            "t": 4080,
            "value": false
          },
          {
            "t": 4320,
            "value": true
          },
          {
            "t": 4440,
            "value": false
          },
          {
            "t": 4680,
            "value": true
          },
          {
            "t": 4800,
            "value": false
          },
          {
            "t": 5040,
            "value": true
          },
          {
            "t": 5160,
            "value": false
          },
          {
            "t": 5400,
            "value": true
          },
          {
            "t": 5520,
            "value": false
          },
          {
            "t": 5760,
            "value": true
          },
          {
            "t": 5880,
            "value": false
          },
          {
            "t": 6120,
            "value": true
          },
          {
            "t": 6240,
            "value": false
          },
          {
            "t": 6480,
            "value": true
          },
          {
            "t": 6600,
            "value": false
          },
          {
            "t": 6840,
            "value": true
          },
          {
            "t": 6960,
            "value": false
          },
          {
            "t": 7200,
            "value": true
          },
          {
            "t": 7320,
            "value": false
          },
          {
            "t": 7560,
            "value": true
          },
          {
            "t": 7680,
            "value": false
          },
          {
            "t": 7920,
            "value": true
          },
          {
            "t": 8040,
            "value": false
          },
          {
            "t": 8280,
            "value": true
          },
          {
            "t": 8400,
            "value": false
          },
          {
            "t": 8640,
            "value": true
          },
          {
            "t": 8760,
            "value": false
          },
          {
            "t": 9000,
            "value": true
          },
          {
            "t": 9120,
            "value": false
          },
          {
            "t": 9360,
            "value": true
          },
          {
            "t": 9480,
            "value": false
          },
          {
            "t": 9720,
            "value": true
          },
          {
            "t": 9840,
            "value": false
          },
          {
            "t": 10080,
            "value": true
          },
          {
            "t": 10200,
            "value": false
          },
          {
            "t": 10440,
            "value": true
          },
          {
            "t": 10560,
            "value": false
          },
          {
            "t": 10800,
            "value": true
          },
          {
            "t": 10920,
            "value": false
          },
          {
            "t": 11160,
            "value": true
          },
          {
            "t": 11280,
            "value": false
          },
          {
            "t": 11520,
            "value": true
          },
          {
            "t": 11640,
            "value": false
          },
          {
            "t": 11880,
            "value": true
          },
          {
            "t": 12000,
            "value": false
          },
          {
            "t": 12240,
            "value": true
          },
          {
            "t": 12360,
            "value": false
          },
          {
            "t": 12600,
            "value": true
          },
          {
            "t": 12720,
            "value": false
          },
          {
            "t": 12960,
            "value": true
          },
          {
            "t": 13080,
            "value": false
          },
          {
            "t": 13320,
            "value": true
          },
          {
            "t": 13440,
            "value": false
          },
          {
            "t": 13680,
            "value": true
          },
          {
            "t": 13800,
            "value": false
          },
          {
            "t": 14040,
            "value": true
          },
          {
            "t": 14160,
            "value": false
          },
          {
            "t": 14400,
            "value": true
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 6240,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 6360,
          "to_s": 14400,
          "equals": true
        }
      ]
    },
    {
      "name": "five_starts_per_hour_stays_clear",
      "description": "Boundary, low side. After an idle first hour the fan runs 2 minutes every 12 \u2014 exactly five starts an hour, the top of the sources' 4-5/h band met exactly. count lands on exactly 5.0 (60.0 x 5 x 60 / 3600 is exact in IEEE-754) and max_starts_per_hour is a strict `>`, so five starts an hour reads clear.",
      "inputs": {
        "tower_fan_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 3600,
            "value": true
          },
          {
            "t": 3720,
            "value": false
          },
          {
            "t": 4320,
            "value": true
          },
          {
            "t": 4440,
            "value": false
          },
          {
            "t": 5040,
            "value": true
          },
          {
            "t": 5160,
            "value": false
          },
          {
            "t": 5760,
            "value": true
          },
          {
            "t": 5880,
            "value": false
          },
          {
            "t": 6480,
            "value": true
          },
          {
            "t": 6600,
            "value": false
          },
          {
            "t": 7200,
            "value": true
          },
          {
            "t": 7320,
            "value": false
          },
          {
            "t": 7920,
            "value": true
          },
          {
            "t": 8040,
            "value": false
          },
          {
            "t": 8640,
            "value": true
          },
          {
            "t": 8760,
            "value": false
          },
          {
            "t": 9360,
            "value": true
          },
          {
            "t": 9480,
            "value": false
          },
          {
            "t": 10080,
            "value": true
          },
          {
            "t": 10200,
            "value": false
          },
          {
            "t": 10800,
            "value": true
          },
          {
            "t": 10920,
            "value": false
          },
          {
            "t": 11520,
            "value": true
          },
          {
            "t": 11640,
            "value": false
          },
          {
            "t": 12240,
            "value": true
          },
          {
            "t": 12360,
            "value": false
          },
          {
            "t": 12960,
            "value": true
          },
          {
            "t": 13080,
            "value": false
          },
          {
            "t": 13680,
            "value": true
          },
          {
            "t": 13800,
            "value": false
          },
          {
            "t": 14400,
            "value": true
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 14400,
          "equals": false
        }
      ]
    },
    {
      "name": "six_starts_per_hour_trips",
      "description": "Boundary, high side. Same shape with one more start folded into each hour: 2 minutes on, 8 off, six starts an hour repeating on a 3600 s pattern so every trailing hour holds exactly six. count steps to 6.0 at t = 6600 s and yFault asserts one alarm_delay later at t = 7500 s.",
      "inputs": {
        "tower_fan_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 3600,
            "value": true
          },
          {
            "t": 3720,
            "value": false
          },
          {
            "t": 4200,
            "value": true
          },
          {
            "t": 4320,
            "value": false
          },
          {
            "t": 4800,
            "value": true
          },
          {
            "t": 4920,
            "value": false
          },
          {
            "t": 5400,
            "value": true
          },
          {
            "t": 5520,
            "value": false
          },
          {
            "t": 6000,
            "value": true
          },
          {
            "t": 6120,
            "value": false
          },
          {
            "t": 6600,
            "value": true
          },
          {
            "t": 6720,
            "value": false
          },
          {
            "t": 7200,
            "value": true
          },
          {
            "t": 7320,
            "value": false
          },
          {
            "t": 7800,
            "value": true
          },
          {
            "t": 7920,
            "value": false
          },
          {
            "t": 8400,
            "value": true
          },
          {
            "t": 8520,
            "value": false
          },
          {
            "t": 9000,
            "value": true
          },
          {
            "t": 9120,
            "value": false
          },
          {
            "t": 9600,
            "value": true
          },
          {
            "t": 9720,
            "value": false
          },
          {
            "t": 10200,
            "value": true
          },
          {
            "t": 10320,
            "value": false
          },
          {
            "t": 10800,
            "value": true
          },
          {
            "t": 10920,
            "value": false
          },
          {
            "t": 11400,
            "value": true
          },
          {
            "t": 11520,
            "value": false
          },
          {
            "t": 12000,
            "value": true
          },
          {
            "t": 12120,
            "value": false
          },
          {
            "t": 12600,
            "value": true
          },
          {
            "t": 12720,
            "value": false
          },
          {
            "t": 13200,
            "value": true
          },
          {
            "t": 13320,
            "value": false
          },
          {
            "t": 13800,
            "value": true
          },
          {
            "t": 13920,
            "value": false
          },
          {
            "t": 14400,
            "value": true
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7440,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 7560,
          "to_s": 14400,
          "equals": true
        }
      ]
    },
    {
      "name": "cycling_stops_alarm_clears",
      "description": "Recovery. Ten short cycles between t = 3600 s and t = 6840 s push count to 10.0 and yFault asserts at t = 6300 s; then the fan settles into one long run. The count does not drop at once \u2014 it decays as starts age out of the trailing hour, one per 360 s \u2014 and reaches 5.0 at t = 8640 s, where the strict threshold goes false and the alarm clears on that same tick, since TrueDelay has no off-delay.",
      "inputs": {
        "tower_fan_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 3600,
            "value": true
          },
          {
            "t": 3720,
            "value": false
          },
          {
            "t": 3960,
            "value": true
          },
          {
            "t": 4080,
            "value": false
          },
          {
            "t": 4320,
            "value": true
          },
          {
            "t": 4440,
            "value": false
          },
          {
            "t": 4680,
            "value": true
          },
          {
            "t": 4800,
            "value": false
          },
          {
            "t": 5040,
            "value": true
          },
          {
            "t": 5160,
            "value": false
          },
          {
            "t": 5400,
            "value": true
          },
          {
            "t": 5520,
            "value": false
          },
          {
            "t": 5760,
            "value": true
          },
          {
            "t": 5880,
            "value": false
          },
          {
            "t": 6120,
            "value": true
          },
          {
            "t": 6240,
            "value": false
          },
          {
            "t": 6480,
            "value": true
          },
          {
            "t": 6600,
            "value": false
          },
          {
            "t": 6840,
            "value": true
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 6240,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 6360,
          "to_s": 8580,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 8700,
          "to_s": 14400,
          "equals": false
        }
      ]
    },
    {
      "name": "warmup_rate_asserts",
      "description": "Startup artifact, and the reason the host must report NO_EVAL for the first count_window: two starts two minutes apart read as a 40/h pace, because the growing-window divisor is elapsed time rather than count_window. cntHigh is true from t = 60 s and alarm_delay (900 s) expires long before the window fills, so yFault asserts at t = 960 s on the strength of two starts. The reading decays as the window grows and falls below 5.0 at t = 1440 s, clearing the alarm.",
      "inputs": {
        "tower_fan_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 60,
            "value": true
          },
          {
            "t": 120,
            "value": false
          },
          {
            "t": 180,
            "value": true
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 900,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 1020,
          "to_s": 1380,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 1500,
          "to_s": 14400,
          "equals": false
        }
      ]
    },
    {
      "name": "startup_spike_clears",
      "description": "Transient shorter than alarm_delay. A single start one tick after load extrapolates to a 60/h pace, so cntHigh is true from t = 60 s, but the reading decays with the growing window and drops below 5.0 at t = 720 s. cntHigh held for 600 s against a 900 s alarm_delay, so nothing asserts \u2014 the same single-start history as single_long_run, seen from inside the warm-up window.",
      "inputs": {
        "tower_fan_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 60,
            "value": true
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 14400,
          "equals": false
        }
      ]
    },
    {
      "name": "spread_burst_clears_before_delay",
      "description": "Transient in the steady-state regime, and the contrast case for six_starts_per_hour_trips: the same six starts 600 s apart, but the burst stops after the sixth. count reaches 6.0 at t = 6600 s and falls back to 5.0 at t = 7200 s when the first start leaves the trailing hour, so cntHigh held 540 s against a 900 s alarm_delay and nothing asserts. How long a crossing survives is count_window minus the span of the starts that caused it.",
      "inputs": {
        "tower_fan_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 3600,
            "value": true
          },
          {
            "t": 3720,
            "value": false
          },
          {
            "t": 4200,
            "value": true
          },
          {
            "t": 4320,
            "value": false
          },
          {
            "t": 4800,
            "value": true
          },
          {
            "t": 4920,
            "value": false
          },
          {
            "t": 5400,
            "value": true
          },
          {
            "t": 5520,
            "value": false
          },
          {
            "t": 6000,
            "value": true
          },
          {
            "t": 6120,
            "value": false
          },
          {
            "t": 6600,
            "value": true
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 14400,
          "equals": false
        }
      ]
    }
  ]
}

TOWER-0004 — Tower fan proof-of-operation failure

Statusverified — engine e2ff2f8, cxf:fnv1a128:8cea8bc88b1dfcc84aa0e09731947555, 2026-08-20
Severity2
Methodrule
Phase2
CategoryPROTECTIVE
ConfidenceHIGH
EstimationPROXY_ESTIMATION
G36
Clusters
Suppresses
Suppressed by
RelatedTOWER-0001, TOWER-0003, TOWER-0005
Playbookscooling-tower-performance
SourceLibrary proof-of-operation precedents CHW-0008, PMP-0003, and HW-0009 — independent command/status mismatch lanes with initialization-safe direction-specific timers; EnergyPlus Engineering Reference, Cooling Towers and Evaporative Fluid Coolers — variable-speed tower fan operation follows leaving-water control and free-convection logic; control semantics only, no portable proof timer; Library-authored tower timing adaptation; no cited source publishes 120 s start and stop proof limits as portable values
Operating statesall states in which the final individual fan/cell command and an independent proof of that same fan’s operation are authoritative

Preconditions (host-enforced): Bind tower_fan_cmd after cell staging, free-convection logic, minimum on/off timing, vibration lockout, and ordinary safeties; a plant tower-enable request is not a per-fan final command. Bind tower_fan_status to independent electrical, airflow, rotation, or auxiliary-contact proof for the same fan, never to an echoed command or an OR of the tower fleet. Evaluate one rule instance per independently commanded fan/cell. Normalize multi-speed stages to Boolean only when any-commanded-stage and any-proven-stage preserve real transitions. Inputs must be fresh and time-aligned, and both timers must exceed normal delivery latency and the real mechanical sequence. Exclude maintenance, local/manual operation, exercise tests, approved coast-down, vibration or OEM safety actions, and any state in which another controller owns the fan; otherwise report NO_EVAL.

Points: tower_fan_cmd, tower_fan_status

Outputs:

  • yFault — True while either independent command/status mismatch has matured through its own proof timer
  • yFailToStart — True after a final per-fan start command remains unproved for start_proof_time
  • yUnexpectedRun — True after independent fan operation continues without a final per-fan command for stop_proof_time

Parameters:

NameDefaultUnitCXF pathDescription
start_proof_time120.0sstartProof.delayTimeAllowed fan start and proof sequence. ADOPTED_TUNABLE: exceed normal VFD/starter acceleration, proof pickup, and telemetry latency.
stop_proof_time120.0sstopProof.delayTimeAllowed fan ramp-down, coast-down, and proof dropout. ADOPTED_TUNABLE and independent of the start window.

Description

This rule checks whether one cooling-tower fan did what its final command requested. A commanded fan without independent proof can mean a failed drive, starter, belt, motor, interlock, or proof point. A proven fan after its command has gone away can mean local control, a stuck output or contactor, a second controller, or a command bound upstream of the true owner.

Detection Logic

fail_to_start  = tower_fan_cmd AND NOT tower_fan_status
unexpected_run = NOT tower_fan_cmd AND tower_fan_status

yFailToStart   = fail_to_start sustained for start_proof_time
yUnexpectedRun = unexpected_run sustained for stop_proof_time
yFault         = yFailToStart OR yUnexpectedRun

TOWER-0004 block graph

Each mismatch has a separate TrueDelay(delayOnInit=true). A direct direction reversal therefore clears the old diagnostic immediately and starts the other timer from zero. Time never survives a healthy sample or transfers between directions.

Possible Diagnoses

yFailToStart:

  1. VFD/starter trip, disconnect, overload, failed motor, belt, gearbox, or fan
  2. Vibration, freeze, low-water, fire, or OEM safety interlock is active
  3. Final cell-stage command was mapped to the wrong fan
  4. Proof switch, airflow/rotation sensor, auxiliary contact, or integration is bad
  5. Command point is upstream of free-convection, anti-cycle, or safety logic

yUnexpectedRun:

  1. Local/manual mode, service override, or a second tower controller
  2. Welded contactor, stuck output, or VFD run command held internally
  3. Normal deceleration/coast-down exceeds the configured stop window
  4. Fleet status was incorrectly bound to an individual fan instance

Energy Impact

PROTECTIVE, direction-dependent. Failure to start threatens heat rejection and can raise chiller lift, but its energy effect cannot be calculated from these two Boolean points. Unexpected operation can waste the same fan’s measured kW; use that only as an upper bound because some rejected heat may still be useful.

Emissions Impact

Scope 2, proxy-only for unexpected fan operation. Do not claim avoided energy or emissions for a failed start without a separate condenser-plant model.

Deviations

  • Both 120 s timers are adopted commissioning values. No cited source establishes a universal tower-fan proof window. Configure them independently around actual acceleration, deceleration, proof pickup, and network latency.
  • The command is downstream of normal tower logic. A plant enable or upstream leaving-water request is intentionally rejected because free convection, cell staging, minimum timers, and safeties can all keep an individual fan off correctly.
  • No rule-wide suppression targets TOWER-0001. Failure to start can make approach-high non-evaluable, but unexpected operation can leave approach physically meaningful. Current metadata cannot suppress only one direction.
  • delayOnInit=true is explicit on both lanes. A runtime restart does not turn an existing command/status disagreement into an immediate alarm.
  • EnergyPlus validation is not claimed. Fan power or airflow ratio can establish status, but the current fixture has no independent final BAS fan command. Deriving command from status would make proof tautological.

Notes

Dispatch from the direction output, not yFault alone. Confirm authority and point identity remotely before sending a technician: a correct fan paired with the wrong cell’s command produces a perfectly repeatable false diagnosis.

Test Vectors

19 scenarios, clock step 60 s over 1800 s.

ScenarioDescription
agreement_stoppedFinal command and independent proof both false are healthy agreement.
agreement_runningFinal command and independent proof both true are healthy agreement.
fail_to_start_at_initializationA standing start mismatch serves the full initialization-safe proof time.
unexpected_run_at_initializationA standing stop mismatch serves its own complete initialization-safe proof time.
normal_delayed_start_and_stopIndependent proof follows the final command inside both adopted windows.
start_mismatch_ends_one_tick_before_proofA start mismatch clearing at 60 seconds cannot mature the 120-second timer.
stop_mismatch_ends_one_tick_before_proofA stop mismatch clearing at 60 seconds cannot mature the independent timer.
mature_start_alarm_recoversAn already-mature failure-to-start clears immediately when independent run proof arrives.
mature_stop_alarm_recoversAn already-mature unexpected-run alarm clears when the fan stops.
direction_reversal_restarts_timerA direct mismatch reversal clears the old lane and serves the new lane’s complete timer.
command_chatter_never_maturesAlternating command ownership resets both direction-specific candidates.
status_chatter_never_maturesStatus chatter around a steady start command cannot accumulate across healthy samples.
two_short_start_mismatches_do_not_accumulateSeparated start-proof episodes each need their own complete duration.
pending_direction_reversal_starts_fresh_laneA start mismatch reverses before maturity; the start lane never asserts and the stop lane receives a fresh timer.
unexpected_run_chatter_never_maturesStop-lane status chatter repeatedly restores healthy agreement before its proof timer matures.
two_short_stop_mismatches_do_not_accumulateSeparated unexpected-run episodes each require their own complete duration.
upstream_enable_raw_false_positiveA plant enable misbound as this fan’s final command raises the raw start alarm for an unselected cell; the host must report NO_EVAL.
command_echo_raw_blind_spotAn echoed command masquerading as status keeps the raw graph clear even when physical truth is unknown; independent proof is mandatory.
manual_local_run_is_host_excludedA locally owned fan produces a raw unexpected-run alarm, but the host excludes the ownership window.
vectors.json
{
  "schema": "cxf-library/vectors/v1",
  "clock": {
    "step_s": 60,
    "horizon_s": 1800
  },
  "scenarios": [
    {
      "name": "agreement_stopped",
      "description": "Final command and independent proof both false are healthy agreement.",
      "inputs": {
        "tower_fan_cmd": false,
        "tower_fan_status": false
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        }
      ]
    },
    {
      "name": "agreement_running",
      "description": "Final command and independent proof both true are healthy agreement.",
      "inputs": {
        "tower_fan_cmd": true,
        "tower_fan_status": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        }
      ]
    },
    {
      "name": "fail_to_start_at_initialization",
      "description": "A standing start mismatch serves the full initialization-safe proof time.",
      "inputs": {
        "tower_fan_cmd": true,
        "tower_fan_status": false
      },
      "expect": [
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 60,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 180,
          "to_s": 1800,
          "equals": true
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 60,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 180,
          "to_s": 1800,
          "equals": true
        }
      ]
    },
    {
      "name": "unexpected_run_at_initialization",
      "description": "A standing stop mismatch serves its own complete initialization-safe proof time.",
      "inputs": {
        "tower_fan_cmd": false,
        "tower_fan_status": true
      },
      "expect": [
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 60,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 180,
          "to_s": 1800,
          "equals": true
        },
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 60,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 180,
          "to_s": 1800,
          "equals": true
        }
      ]
    },
    {
      "name": "normal_delayed_start_and_stop",
      "description": "Independent proof follows the final command inside both adopted windows.",
      "inputs": {
        "tower_fan_cmd": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 300,
            "value": true
          },
          {
            "t": 900,
            "value": false
          }
        ],
        "tower_fan_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 360,
            "value": true
          },
          {
            "t": 960,
            "value": false
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        }
      ]
    },
    {
      "name": "start_mismatch_ends_one_tick_before_proof",
      "description": "A start mismatch clearing at 60 seconds cannot mature the 120-second timer.",
      "inputs": {
        "tower_fan_cmd": true,
        "tower_fan_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 60,
            "value": true
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        }
      ]
    },
    {
      "name": "stop_mismatch_ends_one_tick_before_proof",
      "description": "A stop mismatch clearing at 60 seconds cannot mature the independent timer.",
      "inputs": {
        "tower_fan_cmd": false,
        "tower_fan_status": [
          {
            "t": 0,
            "value": true
          },
          {
            "t": 60,
            "value": false
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        }
      ]
    },
    {
      "name": "mature_start_alarm_recovers",
      "description": "An already-mature failure-to-start clears immediately when independent run proof arrives.",
      "inputs": {
        "tower_fan_cmd": true,
        "tower_fan_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 600,
            "value": true
          }
        ]
      },
      "expect": [
        {
          "output": "yFailToStart",
          "from_s": 180,
          "to_s": 540,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 180,
          "to_s": 540,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 600,
          "to_s": 1800,
          "equals": false
        }
      ]
    },
    {
      "name": "mature_stop_alarm_recovers",
      "description": "An already-mature unexpected-run alarm clears when the fan stops.",
      "inputs": {
        "tower_fan_cmd": false,
        "tower_fan_status": [
          {
            "t": 0,
            "value": true
          },
          {
            "t": 600,
            "value": false
          }
        ]
      },
      "expect": [
        {
          "output": "yUnexpectedRun",
          "from_s": 180,
          "to_s": 540,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 180,
          "to_s": 540,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 600,
          "to_s": 1800,
          "equals": false
        }
      ]
    },
    {
      "name": "direction_reversal_restarts_timer",
      "description": "A direct mismatch reversal clears the old lane and serves the new lane's complete timer.",
      "inputs": {
        "tower_fan_cmd": [
          {
            "t": 0,
            "value": true
          },
          {
            "t": 600,
            "value": false
          }
        ],
        "tower_fan_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 600,
            "value": true
          }
        ]
      },
      "expect": [
        {
          "output": "yFailToStart",
          "from_s": 180,
          "to_s": 540,
          "equals": true
        },
        {
          "output": "yFailToStart",
          "from_s": 600,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 600,
          "to_s": 660,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 780,
          "to_s": 1800,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 780,
          "to_s": 1800,
          "equals": true
        }
      ]
    },
    {
      "name": "command_chatter_never_matures",
      "description": "Alternating command ownership resets both direction-specific candidates.",
      "inputs": {
        "tower_fan_cmd": [
          {
            "t": 0,
            "value": true
          },
          {
            "t": 60,
            "value": false
          },
          {
            "t": 120,
            "value": true
          },
          {
            "t": 180,
            "value": false
          },
          {
            "t": 240,
            "value": true
          },
          {
            "t": 300,
            "value": false
          }
        ],
        "tower_fan_status": false
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        }
      ]
    },
    {
      "name": "status_chatter_never_matures",
      "description": "Status chatter around a steady start command cannot accumulate across healthy samples.",
      "inputs": {
        "tower_fan_cmd": true,
        "tower_fan_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 60,
            "value": true
          },
          {
            "t": 120,
            "value": false
          },
          {
            "t": 180,
            "value": true
          },
          {
            "t": 240,
            "value": false
          },
          {
            "t": 300,
            "value": true
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        }
      ]
    },
    {
      "name": "two_short_start_mismatches_do_not_accumulate",
      "description": "Separated start-proof episodes each need their own complete duration.",
      "inputs": {
        "tower_fan_cmd": true,
        "tower_fan_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 60,
            "value": true
          },
          {
            "t": 300,
            "value": false
          },
          {
            "t": 360,
            "value": true
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        }
      ]
    },
    {
      "name": "pending_direction_reversal_starts_fresh_lane",
      "description": "A start mismatch reverses before maturity; the start lane never asserts and the stop lane receives a fresh timer.",
      "inputs": {
        "tower_fan_cmd": [
          {
            "t": 0,
            "value": true
          },
          {
            "t": 60,
            "value": false
          }
        ],
        "tower_fan_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 60,
            "value": true
          }
        ]
      },
      "expect": [
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 120,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 240,
          "to_s": 1800,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 240,
          "to_s": 1800,
          "equals": true
        }
      ]
    },
    {
      "name": "unexpected_run_chatter_never_matures",
      "description": "Stop-lane status chatter repeatedly restores healthy agreement before its proof timer matures.",
      "inputs": {
        "tower_fan_cmd": false,
        "tower_fan_status": [
          {
            "t": 0,
            "value": true
          },
          {
            "t": 60,
            "value": false
          },
          {
            "t": 180,
            "value": true
          },
          {
            "t": 240,
            "value": false
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        }
      ]
    },
    {
      "name": "two_short_stop_mismatches_do_not_accumulate",
      "description": "Separated unexpected-run episodes each require their own complete duration.",
      "inputs": {
        "tower_fan_cmd": false,
        "tower_fan_status": [
          {
            "t": 0,
            "value": true
          },
          {
            "t": 60,
            "value": false
          },
          {
            "t": 300,
            "value": true
          },
          {
            "t": 360,
            "value": false
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        }
      ]
    },
    {
      "name": "upstream_enable_raw_false_positive",
      "description": "A plant enable misbound as this fan's final command raises the raw start alarm for an unselected cell; the host must report NO_EVAL.",
      "inputs": {
        "tower_fan_cmd": true,
        "tower_fan_status": false
      },
      "expect": [
        {
          "output": "yFailToStart",
          "from_s": 180,
          "to_s": 1800,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 180,
          "to_s": 1800,
          "equals": true
        }
      ]
    },
    {
      "name": "command_echo_raw_blind_spot",
      "description": "An echoed command masquerading as status keeps the raw graph clear even when physical truth is unknown; independent proof is mandatory.",
      "inputs": {
        "tower_fan_cmd": true,
        "tower_fan_status": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        }
      ]
    },
    {
      "name": "manual_local_run_is_host_excluded",
      "description": "A locally owned fan produces a raw unexpected-run alarm, but the host excludes the ownership window.",
      "inputs": {
        "tower_fan_cmd": false,
        "tower_fan_status": true
      },
      "expect": [
        {
          "output": "yUnexpectedRun",
          "from_s": 180,
          "to_s": 1800,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 180,
          "to_s": 1800,
          "equals": true
        }
      ]
    }
  ]
}

TOWER-0005 — Condenser water overcooling with fan energy

Statusverified — engine e2ff2f8, cxf:fnv1a128:b4c4b0066b62393e887a37208afffd2e, 2026-08-20
Severity3
Methodrule
Phase2
CategoryEXCESS_CONSUMPTION
ConfidenceMEDIUM
EstimationPROXY_ESTIMATION
G36
Clusters
Suppresses
Suppressed by
RelatedTOWER-0001, TOWER-0002, TOWER-0004, CHW-0005
Playbookscooling-tower-performance
SourceNREL, Refrigeration Playbook: Natural Refrigerants (2021), cooling-tower control discussion near p.45 — variable-speed fans seek leaving-water setpoint and turn off when leaving water is below setpoint; mechanism only; EnergyPlus Engineering Reference, Cooling Towers and Evaporative Fluid Coolers — fan-off free convection and fan modulation/cycling are normal ways to meet tower outlet setpoint; simulation semantics only; Library-authored thresholds and persistence; no cited source publishes 1 K, 30%, or 600 s as portable fault limits
Operating statesTower cell enabled for normal mechanical heat rejection with an active setpoint, proven fan operation, and normal automatic isolation/bypass control

Preconditions (host-enforced): Bind tower_leaving_temp to the cold water leaving this tower/cell and tower_leaving_temp_sp to the active target for that same stream; the latter is commonly named condenser-water supply or entering-chiller setpoint. Parallel cells may share a common setpoint only when that target truly applies to each cell and per-cell outlet sensing exists. Fan status and speed must describe the same cell: status is independent run proof, while speed is preferably feedback. A verified command proxy is allowed only with a documented limitation and must not be used where local control, current limiting, or drive overrides can separate command from delivered airflow. Configure minimum_fan_speed from the same-cell feedback/power relationship and effective drive minimum before adoption; the shipped 30% is NO_PORTABLE_DEFAULT. Points must be fresh and time-aligned. Exclude waterside economizer/free cooling, thermal-storage charging, emergency heat rejection, mandated low-condenser-water modes, startup/shutdown transients, drain-down, manual/local operation, and abnormal isolation or bypass states. Sensor calibration and topology must be valid; otherwise report NO_EVAL.

Points: tower_leaving_temp, tower_leaving_temp_sp, tower_fan_status, tower_fan_speed

Outputs:

  • yFault — True after material overcooling and proven loaded fan operation persist continuously for sustained_duration
  • yOvercooled — True while active tower-leaving setpoint exceeds measured leaving temperature by strictly more than overcooling_allowance
  • yFanLoaded — True while this fan is independently proven on and speed is strictly above minimum_fan_speed

Parameters:

NameDefaultUnitCXF pathDescription
overcooling_allowance1.0Kovercooled.tAllowed tower-leaving temperature undershoot. ADOPTED_TUNABLE: set outside combined sensor, setpoint-distribution, and control-loop uncertainty.
minimum_fan_speed30.0%speedHigh.tSpeed above which fan use is considered materially loaded. NO_PORTABLE_DEFAULT: 30% is an adoption-blocking placeholder; configure from measured same-cell fan feedback/power and the drive’s effective minimum. The strict graph comparison leaves exactly 30% clear.
sustained_duration600.0spersist.delayTimeContinuous overcooling with loaded fan required before alarm. ADOPTED_TUNABLE: longer than ordinary setpoint and cell-stage response.

Description

A healthy variable-speed tower reduces or stops fan work when leaving water is already colder than its active target. This rule finds the narrower waste case: the water is materially below setpoint while the same cell is proven running above a meaningful fan speed. Cold water with the fan off remains explicitly silent; natural convection is normal operation, not a fault.

Detection Logic

temperature_error = tower_leaving_temp_sp - tower_leaving_temp
overcooled         = temperature_error > overcooling_allowance
fan_loaded         = tower_fan_status AND tower_fan_speed > minimum_fan_speed
candidate          = overcooled AND fan_loaded

yOvercooled = overcooled
yFanLoaded  = fan_loaded
yFault      = candidate sustained for sustained_duration

TOWER-0005 block graph

The algebra avoids a second subtract block: setpoint minus measured leaving temperature is positive when the tower overcools. Both comparisons are strict, so exactly 1 K of undershoot or exactly 30% fan speed remains clear. TrueDelay(delayOnInit=true) resets immediately if temperature recovers, fan proof drops, or speed unloads.

Possible Diagnoses

  1. Leaving-water setpoint is reset upward but the tower sequence still follows a stale, local, or differently scaled setpoint
  2. Fan minimum speed, VFD floor, or cell-stage deadband is too high
  3. Fan is in hand/local, speed command is overridden, or drive feedback is wrong
  4. Isolation or bypass valve is stuck/mis-sequenced, making the sensed stream unrepresentative of the controlled common header
  5. Leaving-water sensor reads low or active setpoint is mapped to the wrong node
  6. Cell lead/lag logic keeps too many fans loaded after the heat-rejection load falls
  7. A legitimate low-water strategy was not excluded by the host

Energy Impact

EXCESS_CONSUMPTION with MEDIUM confidence. The direct waste is tower-fan electricity during the persisted condition. Integrating measured same-cell fan power gives a conservative upper bound, not guaranteed savings: the corrected sequence may still require some fan work. Chiller energy can move in either direction with condenser-water temperature and must not be added without a site-specific optimum-lift model.

Emissions Impact

Scope 2 from avoidable fan electricity, using the site’s marginal operating emissions rate. No portable savings or emissions fraction is claimed.

Deviations

  • No source establishes the three numeric defaults. The 1 K allowance and 10-minute persistence are adopted commissioning starts. The 30% loaded floor is NO_PORTABLE_DEFAULT: configure it from same-cell feedback/power before adoption because a speed percentage is not a universal meaningful-energy line.
  • The 1 K allowance must exceed measurement uncertainty. Common-header setpoints compared with individual-cell outlet temperatures can create a false undershoot on parallel towers; topology and setpoint distribution are preconditions, not hidden graph assumptions.
  • Speed feedback is preferred. A verified command proxy is weaker because a drive in local, limited, or overridden operation can deliver something else. The host must label that binding and avoid near-threshold conclusions.
  • This rule stays outside CLU-10. It is a control/energy finding, not the condenser-side degradation syndrome triggered by TOWER-0001.
  • No suppression is encoded. TOWER-0004 failure-to-start can invalidate the fan premise, while its unexpected-run direction can cause this exact fault; whole-rule suppression would erase a useful diagnosis.
  • Simulation validates healthy FPR only, not causal TPR. The recorded Denver July campaign uses native per-object outlet temperature, fan power, and airflow ratio at 60 s. Airflow ratio is an effective-airflow proxy rather than mechanical VFD feedback, and the parallel towers share one loop target.

Notes

Read both diagnostic outputs before dispatch. yOvercooled=true with yFanLoaded=false is often normal free convection. yFanLoaded=true without overcooling says the fan may simply be doing useful work.

Test Vectors

20 scenarios, clock step 60 s over 2400 s.

ScenarioDescription
on_setpoint_fan_loadedA loaded fan at the active leaving-water setpoint is not overcooling.
naturally_cold_fan_offCold water with the fan proven off exposes temperature context but never raises the energy finding.
cold_fan_below_minimumA running fan below the strict diagnostic speed floor is not treated as materially loaded.
cold_loaded_at_initializationA standing violation serves the complete initialization-safe sustained duration.
temperature_exact_boundary_clearExactly 1 K below setpoint is clear because the comparator is strict.
temperature_just_beyond_boundaryOne test increment beyond the adopted temperature allowance is overcooled.
speed_exact_boundary_clearExactly 30 percent is clear because loaded-fan comparison is strict.
speed_just_beyond_boundaryOne test increment above the speed floor is materially loaded.
violation_ends_one_tick_before_persistenceA 540-second candidate clears one evaluator tick before the 600-second delay and never alarms.
mature_alarm_recoversAn already-mature overcooling alarm clears immediately when leaving water returns to setpoint.
fan_unload_resets_timerDropping below the loaded-fan floor resets persistence even while water remains cold.
setpoint_change_resets_candidateA temporary active-setpoint increase creates a sub-duration candidate that clears when the setpoint returns.
status_false_blocks_high_speedA high speed value without independent run proof cannot satisfy the loaded-fan gate.
two_short_episodes_do_not_accumulateSeparated overcooling episodes each need a complete sustained duration.
threshold_chatter_never_maturesTemperature chatter across the strict allowance repeatedly resets persistence.
startup_violation_uses_full_delayA candidate beginning after healthy initialization receives the same complete delay.
both_thresholds_just_beyondTemperature and speed one test increment beyond their strict boundaries mature the full finding.
speed_threshold_chatter_never_maturesSpeed chatter across the strict loaded threshold repeatedly resets persistence.
setpoint_changes_but_candidate_remains_trueA setpoint change that leaves error above the allowance does not reset the Boolean candidate’s timer.
intentional_low_water_mode_is_host_excludedIntentional free-cooling or mandated low-condenser-water operation raises the raw graph; the host must report NO_EVAL for that mode.
vectors.json
{
  "schema": "cxf-library/vectors/v1",
  "clock": {
    "step_s": 60,
    "horizon_s": 2400
  },
  "scenarios": [
    {
      "name": "on_setpoint_fan_loaded",
      "description": "A loaded fan at the active leaving-water setpoint is not overcooling.",
      "inputs": {
        "tower_leaving_temp": 25.0,
        "tower_leaving_temp_sp": 25.0,
        "tower_fan_status": true,
        "tower_fan_speed": 50.0
      },
      "expect": [
        {
          "output": "yOvercooled",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yFanLoaded",
          "from_s": 0,
          "to_s": 2400,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        }
      ]
    },
    {
      "name": "naturally_cold_fan_off",
      "description": "Cold water with the fan proven off exposes temperature context but never raises the energy finding.",
      "inputs": {
        "tower_leaving_temp": 23.0,
        "tower_leaving_temp_sp": 25.0,
        "tower_fan_status": false,
        "tower_fan_speed": 0.0
      },
      "expect": [
        {
          "output": "yOvercooled",
          "from_s": 0,
          "to_s": 2400,
          "equals": true
        },
        {
          "output": "yFanLoaded",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        }
      ]
    },
    {
      "name": "cold_fan_below_minimum",
      "description": "A running fan below the strict diagnostic speed floor is not treated as materially loaded.",
      "inputs": {
        "tower_leaving_temp": 23.0,
        "tower_leaving_temp_sp": 25.0,
        "tower_fan_status": true,
        "tower_fan_speed": 20.0
      },
      "expect": [
        {
          "output": "yOvercooled",
          "from_s": 0,
          "to_s": 2400,
          "equals": true
        },
        {
          "output": "yFanLoaded",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        }
      ]
    },
    {
      "name": "cold_loaded_at_initialization",
      "description": "A standing violation serves the complete initialization-safe sustained duration.",
      "inputs": {
        "tower_leaving_temp": 23.0,
        "tower_leaving_temp_sp": 25.0,
        "tower_fan_status": true,
        "tower_fan_speed": 50.0
      },
      "expect": [
        {
          "output": "yOvercooled",
          "from_s": 0,
          "to_s": 2400,
          "equals": true
        },
        {
          "output": "yFanLoaded",
          "from_s": 0,
          "to_s": 2400,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 540,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 660,
          "to_s": 2400,
          "equals": true
        }
      ]
    },
    {
      "name": "temperature_exact_boundary_clear",
      "description": "Exactly 1 K below setpoint is clear because the comparator is strict.",
      "inputs": {
        "tower_leaving_temp": 24.0,
        "tower_leaving_temp_sp": 25.0,
        "tower_fan_status": true,
        "tower_fan_speed": 50.0
      },
      "expect": [
        {
          "output": "yOvercooled",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        }
      ]
    },
    {
      "name": "temperature_just_beyond_boundary",
      "description": "One test increment beyond the adopted temperature allowance is overcooled.",
      "inputs": {
        "tower_leaving_temp": 23.99,
        "tower_leaving_temp_sp": 25.0,
        "tower_fan_status": true,
        "tower_fan_speed": 50.0
      },
      "expect": [
        {
          "output": "yOvercooled",
          "from_s": 0,
          "to_s": 2400,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 660,
          "to_s": 2400,
          "equals": true
        }
      ]
    },
    {
      "name": "speed_exact_boundary_clear",
      "description": "Exactly 30 percent is clear because loaded-fan comparison is strict.",
      "inputs": {
        "tower_leaving_temp": 23.0,
        "tower_leaving_temp_sp": 25.0,
        "tower_fan_status": true,
        "tower_fan_speed": 30.0
      },
      "expect": [
        {
          "output": "yFanLoaded",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        }
      ]
    },
    {
      "name": "speed_just_beyond_boundary",
      "description": "One test increment above the speed floor is materially loaded.",
      "inputs": {
        "tower_leaving_temp": 23.0,
        "tower_leaving_temp_sp": 25.0,
        "tower_fan_status": true,
        "tower_fan_speed": 30.01
      },
      "expect": [
        {
          "output": "yFanLoaded",
          "from_s": 0,
          "to_s": 2400,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 660,
          "to_s": 2400,
          "equals": true
        }
      ]
    },
    {
      "name": "violation_ends_one_tick_before_persistence",
      "description": "A 540-second candidate clears one evaluator tick before the 600-second delay and never alarms.",
      "inputs": {
        "tower_leaving_temp": [
          {
            "t": 0,
            "value": 23.0
          },
          {
            "t": 540,
            "value": 25.0
          }
        ],
        "tower_leaving_temp_sp": 25.0,
        "tower_fan_status": true,
        "tower_fan_speed": 50.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        }
      ]
    },
    {
      "name": "mature_alarm_recovers",
      "description": "An already-mature overcooling alarm clears immediately when leaving water returns to setpoint.",
      "inputs": {
        "tower_leaving_temp": [
          {
            "t": 0,
            "value": 23.0
          },
          {
            "t": 900,
            "value": 25.0
          }
        ],
        "tower_leaving_temp_sp": 25.0,
        "tower_fan_status": true,
        "tower_fan_speed": 50.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 660,
          "to_s": 840,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 900,
          "to_s": 2400,
          "equals": false
        }
      ]
    },
    {
      "name": "fan_unload_resets_timer",
      "description": "Dropping below the loaded-fan floor resets persistence even while water remains cold.",
      "inputs": {
        "tower_leaving_temp": 23.0,
        "tower_leaving_temp_sp": 25.0,
        "tower_fan_status": true,
        "tower_fan_speed": [
          {
            "t": 0,
            "value": 50.0
          },
          {
            "t": 300,
            "value": 20.0
          },
          {
            "t": 600,
            "value": 50.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1140,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 1260,
          "to_s": 2400,
          "equals": true
        }
      ]
    },
    {
      "name": "setpoint_change_resets_candidate",
      "description": "A temporary active-setpoint increase creates a sub-duration candidate that clears when the setpoint returns.",
      "inputs": {
        "tower_leaving_temp": 25.0,
        "tower_leaving_temp_sp": [
          {
            "t": 0,
            "value": 25.0
          },
          {
            "t": 300,
            "value": 27.0
          },
          {
            "t": 840,
            "value": 25.0
          }
        ],
        "tower_fan_status": true,
        "tower_fan_speed": 50.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        }
      ]
    },
    {
      "name": "status_false_blocks_high_speed",
      "description": "A high speed value without independent run proof cannot satisfy the loaded-fan gate.",
      "inputs": {
        "tower_leaving_temp": 23.0,
        "tower_leaving_temp_sp": 25.0,
        "tower_fan_status": false,
        "tower_fan_speed": 100.0
      },
      "expect": [
        {
          "output": "yFanLoaded",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        }
      ]
    },
    {
      "name": "two_short_episodes_do_not_accumulate",
      "description": "Separated overcooling episodes each need a complete sustained duration.",
      "inputs": {
        "tower_leaving_temp": [
          {
            "t": 0,
            "value": 23.0
          },
          {
            "t": 300,
            "value": 25.0
          },
          {
            "t": 600,
            "value": 23.0
          },
          {
            "t": 900,
            "value": 25.0
          }
        ],
        "tower_leaving_temp_sp": 25.0,
        "tower_fan_status": true,
        "tower_fan_speed": 50.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        }
      ]
    },
    {
      "name": "threshold_chatter_never_matures",
      "description": "Temperature chatter across the strict allowance repeatedly resets persistence.",
      "inputs": {
        "tower_leaving_temp": [
          {
            "t": 0,
            "value": 23.99
          },
          {
            "t": 120,
            "value": 24.01
          },
          {
            "t": 240,
            "value": 23.99
          },
          {
            "t": 360,
            "value": 24.01
          },
          {
            "t": 480,
            "value": 23.99
          },
          {
            "t": 600,
            "value": 24.01
          }
        ],
        "tower_leaving_temp_sp": 25.0,
        "tower_fan_status": true,
        "tower_fan_speed": 50.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        }
      ]
    },
    {
      "name": "startup_violation_uses_full_delay",
      "description": "A candidate beginning after healthy initialization receives the same complete delay.",
      "inputs": {
        "tower_leaving_temp": [
          {
            "t": 0,
            "value": 25.0
          },
          {
            "t": 300,
            "value": 23.0
          }
        ],
        "tower_leaving_temp_sp": 25.0,
        "tower_fan_status": true,
        "tower_fan_speed": 50.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 840,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 960,
          "to_s": 2400,
          "equals": true
        }
      ]
    },
    {
      "name": "both_thresholds_just_beyond",
      "description": "Temperature and speed one test increment beyond their strict boundaries mature the full finding.",
      "inputs": {
        "tower_leaving_temp": 23.99,
        "tower_leaving_temp_sp": 25.0,
        "tower_fan_status": true,
        "tower_fan_speed": 30.01
      },
      "expect": [
        {
          "output": "yOvercooled",
          "from_s": 0,
          "to_s": 2400,
          "equals": true
        },
        {
          "output": "yFanLoaded",
          "from_s": 0,
          "to_s": 2400,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 540,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 660,
          "to_s": 2400,
          "equals": true
        }
      ]
    },
    {
      "name": "speed_threshold_chatter_never_matures",
      "description": "Speed chatter across the strict loaded threshold repeatedly resets persistence.",
      "inputs": {
        "tower_leaving_temp": 23.0,
        "tower_leaving_temp_sp": 25.0,
        "tower_fan_status": true,
        "tower_fan_speed": [
          {
            "t": 0,
            "value": 30.01
          },
          {
            "t": 120,
            "value": 30.0
          },
          {
            "t": 240,
            "value": 30.01
          },
          {
            "t": 360,
            "value": 30.0
          },
          {
            "t": 480,
            "value": 30.01
          },
          {
            "t": 600,
            "value": 30.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        }
      ]
    },
    {
      "name": "setpoint_changes_but_candidate_remains_true",
      "description": "A setpoint change that leaves error above the allowance does not reset the Boolean candidate's timer.",
      "inputs": {
        "tower_leaving_temp": 23.0,
        "tower_leaving_temp_sp": [
          {
            "t": 0,
            "value": 25.0
          },
          {
            "t": 300,
            "value": 26.0
          }
        ],
        "tower_fan_status": true,
        "tower_fan_speed": 50.0
      },
      "expect": [
        {
          "output": "yOvercooled",
          "from_s": 0,
          "to_s": 2400,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 660,
          "to_s": 2400,
          "equals": true
        }
      ]
    },
    {
      "name": "intentional_low_water_mode_is_host_excluded",
      "description": "Intentional free-cooling or mandated low-condenser-water operation raises the raw graph; the host must report NO_EVAL for that mode.",
      "inputs": {
        "tower_leaving_temp": 23.0,
        "tower_leaving_temp_sp": 25.0,
        "tower_fan_status": true,
        "tower_fan_speed": 50.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 660,
          "to_s": 2400,
          "equals": true
        }
      ]
    }
  ]
}

TOWER-0006 — Cooling-tower basin freeze-protection failure

Statusverified — engine e2ff2f8, cxf:fnv1a128:6a926c5a2ff88dc03e2cfebe65d131c0, 2026-08-20
Severity2
Methodrule
Phase2
CategoryPROTECTIVE
ConfidenceMEDIUM
EstimationQUALITATIVE_ONLY
G36
Clusters
Suppresses
Suppressed by
Related
Playbookscooling-tower-performance
SourceEVAPCO, Cooling Towers Operation and Maintenance Instructions (2017), p.21 — remote sump is the most effective idle freeze strategy; basin heaters protect basin water but not external piping and are for idle/no-flow operation; SPX Cooling Technologies, Cooling Tower Fundamentals, 2nd ed., p.90 — basin heater systems use a thermostat and low-water protection; the example maintains at least 40 °F and warns of fire or heater burnout without water; SPX Cooling Technologies, Basin Heater System Engineering Data TECH-BH-19 — packaged basin heater control includes temperature control and low-water-level protection; Library-authored watchdog thresholds/timers; OEM/site freeze plan remains authoritative
Operating stateswet cooling tower with water intentionally present in its basin/sump and a monitored heater or equivalent freeze-protection device under automatic control

Preconditions (host-enforced): Adopt only where the site/OEM freeze plan intentionally retains basin water and makes this monitored heater or equivalent device responsible for protection. Exclude drained-down towers, remote-sump strategies with an empty outdoor basin, dry coolers/fluid coolers, seasonal shutdown, maintenance, manual operation, and any interval in which basin level is below the heater’s safe operating requirement. Never use this diagnostic to bypass low-water cutoff, thermostat, over-temperature, fire, electrical, or OEM safeties, and never energize a heater manually from an alarm. Bind tower_basin_heater_cmd to the final post-safety request and status to independent current, power, contactor, or thermal proof rather than a command echo. Basin temperature must represent the bulk water, away from the local heater plume; OAT must represent the tower exposure. Points must be fresh and time-aligned. Configure both minimum_basin_temp and thermal_response_time from the actual OEM/site plan before evaluation; both shipped values are NO_PORTABLE_DEFAULT and otherwise require NO_EVAL. Basin heaters do not protect external piping, pumps, or heat exchangers.

Points: oat, tower_basin_temp, tower_basin_heater_cmd, tower_basin_heater_status

Outputs:

  • yFault — True while either the independent heater-proof lane or the freeze-exposed low-basin-temperature lane has matured
  • yHeaterFailToRun — True after a final heater command lacks independent proof for heater_proof_time
  • yLowBasinTemp — True after representative basin water remains below its configured minimum during freeze exposure for thermal_response_time
  • yFreezeExposure — True while OAT is strictly below freeze_exposure_oat; a diagnostic subcondition only, not a rule-wide data-quality flag (false does not mean NO_EVAL)

Parameters:

NameDefaultUnitCXF pathDescription
freeze_exposure_oat2.0°CfreezeExposure.tOutdoor exposure threshold. ADOPTED_TUNABLE: review against site climate, sensor bias, wind exposure, and OEM freeze strategy.
minimum_basin_temp4.0°CbasinLow.tMinimum representative basin temperature during exposure. NO_PORTABLE_DEFAULT: 4 °C is an adoption-blocking placeholder; replace it with the OEM/site freeze-plan value before evaluation.
heater_proof_time120.0sheaterProof.delayTimeAllowed independent electrical/operating proof delay. ADOPTED_TUNABLE: exceed contactor/current sensing and telemetry latency.
thermal_response_time1800.0sthermalProof.delayTimeContinuous low bulk-water temperature during exposure required for the thermal alarm. NO_PORTABLE_DEFAULT: 1800 s is an adoption-blocking placeholder; configure for basin volume, heater capacity, circulation, sensor location, and OEM response requirements.

Description

This watchdog exposes two different failures without pretending either is a complete freeze-control system. The electrical lane finds a final heater request that lacks independent proof. The thermal lane finds bulk basin water that remains below a site/OEM minimum while outdoor conditions meet the configured freeze-exposure threshold. Either is a prompt to follow the approved freeze plan—not permission to defeat safeties or energize equipment manually.

Detection Logic

freeze_exposure = oat < freeze_exposure_oat
heater_mismatch = tower_basin_heater_cmd AND NOT tower_basin_heater_status
low_condition   = freeze_exposure AND tower_basin_temp < minimum_basin_temp

yHeaterFailToRun = heater_mismatch sustained for heater_proof_time
yLowBasinTemp     = low_condition sustained for thermal_response_time
yFreezeExposure   = freeze_exposure
yFault            = yHeaterFailToRun OR yLowBasinTemp

TOWER-0006 block graph

Both comparators are strict: exactly 2 °C OAT is not exposure and exactly 4 °C basin temperature is not low under the shipped graph. Each lane has an independent TrueDelay(delayOnInit=true) and clears immediately when its own condition clears.

Possible Diagnoses

yHeaterFailToRun:

  1. Open disconnect, breaker/fuse, contactor, control transformer, or heater element
  2. Low-water, thermostat, over-temperature, or OEM safety correctly blocking operation
  3. Failed current/power proof or status mapped to the wrong heater circuit
  4. Final command is not actually downstream of local thermostat/safety logic

yLowBasinTemp:

  1. Heater is undersized, failed, staged incorrectly, or not receiving voltage
  2. Basin temperature sensor is biased, poorly located, or in a stagnant pocket
  3. Basin level, wind exposure, leakage, or unintended circulation exceeds the design basis
  4. Site intended drain-down/remote-sump operation but the host failed to suppress evaluation
  5. The configured minimum or exposure threshold does not match the OEM freeze plan

Energy Impact

No energy savings estimate. This is a protective finding whose value is avoided freeze damage, loss of heat rejection, water release, and unsafe inspection conditions. The opposite electrical direction—heater proven on with command off—is deliberately not included and may become a future energy rule.

Emissions Impact

Scope 2, qualitative only. Avoided repair and refrigerant/water consequences are real but outside the point set and cannot be defensibly converted to operational emissions here.

Deviations

  • minimum_basin_temp = 4 °C is not portable. OEM examples commonly describe about 40 °F protection, but strategy, equipment, ambient design, circulation, glycol, sensor location, and basin geometry differ. The shipped value is an adoption-blocking placeholder, not a universal safety setpoint.
  • The 2 °C exposure threshold and 120 s electrical timer are adopted. The 1800 s thermal response is NO_PORTABLE_DEFAULT because basin volume, heater capacity, circulation, and sensor placement dominate it. No cited source publishes this complete watchdog algorithm or these limits.
  • The graph does not encode water level. Low-water cutoff is a mandatory independent safety and a host precondition. Never infer that yFault=false makes heater operation safe.
  • A proven heater does not suppress low basin temperature. Electrical proof is not thermal adequacy, which is why the two lanes remain independent.
  • Command false/status true is intentionally silent in this graph, not safe. It may indicate stale proof, a stuck contactor, or unintended heater operation with property/fire consequences—especially without water. Route it to the OEM/site safety workflow immediately; a future rule may separately quantify the excess-energy direction.
  • Basin protection is not plant protection. OEM literature explicitly warns that a basin heater does not protect external piping, pumps, or heat exchangers; the full site drain-down/circulation plan remains authoritative.
  • No EnergyPlus validation is claimed. Simulated basin-heater electricity is neither an independent command/proof pair nor a validation of the physical safety strategy.

Notes

Follow the site/OEM freeze procedure before field inspection. Confirm basin level and electrical isolation from a safe location. Do not bypass low-water or other safeties, and do not manually energize a heater based on this diagnostic.

Test Vectors

28 scenarios, clock step 60 s over 4200 s.

ScenarioDescription
warm_ambient_heater_offA warm wet tower with no heater request is healthy.
freeze_exposure_safe_basinExposure alone is diagnostic context and does not alarm while basin temperature is safe.
heater_mismatch_at_initializationA standing heater command/proof mismatch serves the full electrical proof delay.
low_basin_at_initializationA cold basin during exposure serves the complete initialization-safe thermal delay.
oat_exact_boundary_clearExactly 2 C is outside the strict freeze-exposure condition.
oat_just_below_boundaryOne test increment below 2 C enters freeze exposure.
basin_exact_boundary_clearExactly 4 C is outside the strict low-basin condition.
basin_just_below_boundaryOne test increment below the site-configured minimum matures the thermal branch.
heater_mismatch_ends_one_tick_before_proofIndependent proof arriving at 60 seconds prevents the 120-second timer from maturing.
mature_heater_alarm_recoversA mature electrical proof alarm clears immediately when independent proof arrives.
low_condition_ends_one_tick_before_thermal_proofA 1740-second cold-basin episode clears one evaluator tick before maturity.
mature_low_basin_alarm_recoversAn already-mature thermal alarm clears when representative basin temperature recovers.
exposure_ends_before_thermal_durationOutdoor exposure ending at 1740 seconds resets the thermal candidate.
basin_recovers_before_thermal_durationRepresentative basin recovery before the duration prevents a protective alarm.
heater_proven_but_basin_stays_lowElectrical heater proof cannot mask a basin that remains thermally unsafe.
uncommanded_heater_status_is_uncovered_hazardCommand false and status true is intentionally silent in this graph but may be an uncontrolled-heater hazard; route it through the OEM/site safety workflow.
low_basin_without_exposure_clearA low reading without configured outdoor exposure does not enter the thermal protection lane.
both_branches_mature_independentlyElectrical proof and low-basin protection can both mature without masking one another.
heater_command_chatter_never_maturesShort command/proof mismatches cannot accumulate across healthy intervals.
two_short_low_basin_episodes_do_not_accumulateSeparated thermal episodes each require a complete 1800-second duration.
heater_status_chatter_never_maturesIndependent proof repeatedly restores agreement before the electrical timer can mature.
oat_threshold_chatter_resets_thermal_laneOutdoor temperature alternating across the strict exposure threshold prevents continuous thermal maturity.
basin_threshold_chatter_resets_thermal_laneBasin temperature alternating across the strict configured minimum prevents continuous maturity.
electrical_to_thermal_branch_handoffHeater proof clears a mature electrical alarm, then the independently pending cold-basin lane matures later.
mature_exposure_alarm_recoversA mature low-basin alarm clears when outdoor exposure ends even if the basin reading remains low.
mature_heater_alarm_clears_on_command_withdrawalThe electrical lane clears when its final heater request is withdrawn.
drained_tower_raw_false_positiveA meaningless low basin reading on an intentionally drained tower matures the raw thermal graph; applicability gating must report NO_EVAL.
heater_command_echo_raw_blind_spotAn echoed command presented as status keeps the electrical graph clear without proving current or heat; independent proof is mandatory.
vectors.json
{
  "schema": "cxf-library/vectors/v1",
  "clock": {
    "step_s": 60,
    "horizon_s": 4200
  },
  "scenarios": [
    {
      "name": "warm_ambient_heater_off",
      "description": "A warm wet tower with no heater request is healthy.",
      "inputs": {
        "oat": 10.0,
        "tower_basin_temp": 10.0,
        "tower_basin_heater_cmd": false,
        "tower_basin_heater_status": false
      },
      "expect": [
        {
          "output": "yFreezeExposure",
          "from_s": 0,
          "to_s": 4200,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 4200,
          "equals": false
        }
      ]
    },
    {
      "name": "freeze_exposure_safe_basin",
      "description": "Exposure alone is diagnostic context and does not alarm while basin temperature is safe.",
      "inputs": {
        "oat": 0.0,
        "tower_basin_temp": 6.0,
        "tower_basin_heater_cmd": false,
        "tower_basin_heater_status": false
      },
      "expect": [
        {
          "output": "yFreezeExposure",
          "from_s": 0,
          "to_s": 4200,
          "equals": true
        },
        {
          "output": "yLowBasinTemp",
          "from_s": 0,
          "to_s": 4200,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 4200,
          "equals": false
        }
      ]
    },
    {
      "name": "heater_mismatch_at_initialization",
      "description": "A standing heater command/proof mismatch serves the full electrical proof delay.",
      "inputs": {
        "oat": 0.0,
        "tower_basin_temp": 6.0,
        "tower_basin_heater_cmd": true,
        "tower_basin_heater_status": false
      },
      "expect": [
        {
          "output": "yHeaterFailToRun",
          "from_s": 0,
          "to_s": 60,
          "equals": false
        },
        {
          "output": "yHeaterFailToRun",
          "from_s": 180,
          "to_s": 4200,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 60,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 180,
          "to_s": 4200,
          "equals": true
        }
      ]
    },
    {
      "name": "low_basin_at_initialization",
      "description": "A cold basin during exposure serves the complete initialization-safe thermal delay.",
      "inputs": {
        "oat": 0.0,
        "tower_basin_temp": 3.0,
        "tower_basin_heater_cmd": false,
        "tower_basin_heater_status": false
      },
      "expect": [
        {
          "output": "yLowBasinTemp",
          "from_s": 0,
          "to_s": 1740,
          "equals": false
        },
        {
          "output": "yLowBasinTemp",
          "from_s": 1860,
          "to_s": 4200,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1740,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 1860,
          "to_s": 4200,
          "equals": true
        }
      ]
    },
    {
      "name": "oat_exact_boundary_clear",
      "description": "Exactly 2 C is outside the strict freeze-exposure condition.",
      "inputs": {
        "oat": 2.0,
        "tower_basin_temp": 3.0,
        "tower_basin_heater_cmd": false,
        "tower_basin_heater_status": false
      },
      "expect": [
        {
          "output": "yFreezeExposure",
          "from_s": 0,
          "to_s": 4200,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 4200,
          "equals": false
        }
      ]
    },
    {
      "name": "oat_just_below_boundary",
      "description": "One test increment below 2 C enters freeze exposure.",
      "inputs": {
        "oat": 1.99,
        "tower_basin_temp": 6.0,
        "tower_basin_heater_cmd": false,
        "tower_basin_heater_status": false
      },
      "expect": [
        {
          "output": "yFreezeExposure",
          "from_s": 0,
          "to_s": 4200,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 4200,
          "equals": false
        }
      ]
    },
    {
      "name": "basin_exact_boundary_clear",
      "description": "Exactly 4 C is outside the strict low-basin condition.",
      "inputs": {
        "oat": 0.0,
        "tower_basin_temp": 4.0,
        "tower_basin_heater_cmd": false,
        "tower_basin_heater_status": false
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 4200,
          "equals": false
        }
      ]
    },
    {
      "name": "basin_just_below_boundary",
      "description": "One test increment below the site-configured minimum matures the thermal branch.",
      "inputs": {
        "oat": 0.0,
        "tower_basin_temp": 3.99,
        "tower_basin_heater_cmd": false,
        "tower_basin_heater_status": false
      },
      "expect": [
        {
          "output": "yLowBasinTemp",
          "from_s": 1860,
          "to_s": 4200,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 1860,
          "to_s": 4200,
          "equals": true
        }
      ]
    },
    {
      "name": "heater_mismatch_ends_one_tick_before_proof",
      "description": "Independent proof arriving at 60 seconds prevents the 120-second timer from maturing.",
      "inputs": {
        "oat": 0.0,
        "tower_basin_temp": 6.0,
        "tower_basin_heater_cmd": true,
        "tower_basin_heater_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 60,
            "value": true
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 4200,
          "equals": false
        }
      ]
    },
    {
      "name": "mature_heater_alarm_recovers",
      "description": "A mature electrical proof alarm clears immediately when independent proof arrives.",
      "inputs": {
        "oat": 0.0,
        "tower_basin_temp": 6.0,
        "tower_basin_heater_cmd": true,
        "tower_basin_heater_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 600,
            "value": true
          }
        ]
      },
      "expect": [
        {
          "output": "yHeaterFailToRun",
          "from_s": 180,
          "to_s": 540,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 180,
          "to_s": 540,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 600,
          "to_s": 4200,
          "equals": false
        }
      ]
    },
    {
      "name": "low_condition_ends_one_tick_before_thermal_proof",
      "description": "A 1740-second cold-basin episode clears one evaluator tick before maturity.",
      "inputs": {
        "oat": 0.0,
        "tower_basin_temp": [
          {
            "t": 0,
            "value": 3.0
          },
          {
            "t": 1740,
            "value": 6.0
          }
        ],
        "tower_basin_heater_cmd": false,
        "tower_basin_heater_status": false
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 4200,
          "equals": false
        }
      ]
    },
    {
      "name": "mature_low_basin_alarm_recovers",
      "description": "An already-mature thermal alarm clears when representative basin temperature recovers.",
      "inputs": {
        "oat": 0.0,
        "tower_basin_temp": [
          {
            "t": 0,
            "value": 3.0
          },
          {
            "t": 2400,
            "value": 6.0
          }
        ],
        "tower_basin_heater_cmd": false,
        "tower_basin_heater_status": false
      },
      "expect": [
        {
          "output": "yLowBasinTemp",
          "from_s": 1860,
          "to_s": 2340,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 1860,
          "to_s": 2340,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 2400,
          "to_s": 4200,
          "equals": false
        }
      ]
    },
    {
      "name": "exposure_ends_before_thermal_duration",
      "description": "Outdoor exposure ending at 1740 seconds resets the thermal candidate.",
      "inputs": {
        "oat": [
          {
            "t": 0,
            "value": 0.0
          },
          {
            "t": 1740,
            "value": 5.0
          }
        ],
        "tower_basin_temp": 3.0,
        "tower_basin_heater_cmd": false,
        "tower_basin_heater_status": false
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 4200,
          "equals": false
        }
      ]
    },
    {
      "name": "basin_recovers_before_thermal_duration",
      "description": "Representative basin recovery before the duration prevents a protective alarm.",
      "inputs": {
        "oat": 0.0,
        "tower_basin_temp": [
          {
            "t": 0,
            "value": 3.0
          },
          {
            "t": 1200,
            "value": 6.0
          }
        ],
        "tower_basin_heater_cmd": false,
        "tower_basin_heater_status": false
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 4200,
          "equals": false
        }
      ]
    },
    {
      "name": "heater_proven_but_basin_stays_low",
      "description": "Electrical heater proof cannot mask a basin that remains thermally unsafe.",
      "inputs": {
        "oat": 0.0,
        "tower_basin_temp": 3.0,
        "tower_basin_heater_cmd": true,
        "tower_basin_heater_status": true
      },
      "expect": [
        {
          "output": "yHeaterFailToRun",
          "from_s": 0,
          "to_s": 4200,
          "equals": false
        },
        {
          "output": "yLowBasinTemp",
          "from_s": 1860,
          "to_s": 4200,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 1860,
          "to_s": 4200,
          "equals": true
        }
      ]
    },
    {
      "name": "uncommanded_heater_status_is_uncovered_hazard",
      "description": "Command false and status true is intentionally silent in this graph but may be an uncontrolled-heater hazard; route it through the OEM/site safety workflow.",
      "inputs": {
        "oat": 0.0,
        "tower_basin_temp": 6.0,
        "tower_basin_heater_cmd": false,
        "tower_basin_heater_status": true
      },
      "expect": [
        {
          "output": "yHeaterFailToRun",
          "from_s": 0,
          "to_s": 4200,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 4200,
          "equals": false
        }
      ]
    },
    {
      "name": "low_basin_without_exposure_clear",
      "description": "A low reading without configured outdoor exposure does not enter the thermal protection lane.",
      "inputs": {
        "oat": 5.0,
        "tower_basin_temp": 3.0,
        "tower_basin_heater_cmd": false,
        "tower_basin_heater_status": false
      },
      "expect": [
        {
          "output": "yFreezeExposure",
          "from_s": 0,
          "to_s": 4200,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 4200,
          "equals": false
        }
      ]
    },
    {
      "name": "both_branches_mature_independently",
      "description": "Electrical proof and low-basin protection can both mature without masking one another.",
      "inputs": {
        "oat": 0.0,
        "tower_basin_temp": 3.0,
        "tower_basin_heater_cmd": true,
        "tower_basin_heater_status": false
      },
      "expect": [
        {
          "output": "yHeaterFailToRun",
          "from_s": 180,
          "to_s": 4200,
          "equals": true
        },
        {
          "output": "yLowBasinTemp",
          "from_s": 1860,
          "to_s": 4200,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 180,
          "to_s": 4200,
          "equals": true
        }
      ]
    },
    {
      "name": "heater_command_chatter_never_matures",
      "description": "Short command/proof mismatches cannot accumulate across healthy intervals.",
      "inputs": {
        "oat": 0.0,
        "tower_basin_temp": 6.0,
        "tower_basin_heater_cmd": [
          {
            "t": 0,
            "value": true
          },
          {
            "t": 60,
            "value": false
          },
          {
            "t": 180,
            "value": true
          },
          {
            "t": 240,
            "value": false
          }
        ],
        "tower_basin_heater_status": false
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 4200,
          "equals": false
        }
      ]
    },
    {
      "name": "two_short_low_basin_episodes_do_not_accumulate",
      "description": "Separated thermal episodes each require a complete 1800-second duration.",
      "inputs": {
        "oat": 0.0,
        "tower_basin_temp": [
          {
            "t": 0,
            "value": 3.0
          },
          {
            "t": 1200,
            "value": 6.0
          },
          {
            "t": 1800,
            "value": 3.0
          },
          {
            "t": 3000,
            "value": 6.0
          }
        ],
        "tower_basin_heater_cmd": false,
        "tower_basin_heater_status": false
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 4200,
          "equals": false
        }
      ]
    },
    {
      "name": "heater_status_chatter_never_matures",
      "description": "Independent proof repeatedly restores agreement before the electrical timer can mature.",
      "inputs": {
        "oat": 0.0,
        "tower_basin_temp": 6.0,
        "tower_basin_heater_cmd": true,
        "tower_basin_heater_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 60,
            "value": true
          },
          {
            "t": 180,
            "value": false
          },
          {
            "t": 240,
            "value": true
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 4200,
          "equals": false
        }
      ]
    },
    {
      "name": "oat_threshold_chatter_resets_thermal_lane",
      "description": "Outdoor temperature alternating across the strict exposure threshold prevents continuous thermal maturity.",
      "inputs": {
        "oat": [
          {
            "t": 0,
            "value": 1.99
          },
          {
            "t": 600,
            "value": 2.0
          },
          {
            "t": 1200,
            "value": 1.99
          },
          {
            "t": 1800,
            "value": 2.0
          }
        ],
        "tower_basin_temp": 3.0,
        "tower_basin_heater_cmd": false,
        "tower_basin_heater_status": false
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 4200,
          "equals": false
        }
      ]
    },
    {
      "name": "basin_threshold_chatter_resets_thermal_lane",
      "description": "Basin temperature alternating across the strict configured minimum prevents continuous maturity.",
      "inputs": {
        "oat": 0.0,
        "tower_basin_temp": [
          {
            "t": 0,
            "value": 3.99
          },
          {
            "t": 600,
            "value": 4.0
          },
          {
            "t": 1200,
            "value": 3.99
          },
          {
            "t": 1800,
            "value": 4.0
          }
        ],
        "tower_basin_heater_cmd": false,
        "tower_basin_heater_status": false
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 4200,
          "equals": false
        }
      ]
    },
    {
      "name": "electrical_to_thermal_branch_handoff",
      "description": "Heater proof clears a mature electrical alarm, then the independently pending cold-basin lane matures later.",
      "inputs": {
        "oat": 0.0,
        "tower_basin_temp": 3.0,
        "tower_basin_heater_cmd": true,
        "tower_basin_heater_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 600,
            "value": true
          }
        ]
      },
      "expect": [
        {
          "output": "yHeaterFailToRun",
          "from_s": 180,
          "to_s": 540,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 180,
          "to_s": 540,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 600,
          "to_s": 1740,
          "equals": false
        },
        {
          "output": "yLowBasinTemp",
          "from_s": 1860,
          "to_s": 4200,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 1860,
          "to_s": 4200,
          "equals": true
        }
      ]
    },
    {
      "name": "mature_exposure_alarm_recovers",
      "description": "A mature low-basin alarm clears when outdoor exposure ends even if the basin reading remains low.",
      "inputs": {
        "oat": [
          {
            "t": 0,
            "value": 0.0
          },
          {
            "t": 2400,
            "value": 5.0
          }
        ],
        "tower_basin_temp": 3.0,
        "tower_basin_heater_cmd": false,
        "tower_basin_heater_status": false
      },
      "expect": [
        {
          "output": "yLowBasinTemp",
          "from_s": 1860,
          "to_s": 2340,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 1860,
          "to_s": 2340,
          "equals": true
        },
        {
          "output": "yFreezeExposure",
          "from_s": 2400,
          "to_s": 4200,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2400,
          "to_s": 4200,
          "equals": false
        }
      ]
    },
    {
      "name": "mature_heater_alarm_clears_on_command_withdrawal",
      "description": "The electrical lane clears when its final heater request is withdrawn.",
      "inputs": {
        "oat": 0.0,
        "tower_basin_temp": 6.0,
        "tower_basin_heater_cmd": [
          {
            "t": 0,
            "value": true
          },
          {
            "t": 600,
            "value": false
          }
        ],
        "tower_basin_heater_status": false
      },
      "expect": [
        {
          "output": "yHeaterFailToRun",
          "from_s": 180,
          "to_s": 540,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 180,
          "to_s": 540,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 600,
          "to_s": 4200,
          "equals": false
        }
      ]
    },
    {
      "name": "drained_tower_raw_false_positive",
      "description": "A meaningless low basin reading on an intentionally drained tower matures the raw thermal graph; applicability gating must report NO_EVAL.",
      "inputs": {
        "oat": 0.0,
        "tower_basin_temp": 0.0,
        "tower_basin_heater_cmd": false,
        "tower_basin_heater_status": false
      },
      "expect": [
        {
          "output": "yLowBasinTemp",
          "from_s": 1860,
          "to_s": 4200,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 1860,
          "to_s": 4200,
          "equals": true
        }
      ]
    },
    {
      "name": "heater_command_echo_raw_blind_spot",
      "description": "An echoed command presented as status keeps the electrical graph clear without proving current or heat; independent proof is mandatory.",
      "inputs": {
        "oat": 0.0,
        "tower_basin_temp": 6.0,
        "tower_basin_heater_cmd": true,
        "tower_basin_heater_status": true
      },
      "expect": [
        {
          "output": "yHeaterFailToRun",
          "from_s": 0,
          "to_s": 4200,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 4200,
          "equals": false
        }
      ]
    }
  ]
}

VAV Fault Rules

VAV terminal unit fault detection rules (VAV-*). Source grounding: HVAC FDD Reference v1.0 ch.10 (adapted authority — see each card’s Deviations section). The chapter’s economics are multiplicative: a building has dozens or hundreds of boxes, so a small per-box inefficiency — an oversized minimum, a leaking reheat valve — compounds into the “74% problem” class of building-wide waste. Excess minimum flow (VAV-0001) is PNNL’s top-performing office EEM.

Point dictionary: points/vav.points.json.

Index

IDNameSevMethodStatus
VAV-0001Minimum airflow setpoint too high3ruleverified
VAV-0002Rogue zone driving AHU reset3ruleverified
VAV-0003Reheat valve open with zone satisfied3ruleverified
VAV-0004Airflow tracking error3ruleverified
VAV-0005Damper hunting / oscillation3ruleverified
VAV-0006Reheat waste during cooling season3ruleverified
VAV-0010Zone temperature sensor drift (neighbor-median)3statisticalverified
VAV-0007VAV airflow tracking CUSUM3statisticalverified
VAV-0008Zone temperature CUSUM3statisticalverified
VAV-0009Reheat coil leakage CUSUM3statisticalverified

Severity and method per the reference’s ch.10 cards (its §5.8.2 index carries no severity column). VAV-0010 is the neighbor-median drift rule the CUSUM batch reserved: it adjudicates zone_temp (verdict: invalid_while_active), deciding whether the VPACC cards’ input can be believed.

VAV-0007/VAV-0008/VAV-0009 are the VPACC trio (NIST/CEC PIER Project 2.3 §5.1): two-sided CUSUM charts over the three per-box error signals, the library’s first feedback-loop topology (Discrete.UnitDelay accumulators with in-graph occupancy reset). A box without a discharge-air sensor runs 101/102 as the source’s reduced two-channel VPACC; 103 is the one channel that needs vav_dat. Parameter defaults are calibrated by the committed harness method (tools/simharness vavcal) plus the source’s Iowa Energy Center campaign, and remain per-box commissioning values on every card.

Relationships

  • VAV-0001 / 052 / 055 are the zone-level reheat-waste family — the terminal-unit end of CLU-02’s “74% problem” (AHU-0019/AHU-0023 see the same defect from the air handler’s side via zone_reheat_fraction).
  • VAV-0002 is the zone-side cause of the reset failures AHU-0023/AHU-0024 detect at the AHU: one rogue zone holds the reset down for everyone.
  • VAV-0004/VAV-0005 are the box-mechanics pair (tracking and stability); VAV-0005 is AHU-0022’s zone-level sibling and reuses its detector patterns.

VAV-0001 — VAV minimum airflow setpoint too high

Statusverified — engine e2ff2f8, cxf:fnv1a128:e251b00bd4acce37ca4d81521c086a37, 2026-08-17
Severity3
Methodrule
Phase2
CategoryEXCESS_CONSUMPTION
ConfidenceHIGH
EstimationPROXY_ESTIMATION
G36
Clusters
Suppresses
Suppressed by
RelatedAHU-0019, VAV-0003, VAV-0006
Playbooksvav-min-flow-reheat
SourceHVAC FDD Reference v1.0 §10, VAV-0001; PNNL-25985 EEM-15 (minimum VAV flow reductions); PNNL retuning
Operating statesheating and deadband (host-gated)

Preconditions (host-enforced): The AHU serving this VAV box is running and the zone is in occupied mode. Both are host-enforced: an unoccupied box sitting at a high programmed minimum costs nothing while no air is moving, and the ventilation requirement this rule compares against is an occupied-mode quantity. The host must also confirm that zone_airflow_sp_min carries the box’s configured minimum in L/s — the number the damper loop clamps to, not the active setpoint and not a design value in CFM — and that ventilation_requirement has been set for this specific zone (see Deviations; the shipped default is a placeholder from the reference’s own test vectors, not a site value).

Points: zone_airflow_sp_min, rht_vlv_cmd

Outputs:

  • yFault — True while the programmed minimum exceeds the scaled ventilation requirement with reheat active, continuously for at least alarm_delay

Parameters:

NameDefaultUnitCXF pathDescription
ventilation_requirement70.0L/sventReq.kThe zone’s design minimum outdoor-air requirement per ASHRAE 62.1. PER-ZONE SITE CONFIGURATION — the reference gives no default and the shipped 70.0 is a placeholder; a host that leaves it unset is comparing against an arbitrary number.
min_flow_multiplier1.51scaled.kHow far above the ventilation requirement a programmed minimum may sit before it counts as oversized
reheat_active_threshold10.0%rhtOn.tReheat valve command above which the coil counts as actively heating
alarm_delay3600.0spersist.delayTimeContinuous fault persistence required before the alarm asserts (60 min)

Description

The box is configured never to deliver less than some floor of airflow, and that floor sits far above what the zone needs for ventilation. Every hour the zone is not calling for cooling, the box pushes cold supply air it does not need into the space and the reheat coil pays to warm it back up — the fan on one side, the boiler on the other, with the zone comfortable throughout, so nothing about the symptom points at the cause. The fault lives in a number a commissioning technician typed rather than in any equipment behavior, which is why the second term matters: an oversized minimum only costs money while the reheat coil is running against it. The setpoint test finds the defect, the reheat test proves it is being paid for now. The economics are multiplicative — the same default-minimum habit repeats across every box in a building — and PNNL-25985 ranks minimum VAV flow reduction (EEM-15) as the highest-impact individual retuning measure for offices, at 5–16% of site energy.

Detection Logic

sp_high = zone_airflow_sp_min > (ventilation_requirement × min_flow_multiplier)
rht_on  = rht_vlv_cmd > reheat_active_threshold

yFault  = (sp_high AND rht_on) sustained continuously for alarm_delay

Block graph (rule.cxf.jsonld):

VAV-0001 block graph

At the shipped defaults the trip point is 70 × 1.5 = 105 L/s (about 222 cfm), so a box configured at the reference’s 235 L/s is flagged and one at 95 L/s is not — but the 70 L/s is a placeholder, and the rule is only as meaningful as the per-zone ventilation_requirement a host binds in its place (see Deviations). Both comparisons are strict, as the reference writes them: a box configured at exactly the allowance and a valve reported at exactly 10% both clear. The 60-minute persist delay matters more here than the block count suggests. Neither input is noisy — a configured minimum does not jitter — so the delay is not filtering measurement noise; it is filtering morning warm-up, when a box legitimately runs reheat at whatever minimum it has while the space recovers from setback. An hour of continuous reheat against an oversized minimum is no longer warm-up. delayOnInit = true holds that window across a restart.

Possible Diagnoses

  1. Minimum flow setpoint set too high during commissioning — the common case, usually a default the technician never revisited
  2. Minimum flow reset logic disabled: the box supports a dynamic minimum (dual-maximum, or a ventilation reset driven by occupancy) and it was switched off or never enabled
  3. Code-required minimum genuinely higher than necessary — overdesign in the original ventilation calculation, a design-review item rather than a BAS fix

Energy Impact

EXCESS_CONSUMPTION, HIGH confidence, PROXY_ESTIMATION. Two subsystems pay at once: the reheat coil warms air the zone never needed and the supply fan moves it. The reference’s 10–20% excess reheat energy is the per-box figure; PNNL-25985’s EEM-15 gives the building-scale one at 5–16% of site energy, and for a 50,000 ft² office at $2/ft² the playbook puts annual recovery at $5,000–16,000. Estimation is PROXY because the rule sees a setpoint and a valve command, not thermal flow: the waste share (zone_airflow_sp_min − ventilation_requirement) / zone_airflow_sp_min leans entirely on ventilation_requirement being right. Heating-dominant, though the fan share runs whenever the box does.

Emissions Impact

Scope 1 + 2, PROXY_EMISSIONS, HIGH confidence; typically 200–1,500 kg CO₂e/yr per zone. The split follows the two subsystems — gas at the boiler serving the reheat coil is scope 1, fan electricity is scope 2 — and hydronic reheat off an electric boiler or heat pump moves the whole thing into scope 2. Avoided-emissions basis: marginal operating emissions rate (MOER).

Deviations

  • ventilation_requirement ships with a placeholder default. The reference gives it as “Config” — no number, because the value comes from that zone’s ASHRAE 62.1 calculation. This card ships 70.0 L/s (the reference’s own test value, roughly an 1,800 ft² office at 8 occupants) so the document is runnable as delivered. It is not a site value; hosts MUST set ventReq.k per box. Set it 2× too high and the rule never fires on a genuinely oversized box, 2× too low and every box in the building alarms. It is the only parameter in the rule whose default carries no authority.
  • reheat_active_threshold is adopted, not transcribed. It appears in the reference’s equation but not its tunables table. This card adopts 10.0%, the value the same chapter gives VAV-0006’s identically-named parameter, which asks the same question of the same point. Sites whose valve commands park at a nonzero rest position should retune above that position.
  • zone_airflow is dropped from the points list. The reference’s points table lists it but its equation never uses it — the test compares a configured setpoint against a ventilation requirement. It is verification context, not a rule input, and carrying it would force every host to bind a point the graph ignores. Precedent: AHU-0029 drops oat for the same reason.
  • A configuration value is consumed as a live point. Hosts whose BAS does not expose the configured minimum as readable cannot run this rule from a trend archive alone; they need a config export bound as a point (Min_Air_Flow_Setpoint_Limit in the dictionary). Binding the active airflow setpoint instead breaks the rule — it rises above the minimum whenever the zone calls for cooling, producing alarms during normal cooling.
  • AlarmDelay = 60 min becomes persist.delayTime = 3600 s with delayOnInit = true (Modelica/CDL default is false), the library’s standing choice: a box already faulted at load waits out the full hour rather than alarming on the first tick after a restart.
  • Operating states (heating, deadband) and preconditions (AHU running, zone occupied) are declared in frontmatter for host enforcement rather than encoded in the block graph, per the library’s design stance.
  • Frontmatter clusters is empty. CLU-05 covers this fault’s neighbours (VAV-0003, VAV-0006, SYS-0007) but chapter 7 does not list VAV-0001 among its members, and this card does not edit the cluster definition. The relationship is carried by the shared playbook and by related; worth revisiting when the cluster set is next reviewed, since an oversized minimum is diagnosis 1 for VAV-0006.
  • g36: null. This is a research-backed 050-range rule; G36’s own guidance on VAV minimums (20% of design airflow or the ventilation minimum, whichever is greater) informs the playbook’s remediation but is not the source of the detection logic.

Notes

Check the air handler before touching individual boxes: if this rule fires on more than half the boxes on one AHU, the boxes are probably fine and the supply air is too cold (AHU-0019). PNNL-27338’s AIRCx check is the sharper version — more than 25% of zones with reheat valves above 50% means look at the SAT reset first.

The remote fix is the vav-min-flow-reheat playbook’s step 2.1: reduce the minimum to the calculated ventilation requirement, reviewing the heating maximum on dual-maximum boxes at the same time. Clearing this fault should also quiet VAV-0006 on the same box, since an oversized minimum is its diagnosis 1; it will not quiet VAV-0003, which sees a valve open with the zone already satisfied.

Test Vectors

9 scenarios, clock step 300 s over 9000 s.

ScenarioDescription
normal_minimumReference vector: minimum airflow setpoint 95 L/s against a 70 L/s ventilation requirement, reheat valve at 40%. 95 sits below the 105 L/s trip point (70 × 1.5), so the reheat is heating air the zone is required to receive and nothing fires.
excessive_minimumReference vector: minimum airflow setpoint 235 L/s — 3.4× the ventilation requirement — with the reheat valve at 40%. The box is pushing cold air the zone does not need and paying the boiler to warm it back up. Both terms hold from t=0, so delayOnInit makes the alarm land one alarm_delay in.
high_min_no_reheatReference vector: the same oversized 235 L/s minimum with the reheat valve shut. The setpoint is still wrong, but no heat is being spent against it — this rule reports live energy waste, not a configuration audit, so it stays silent.
min_sp_exactly_at_multipleBoundary: the minimum setpoint sits exactly on ventilation_requirement × min_flow_multiplier (70.0 × 1.5 = 105.0) with reheat well open. The comparison is strict >, so a box configured precisely at the allowance clears.
min_sp_just_above_multipleBoundary from the other side: 105.1 L/s, one tenth of a litre per second over the allowance, with the same reheat. The setpoint test now passes and the alarm lands after alarm_delay.
reheat_exactly_at_thresholdBoundary on the reheat term: an oversized minimum with the valve reported at exactly reheat_active_threshold (10.0%). Strict > again, so a valve parked on the threshold — typical of a leak-by allowance or a quantized command — does not count as active.
reheat_just_above_thresholdBoundary from the other side: the same oversized minimum with the valve at 10.1%. Both terms hold and the alarm lands after alarm_delay.
transient_reheat_pulseThe oversized minimum is present all along, but reheat only runs for the first 3000 s — a morning warm-up burst that ends 600 s short of the 3600 s alarm_delay. The timer resets when the valve shuts and no alarm is ever raised.
alarm_clears_after_minimum_loweredRecovery: the alarm asserts at 3600 s, then the technician retunes the box minimum from 235 L/s down to 100 L/s at t=5400 s (playbook step 2.1). yFault drops on that same tick — TrueDelay only delays the rising edge — even though the reheat valve is still modulating.
vectors.json
{
  "schema": "cxf-library/vectors/v1",
  "clock": {
    "step_s": 300,
    "horizon_s": 9000
  },
  "scenarios": [
    {
      "name": "normal_minimum",
      "description": "Reference vector: minimum airflow setpoint 95 L/s against a 70 L/s ventilation requirement, reheat valve at 40%. 95 sits below the 105 L/s trip point (70 \u00d7 1.5), so the reheat is heating air the zone is required to receive and nothing fires.",
      "inputs": {
        "zone_airflow_sp_min": 95.0,
        "rht_vlv_cmd": 40.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 9000,
          "equals": false
        }
      ]
    },
    {
      "name": "excessive_minimum",
      "description": "Reference vector: minimum airflow setpoint 235 L/s \u2014 3.4\u00d7 the ventilation requirement \u2014 with the reheat valve at 40%. The box is pushing cold air the zone does not need and paying the boiler to warm it back up. Both terms hold from t=0, so delayOnInit makes the alarm land one alarm_delay in.",
      "inputs": {
        "zone_airflow_sp_min": 235.0,
        "rht_vlv_cmd": 40.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3300,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 3900,
          "to_s": 9000,
          "equals": true
        }
      ]
    },
    {
      "name": "high_min_no_reheat",
      "description": "Reference vector: the same oversized 235 L/s minimum with the reheat valve shut. The setpoint is still wrong, but no heat is being spent against it \u2014 this rule reports live energy waste, not a configuration audit, so it stays silent.",
      "inputs": {
        "zone_airflow_sp_min": 235.0,
        "rht_vlv_cmd": 0.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 9000,
          "equals": false
        }
      ]
    },
    {
      "name": "min_sp_exactly_at_multiple",
      "description": "Boundary: the minimum setpoint sits exactly on ventilation_requirement \u00d7 min_flow_multiplier (70.0 \u00d7 1.5 = 105.0) with reheat well open. The comparison is strict `>`, so a box configured precisely at the allowance clears.",
      "inputs": {
        "zone_airflow_sp_min": 105.0,
        "rht_vlv_cmd": 40.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 9000,
          "equals": false
        }
      ]
    },
    {
      "name": "min_sp_just_above_multiple",
      "description": "Boundary from the other side: 105.1 L/s, one tenth of a litre per second over the allowance, with the same reheat. The setpoint test now passes and the alarm lands after alarm_delay.",
      "inputs": {
        "zone_airflow_sp_min": 105.1,
        "rht_vlv_cmd": 40.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3300,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 3900,
          "to_s": 9000,
          "equals": true
        }
      ]
    },
    {
      "name": "reheat_exactly_at_threshold",
      "description": "Boundary on the reheat term: an oversized minimum with the valve reported at exactly reheat_active_threshold (10.0%). Strict `>` again, so a valve parked on the threshold \u2014 typical of a leak-by allowance or a quantized command \u2014 does not count as active.",
      "inputs": {
        "zone_airflow_sp_min": 235.0,
        "rht_vlv_cmd": 10.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 9000,
          "equals": false
        }
      ]
    },
    {
      "name": "reheat_just_above_threshold",
      "description": "Boundary from the other side: the same oversized minimum with the valve at 10.1%. Both terms hold and the alarm lands after alarm_delay.",
      "inputs": {
        "zone_airflow_sp_min": 235.0,
        "rht_vlv_cmd": 10.1
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3300,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 3900,
          "to_s": 9000,
          "equals": true
        }
      ]
    },
    {
      "name": "transient_reheat_pulse",
      "description": "The oversized minimum is present all along, but reheat only runs for the first 3000 s \u2014 a morning warm-up burst that ends 600 s short of the 3600 s alarm_delay. The timer resets when the valve shuts and no alarm is ever raised.",
      "inputs": {
        "zone_airflow_sp_min": 235.0,
        "rht_vlv_cmd": [
          {
            "t": 0,
            "value": 40.0
          },
          {
            "t": 3000,
            "value": 0.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 9000,
          "equals": false
        }
      ]
    },
    {
      "name": "alarm_clears_after_minimum_lowered",
      "description": "Recovery: the alarm asserts at 3600 s, then the technician retunes the box minimum from 235 L/s down to 100 L/s at t=5400 s (playbook step 2.1). yFault drops on that same tick \u2014 TrueDelay only delays the rising edge \u2014 even though the reheat valve is still modulating.",
      "inputs": {
        "zone_airflow_sp_min": [
          {
            "t": 0,
            "value": 235.0
          },
          {
            "t": 5400,
            "value": 100.0
          }
        ],
        "rht_vlv_cmd": 40.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3300,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 3900,
          "to_s": 5100,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 5700,
          "to_s": 9000,
          "equals": false
        }
      ]
    }
  ]
}

VAV-0002 — Rogue zone driving AHU reset

Statusverified — engine e2ff2f8, cxf:fnv1a128:37348fd287c6a793b5a1d0b6f46b350a, 2026-08-17
Severity3
Methodrule
Phase2
CategoryEXCESS_CONSUMPTION
ConfidenceMEDIUM
EstimationPROXY_ESTIMATION
G36
Clusters
Suppresses
Suppressed by
RelatedAHU-0019, AHU-0023, AHU-0024
Playbooks
SourceHVAC FDD Reference v1.0 §10, VAV-0002; PNNL retuning measures; PNNL-25985 EEM-15
Operating statesCooling modes

Preconditions (host-enforced): AHU running and serving multiple zones — a reset loop with two or three boxes on it has no majority to be rogue against. satisfied_zone_fraction must be fresh and computed over the zones on this AHU’s reset loop; when the aggregate is stale, or covers too few zones for a fraction to mean anything, the verdict is NO_EVAL rather than healthy. The zone’s own request must be reaching the AHU reset logic: a box whose requests never arrive cannot be driving anything, and its saturated request is a comms finding instead.

Points: zone_clg_request, satisfied_zone_fraction

Outputs:

  • yFault — True once this zone’s cooling request has been saturated with more than satisfied_threshold of sibling zones satisfied, continuously for rogue_duration plus alarm_delay

Parameters:

NameDefaultUnitCXF pathDescription
request_max_threshold99.0%reqMax.tCooling request above which the zone counts as asking for maximum cooling
satisfied_threshold0.81mostHappy.tFraction of sibling zones satisfied (0-1) above which this zone is the outlier rather than the messenger
rogue_duration3600.0srogue.delayTimeContinuous duration of the saturated request with most sibling zones satisfied before the zone counts as rogue (60 min)
alarm_delay1800.0spersist.delayTimeFurther persistence required after rogue_duration before the alarm asserts (30 min)

Description

One zone holds its cooling request at maximum while the rest of the building is comfortable. Trim-and-respond does what it was written to do: the AHU answers the loudest zone, walks its supply temperature or duct static setpoint to the aggressive end of the reset range, and leaves it there. The cost lands on every other box on that air handler — colder air than they asked for, which they reheat, or more static pressure than they need, which the fan pays for cubed — while the rogue zone itself usually stays uncomfortable, because whatever is wrong with it is not something more cooling fixes. The whole design turns on telling a rogue zone from a messenger: a saturated request means “I need more cooling”, and what makes it a fault is the company it keeps. With half the building unsatisfied the same request is a correct report of a building-wide shortfall, and firing on it would send a technician to the wrong end of the system.

Detection Logic

yFault = zone_clg_request > request_max_threshold
     AND satisfied_zone_fraction > satisfied_threshold
     sustained continuously for rogue_duration
     and then held a further alarm_delay

Block graph (rule.cxf.jsonld):

VAV-0002 block graph

Two threshold tests feed one conjunction and then two delays in series: rogue turns true only after the conjunction has held for rogue_duration (60 min), and persist requires a further alarm_delay (30 min), so a zone that never lets go alarms at 5400 s. Any break in either term drops both timers and discards the accumulated time — the alarm only ever describes one continuous rogue episode, and a zone that recovers between morning warm-up peaks never raises it. Both comparisons are strict, so a request sitting exactly on 99.0% and a satisfaction fraction of exactly 0.80 both stay clear. Both delayOnInit flags are true, so a rogue condition already present at load waits out the full 90 minutes; the trade is that in-rule timing does not survive a restart, which is the conservative direction.

Possible Diagnoses

  1. Zone has an internal load the design never accounted for — a server closet, a copy room, a tenant fit-out that added people or equipment
  2. Zone thermostat miscalibrated or badly sited — direct sun on the sensor, a diffuser blowing across it, or a drift the zone cannot argue with
  3. VAV box undersized for the actual zone load, so full cooling airflow still cannot hold the setpoint
  4. Stuck VAV damper: the box commands full open, the blade does not move, and the zone stays hot no matter what the AHU sends it (confirm with VAV-0004 before believing the load story)

Energy Impact

EXCESS_CONSUMPTION, MEDIUM confidence, PROXY_ESTIMATION. The reference gives 3–10% of AHU energy, mapped to PNNL-25985 EEM-15. Estimation is PROXY because the waste is a counterfactual — ahu_penalty_kw ≈ ahu_actual_energy − ahu_optimal_energy, what the AHU would have consumed at the setpoint the rest of the building would have permitted — and neither term is measured directly. Climate sensitivity is both: a rogue zone dragging supply air down costs chiller energy in summer and reheat energy in winter, and a rogue airflow request costs fan energy year-round. The multiplier is what makes it worth catching: one box holds a reset that serves thirty or three hundred.

Emissions Impact

Scope 2, PROXY_EMISSIONS, MEDIUM confidence; typically 100–800 kg CO₂e/yr for the AHU-level penalty one zone imposes. Scope 2 because the penalty is dominated by fan and chiller electricity; a site whose reheat is gas or steam moves the reheat share of the consequential waste into scope 1, which this card leaves to VAV-0003 and VAV-0006 to account for. Avoided-emissions basis: marginal operating emissions rate (MOER).

Deviations

  • zone_temp and zone_temp_sp are dropped. The reference lists both in its points table but its equation consumes neither — they are inputs to the host’s derivation of “satisfied”, which the equation reads only through the ratio. Inputs to a host derivation are not rule points (precedent: AHU-0029 consumes expected_mode rather than the oat behind it).
  • satisfied_zones / total_zones becomes the host-derived point satisfied_zone_fraction. Library v1 avoids array boundary points, so the host counts satisfied siblings and feeds one fraction, flagged derived in the point dictionary. Same pattern as zone_reheat_fraction in AHU-0019.
  • satisfied_threshold is a fraction 0–1, not a percent. The reference states 80%; the point it compares against carries unit 1, so the default is 0.8. A host feeding a 0–100 percentage will never fire this rule.
  • MAX_COOLING is implemented as a threshold at 99.0%, an adopted value the reference does not state. Equality against 100.0 is the wrong test for a real signal: a request scaled from a 16-bit analog value lands on 99.6 and an equality test would never fire. > 99.0 reads “saturated at its ceiling” while excluding a genuine 99% request one count short of maximum, and request_max_threshold is exposed so a site with a coarser signal can lower it.
  • The satisfied_zone_fraction comparison is strict >, which is what the reference writes — no boundary deviation on that term, though it is the boundary where two hosts’ definitions of “satisfied” will disagree.
  • Two delays in series rather than one. rogue_duration and AlarmDelay are separate tunables in the reference, so they stay separately tunable here even though a single 5400 s delay behaves identically at the defaults (precedent: AHU-0027).
  • rogue.delayOnInit and persist.delayOnInit are both true (Modelica/CDL default is false), the library’s standing choice against alarming on the first tick after a controller restart.
  • The reference tags this fault for VAV and AHU. This card is the VAV-family instance, bound to one box’s request and its siblings’ satisfaction (precedent: AHU-0025); the AHU-side view is covered by AHU-0019, AHU-0023 and AHU-0024, which see the reset stuck at its limit without knowing which zone is holding it there.
  • Operating-state gating (cooling modes) and the multi-zone precondition are declared in frontmatter for host enforcement rather than encoded in the block graph, per the library’s design stance.

Notes

playbooks is empty because no playbook here covers the zone-side diagnosis this fault opens; the nearest relative is missing-reset, whose step 1.3 (do zone requests reach the AHU controller?) is this rule’s precondition check read from the other end.

Fix order matters when this rule fires alongside AHU-0023 or AHU-0024. A rogue zone is the classic reason a correctly programmed reset still looks dead in trend data — the setpoint sits at the aggressive end all day because one box keeps voting for it — which is exactly the signature those two statistical rules detect. Fix the zone first; the reset should then start modulating within an occupied day and the AHU-side faults clear on their own.

G36 sites run discrete cooling requests, not a continuous 0–100 signal. The point dictionary’s binding note for zone_clg_request covers the mapping: bind 100 to importance-weighted requests at their maximum and record the convention with the point. This rule only tests for saturation at the ceiling, so it survives the translation as long as the ceiling is the same number on both sides.

Test Vectors

10 scenarios, clock step 300 s over 21600 s.

ScenarioDescription
normal_diversityReference vector: the zone asks for 80% cooling while 70% of its siblings are satisfied. A box working partway up its request range is the system doing its job — normal load diversity, no rogue.
rogue_zone_others_satisfiedReference vector: the zone pins its request at 100% while 90% of the building is satisfied, held for 90 min. One box is dragging the AHU reset for everyone; the alarm lands at rogue_duration (3600 s) + alarm_delay (1800 s) = 5400 s.
many_zones_unsatisfiedReference vector: the same saturated request, but only 50% of zones are satisfied. Half the building is hot — the AHU should be resetting its setpoint down, and the zone asking for it is right. A building-wide cooling shortfall is an AHU or plant finding, not a rogue zone.
request_exactly_at_thresholdBoundary: the request sits exactly on request_max_threshold (99.0%) with 90% of zones satisfied. The test is strict >, so a request one count short of the adopted ceiling is not saturation and nothing fires.
quantized_request_just_above_thresholdBoundary from the other side: a box reporting 99.2% — a scaled 16-bit request that never renders as exactly 100 — clears the 99.0 threshold and alarms on the same 5400 s schedule as a clean 100%. This is why the threshold is 99.0 rather than 100.0.
satisfaction_exactly_at_thresholdBoundary: exactly 80% of zones satisfied — satisfied_threshold on the nose — against a saturated request. The reference writes >, so the fraction must exceed the threshold and a four-in-five split stays clear.
satisfaction_just_above_thresholdBoundary from the other side: 81% satisfied with the request saturated crosses satisfied_threshold and alarms at 5400 s.
request_released_before_rogue_durationTransient: the zone rides at maximum request for 50 min after a warm morning and then falls back to 60% at t=3000 s, 600 s short of rogue_duration. A zone that recovers on its own was tracking real load, so the duration timer discards the accumulated time and nothing is ever raised.
satisfaction_drops_mid_holdTransient: the request stays pinned, but the rest of the building loses ground at t=4200 s (0.90 to 0.60) and recovers at t=4800 s. rogue_duration had already elapsed and the alarm delay was running; the interruption drops both timers, so the alarm restarts from 4800 s and lands at 10200 s rather than 5400 s.
rogue_zone_alarms_then_clearsRecovery: the alarm asserts at 5400 s, then the zone is fixed at t=10800 s — the thermostat is re-sited out of the sun and the request falls to 40%. yFault drops on that tick, since TrueDelay only delays the rising edge.
vectors.json
{
  "schema": "cxf-library/vectors/v1",
  "clock": {
    "step_s": 300,
    "horizon_s": 21600
  },
  "scenarios": [
    {
      "name": "normal_diversity",
      "description": "Reference vector: the zone asks for 80% cooling while 70% of its siblings are satisfied. A box working partway up its request range is the system doing its job \u2014 normal load diversity, no rogue.",
      "inputs": {
        "zone_clg_request": 80.0,
        "satisfied_zone_fraction": 0.7
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 21600,
          "equals": false
        }
      ]
    },
    {
      "name": "rogue_zone_others_satisfied",
      "description": "Reference vector: the zone pins its request at 100% while 90% of the building is satisfied, held for 90 min. One box is dragging the AHU reset for everyone; the alarm lands at rogue_duration (3600 s) + alarm_delay (1800 s) = 5400 s.",
      "inputs": {
        "zone_clg_request": 100.0,
        "satisfied_zone_fraction": 0.9
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5100,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 5700,
          "to_s": 21600,
          "equals": true
        }
      ]
    },
    {
      "name": "many_zones_unsatisfied",
      "description": "Reference vector: the same saturated request, but only 50% of zones are satisfied. Half the building is hot \u2014 the AHU should be resetting its setpoint down, and the zone asking for it is right. A building-wide cooling shortfall is an AHU or plant finding, not a rogue zone.",
      "inputs": {
        "zone_clg_request": 100.0,
        "satisfied_zone_fraction": 0.5
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 21600,
          "equals": false
        }
      ]
    },
    {
      "name": "request_exactly_at_threshold",
      "description": "Boundary: the request sits exactly on request_max_threshold (99.0%) with 90% of zones satisfied. The test is strict `>`, so a request one count short of the adopted ceiling is not saturation and nothing fires.",
      "inputs": {
        "zone_clg_request": 99.0,
        "satisfied_zone_fraction": 0.9
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 21600,
          "equals": false
        }
      ]
    },
    {
      "name": "quantized_request_just_above_threshold",
      "description": "Boundary from the other side: a box reporting 99.2% \u2014 a scaled 16-bit request that never renders as exactly 100 \u2014 clears the 99.0 threshold and alarms on the same 5400 s schedule as a clean 100%. This is why the threshold is 99.0 rather than 100.0.",
      "inputs": {
        "zone_clg_request": 99.2,
        "satisfied_zone_fraction": 0.9
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5100,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 5700,
          "to_s": 21600,
          "equals": true
        }
      ]
    },
    {
      "name": "satisfaction_exactly_at_threshold",
      "description": "Boundary: exactly 80% of zones satisfied \u2014 satisfied_threshold on the nose \u2014 against a saturated request. The reference writes `>`, so the fraction must exceed the threshold and a four-in-five split stays clear.",
      "inputs": {
        "zone_clg_request": 100.0,
        "satisfied_zone_fraction": 0.8
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 21600,
          "equals": false
        }
      ]
    },
    {
      "name": "satisfaction_just_above_threshold",
      "description": "Boundary from the other side: 81% satisfied with the request saturated crosses satisfied_threshold and alarms at 5400 s.",
      "inputs": {
        "zone_clg_request": 100.0,
        "satisfied_zone_fraction": 0.81
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5100,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 5700,
          "to_s": 21600,
          "equals": true
        }
      ]
    },
    {
      "name": "request_released_before_rogue_duration",
      "description": "Transient: the zone rides at maximum request for 50 min after a warm morning and then falls back to 60% at t=3000 s, 600 s short of rogue_duration. A zone that recovers on its own was tracking real load, so the duration timer discards the accumulated time and nothing is ever raised.",
      "inputs": {
        "zone_clg_request": [
          {
            "t": 0,
            "value": 100.0
          },
          {
            "t": 3000,
            "value": 60.0
          }
        ],
        "satisfied_zone_fraction": 0.9
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 21600,
          "equals": false
        }
      ]
    },
    {
      "name": "satisfaction_drops_mid_hold",
      "description": "Transient: the request stays pinned, but the rest of the building loses ground at t=4200 s (0.90 to 0.60) and recovers at t=4800 s. rogue_duration had already elapsed and the alarm delay was running; the interruption drops both timers, so the alarm restarts from 4800 s and lands at 10200 s rather than 5400 s.",
      "inputs": {
        "zone_clg_request": 100.0,
        "satisfied_zone_fraction": [
          {
            "t": 0,
            "value": 0.9
          },
          {
            "t": 4200,
            "value": 0.6
          },
          {
            "t": 4800,
            "value": 0.9
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 9900,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 10500,
          "to_s": 21600,
          "equals": true
        }
      ]
    },
    {
      "name": "rogue_zone_alarms_then_clears",
      "description": "Recovery: the alarm asserts at 5400 s, then the zone is fixed at t=10800 s \u2014 the thermostat is re-sited out of the sun and the request falls to 40%. yFault drops on that tick, since TrueDelay only delays the rising edge.",
      "inputs": {
        "zone_clg_request": [
          {
            "t": 0,
            "value": 100.0
          },
          {
            "t": 10800,
            "value": 40.0
          }
        ],
        "satisfied_zone_fraction": 0.9
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5100,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 5700,
          "to_s": 10500,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 11100,
          "to_s": 21600,
          "equals": false
        }
      ]
    }
  ]
}

VAV-0003 — Reheat valve open with zone satisfied

Statusverified — engine e2ff2f8, cxf:fnv1a128:ea366afd07e1888e9fd147a855249bcc, 2026-08-17
Severity3
Methodrule
Phase2
CategoryCRITICAL_WASTE
ConfidenceHIGH
EstimationDIRECT_MEASUREMENT
G36
ClustersCLU-05
Suppresses
Suppressed by
RelatedAHU-0016, VAV-0001, VAV-0006
Playbooksvav-min-flow-reheat
SourceHVAC FDD Reference v1.0 §10, VAV-0003; PNNL retuning; PNNL-25985 EEM-15/38
Operating statesdeadband / satisfied (host-gated)

Preconditions (host-enforced): The AHU serving this VAV box is running — with no supply air moving, a reheat valve position means nothing thermally and the zone temperature is drifting on envelope loads rather than on anything the box is doing. The host must bind the ACTIVE occupied-mode heating and cooling setpoints the zone loop is tracking, not schedule defaults: during setback the effective band is wider than the occupied one and reheat inside the occupied band can be legitimate. The zone temperature sensor must be trustworthy — VAV-0010 territory — since a sensor reading high while the space is genuinely cold produces exactly this signature with nothing wrong.

Points: rht_vlv_cmd, zone_temp, zone_temp_sp_htg, zone_temp_sp_clg

Outputs:

  • yFault — True while the reheat valve is open past its threshold with the zone inside the satisfied band, continuously for at least alarm_delay

Parameters:

NameDefaultUnitCXF pathDescription
reheat_open_threshold15.0%rhtOn.tReheat valve command above which the coil counts as open
zone_deadband0.5°CaboveHtg.t, belowClg.tTolerance widening the satisfied band past each setpoint; binds both the lower and the upper bound
alarm_delay1800.0spersist.delayTimeContinuous fault persistence required before the alarm asserts (30 min)

Description

The zone is comfortable — sitting between its heating and cooling setpoints, asking for nothing — and the reheat coil is running anyway. Unlike an oversized minimum flow, where the reheat at least offsets air the ventilation code required, every kilowatt through this coil is waste with no offsetting benefit, which is why the category is CRITICAL_WASTE and the runtime estimate has no efficiency term: waste_kw = rht_vlv_cmd/100 × vav_rht_capacity_kw, the whole coil load. The fault hides well, because a satisfied zone with extra heat in it drifts up until the cooling loop opens the damper and takes the heat back out — the box masks its own symptom, and the only trace is two subsystems working against each other at a scale too small to see on a meter until you multiply by the number of boxes in the building.

Detection Logic

rht_on    = rht_vlv_cmd > reheat_open_threshold
satisfied = zone_temp > (zone_temp_sp_htg − zone_deadband)
        AND zone_temp < (zone_temp_sp_clg + zone_deadband)

yFault    = (rht_on AND satisfied) sustained continuously for alarm_delay

Block graph (rule.cxf.jsonld):

VAV-0003 block graph

Both band bounds are computed as a positive gap against the deadband — gapH is zone_temp_sp_htg − zone_temp, gapC is zone_temp − zone_temp_sp_clg — which keeps zone_deadband a single positive parameter instead of a value added on one side and subtracted on the other. Both bounds read that one value, so a host retuning it must set both CXF paths together. At the shipped defaults and the reference’s 21/23 °C setpoints the satisfied band runs 20.5 to 23.5 °C, exclusive at both ends (see Deviations): below it the zone is genuinely calling for heat and the coil is doing its job, above it the zone is in cooling territory and reheat there belongs to VAV-0006. The 30-minute persist delay covers ordinary overshoot — a heating loop that carries the zone a little past setpoint before releasing the coil shows this signature for a few minutes on every recovery cycle, and that is control, not a fault. delayOnInit = true holds the window across a restart.

Possible Diagnoses

  1. Reheat valve stuck or leaking — the actuator is not where the command says, or the seat passes flow at closed (hands off to the stuck-actuator playbook)
  2. Control sequence not releasing reheat when the zone is satisfied: the loop or the mode logic keeps a heating output alive past its drop-out point
  3. Incorrect valve stroke calibration — the actuator honors the command but the linkage maps 0% to a partly open plug

Energy Impact

CRITICAL_WASTE, HIGH confidence, DIRECT_MEASUREMENT. The reference gives 5–15% of zone thermal energy, under PNNL-25985’s EEM-15 and EEM-38 (minimum flow reduction and eliminating simultaneous heating and cooling). Estimation is DIRECT because the valve command is the waste: with the zone inside its deadband there is no useful heating output to subtract, so coil load scaled by command position is the whole figure and the only inference left is the coil’s rated capacity. Heating-dominant, though the waste itself does not care about the weather and in cooling season it compounds by loading the chiller as well.

Emissions Impact

Scope 1, DIRECT_EMISSIONS, HIGH confidence; typically 300–2,000 kg CO₂e/yr per zone. Scope 1 covers the usual case of a hydronic coil on a gas boiler. Sites with electric reheat or a heat-pump-fed hot water loop should read this as scope 2 and rescale — the kilowatts are the same but the inventory is not. Avoided-emissions basis: marginal operating emissions rate (MOER).

Deviations

  • The reference’s >= and <= band bounds become strict. CDL Reals has no GreaterEqual or LessEqual, so both bounds are LessThreshold on a gap and a zone sitting exactly on either edge reads as outside the satisfied band where the reference reads it inside. The disagreement has measure zero on a real temperature signal and errs toward silence. A host binding coarsely quantized zone temperatures — integer °C, or a BAS that rounds to 0.5 — should widen zone_deadband slightly rather than rely on the signal landing off the boundary.
  • zone_deadband is one card parameter bound to two CXF paths (aboveHtg.t, belowClg.t), matching the reference’s single tunable. Hosts must set both together; a site wanting an asymmetric band retunes the paths individually and notes the divergence. Precedent: AHU-0025’s valve_open_threshold.
  • Band bounds are expressed as gaps rather than shifted setpoints. Implementing zone_temp >= zone_temp_sp_htg − zone_deadband literally would need a constant subtracted from the setpoint and a two-input comparison per bound; the gap form gets the same answer with one Subtract and one threshold per bound and keeps zone_deadband a single positive set_param path. Algebraically identical apart from the strictness above.
  • AlarmDelay = 30 min becomes persist.delayTime = 1800 s with delayOnInit = true (Modelica/CDL default is false), the library’s standing choice against alarming on the first tick after a controller restart.
  • The rule does not test whether the AHU is running, whether the zone is occupied, or whether the zone temperature sensor is trustworthy; all three are frontmatter preconditions for host enforcement. The sensor one bites hardest — a zone sensor reading 2 °C high puts a genuinely cold space inside the satisfied band with the coil correctly responding, and this rule calls that a fault.
  • g36: null, and no G36 clause appears in source. G36 sequences terminal-unit reheat, but the reference derives this logic from the PNNL retuning work, and SCHEMA.md reserves the g36 field for the 001–049 range regardless.
  • Severity 3 (warning) is the reference’s chapter 10 value, kept despite the CRITICAL_WASTE category: the category describes the character of the waste, the severity the response urgency, and a leaking reheat valve on one box is not an emergency. The reference’s §5.8.2 index carries no severity column.

Notes

The remote discriminator is the vav-min-flow-reheat playbook’s step 2.2: command the valve to 0% and watch the zone. Temperature still climbing means the valve is not where it says it is (diagnosis 1, hand off to stuck-actuator); temperature responding and reheat returning once the override is released means the sequence is the problem (diagnosis 2, a programming fix); a valve that responds but never fully closes is diagnosis 3.

Within CLU-05 this rule is a member and VAV-0006 is the trigger. Where both fire on one box, fix the trigger first — reheat during cooling season usually traces to an oversized minimum or a too-low supply air temperature, and correcting either can quiet this rule without touching the valve. All three siblings watch the same valve: VAV-0001 asks whether the box is configured to force reheat, VAV-0006 whether reheat runs while the building is trying to cool, and this rule whether reheat runs when the zone wanted nothing at all.

Test Vectors

12 scenarios, clock step 300 s over 5400 s.

ScenarioDescription
zone_satisfied_no_reheatReference vector: zone at 22 °C between a 21 °C heating and 23 °C cooling setpoint, reheat valve shut. The zone is satisfied and nothing is being spent on it.
zone_satisfied_reheat_onReference vector: the same satisfied 22 °C zone with the reheat valve at 30%. Every kilowatt through that coil is waste. Both terms hold from t=0, so delayOnInit makes the alarm land one alarm_delay in.
zone_cold_reheat_onReference vector: zone at 19 °C, 2 °C below its heating setpoint, reheat at 30%. The coil is doing exactly what it is for — the lower band bound fails and the rule stays silent.
zone_hot_reheat_onThe other side of the band: zone at 24 °C, a full degree above the cooling setpoint, with reheat at 30%. This is a worse fault than the one being tested — reheat during a call for cooling — but it is VAV-0006’s, not this rule’s: satisfied fails on the upper bound and yFault stays clear.
zone_at_lower_band_edgeBoundary: zone exactly at zone_temp_sp_htg − zone_deadband (21.0 − 0.5 = 20.5 °C) with reheat at 30%. The gap is exactly 0.5 and aboveHtg tests < 0.5, so the zone reads as still calling for heat and the rule stays silent — the reference’s >= would have called it satisfied.
zone_just_inside_lower_band_edgeBoundary from the other side: 20.6 °C, one tenth of a degree inside the band, same 30% reheat. The zone now reads satisfied and the alarm lands after alarm_delay.
zone_at_upper_band_edgeBoundary: zone exactly at zone_temp_sp_clg + zone_deadband (23.0 + 0.5 = 23.5 °C) with reheat at 30%. belowClg tests < 0.5 on a gap of exactly 0.5, so the zone reads as outside the band and the rule stays silent — the mirror of the lower-edge deviation.
zone_just_inside_upper_band_edgeBoundary from the other side: 23.4 °C, one tenth of a degree inside the upper bound, same 30% reheat. Both band bounds hold and the alarm lands after alarm_delay.
reheat_exactly_at_thresholdBoundary on the valve term: satisfied zone with the valve reported at exactly reheat_open_threshold (15.0%). Strict >, so a valve sitting on the threshold does not raise the alarm.
reheat_just_above_thresholdBoundary from the other side: the same satisfied zone with the valve at 15.1%. The valve test passes and the alarm lands after alarm_delay.
transient_reheat_pulseThe zone is satisfied throughout, but reheat runs only for the first 1500 s — a control loop overshooting past setpoint before it releases the coil — which is 300 s short of the 1800 s alarm_delay. The timer resets and no alarm is raised.
alarm_clears_when_valve_closesRecovery: the alarm asserts at 1800 s, then the valve is commanded shut at t=3000 s (playbook step 2.2 — the discriminator between a stuck valve and a sequence bug) and it obeys. yFault drops on that same tick, since TrueDelay only delays the rising edge.
vectors.json
{
  "schema": "cxf-library/vectors/v1",
  "clock": {
    "step_s": 300,
    "horizon_s": 5400
  },
  "scenarios": [
    {
      "name": "zone_satisfied_no_reheat",
      "description": "Reference vector: zone at 22 \u00b0C between a 21 \u00b0C heating and 23 \u00b0C cooling setpoint, reheat valve shut. The zone is satisfied and nothing is being spent on it.",
      "inputs": {
        "rht_vlv_cmd": 0.0,
        "zone_temp": 22.0,
        "zone_temp_sp_htg": 21.0,
        "zone_temp_sp_clg": 23.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        }
      ]
    },
    {
      "name": "zone_satisfied_reheat_on",
      "description": "Reference vector: the same satisfied 22 \u00b0C zone with the reheat valve at 30%. Every kilowatt through that coil is waste. Both terms hold from t=0, so delayOnInit makes the alarm land one alarm_delay in.",
      "inputs": {
        "rht_vlv_cmd": 30.0,
        "zone_temp": 22.0,
        "zone_temp_sp_htg": 21.0,
        "zone_temp_sp_clg": 23.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1500,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2100,
          "to_s": 5400,
          "equals": true
        }
      ]
    },
    {
      "name": "zone_cold_reheat_on",
      "description": "Reference vector: zone at 19 \u00b0C, 2 \u00b0C below its heating setpoint, reheat at 30%. The coil is doing exactly what it is for \u2014 the lower band bound fails and the rule stays silent.",
      "inputs": {
        "rht_vlv_cmd": 30.0,
        "zone_temp": 19.0,
        "zone_temp_sp_htg": 21.0,
        "zone_temp_sp_clg": 23.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        }
      ]
    },
    {
      "name": "zone_hot_reheat_on",
      "description": "The other side of the band: zone at 24 \u00b0C, a full degree above the cooling setpoint, with reheat at 30%. This is a worse fault than the one being tested \u2014 reheat during a call for cooling \u2014 but it is VAV-0006's, not this rule's: `satisfied` fails on the upper bound and yFault stays clear.",
      "inputs": {
        "rht_vlv_cmd": 30.0,
        "zone_temp": 24.0,
        "zone_temp_sp_htg": 21.0,
        "zone_temp_sp_clg": 23.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        }
      ]
    },
    {
      "name": "zone_at_lower_band_edge",
      "description": "Boundary: zone exactly at zone_temp_sp_htg \u2212 zone_deadband (21.0 \u2212 0.5 = 20.5 \u00b0C) with reheat at 30%. The gap is exactly 0.5 and `aboveHtg` tests `< 0.5`, so the zone reads as still calling for heat and the rule stays silent \u2014 the reference's `>=` would have called it satisfied.",
      "inputs": {
        "rht_vlv_cmd": 30.0,
        "zone_temp": 20.5,
        "zone_temp_sp_htg": 21.0,
        "zone_temp_sp_clg": 23.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        }
      ]
    },
    {
      "name": "zone_just_inside_lower_band_edge",
      "description": "Boundary from the other side: 20.6 \u00b0C, one tenth of a degree inside the band, same 30% reheat. The zone now reads satisfied and the alarm lands after alarm_delay.",
      "inputs": {
        "rht_vlv_cmd": 30.0,
        "zone_temp": 20.6,
        "zone_temp_sp_htg": 21.0,
        "zone_temp_sp_clg": 23.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1500,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2100,
          "to_s": 5400,
          "equals": true
        }
      ]
    },
    {
      "name": "zone_at_upper_band_edge",
      "description": "Boundary: zone exactly at zone_temp_sp_clg + zone_deadband (23.0 + 0.5 = 23.5 \u00b0C) with reheat at 30%. `belowClg` tests `< 0.5` on a gap of exactly 0.5, so the zone reads as outside the band and the rule stays silent \u2014 the mirror of the lower-edge deviation.",
      "inputs": {
        "rht_vlv_cmd": 30.0,
        "zone_temp": 23.5,
        "zone_temp_sp_htg": 21.0,
        "zone_temp_sp_clg": 23.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        }
      ]
    },
    {
      "name": "zone_just_inside_upper_band_edge",
      "description": "Boundary from the other side: 23.4 \u00b0C, one tenth of a degree inside the upper bound, same 30% reheat. Both band bounds hold and the alarm lands after alarm_delay.",
      "inputs": {
        "rht_vlv_cmd": 30.0,
        "zone_temp": 23.4,
        "zone_temp_sp_htg": 21.0,
        "zone_temp_sp_clg": 23.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1500,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2100,
          "to_s": 5400,
          "equals": true
        }
      ]
    },
    {
      "name": "reheat_exactly_at_threshold",
      "description": "Boundary on the valve term: satisfied zone with the valve reported at exactly reheat_open_threshold (15.0%). Strict `>`, so a valve sitting on the threshold does not raise the alarm.",
      "inputs": {
        "rht_vlv_cmd": 15.0,
        "zone_temp": 22.0,
        "zone_temp_sp_htg": 21.0,
        "zone_temp_sp_clg": 23.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        }
      ]
    },
    {
      "name": "reheat_just_above_threshold",
      "description": "Boundary from the other side: the same satisfied zone with the valve at 15.1%. The valve test passes and the alarm lands after alarm_delay.",
      "inputs": {
        "rht_vlv_cmd": 15.1,
        "zone_temp": 22.0,
        "zone_temp_sp_htg": 21.0,
        "zone_temp_sp_clg": 23.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1500,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2100,
          "to_s": 5400,
          "equals": true
        }
      ]
    },
    {
      "name": "transient_reheat_pulse",
      "description": "The zone is satisfied throughout, but reheat runs only for the first 1500 s \u2014 a control loop overshooting past setpoint before it releases the coil \u2014 which is 300 s short of the 1800 s alarm_delay. The timer resets and no alarm is raised.",
      "inputs": {
        "rht_vlv_cmd": [
          {
            "t": 0,
            "value": 30.0
          },
          {
            "t": 1500,
            "value": 0.0
          }
        ],
        "zone_temp": 22.0,
        "zone_temp_sp_htg": 21.0,
        "zone_temp_sp_clg": 23.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        }
      ]
    },
    {
      "name": "alarm_clears_when_valve_closes",
      "description": "Recovery: the alarm asserts at 1800 s, then the valve is commanded shut at t=3000 s (playbook step 2.2 \u2014 the discriminator between a stuck valve and a sequence bug) and it obeys. yFault drops on that same tick, since TrueDelay only delays the rising edge.",
      "inputs": {
        "rht_vlv_cmd": [
          {
            "t": 0,
            "value": 30.0
          },
          {
            "t": 3000,
            "value": 0.0
          }
        ],
        "zone_temp": 22.0,
        "zone_temp_sp_htg": 21.0,
        "zone_temp_sp_clg": 23.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1500,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2100,
          "to_s": 2700,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 3300,
          "to_s": 5400,
          "equals": false
        }
      ]
    }
  ]
}

VAV-0004 — VAV airflow tracking error

Statusverified — engine e2ff2f8, cxf:fnv1a128:14bf3a6e9f1e4f032ceae6e45dd294f9, 2026-08-17
Severity3
Methodrule
Phase2
CategoryCOMFORT_ENERGY
ConfidenceMEDIUM
EstimationPROXY_ESTIMATION
G36
Clusters
Suppresses
Suppressed by
RelatedAHU-0020, VAV-0001, VAV-0005, FPB-0002
Playbooksstuck-actuator
SourceHVAC FDD Reference v1.0 §10, VAV-0004; Schein et al. 2006 (VPACC); ORNL Im et al. 2025; Gunay et al. 2020
Operating statesall (fan running)

Preconditions (host-enforced): AHU fan running — a box cannot track a setpoint with no branch pressure behind it, and every zone on a stopped fan would report this fault. Airflow sensor and active setpoint both available and fresh; a stale setpoint held at its last value while the measurement moves reads as a tracking error that is really a communication fault. The evaluability gate is signalled in-rule by ySetpointOk: when it is false the verdict is NO_EVAL, not healthy. Boxes commissioned with the airflow sensor disabled (pressure-independent boxes converted to pressure-dependent control) must be excluded host-side — there is no setpoint to track.

Points: zone_airflow, zone_airflow_sp

Outputs:

  • yFault — True while the fractional airflow tracking error has stayed above tracking_error_threshold, at an evaluable setpoint, for tracking_duration plus alarm_delay
  • ySetpointOk — Evaluability signal — true when zone_airflow_sp exceeds min_evaluable_setpoint; false means NO_EVAL and the host must ignore yFault

Parameters:

NameDefaultUnitCXF pathDescription
tracking_error_threshold0.31ratioHigh.tFractional deviation of measured airflow from setpoint above which tracking counts as failed (0.30 = 30% of setpoint, in either direction)
min_evaluable_setpoint50.0L/sspOk.tAirflow setpoint below which the fraction is not meaningful — at low flow the box’s differential-pressure sensor is near its noise floor and a large percentage error is a small absolute one
tracking_duration900.0strack.delayTimeContinuous violation required before the tracking error counts as sustained rather than a damper stroke in progress (15 min)
alarm_delay300.0spersist.delayTimeFurther persistence required after tracking_duration before the alarm asserts (5 min)

Description

The box is not delivering the air it is being asked for. A VAV terminal is a flow controller: the zone loop computes an airflow setpoint and the damper loop strokes the blade until measured flow matches it, so a persistent gap means that inner loop has lost its authority — a stuck damper, an actuator off its shaft, a fouled flow sensor, or not enough static pressure at the branch. The test is a fraction rather than an absolute flow so one threshold covers a 60 L/s office and a 900 L/s conference room; that costs something at the bottom of the range, where a box holding minimum measures a few pascals of velocity pressure and a 30% error is a handful of litres per second inside the sensor’s noise, which is why the rule carries an explicit evaluability test. Comfort is the first casualty and energy the second, hence severity 3.

Detection Logic

ratio       = |zone_airflow − zone_airflow_sp| / zone_airflow_sp
ySetpointOk = zone_airflow_sp > min_evaluable_setpoint     (false ⇒ host reports NO_EVAL)
yFault      = (ratio > tracking_error_threshold) AND ySetpointOk
              held continuously for tracking_duration, then a further alarm_delay

Block graph (rule.cxf.jsonld):

VAV-0004 block graph

Taking the absolute value before the division makes the test symmetric: 100 L/s and 300 L/s against a 200 L/s setpoint are both 50% out and both report, since a damper stuck open is as much a failure of the flow loop as one stuck shut. gate carries the evaluability branch and is also what makes the unguarded division safe — CDL Divide follows IEEE-754, so a zero setpoint yields ±∞ or NaN and a near-zero denominator amplifies noise into a fraction of any magnitude, but a denominator small enough to misbehave is below min_evaluable_setpoint by construction. False yFault under false ySetpointOk means unknown, not healthy, and the host must treat it that way. track and persist are two delays in series, so 20 minutes of continuous violation are required; any momentary return to setpoint drops both timers and discards the accumulated time, so the alarm describes one continuous excursion or nothing. Both comparisons are strict, matching the reference. Both delayOnInit flags are true, holding the full 1200 s across a restart.

Possible Diagnoses

  1. VAV damper stuck — mechanical failure of the blade, shaft, or linkage
  2. VAV damper actuator disconnected, so the loop commands into thin air while the blade sits wherever it was left
  3. Airflow sensor fouled or failed — a lint-blocked or water-logged differential-pressure pickup reads low and the loop opens the damper against a measurement that will not move
  4. Duct obstruction downstream of the box — a closed fire damper, a collapsed flex duct, or a balancing damper someone shut during a complaint call
  5. Insufficient system static pressure at this branch, in which case the box is working correctly and the fault is upstream

Energy Impact

COMFORT_ENERGY, MEDIUM confidence, PROXY_ESTIMATION. The reference puts the loss at 2–5% of zone energy and names comfort as the primary impact. The waste term is computable only in the over-delivery direction — waste_kw ≈ (zone_airflow − zone_airflow_sp) × cp × |sat − zone_temp|, assembled host-side since neither temperature is a rule input; under-delivery costs unmet load and complaints instead. MEDIUM confidence: the mechanism is well established (Schein et al. 2006’s VPACC rules are the origin of this test, with ORNL’s Im et al. 2025 dataset as ground truth for stuck boxes) but the per-zone figure depends on the direction of failure and on what the reheat coil does about it. Climate-neutral; no PNNL measure maps to this fault. The multiplier is the point — what matters is the fraction of a building’s boxes doing this.

Emissions Impact

Scope 1 or 2, PROXY_EMISSIONS, MEDIUM confidence; typically 50–500 kg CO₂e/yr per zone. Which scope applies follows what the excess air is conditioned by: an over-delivering box on a hot-water reheat coil drives on-site combustion (scope 1), while the fan moving the air and the chiller cooling it are purchased electricity (scope 2). Avoided-emissions basis: marginal operating emissions rate (MOER).

Deviations

  • Evaluability is an output, not just a precondition. The reference labels its low-setpoint vector NO_EVAL rather than NO_FAULT, and zone_airflow_sp > min_evaluable_setpoint is computable from this rule’s own inputs, so SCHEMA.md requires exposing it as ySetpointOk.
  • Two delays in series rather than one. The reference separates tracking_duration (15 min) from AlarmDelay (5 min), so both stay independently tunable even though a single 1200 s delay behaves identically at the defaults (AHU-0027’s arrangement). A site that wants a shorter duration test changes one parameter.
  • The tracking-duration condition alone is not a fault. If the error clears after track has matured but before persist has served its 5 minutes, both timers discard their accumulated time and a host reading yFault never learns the box was 15 minutes into the condition. Intended: the alarm describes one continuous excursion.
  • The division is unguarded, and the gate is what makes that safe. AHU-0021’s arrangement verbatim — NaN cannot raise a comparison, ±∞ and noise-inflated finite ratios can, and gate holds yFault down over exactly the interval the host is told to disregard.
  • Both comparisons are strict, which is the reference’s own notation on both sides rather than a substitution forced by the block set, so no measure-zero deviation arises. The boundary is exact rather than approximately exact: 140 L/s against 200 L/s evaluates to precisely the same double as the 0.30 threshold parameter.
  • playbooks: [stuck-actuator] is library-assigned, not transcribed. The reference’s card carries no playbook row, but diagnoses 1 and 2 are literally a stuck damper and a disconnected actuator and the playbook’s procedures apply unmodified. Its Applies to row lists AHU-0020, AHU-0014 and AHU-0015; the addition is recorded here because playbooks/ is single-writer.
  • related adds VAV-0005. Hunting and tracking failure are the two ways a box’s flow loop misbehaves, and a damper oscillating hard enough will also fail an averaged tracking test.
  • Vector tick is the library’s usual 300 s. This rule holds no windowed statistics — the only state is the two delay timers — so nothing couples the verdict to the host’s tick interval, unlike VAV-0005. Both delays are exact multiples of 300 s.
  • track.delayOnInit and persist.delayOnInit are both true (Modelica/CDL default is false), the library’s standing choice against alarming on the first tick after a controller restart.
  • Severity 3 (warning), phase 2, method rule, and the four tunable defaults are the reference’s chapter 10 card; its §5.8.2 index carries no severity column. g36: null — Schein’s VPACC work predates G36 and no §5.16 clause covers terminal-unit flow tracking.
  • Operating states are declared, not gated: the reference marks the fault applicable in every state with the fan running, and the graph has nothing to exclude.

Notes

This rule says the flow loop is failing, not which part. The cheapest split of diagnoses 1–3 is to plot zone_dmpr_pos alongside the two flow points for a day: a damper that never moves while the error persists is actuator or linkage (stuck-actuator step 3), a damper that strokes its full range while the measurement barely responds is a sensor, a plugged pickup, or an obstruction — check the sensor first, since a fouled pickup and a collapsed flex duct look identical from the BAS and one is a five-minute fix. Diagnosis 5 is not about this box at all: a dozen boxes short of air on the same riser is a system problem and replacing twelve actuators will not fix it.

min_evaluable_setpoint deserves a moment at commissioning. At the 50 L/s default a box whose minimum setpoint is 40 L/s is never evaluated while it sits at minimum, which is most of the year for an interior zone. That is deliberate — the measurement is not trustworthy down there — but it means a damper stuck at minimum stays invisible until the zone calls for cooling and the setpoint rises past 50 L/s.

Test Vectors

11 scenarios, clock step 300 s over 7200 s.

ScenarioDescription
tracking_normallyReference vector 1: 180 L/s measured against a 200 L/s setpoint. The 10% error is ordinary damper trim — a VAV box modulating against a moving branch pressure never sits exactly on setpoint. ratio holds at 0.10, well under the 0.30 threshold, so neither delay ever starts and the setpoint is evaluable throughout.
sustained_tracking_errorReference vector 2: 100 L/s delivered against a 200 L/s setpoint — a 50% shortfall, the signature of a damper stuck near minimum while the loop calls for more. ratio is 0.50 from the first tick, so track satisfies tracking_duration at t=900 s and persist adds alarm_delay for an assertion at t=1200 s, matching the reference’s 20-minute sustained-error vector exactly.
setpoint_below_minimumReference vector 3, the NO_EVAL case: 20 L/s against a 30 L/s setpoint is a 33% error on paper, but a 30 L/s setpoint is below min_evaluable_setpoint — at that flow the box’s differential-pressure sensor is reading a few pascals and the fraction is sensor noise, not tracking. ySetpointOk stays false for the whole run and yFault is held down with it; false yFault here means unknown, not healthy.
error_exactly_at_thresholdThreshold edge, low side: 140 L/s against 200 L/s is 60/200 = 0.30 exactly in IEEE-754, the same double the threshold parameter carries. ratioHigh is a strict comparison, so an error sitting exactly on tracking_error_threshold reads clear and no timer starts.
error_just_over_thresholdThreshold edge, high side: 139 L/s against 200 L/s is 0.305, one L/s past the previous scenario. The pair brackets tracking_error_threshold from both sides. The alarm follows the same 900 + 300 s chain and asserts at t=1200 s.
setpoint_exactly_at_minimumEvaluability edge, low side: a 50.0 L/s setpoint with 200 L/s measured is a 300% error, and the rule still refuses to judge it. spOk is strict, so a setpoint sitting exactly on min_evaluable_setpoint is not evaluable; ySetpointOk stays false and the gate holds yFault down for the whole run.
setpoint_just_above_minimumEvaluability edge, high side: a 51 L/s setpoint with the box delivering nothing — a disconnected actuator on a small zone. One L/s of setpoint separates this from the previous scenario: ySetpointOk is true, the 100% error clears the ratio test, and yFault asserts at t=1200 s.
over_delivery300 L/s against a 200 L/s setpoint — a damper stuck open, or branch pressure high enough that the box cannot throttle down to setpoint. The absolute value makes the test symmetric, so a 50% overshoot alarms on the same schedule as the 50% shortfall in sustained_tracking_error. This is the case the card’s over-delivery runtime estimate prices.
error_clears_between_delaysTransient across the two-timer boundary: a 50% error runs from t=0 and clears at t=1000 s, after track has satisfied tracking_duration (t=900 s) but before persist has served its 300 s alarm_delay. The gate drops at t=1200 s, both timers discard their accumulated time, and nothing is reported. The duration condition alone is not a fault.
brief_excursionTransient shorter than tracking_duration: the box loses flow for 700 s — a branch-pressure dip while a neighbouring zone opens up — and recovers. The gate is true for three ticks, short of the 900 s track needs, so no timer survives and no alarm is raised.
recovery_clearsRecovery: the same 50% shortfall asserts at t=1200 s, then flow is restored at t=3000 s — a technician reseats the actuator coupling. TrueDelay has no off-delay, so the gate falling drops yFault on the same tick the error clears.
vectors.json
{
  "schema": "cxf-library/vectors/v1",
  "clock": {
    "step_s": 300,
    "horizon_s": 7200
  },
  "scenarios": [
    {
      "name": "tracking_normally",
      "description": "Reference vector 1: 180 L/s measured against a 200 L/s setpoint. The 10% error is ordinary damper trim \u2014 a VAV box modulating against a moving branch pressure never sits exactly on setpoint. ratio holds at 0.10, well under the 0.30 threshold, so neither delay ever starts and the setpoint is evaluable throughout.",
      "inputs": {
        "zone_airflow": 180.0,
        "zone_airflow_sp": 200.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        },
        {
          "output": "ySetpointOk",
          "from_s": 0,
          "to_s": 7200,
          "equals": true
        }
      ]
    },
    {
      "name": "sustained_tracking_error",
      "description": "Reference vector 2: 100 L/s delivered against a 200 L/s setpoint \u2014 a 50% shortfall, the signature of a damper stuck near minimum while the loop calls for more. ratio is 0.50 from the first tick, so track satisfies tracking_duration at t=900 s and persist adds alarm_delay for an assertion at t=1200 s, matching the reference's 20-minute sustained-error vector exactly.",
      "inputs": {
        "zone_airflow": 100.0,
        "zone_airflow_sp": 200.0
      },
      "expect": [
        {
          "output": "ySetpointOk",
          "from_s": 0,
          "to_s": 7200,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 900,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 1500,
          "to_s": 7200,
          "equals": true
        }
      ]
    },
    {
      "name": "setpoint_below_minimum",
      "description": "Reference vector 3, the NO_EVAL case: 20 L/s against a 30 L/s setpoint is a 33% error on paper, but a 30 L/s setpoint is below min_evaluable_setpoint \u2014 at that flow the box's differential-pressure sensor is reading a few pascals and the fraction is sensor noise, not tracking. ySetpointOk stays false for the whole run and yFault is held down with it; false yFault here means unknown, not healthy.",
      "inputs": {
        "zone_airflow": 20.0,
        "zone_airflow_sp": 30.0
      },
      "expect": [
        {
          "output": "ySetpointOk",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        }
      ]
    },
    {
      "name": "error_exactly_at_threshold",
      "description": "Threshold edge, low side: 140 L/s against 200 L/s is 60/200 = 0.30 exactly in IEEE-754, the same double the threshold parameter carries. ratioHigh is a strict comparison, so an error sitting exactly on tracking_error_threshold reads clear and no timer starts.",
      "inputs": {
        "zone_airflow": 140.0,
        "zone_airflow_sp": 200.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        },
        {
          "output": "ySetpointOk",
          "from_s": 0,
          "to_s": 7200,
          "equals": true
        }
      ]
    },
    {
      "name": "error_just_over_threshold",
      "description": "Threshold edge, high side: 139 L/s against 200 L/s is 0.305, one L/s past the previous scenario. The pair brackets tracking_error_threshold from both sides. The alarm follows the same 900 + 300 s chain and asserts at t=1200 s.",
      "inputs": {
        "zone_airflow": 139.0,
        "zone_airflow_sp": 200.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 900,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 1500,
          "to_s": 7200,
          "equals": true
        }
      ]
    },
    {
      "name": "setpoint_exactly_at_minimum",
      "description": "Evaluability edge, low side: a 50.0 L/s setpoint with 200 L/s measured is a 300% error, and the rule still refuses to judge it. spOk is strict, so a setpoint sitting exactly on min_evaluable_setpoint is not evaluable; ySetpointOk stays false and the gate holds yFault down for the whole run.",
      "inputs": {
        "zone_airflow": 200.0,
        "zone_airflow_sp": 50.0
      },
      "expect": [
        {
          "output": "ySetpointOk",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        }
      ]
    },
    {
      "name": "setpoint_just_above_minimum",
      "description": "Evaluability edge, high side: a 51 L/s setpoint with the box delivering nothing \u2014 a disconnected actuator on a small zone. One L/s of setpoint separates this from the previous scenario: ySetpointOk is true, the 100% error clears the ratio test, and yFault asserts at t=1200 s.",
      "inputs": {
        "zone_airflow": 0.0,
        "zone_airflow_sp": 51.0
      },
      "expect": [
        {
          "output": "ySetpointOk",
          "from_s": 0,
          "to_s": 7200,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 900,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 1500,
          "to_s": 7200,
          "equals": true
        }
      ]
    },
    {
      "name": "over_delivery",
      "description": "300 L/s against a 200 L/s setpoint \u2014 a damper stuck open, or branch pressure high enough that the box cannot throttle down to setpoint. The absolute value makes the test symmetric, so a 50% overshoot alarms on the same schedule as the 50% shortfall in sustained_tracking_error. This is the case the card's over-delivery runtime estimate prices.",
      "inputs": {
        "zone_airflow": 300.0,
        "zone_airflow_sp": 200.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 900,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 1500,
          "to_s": 7200,
          "equals": true
        }
      ]
    },
    {
      "name": "error_clears_between_delays",
      "description": "Transient across the two-timer boundary: a 50% error runs from t=0 and clears at t=1000 s, after track has satisfied tracking_duration (t=900 s) but before persist has served its 300 s alarm_delay. The gate drops at t=1200 s, both timers discard their accumulated time, and nothing is reported. The duration condition alone is not a fault.",
      "inputs": {
        "zone_airflow": [
          {
            "t": 0,
            "value": 100.0
          },
          {
            "t": 1000,
            "value": 200.0
          }
        ],
        "zone_airflow_sp": 200.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        }
      ]
    },
    {
      "name": "brief_excursion",
      "description": "Transient shorter than tracking_duration: the box loses flow for 700 s \u2014 a branch-pressure dip while a neighbouring zone opens up \u2014 and recovers. The gate is true for three ticks, short of the 900 s track needs, so no timer survives and no alarm is raised.",
      "inputs": {
        "zone_airflow": [
          {
            "t": 0,
            "value": 100.0
          },
          {
            "t": 700,
            "value": 200.0
          }
        ],
        "zone_airflow_sp": 200.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        }
      ]
    },
    {
      "name": "recovery_clears",
      "description": "Recovery: the same 50% shortfall asserts at t=1200 s, then flow is restored at t=3000 s \u2014 a technician reseats the actuator coupling. TrueDelay has no off-delay, so the gate falling drops yFault on the same tick the error clears.",
      "inputs": {
        "zone_airflow": [
          {
            "t": 0,
            "value": 100.0
          },
          {
            "t": 3000,
            "value": 200.0
          }
        ],
        "zone_airflow_sp": 200.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 900,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 1500,
          "to_s": 2700,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 3300,
          "to_s": 7200,
          "equals": false
        }
      ]
    }
  ]
}

VAV-0005 — VAV damper hunting or oscillation

Statusverified — engine e2ff2f8, cxf:fnv1a128:5f4289e7553426911e4a689b1e3c83a6, 2026-08-17
Severity3
Methodrule
Phase2
CategoryCOMFORT_ENERGY
ConfidenceLOW
EstimationQUALITATIVE_ONLY
G36
Clusters
Suppresses
Suppressed by
RelatedAHU-0022, VAV-0004
Playbooks
SourceHVAC FDD Reference v1.0 §10, VAV-0005; Torabi et al. 2022; Gunay et al. 2020
Operating statesall (fan running)

Preconditions (host-enforced): Supply fan running — a damper in a dead air stream does not oscillate, and a box parked at a fixed position during unoccupied hours produces no crossings either way. Damper position feedback (or, failing that, the position command) available as a live analog signal; a point that only updates on change-of-value with a wide deadband will hide the oscillation entirely. The host must report NO_EVAL for the first eval_window (30 min) after engine start: while the three moving averages fill, each divides by elapsed time rather than by the window, so the crossing count reads as an extrapolated pace and the amplitude as a partial average. This gate is load-bearing, not a formality — alarm_delay (15 min) is half of eval_window, so delayOnInit cannot cover the warm-up and a short burst at startup can reach a verdict (warmup_burst_asserts pins it). Host tick interval must lie in [28.6 s, 180 s) with count_scale set to match; 60 s is recommended (see Deviations). When any gate is unmet the verdict is NO_EVAL, not healthy.

Points: zone_dmpr_pos

Outputs:

  • yFault — True while the damper has crossed its own rolling mean more than max_reversals_per_window times per eval_window, with a mean absolute deviation above min_oscillation_amplitude, for at least alarm_delay

Parameters:

NameDefaultUnitCXF pathDescription
eval_window1800.0smuS.delta, xRate.delta, mad.deltaRolling window the crossings are counted over and the amplitude averaged over (30 min). It drives three moving-average stages — the mean, the crossing rate, and the deviation average — and a host must move all three paths together; count_scale must be retuned with it
max_reversals_per_window10.01xHigh.tMean crossings per eval_window above which damper motion counts as hunting rather than load following. Each oscillation cycle produces two crossings, so the default is five cycles per half hour
min_oscillation_amplitude5.0%madHigh.tRolling mean absolute deviation of damper position above which the swing is large enough to matter. MAD units, not peak-to-peak: the reference’s 10% swing is ±5% about the mean, which is a MAD of 5.0 for a square oscillation and 3.2 for a sinusoidal one (see Deviations)
count_scale30.01xCount.kRescales the moving average of the one-tick crossing pulse train back into a crossing count: k = eval_window / host tick interval in seconds. The default 30.0 is 1800/60, correct only at a 60 s tick; a host on a different tick MUST retune this or every count is wrong by the ratio of the two intervals
alarm_delay900.0spersist.delayTimeContinuous fault persistence required before the alarm asserts (15 min)

Description

The damper never stops moving. A VAV box’s flow loop is supposed to find a blade position that delivers the setpoint and stay there until the setpoint moves; a hunting box overshoots, corrects, overshoots the other way, and repeats every few minutes all day. The zone usually feels fine — the swings average out — so nobody reports it, and the box quietly strokes its actuator tens of thousands of extra times a year. That wear is the real cost; the energy penalty is small and diffuse (the reference puts it at 1–3% of zone energy), but a floating actuator driven to death takes the zone with it when it fails. The usual cause is a proportional gain too high for the box’s damper authority, though a noisy airflow signal or a duct-static loop fighting a zone-demand loop produce the same picture with nothing mistuned. Prevalence roughly 5%.

Detection Logic

muS    = MovingAverage(zone_dmpr_pos, eval_window)            rolling mean position
above  = zone_dmpr_pos > muS                                  which side of the mean
pulse  = (above ≠ previous tick's above)                      one tick wide, one per crossing
count  = MovingAverage(pulse, eval_window) × count_scale       crossings in the trailing 30 min
mad    = MovingAverage(|zone_dmpr_pos − muS|, eval_window)     amplitude, in MAD units

yFault = (count > max_reversals_per_window)
     AND (mad   > min_oscillation_amplitude)
         sustained continuously for alarm_delay

Block graph (rule.cxf.jsonld):

VAV-0005 block graph

muS is the spine: one rolling mean feeds both tests — the crossing count on one branch, the mean absolute deviation on the other — so the rule asks how often the position ends up on the other side of its own half-hour average and how far from that average it typically sits. Frequent crossings with a large deviation is hunting; frequent crossings with a small deviation is dither on a feedback signal; a large deviation with few crossings is a box following a load. Reals.MovingAverage is a continuous-time integral mean, so a one-tick pulse of height 1.0 encloses one tick interval of area and count_scale = eval_window/dt converts the pulse average back into a crossing count. That couples this rule to the host’s tick harder than any other in the library: read the Nyquist band and count_scale deviations before deploying, and honour the warm-up NO_EVAL precondition. Both comparisons are strict — exactly ten crossings reads clear, eleven alarms — and persist requires 15 continuous minutes of both conditions, three or four more cycles at the reference’s severe rate. delayOnInit = true holds that window across a restart.

Possible Diagnoses

  1. PID loop tuning too aggressive — proportional gain too high for the damper’s authority, or integral time too short for the box’s response
  2. Airflow sensor noise causing erratic setpoint tracking: the loop is chasing measurement noise, and no tuning change fixes a bad velocity pickup
  3. Conflicting control signals — the AHU’s duct static pressure loop and the zone demand loop acting on the same damper with overlapping response times
  4. Damper actuator mechanical backlash: the blade lags the command, the loop over-drives to compensate, and the limit cycle is mechanical, not computational
  5. Zone load disturbance near the thermostat — a copier, a projector, direct sun through a blind that opens and closes — a real load the box is following

Energy Impact

COMFORT_ENERGY, LOW confidence, QUALITATIVE_ONLY. There is no waste term to compute from this rule’s inputs: it sees a damper position and cannot price a stroke. The reference puts the loss at 1–3% of zone energy from inefficient control and names actuator wear as the affected subsystem, which is the honest ordering. Size the opportunity host-side per Energy Impact Reference §4.4 (hunting hours × zone fan and reheat energy); this rule contributes the hours. LOW confidence for AHU-0022’s reason — no controlled study isolates the losses of a hunting loop from the tuning change that fixes it, and no PNNL measure covers loop stability. Climate-neutral. Per zone the number is small enough to ignore, which is why it goes unfixed; several hundred boxes with 5% hunting is a dozen actuators being consumed years early against a fix that costs a gain setting.

Emissions Impact

Scope 2, QUALITATIVE_EMISSIONS, LOW confidence. Minimal in absolute terms: the direct emissions are the marginal fan and reheat energy above, and the reference records the concern as actuator wear rather than emissions. The larger indirect term is embodied carbon in actuators replaced early. Avoided-emissions basis: N/A.

Deviations

  • Direction reversals → mean crossings, the substitution this rule is built on. The reference counts direction changes, which need the previous sample (CDL.Discrete.UnitDelay, whose samplePeriod is one more silent coupling to the host tick) and have no noise floor — a position quantized to 1% reverses on essentially every tick it is not moving. The two counts agree on anything periodic (a cycle contains exactly two reversals and two mean crossings, and all three reference vectors reproduce) and diverge only on drift with small ripple, where the crossing count under-reads. That error is one-directional and the cases it misses are small-amplitude ripple min_oscillation_amplitude would have rejected anyway.
  • min_oscillation_amplitude is restated in MAD units. The reference’s default 10% swing reads as peak-to-peak; the engine has no peak detector and no rolling standard deviation, so the available statistic is AHU-0022’s rolling mean absolute deviation. For a square oscillation of ±A about the mean MAD = A, so 10% peak-to-peak becomes 5.0 MAD; for a sinusoid MAD = 2A/π = 0.637 A, so a 10% sine has a MAD of 3.2 and needs 15.7% to trip. The default matches the reference for the square-ish limit cycles a floating actuator produces and is conservative for smooth ones; a site wanting sine-equivalence sets 3.2.
  • Nyquist: this rule cannot run at the library’s usual 300 s tick. above changes at most once per tick, so the observable ceiling on the count is eval_window/dt — 6 at a 300 s tick, below the threshold of 10, and the rule could never fire (RTU-0001’s failure mode). A legal deployment needs dt ≥ eval_window/6328.6 s (the moving average’s 64-checkpoint ring holds eval_window/dt + 1 entries) and dt < eval_window/max_reversals = 180 s for the threshold to be reachable. Detection before the ceiling wants headroom, so 60 s is recommended and is the only tick these vectors exercise.
  • count_scale is coupled to the host’s tick interval, and the failure is silent. k = eval_window / dt, so a host ticking every 120 s must set count_scale to 15.0; left at 30.0 it reports double the true count and alarms on six crossings. Worse here than in AHU-0004 or RTU-0001 because tick, count_scale and eval_window must move together with the Nyquist band above.
  • eval_window binds three CXF parameter pathsmuS.delta, xRate.delta, mad.delta — and a host must set all three together and recompute count_scale with them. Splitting them changes what the statistic means: a mean over one window compared against deviations averaged over another is not a MAD of anything. AHU-0022 bound two paths per window for the same reason.
  • Rolling count built from a moving average, because the block set has no windowed counter. Integers.OnCounter counts monotonically from a reset, so a trailing-window count would need a host-driven reset — a tumbling count whose verdict depends on where the boundary fell. AHU-0004’s trade, including its half-open window: a crossing exactly eval_window old has just left the count.
  • Reals.MovingAverage is a continuous-time integral mean, not a sample mean. The engine accumulates u·dt forward-Euler and divides by the window, so hand-computed sample statistics do not match it and every assertion edge in vectors.json was derived by replaying the graph at the pinned engine rev.
  • Warm-up NO_EVAL is load-bearing, and a warm-up transient can assert. While t < eval_window all three averages divide by elapsed time, so eight crossings in the first ten minutes read as a count of 24. alarm_delay (900 s) is half of eval_window, so delayOnInit cannot cover the gap as it does in AHU-0004 and a startup burst can reach a verdict (warmup_burst_asserts pins it). The host precondition is not optional here (RTU-0001 precedent).
  • Startup artifact: a spurious first-tick pulse, which costs nothing. On the first tick muS outputs 0.0, so any positive damper position makes above true and Logical.Change emits a pulse at t = 0. It encloses no area (dt is zero on the first tick) and never reaches the count. flip.pre_u_start is written explicitly as false and not exposed as a card parameter.
  • A perfectly steady damper produces exactly one crossing. While the window fills, muS runs a hair below a constant input (its divisor is elapsed time plus a 1 ms guard) so above is true; when the window fills the mean equals the input exactly and flip emits one pulse. Harmless, but it is why a quiet box reads a count of 1 rather than 0 for its first half hour.
  • Neither statistic drops when the hunting stops; both decay across the trailing window. The count falls only as old crossings age out and mad only as the flat stretch dilutes the average, so the alarm outlives the fix by design, the release time is a property of the window rather than the repair, and whichever statistic crosses back first releases it. What the rule reports is oscillation sustained above both thresholds, not every excursion through them.
  • The reference’s three test vectors are statistical summaries (reversal counts and swing percentages), not tick traces, so each is re-expressed as a damper trajectory producing the stated statistics, following AHU-0022’s treatment of the same problem; the remaining scenarios are authored here.
  • playbooks: []. Nothing in playbooks/ covers control-loop tuning — the gap AHU-0022 recorded when it landed. A loop-tuning playbook can adopt both faults when it is written.
  • related adds VAV-0004. The reference lists AHU-0022 only, which this card keeps as the same instability one level up. VAV-0004 is the box-mechanics pair: FC-053 asks whether the flow loop reaches its setpoint, this one whether it can stay there.
  • Method stays rule per the reference, even though sibling AHU-0022 is statistical and this rule computes two rolling statistics: the crossing count is a deterministic count of events, and nothing here estimates a parameter or compares against a learned baseline. Severity 3 and phase 2 are the chapter 10 card’s; its §5.8.2 index carries no severity column.
  • Operating states are declared, not gated: the reference marks the fault applicable in every state with the fan running, and the graph has nothing to exclude.
  • persist.delayOnInit = true (Modelica/CDL default is false), the library’s standing choice: an oscillation already in progress at load waits out the full 15 minutes instead of alarming on the first tick after a restart.

Notes

Bind position feedback where the box has it. A rule bound to the damper command still catches diagnoses 1, 2, 3 and 5, since the loop is what oscillates; what it cannot see is diagnosis 4, where backlash makes the blade lag and overshoot while the command looks calmer than the motion. A command-bound instance is a legitimate deployment with one diagnosis out of reach.

Check the airflow signal before touching the gains: diagnosis 2 produces textbook hunting statistics behind a perfectly tuned loop, and retuning to compensate for a bad measurement leaves the noise where it was. Trend measured flow next to position — control hunting is smooth and roughly periodic, a noisy velocity pickup is neither. Diagnosis 5 is the case where nothing is broken, and the 30-minute window helps: a genuine load disturbance gives a handful of large excursions, not ten crossings of the running mean, so a marginal count with a large amplitude means look at the zone. For diagnosis 3 the cross-check is the number of zones — a dozen boxes on one trunk hunting together is the duct static loop, and no zone-level retuning will settle it.

Test Vectors

13 scenarios, clock step 60 s over 10800 s.

ScenarioDescription
stable_positionA damper parked at 50% for the whole run — a zone whose load has not moved. The only crossing in the entire trace is an artifact of the statistic: while the window fills, muS runs a hair under the constant input, so above is true; at t=1800 s the window is full, muS reads exactly 50.0, the strict comparison goes false and Logical.Change emits one pulse. The count reads 1.0 against a threshold of 10 for one window and ages back to zero at t=3600 s; mad stays at zero throughout. This is the baseline every other scenario departs from.
stable_controlReference vector 1 (3 reversals per 30 min, 5% amplitude). A 20-minute square wave of +/-2.5% about 50% — a damper following a slow load swing, which is what control is supposed to look like. Two crossings per cycle gives a steady count of exactly 3.0 and mad settles at 2.42; both tests fail and nothing is reported.
mild_huntingReference vector 2 (8 reversals per 30 min, 8% amplitude). An 8-minute square wave of +/-4% about 50%. The window holds 3.75 cycles, so the rolling count alternates between 7.0 and 8.0 — the reference’s 8 reversals, as close as a symmetric wave can be placed on a 60 s grid — and mad settles at 4.06. Both tests fail, and either one alone would be enough.
severe_huntingReference vector 3 (15 reversals per 30 min, 20% amplitude). A 4-minute square wave of +/-10% about 50% — a proportional gain far too high for the box’s damper authority, stroking the actuator every two ticks. The count settles at exactly 15.0 and mad at 9.67 (the 0.33 shortfall from 10 is the mean wobbling by +/-0.67 across the window’s 7.5 cycles). Both conditions latch at t=240 s and yFault asserts one alarm_delay later at t=1140 s. Note this is inside the host’s warm-up NO_EVAL window, but unlike warmup_burst_asserts the verdict survives the window filling: the fault stays asserted for the rest of the horizon.
fast_but_shallowCount without amplitude: the damper alternates +/-2% every tick, 30 crossings per window — the fastest this rule can observe at a 60 s tick and three times the threshold. mad is 2.0, under min_oscillation_amplitude, so nothing is reported. A hair of dither on a position feedback is not hunting, and this vector pins that the And is load-bearing.
slow_and_deepAmplitude without count, the mirror of fast_but_shallow: a 20-minute +/-15% swing gives mad 14.5 — three times the amplitude threshold — on a count of exactly 3.0. A damper making large slow moves is tracking a load, not hunting, and the count test blocks the fault.
amplitude_exactly_at_thresholdAmplitude edge, low side. A tick-rate square wave of +/-5% about 50%: the window holds 15 whole cycles, so muS is exactly 50.0, every deviation is exactly 5.0, and mad reads exactly 5.000000 with no rounding. madHigh is strict, so scatter sitting exactly on min_oscillation_amplitude reads clear even with the count at its 30-crossing ceiling.
amplitude_just_over_thresholdAmplitude edge, high side: the same wave at +/-5.5%, half a percent past the previous scenario. mad climbs past 5.0 at t=1500 s — still inside the warm-up, where both averaging stages are working on partial windows — and yFault asserts one alarm_delay later at t=2400 s; the settled reading is exactly 5.5 once the deviation average has flushed the warm-up at t=3600 s. The pair brackets min_oscillation_amplitude from both sides.
count_exactly_at_thresholdCount edge, low side. A 6-minute square wave of +/-15% about 50%: five whole cycles per window, so every rolling window holds exactly 10 crossings and xCount lands on exactly 10.000000 (the pulse integral is 600 s of area against an 1800 s window, times a count_scale of 30, all exact in IEEE-754). xHigh is strict, so ten crossings reads clear. mad is 14.7, far above its threshold, so the count test is the only thing blocking the fault.
count_just_over_thresholdCount edge, high side. A 5-minute asymmetric cycle — one tick at 90%, four at 45% — puts 12 crossings in every window at mad 14.4. A symmetric wave cannot place 11 or 12 crossings on a 60 s grid (the available periods jump from 10 crossings at 360 s to 15 at 240 s), so the duty cycle is skewed instead; the mean sits at 54%, above the low value and below the high one, and each cycle still crosses it twice. yFault asserts at t=2760 s.
hunting_stops_before_delayTransient: 22 minutes of hard hunting (+/-10%, 4-minute period) starting at t=1800 s, after the windows have filled, then the damper settles. The eleventh crossing lands at t=3120 s and lifts the count over the threshold just as the oscillation ends; the count falls back to exactly 10.0 at t=3720 s as the first crossing ages out of the trailing window. both holds for 540 s against a 900 s alarm_delay, so one disturbance rejection does not report.
recovery_clearsRecovery: the same hunting starts at t=1800 s and runs for 90 minutes before a technician retunes the loop at t=7200 s. yFault asserts at t=4020 s and clears at t=7800 s — 600 s after the last swing, the time five of the fifteen crossings in the window need to age out and bring the count back to exactly 10.0. Neither statistic drops when the hunting stops; both decay across the trailing window, and here the count reaches its threshold first (mad does not fall under 5.0 until t=8040 s).
warmup_burst_assertsThe warm-up artifact, and why the host NO_EVAL precondition is load-bearing. Ten minutes of tick-rate +/-20% swinging at engine start produces 8 crossings, and 8 crossings per 30 minutes is the reference’s own NO_FAULT vector. But while the window is filling, xRate divides by elapsed time rather than by eval_window, so those 8 crossings read as a count of 24 at t=600 s and stay above 10 until t=1440 s. mad is inflated the same way. Both hold long enough for yFault to assert at t=1140 s; it clears at t=1440 s as the divisor grows, and the settled count after the window fills is 8. alarm_delay (900 s) is half of eval_window (1800 s) and cannot cover this — only the host gate can.
vectors.json
{
  "schema": "cxf-library/vectors/v1",
  "clock": {
    "step_s": 60,
    "horizon_s": 10800
  },
  "scenarios": [
    {
      "name": "stable_position",
      "description": "A damper parked at 50% for the whole run \u2014 a zone whose load has not moved. The only crossing in the entire trace is an artifact of the statistic: while the window fills, muS runs a hair under the constant input, so `above` is true; at t=1800 s the window is full, muS reads exactly 50.0, the strict comparison goes false and Logical.Change emits one pulse. The count reads 1.0 against a threshold of 10 for one window and ages back to zero at t=3600 s; mad stays at zero throughout. This is the baseline every other scenario departs from.",
      "inputs": {
        "zone_dmpr_pos": 50.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 10800,
          "equals": false
        }
      ]
    },
    {
      "name": "stable_control",
      "description": "Reference vector 1 (3 reversals per 30 min, 5% amplitude). A 20-minute square wave of +/-2.5% about 50% \u2014 a damper following a slow load swing, which is what control is supposed to look like. Two crossings per cycle gives a steady count of exactly 3.0 and mad settles at 2.42; both tests fail and nothing is reported.",
      "inputs": {
        "zone_dmpr_pos": [
          {
            "t": 0,
            "value": 52.5
          },
          {
            "t": 600,
            "value": 47.5
          },
          {
            "t": 1200,
            "value": 52.5
          },
          {
            "t": 1800,
            "value": 47.5
          },
          {
            "t": 2400,
            "value": 52.5
          },
          {
            "t": 3000,
            "value": 47.5
          },
          {
            "t": 3600,
            "value": 52.5
          },
          {
            "t": 4200,
            "value": 47.5
          },
          {
            "t": 4800,
            "value": 52.5
          },
          {
            "t": 5400,
            "value": 47.5
          },
          {
            "t": 6000,
            "value": 52.5
          },
          {
            "t": 6600,
            "value": 47.5
          },
          {
            "t": 7200,
            "value": 52.5
          },
          {
            "t": 7800,
            "value": 47.5
          },
          {
            "t": 8400,
            "value": 52.5
          },
          {
            "t": 9000,
            "value": 47.5
          },
          {
            "t": 9600,
            "value": 52.5
          },
          {
            "t": 10200,
            "value": 47.5
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 10800,
          "equals": false
        }
      ]
    },
    {
      "name": "mild_hunting",
      "description": "Reference vector 2 (8 reversals per 30 min, 8% amplitude). An 8-minute square wave of +/-4% about 50%. The window holds 3.75 cycles, so the rolling count alternates between 7.0 and 8.0 \u2014 the reference's 8 reversals, as close as a symmetric wave can be placed on a 60 s grid \u2014 and mad settles at 4.06. Both tests fail, and either one alone would be enough.",
      "inputs": {
        "zone_dmpr_pos": [
          {
            "t": 0,
            "value": 54.0
          },
          {
            "t": 240,
            "value": 46.0
          },
          {
            "t": 480,
            "value": 54.0
          },
          {
            "t": 720,
            "value": 46.0
          },
          {
            "t": 960,
            "value": 54.0
          },
          {
            "t": 1200,
            "value": 46.0
          },
          {
            "t": 1440,
            "value": 54.0
          },
          {
            "t": 1680,
            "value": 46.0
          },
          {
            "t": 1920,
            "value": 54.0
          },
          {
            "t": 2160,
            "value": 46.0
          },
          {
            "t": 2400,
            "value": 54.0
          },
          {
            "t": 2640,
            "value": 46.0
          },
          {
            "t": 2880,
            "value": 54.0
          },
          {
            "t": 3120,
            "value": 46.0
          },
          {
            "t": 3360,
            "value": 54.0
          },
          {
            "t": 3600,
            "value": 46.0
          },
          {
            "t": 3840,
            "value": 54.0
          },
          {
            "t": 4080,
            "value": 46.0
          },
          {
            "t": 4320,
            "value": 54.0
          },
          {
            "t": 4560,
            "value": 46.0
          },
          {
            "t": 4800,
            "value": 54.0
          },
          {
            "t": 5040,
            "value": 46.0
          },
          {
            "t": 5280,
            "value": 54.0
          },
          {
            "t": 5520,
            "value": 46.0
          },
          {
            "t": 5760,
            "value": 54.0
          },
          {
            "t": 6000,
            "value": 46.0
          },
          {
            "t": 6240,
            "value": 54.0
          },
          {
            "t": 6480,
            "value": 46.0
          },
          {
            "t": 6720,
            "value": 54.0
          },
          {
            "t": 6960,
            "value": 46.0
          },
          {
            "t": 7200,
            "value": 54.0
          },
          {
            "t": 7440,
            "value": 46.0
          },
          {
            "t": 7680,
            "value": 54.0
          },
          {
            "t": 7920,
            "value": 46.0
          },
          {
            "t": 8160,
            "value": 54.0
          },
          {
            "t": 8400,
            "value": 46.0
          },
          {
            "t": 8640,
            "value": 54.0
          },
          {
            "t": 8880,
            "value": 46.0
          },
          {
            "t": 9120,
            "value": 54.0
          },
          {
            "t": 9360,
            "value": 46.0
          },
          {
            "t": 9600,
            "value": 54.0
          },
          {
            "t": 9840,
            "value": 46.0
          },
          {
            "t": 10080,
            "value": 54.0
          },
          {
            "t": 10320,
            "value": 46.0
          },
          {
            "t": 10560,
            "value": 54.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 10800,
          "equals": false
        }
      ]
    },
    {
      "name": "severe_hunting",
      "description": "Reference vector 3 (15 reversals per 30 min, 20% amplitude). A 4-minute square wave of +/-10% about 50% \u2014 a proportional gain far too high for the box's damper authority, stroking the actuator every two ticks. The count settles at exactly 15.0 and mad at 9.67 (the 0.33 shortfall from 10 is the mean wobbling by +/-0.67 across the window's 7.5 cycles). Both conditions latch at t=240 s and yFault asserts one alarm_delay later at t=1140 s. Note this is inside the host's warm-up NO_EVAL window, but unlike warmup_burst_asserts the verdict survives the window filling: the fault stays asserted for the rest of the horizon.",
      "inputs": {
        "zone_dmpr_pos": [
          {
            "t": 0,
            "value": 60.0
          },
          {
            "t": 120,
            "value": 40.0
          },
          {
            "t": 240,
            "value": 60.0
          },
          {
            "t": 360,
            "value": 40.0
          },
          {
            "t": 480,
            "value": 60.0
          },
          {
            "t": 600,
            "value": 40.0
          },
          {
            "t": 720,
            "value": 60.0
          },
          {
            "t": 840,
            "value": 40.0
          },
          {
            "t": 960,
            "value": 60.0
          },
          {
            "t": 1080,
            "value": 40.0
          },
          {
            "t": 1200,
            "value": 60.0
          },
          {
            "t": 1320,
            "value": 40.0
          },
          {
            "t": 1440,
            "value": 60.0
          },
          {
            "t": 1560,
            "value": 40.0
          },
          {
            "t": 1680,
            "value": 60.0
          },
          {
            "t": 1800,
            "value": 40.0
          },
          {
            "t": 1920,
            "value": 60.0
          },
          {
            "t": 2040,
            "value": 40.0
          },
          {
            "t": 2160,
            "value": 60.0
          },
          {
            "t": 2280,
            "value": 40.0
          },
          {
            "t": 2400,
            "value": 60.0
          },
          {
            "t": 2520,
            "value": 40.0
          },
          {
            "t": 2640,
            "value": 60.0
          },
          {
            "t": 2760,
            "value": 40.0
          },
          {
            "t": 2880,
            "value": 60.0
          },
          {
            "t": 3000,
            "value": 40.0
          },
          {
            "t": 3120,
            "value": 60.0
          },
          {
            "t": 3240,
            "value": 40.0
          },
          {
            "t": 3360,
            "value": 60.0
          },
          {
            "t": 3480,
            "value": 40.0
          },
          {
            "t": 3600,
            "value": 60.0
          },
          {
            "t": 3720,
            "value": 40.0
          },
          {
            "t": 3840,
            "value": 60.0
          },
          {
            "t": 3960,
            "value": 40.0
          },
          {
            "t": 4080,
            "value": 60.0
          },
          {
            "t": 4200,
            "value": 40.0
          },
          {
            "t": 4320,
            "value": 60.0
          },
          {
            "t": 4440,
            "value": 40.0
          },
          {
            "t": 4560,
            "value": 60.0
          },
          {
            "t": 4680,
            "value": 40.0
          },
          {
            "t": 4800,
            "value": 60.0
          },
          {
            "t": 4920,
            "value": 40.0
          },
          {
            "t": 5040,
            "value": 60.0
          },
          {
            "t": 5160,
            "value": 40.0
          },
          {
            "t": 5280,
            "value": 60.0
          },
          {
            "t": 5400,
            "value": 40.0
          },
          {
            "t": 5520,
            "value": 60.0
          },
          {
            "t": 5640,
            "value": 40.0
          },
          {
            "t": 5760,
            "value": 60.0
          },
          {
            "t": 5880,
            "value": 40.0
          },
          {
            "t": 6000,
            "value": 60.0
          },
          {
            "t": 6120,
            "value": 40.0
          },
          {
            "t": 6240,
            "value": 60.0
          },
          {
            "t": 6360,
            "value": 40.0
          },
          {
            "t": 6480,
            "value": 60.0
          },
          {
            "t": 6600,
            "value": 40.0
          },
          {
            "t": 6720,
            "value": 60.0
          },
          {
            "t": 6840,
            "value": 40.0
          },
          {
            "t": 6960,
            "value": 60.0
          },
          {
            "t": 7080,
            "value": 40.0
          },
          {
            "t": 7200,
            "value": 60.0
          },
          {
            "t": 7320,
            "value": 40.0
          },
          {
            "t": 7440,
            "value": 60.0
          },
          {
            "t": 7560,
            "value": 40.0
          },
          {
            "t": 7680,
            "value": 60.0
          },
          {
            "t": 7800,
            "value": 40.0
          },
          {
            "t": 7920,
            "value": 60.0
          },
          {
            "t": 8040,
            "value": 40.0
          },
          {
            "t": 8160,
            "value": 60.0
          },
          {
            "t": 8280,
            "value": 40.0
          },
          {
            "t": 8400,
            "value": 60.0
          },
          {
            "t": 8520,
            "value": 40.0
          },
          {
            "t": 8640,
            "value": 60.0
          },
          {
            "t": 8760,
            "value": 40.0
          },
          {
            "t": 8880,
            "value": 60.0
          },
          {
            "t": 9000,
            "value": 40.0
          },
          {
            "t": 9120,
            "value": 60.0
          },
          {
            "t": 9240,
            "value": 40.0
          },
          {
            "t": 9360,
            "value": 60.0
          },
          {
            "t": 9480,
            "value": 40.0
          },
          {
            "t": 9600,
            "value": 60.0
          },
          {
            "t": 9720,
            "value": 40.0
          },
          {
            "t": 9840,
            "value": 60.0
          },
          {
            "t": 9960,
            "value": 40.0
          },
          {
            "t": 10080,
            "value": 60.0
          },
          {
            "t": 10200,
            "value": 40.0
          },
          {
            "t": 10320,
            "value": 60.0
          },
          {
            "t": 10440,
            "value": 40.0
          },
          {
            "t": 10560,
            "value": 60.0
          },
          {
            "t": 10680,
            "value": 40.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1020,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 1260,
          "to_s": 10800,
          "equals": true
        }
      ]
    },
    {
      "name": "fast_but_shallow",
      "description": "Count without amplitude: the damper alternates +/-2% every tick, 30 crossings per window \u2014 the fastest this rule can observe at a 60 s tick and three times the threshold. mad is 2.0, under min_oscillation_amplitude, so nothing is reported. A hair of dither on a position feedback is not hunting, and this vector pins that the `And` is load-bearing.",
      "inputs": {
        "zone_dmpr_pos": [
          {
            "t": 0,
            "value": 52.0
          },
          {
            "t": 60,
            "value": 48.0
          },
          {
            "t": 120,
            "value": 52.0
          },
          {
            "t": 180,
            "value": 48.0
          },
          {
            "t": 240,
            "value": 52.0
          },
          {
            "t": 300,
            "value": 48.0
          },
          {
            "t": 360,
            "value": 52.0
          },
          {
            "t": 420,
            "value": 48.0
          },
          {
            "t": 480,
            "value": 52.0
          },
          {
            "t": 540,
            "value": 48.0
          },
          {
            "t": 600,
            "value": 52.0
          },
          {
            "t": 660,
            "value": 48.0
          },
          {
            "t": 720,
            "value": 52.0
          },
          {
            "t": 780,
            "value": 48.0
          },
          {
            "t": 840,
            "value": 52.0
          },
          {
            "t": 900,
            "value": 48.0
          },
          {
            "t": 960,
            "value": 52.0
          },
          {
            "t": 1020,
            "value": 48.0
          },
          {
            "t": 1080,
            "value": 52.0
          },
          {
            "t": 1140,
            "value": 48.0
          },
          {
            "t": 1200,
            "value": 52.0
          },
          {
            "t": 1260,
            "value": 48.0
          },
          {
            "t": 1320,
            "value": 52.0
          },
          {
            "t": 1380,
            "value": 48.0
          },
          {
            "t": 1440,
            "value": 52.0
          },
          {
            "t": 1500,
            "value": 48.0
          },
          {
            "t": 1560,
            "value": 52.0
          },
          {
            "t": 1620,
            "value": 48.0
          },
          {
            "t": 1680,
            "value": 52.0
          },
          {
            "t": 1740,
            "value": 48.0
          },
          {
            "t": 1800,
            "value": 52.0
          },
          {
            "t": 1860,
            "value": 48.0
          },
          {
            "t": 1920,
            "value": 52.0
          },
          {
            "t": 1980,
            "value": 48.0
          },
          {
            "t": 2040,
            "value": 52.0
          },
          {
            "t": 2100,
            "value": 48.0
          },
          {
            "t": 2160,
            "value": 52.0
          },
          {
            "t": 2220,
            "value": 48.0
          },
          {
            "t": 2280,
            "value": 52.0
          },
          {
            "t": 2340,
            "value": 48.0
          },
          {
            "t": 2400,
            "value": 52.0
          },
          {
            "t": 2460,
            "value": 48.0
          },
          {
            "t": 2520,
            "value": 52.0
          },
          {
            "t": 2580,
            "value": 48.0
          },
          {
            "t": 2640,
            "value": 52.0
          },
          {
            "t": 2700,
            "value": 48.0
          },
          {
            "t": 2760,
            "value": 52.0
          },
          {
            "t": 2820,
            "value": 48.0
          },
          {
            "t": 2880,
            "value": 52.0
          },
          {
            "t": 2940,
            "value": 48.0
          },
          {
            "t": 3000,
            "value": 52.0
          },
          {
            "t": 3060,
            "value": 48.0
          },
          {
            "t": 3120,
            "value": 52.0
          },
          {
            "t": 3180,
            "value": 48.0
          },
          {
            "t": 3240,
            "value": 52.0
          },
          {
            "t": 3300,
            "value": 48.0
          },
          {
            "t": 3360,
            "value": 52.0
          },
          {
            "t": 3420,
            "value": 48.0
          },
          {
            "t": 3480,
            "value": 52.0
          },
          {
            "t": 3540,
            "value": 48.0
          },
          {
            "t": 3600,
            "value": 52.0
          },
          {
            "t": 3660,
            "value": 48.0
          },
          {
            "t": 3720,
            "value": 52.0
          },
          {
            "t": 3780,
            "value": 48.0
          },
          {
            "t": 3840,
            "value": 52.0
          },
          {
            "t": 3900,
            "value": 48.0
          },
          {
            "t": 3960,
            "value": 52.0
          },
          {
            "t": 4020,
            "value": 48.0
          },
          {
            "t": 4080,
            "value": 52.0
          },
          {
            "t": 4140,
            "value": 48.0
          },
          {
            "t": 4200,
            "value": 52.0
          },
          {
            "t": 4260,
            "value": 48.0
          },
          {
            "t": 4320,
            "value": 52.0
          },
          {
            "t": 4380,
            "value": 48.0
          },
          {
            "t": 4440,
            "value": 52.0
          },
          {
            "t": 4500,
            "value": 48.0
          },
          {
            "t": 4560,
            "value": 52.0
          },
          {
            "t": 4620,
            "value": 48.0
          },
          {
            "t": 4680,
            "value": 52.0
          },
          {
            "t": 4740,
            "value": 48.0
          },
          {
            "t": 4800,
            "value": 52.0
          },
          {
            "t": 4860,
            "value": 48.0
          },
          {
            "t": 4920,
            "value": 52.0
          },
          {
            "t": 4980,
            "value": 48.0
          },
          {
            "t": 5040,
            "value": 52.0
          },
          {
            "t": 5100,
            "value": 48.0
          },
          {
            "t": 5160,
            "value": 52.0
          },
          {
            "t": 5220,
            "value": 48.0
          },
          {
            "t": 5280,
            "value": 52.0
          },
          {
            "t": 5340,
            "value": 48.0
          },
          {
            "t": 5400,
            "value": 52.0
          },
          {
            "t": 5460,
            "value": 48.0
          },
          {
            "t": 5520,
            "value": 52.0
          },
          {
            "t": 5580,
            "value": 48.0
          },
          {
            "t": 5640,
            "value": 52.0
          },
          {
            "t": 5700,
            "value": 48.0
          },
          {
            "t": 5760,
            "value": 52.0
          },
          {
            "t": 5820,
            "value": 48.0
          },
          {
            "t": 5880,
            "value": 52.0
          },
          {
            "t": 5940,
            "value": 48.0
          },
          {
            "t": 6000,
            "value": 52.0
          },
          {
            "t": 6060,
            "value": 48.0
          },
          {
            "t": 6120,
            "value": 52.0
          },
          {
            "t": 6180,
            "value": 48.0
          },
          {
            "t": 6240,
            "value": 52.0
          },
          {
            "t": 6300,
            "value": 48.0
          },
          {
            "t": 6360,
            "value": 52.0
          },
          {
            "t": 6420,
            "value": 48.0
          },
          {
            "t": 6480,
            "value": 52.0
          },
          {
            "t": 6540,
            "value": 48.0
          },
          {
            "t": 6600,
            "value": 52.0
          },
          {
            "t": 6660,
            "value": 48.0
          },
          {
            "t": 6720,
            "value": 52.0
          },
          {
            "t": 6780,
            "value": 48.0
          },
          {
            "t": 6840,
            "value": 52.0
          },
          {
            "t": 6900,
            "value": 48.0
          },
          {
            "t": 6960,
            "value": 52.0
          },
          {
            "t": 7020,
            "value": 48.0
          },
          {
            "t": 7080,
            "value": 52.0
          },
          {
            "t": 7140,
            "value": 48.0
          },
          {
            "t": 7200,
            "value": 52.0
          },
          {
            "t": 7260,
            "value": 48.0
          },
          {
            "t": 7320,
            "value": 52.0
          },
          {
            "t": 7380,
            "value": 48.0
          },
          {
            "t": 7440,
            "value": 52.0
          },
          {
            "t": 7500,
            "value": 48.0
          },
          {
            "t": 7560,
            "value": 52.0
          },
          {
            "t": 7620,
            "value": 48.0
          },
          {
            "t": 7680,
            "value": 52.0
          },
          {
            "t": 7740,
            "value": 48.0
          },
          {
            "t": 7800,
            "value": 52.0
          },
          {
            "t": 7860,
            "value": 48.0
          },
          {
            "t": 7920,
            "value": 52.0
          },
          {
            "t": 7980,
            "value": 48.0
          },
          {
            "t": 8040,
            "value": 52.0
          },
          {
            "t": 8100,
            "value": 48.0
          },
          {
            "t": 8160,
            "value": 52.0
          },
          {
            "t": 8220,
            "value": 48.0
          },
          {
            "t": 8280,
            "value": 52.0
          },
          {
            "t": 8340,
            "value": 48.0
          },
          {
            "t": 8400,
            "value": 52.0
          },
          {
            "t": 8460,
            "value": 48.0
          },
          {
            "t": 8520,
            "value": 52.0
          },
          {
            "t": 8580,
            "value": 48.0
          },
          {
            "t": 8640,
            "value": 52.0
          },
          {
            "t": 8700,
            "value": 48.0
          },
          {
            "t": 8760,
            "value": 52.0
          },
          {
            "t": 8820,
            "value": 48.0
          },
          {
            "t": 8880,
            "value": 52.0
          },
          {
            "t": 8940,
            "value": 48.0
          },
          {
            "t": 9000,
            "value": 52.0
          },
          {
            "t": 9060,
            "value": 48.0
          },
          {
            "t": 9120,
            "value": 52.0
          },
          {
            "t": 9180,
            "value": 48.0
          },
          {
            "t": 9240,
            "value": 52.0
          },
          {
            "t": 9300,
            "value": 48.0
          },
          {
            "t": 9360,
            "value": 52.0
          },
          {
            "t": 9420,
            "value": 48.0
          },
          {
            "t": 9480,
            "value": 52.0
          },
          {
            "t": 9540,
            "value": 48.0
          },
          {
            "t": 9600,
            "value": 52.0
          },
          {
            "t": 9660,
            "value": 48.0
          },
          {
            "t": 9720,
            "value": 52.0
          },
          {
            "t": 9780,
            "value": 48.0
          },
          {
            "t": 9840,
            "value": 52.0
          },
          {
            "t": 9900,
            "value": 48.0
          },
          {
            "t": 9960,
            "value": 52.0
          },
          {
            "t": 10020,
            "value": 48.0
          },
          {
            "t": 10080,
            "value": 52.0
          },
          {
            "t": 10140,
            "value": 48.0
          },
          {
            "t": 10200,
            "value": 52.0
          },
          {
            "t": 10260,
            "value": 48.0
          },
          {
            "t": 10320,
            "value": 52.0
          },
          {
            "t": 10380,
            "value": 48.0
          },
          {
            "t": 10440,
            "value": 52.0
          },
          {
            "t": 10500,
            "value": 48.0
          },
          {
            "t": 10560,
            "value": 52.0
          },
          {
            "t": 10620,
            "value": 48.0
          },
          {
            "t": 10680,
            "value": 52.0
          },
          {
            "t": 10740,
            "value": 48.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 10800,
          "equals": false
        }
      ]
    },
    {
      "name": "slow_and_deep",
      "description": "Amplitude without count, the mirror of fast_but_shallow: a 20-minute +/-15% swing gives mad 14.5 \u2014 three times the amplitude threshold \u2014 on a count of exactly 3.0. A damper making large slow moves is tracking a load, not hunting, and the count test blocks the fault.",
      "inputs": {
        "zone_dmpr_pos": [
          {
            "t": 0,
            "value": 65.0
          },
          {
            "t": 600,
            "value": 35.0
          },
          {
            "t": 1200,
            "value": 65.0
          },
          {
            "t": 1800,
            "value": 35.0
          },
          {
            "t": 2400,
            "value": 65.0
          },
          {
            "t": 3000,
            "value": 35.0
          },
          {
            "t": 3600,
            "value": 65.0
          },
          {
            "t": 4200,
            "value": 35.0
          },
          {
            "t": 4800,
            "value": 65.0
          },
          {
            "t": 5400,
            "value": 35.0
          },
          {
            "t": 6000,
            "value": 65.0
          },
          {
            "t": 6600,
            "value": 35.0
          },
          {
            "t": 7200,
            "value": 65.0
          },
          {
            "t": 7800,
            "value": 35.0
          },
          {
            "t": 8400,
            "value": 65.0
          },
          {
            "t": 9000,
            "value": 35.0
          },
          {
            "t": 9600,
            "value": 65.0
          },
          {
            "t": 10200,
            "value": 35.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 10800,
          "equals": false
        }
      ]
    },
    {
      "name": "amplitude_exactly_at_threshold",
      "description": "Amplitude edge, low side. A tick-rate square wave of +/-5% about 50%: the window holds 15 whole cycles, so muS is exactly 50.0, every deviation is exactly 5.0, and mad reads exactly 5.000000 with no rounding. madHigh is strict, so scatter sitting exactly on min_oscillation_amplitude reads clear even with the count at its 30-crossing ceiling.",
      "inputs": {
        "zone_dmpr_pos": [
          {
            "t": 0,
            "value": 55.0
          },
          {
            "t": 60,
            "value": 45.0
          },
          {
            "t": 120,
            "value": 55.0
          },
          {
            "t": 180,
            "value": 45.0
          },
          {
            "t": 240,
            "value": 55.0
          },
          {
            "t": 300,
            "value": 45.0
          },
          {
            "t": 360,
            "value": 55.0
          },
          {
            "t": 420,
            "value": 45.0
          },
          {
            "t": 480,
            "value": 55.0
          },
          {
            "t": 540,
            "value": 45.0
          },
          {
            "t": 600,
            "value": 55.0
          },
          {
            "t": 660,
            "value": 45.0
          },
          {
            "t": 720,
            "value": 55.0
          },
          {
            "t": 780,
            "value": 45.0
          },
          {
            "t": 840,
            "value": 55.0
          },
          {
            "t": 900,
            "value": 45.0
          },
          {
            "t": 960,
            "value": 55.0
          },
          {
            "t": 1020,
            "value": 45.0
          },
          {
            "t": 1080,
            "value": 55.0
          },
          {
            "t": 1140,
            "value": 45.0
          },
          {
            "t": 1200,
            "value": 55.0
          },
          {
            "t": 1260,
            "value": 45.0
          },
          {
            "t": 1320,
            "value": 55.0
          },
          {
            "t": 1380,
            "value": 45.0
          },
          {
            "t": 1440,
            "value": 55.0
          },
          {
            "t": 1500,
            "value": 45.0
          },
          {
            "t": 1560,
            "value": 55.0
          },
          {
            "t": 1620,
            "value": 45.0
          },
          {
            "t": 1680,
            "value": 55.0
          },
          {
            "t": 1740,
            "value": 45.0
          },
          {
            "t": 1800,
            "value": 55.0
          },
          {
            "t": 1860,
            "value": 45.0
          },
          {
            "t": 1920,
            "value": 55.0
          },
          {
            "t": 1980,
            "value": 45.0
          },
          {
            "t": 2040,
            "value": 55.0
          },
          {
            "t": 2100,
            "value": 45.0
          },
          {
            "t": 2160,
            "value": 55.0
          },
          {
            "t": 2220,
            "value": 45.0
          },
          {
            "t": 2280,
            "value": 55.0
          },
          {
            "t": 2340,
            "value": 45.0
          },
          {
            "t": 2400,
            "value": 55.0
          },
          {
            "t": 2460,
            "value": 45.0
          },
          {
            "t": 2520,
            "value": 55.0
          },
          {
            "t": 2580,
            "value": 45.0
          },
          {
            "t": 2640,
            "value": 55.0
          },
          {
            "t": 2700,
            "value": 45.0
          },
          {
            "t": 2760,
            "value": 55.0
          },
          {
            "t": 2820,
            "value": 45.0
          },
          {
            "t": 2880,
            "value": 55.0
          },
          {
            "t": 2940,
            "value": 45.0
          },
          {
            "t": 3000,
            "value": 55.0
          },
          {
            "t": 3060,
            "value": 45.0
          },
          {
            "t": 3120,
            "value": 55.0
          },
          {
            "t": 3180,
            "value": 45.0
          },
          {
            "t": 3240,
            "value": 55.0
          },
          {
            "t": 3300,
            "value": 45.0
          },
          {
            "t": 3360,
            "value": 55.0
          },
          {
            "t": 3420,
            "value": 45.0
          },
          {
            "t": 3480,
            "value": 55.0
          },
          {
            "t": 3540,
            "value": 45.0
          },
          {
            "t": 3600,
            "value": 55.0
          },
          {
            "t": 3660,
            "value": 45.0
          },
          {
            "t": 3720,
            "value": 55.0
          },
          {
            "t": 3780,
            "value": 45.0
          },
          {
            "t": 3840,
            "value": 55.0
          },
          {
            "t": 3900,
            "value": 45.0
          },
          {
            "t": 3960,
            "value": 55.0
          },
          {
            "t": 4020,
            "value": 45.0
          },
          {
            "t": 4080,
            "value": 55.0
          },
          {
            "t": 4140,
            "value": 45.0
          },
          {
            "t": 4200,
            "value": 55.0
          },
          {
            "t": 4260,
            "value": 45.0
          },
          {
            "t": 4320,
            "value": 55.0
          },
          {
            "t": 4380,
            "value": 45.0
          },
          {
            "t": 4440,
            "value": 55.0
          },
          {
            "t": 4500,
            "value": 45.0
          },
          {
            "t": 4560,
            "value": 55.0
          },
          {
            "t": 4620,
            "value": 45.0
          },
          {
            "t": 4680,
            "value": 55.0
          },
          {
            "t": 4740,
            "value": 45.0
          },
          {
            "t": 4800,
            "value": 55.0
          },
          {
            "t": 4860,
            "value": 45.0
          },
          {
            "t": 4920,
            "value": 55.0
          },
          {
            "t": 4980,
            "value": 45.0
          },
          {
            "t": 5040,
            "value": 55.0
          },
          {
            "t": 5100,
            "value": 45.0
          },
          {
            "t": 5160,
            "value": 55.0
          },
          {
            "t": 5220,
            "value": 45.0
          },
          {
            "t": 5280,
            "value": 55.0
          },
          {
            "t": 5340,
            "value": 45.0
          },
          {
            "t": 5400,
            "value": 55.0
          },
          {
            "t": 5460,
            "value": 45.0
          },
          {
            "t": 5520,
            "value": 55.0
          },
          {
            "t": 5580,
            "value": 45.0
          },
          {
            "t": 5640,
            "value": 55.0
          },
          {
            "t": 5700,
            "value": 45.0
          },
          {
            "t": 5760,
            "value": 55.0
          },
          {
            "t": 5820,
            "value": 45.0
          },
          {
            "t": 5880,
            "value": 55.0
          },
          {
            "t": 5940,
            "value": 45.0
          },
          {
            "t": 6000,
            "value": 55.0
          },
          {
            "t": 6060,
            "value": 45.0
          },
          {
            "t": 6120,
            "value": 55.0
          },
          {
            "t": 6180,
            "value": 45.0
          },
          {
            "t": 6240,
            "value": 55.0
          },
          {
            "t": 6300,
            "value": 45.0
          },
          {
            "t": 6360,
            "value": 55.0
          },
          {
            "t": 6420,
            "value": 45.0
          },
          {
            "t": 6480,
            "value": 55.0
          },
          {
            "t": 6540,
            "value": 45.0
          },
          {
            "t": 6600,
            "value": 55.0
          },
          {
            "t": 6660,
            "value": 45.0
          },
          {
            "t": 6720,
            "value": 55.0
          },
          {
            "t": 6780,
            "value": 45.0
          },
          {
            "t": 6840,
            "value": 55.0
          },
          {
            "t": 6900,
            "value": 45.0
          },
          {
            "t": 6960,
            "value": 55.0
          },
          {
            "t": 7020,
            "value": 45.0
          },
          {
            "t": 7080,
            "value": 55.0
          },
          {
            "t": 7140,
            "value": 45.0
          },
          {
            "t": 7200,
            "value": 55.0
          },
          {
            "t": 7260,
            "value": 45.0
          },
          {
            "t": 7320,
            "value": 55.0
          },
          {
            "t": 7380,
            "value": 45.0
          },
          {
            "t": 7440,
            "value": 55.0
          },
          {
            "t": 7500,
            "value": 45.0
          },
          {
            "t": 7560,
            "value": 55.0
          },
          {
            "t": 7620,
            "value": 45.0
          },
          {
            "t": 7680,
            "value": 55.0
          },
          {
            "t": 7740,
            "value": 45.0
          },
          {
            "t": 7800,
            "value": 55.0
          },
          {
            "t": 7860,
            "value": 45.0
          },
          {
            "t": 7920,
            "value": 55.0
          },
          {
            "t": 7980,
            "value": 45.0
          },
          {
            "t": 8040,
            "value": 55.0
          },
          {
            "t": 8100,
            "value": 45.0
          },
          {
            "t": 8160,
            "value": 55.0
          },
          {
            "t": 8220,
            "value": 45.0
          },
          {
            "t": 8280,
            "value": 55.0
          },
          {
            "t": 8340,
            "value": 45.0
          },
          {
            "t": 8400,
            "value": 55.0
          },
          {
            "t": 8460,
            "value": 45.0
          },
          {
            "t": 8520,
            "value": 55.0
          },
          {
            "t": 8580,
            "value": 45.0
          },
          {
            "t": 8640,
            "value": 55.0
          },
          {
            "t": 8700,
            "value": 45.0
          },
          {
            "t": 8760,
            "value": 55.0
          },
          {
            "t": 8820,
            "value": 45.0
          },
          {
            "t": 8880,
            "value": 55.0
          },
          {
            "t": 8940,
            "value": 45.0
          },
          {
            "t": 9000,
            "value": 55.0
          },
          {
            "t": 9060,
            "value": 45.0
          },
          {
            "t": 9120,
            "value": 55.0
          },
          {
            "t": 9180,
            "value": 45.0
          },
          {
            "t": 9240,
            "value": 55.0
          },
          {
            "t": 9300,
            "value": 45.0
          },
          {
            "t": 9360,
            "value": 55.0
          },
          {
            "t": 9420,
            "value": 45.0
          },
          {
            "t": 9480,
            "value": 55.0
          },
          {
            "t": 9540,
            "value": 45.0
          },
          {
            "t": 9600,
            "value": 55.0
          },
          {
            "t": 9660,
            "value": 45.0
          },
          {
            "t": 9720,
            "value": 55.0
          },
          {
            "t": 9780,
            "value": 45.0
          },
          {
            "t": 9840,
            "value": 55.0
          },
          {
            "t": 9900,
            "value": 45.0
          },
          {
            "t": 9960,
            "value": 55.0
          },
          {
            "t": 10020,
            "value": 45.0
          },
          {
            "t": 10080,
            "value": 55.0
          },
          {
            "t": 10140,
            "value": 45.0
          },
          {
            "t": 10200,
            "value": 55.0
          },
          {
            "t": 10260,
            "value": 45.0
          },
          {
            "t": 10320,
            "value": 55.0
          },
          {
            "t": 10380,
            "value": 45.0
          },
          {
            "t": 10440,
            "value": 55.0
          },
          {
            "t": 10500,
            "value": 45.0
          },
          {
            "t": 10560,
            "value": 55.0
          },
          {
            "t": 10620,
            "value": 45.0
          },
          {
            "t": 10680,
            "value": 55.0
          },
          {
            "t": 10740,
            "value": 45.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 10800,
          "equals": false
        }
      ]
    },
    {
      "name": "amplitude_just_over_threshold",
      "description": "Amplitude edge, high side: the same wave at +/-5.5%, half a percent past the previous scenario. mad climbs past 5.0 at t=1500 s \u2014 still inside the warm-up, where both averaging stages are working on partial windows \u2014 and yFault asserts one alarm_delay later at t=2400 s; the settled reading is exactly 5.5 once the deviation average has flushed the warm-up at t=3600 s. The pair brackets min_oscillation_amplitude from both sides.",
      "inputs": {
        "zone_dmpr_pos": [
          {
            "t": 0,
            "value": 55.5
          },
          {
            "t": 60,
            "value": 44.5
          },
          {
            "t": 120,
            "value": 55.5
          },
          {
            "t": 180,
            "value": 44.5
          },
          {
            "t": 240,
            "value": 55.5
          },
          {
            "t": 300,
            "value": 44.5
          },
          {
            "t": 360,
            "value": 55.5
          },
          {
            "t": 420,
            "value": 44.5
          },
          {
            "t": 480,
            "value": 55.5
          },
          {
            "t": 540,
            "value": 44.5
          },
          {
            "t": 600,
            "value": 55.5
          },
          {
            "t": 660,
            "value": 44.5
          },
          {
            "t": 720,
            "value": 55.5
          },
          {
            "t": 780,
            "value": 44.5
          },
          {
            "t": 840,
            "value": 55.5
          },
          {
            "t": 900,
            "value": 44.5
          },
          {
            "t": 960,
            "value": 55.5
          },
          {
            "t": 1020,
            "value": 44.5
          },
          {
            "t": 1080,
            "value": 55.5
          },
          {
            "t": 1140,
            "value": 44.5
          },
          {
            "t": 1200,
            "value": 55.5
          },
          {
            "t": 1260,
            "value": 44.5
          },
          {
            "t": 1320,
            "value": 55.5
          },
          {
            "t": 1380,
            "value": 44.5
          },
          {
            "t": 1440,
            "value": 55.5
          },
          {
            "t": 1500,
            "value": 44.5
          },
          {
            "t": 1560,
            "value": 55.5
          },
          {
            "t": 1620,
            "value": 44.5
          },
          {
            "t": 1680,
            "value": 55.5
          },
          {
            "t": 1740,
            "value": 44.5
          },
          {
            "t": 1800,
            "value": 55.5
          },
          {
            "t": 1860,
            "value": 44.5
          },
          {
            "t": 1920,
            "value": 55.5
          },
          {
            "t": 1980,
            "value": 44.5
          },
          {
            "t": 2040,
            "value": 55.5
          },
          {
            "t": 2100,
            "value": 44.5
          },
          {
            "t": 2160,
            "value": 55.5
          },
          {
            "t": 2220,
            "value": 44.5
          },
          {
            "t": 2280,
            "value": 55.5
          },
          {
            "t": 2340,
            "value": 44.5
          },
          {
            "t": 2400,
            "value": 55.5
          },
          {
            "t": 2460,
            "value": 44.5
          },
          {
            "t": 2520,
            "value": 55.5
          },
          {
            "t": 2580,
            "value": 44.5
          },
          {
            "t": 2640,
            "value": 55.5
          },
          {
            "t": 2700,
            "value": 44.5
          },
          {
            "t": 2760,
            "value": 55.5
          },
          {
            "t": 2820,
            "value": 44.5
          },
          {
            "t": 2880,
            "value": 55.5
          },
          {
            "t": 2940,
            "value": 44.5
          },
          {
            "t": 3000,
            "value": 55.5
          },
          {
            "t": 3060,
            "value": 44.5
          },
          {
            "t": 3120,
            "value": 55.5
          },
          {
            "t": 3180,
            "value": 44.5
          },
          {
            "t": 3240,
            "value": 55.5
          },
          {
            "t": 3300,
            "value": 44.5
          },
          {
            "t": 3360,
            "value": 55.5
          },
          {
            "t": 3420,
            "value": 44.5
          },
          {
            "t": 3480,
            "value": 55.5
          },
          {
            "t": 3540,
            "value": 44.5
          },
          {
            "t": 3600,
            "value": 55.5
          },
          {
            "t": 3660,
            "value": 44.5
          },
          {
            "t": 3720,
            "value": 55.5
          },
          {
            "t": 3780,
            "value": 44.5
          },
          {
            "t": 3840,
            "value": 55.5
          },
          {
            "t": 3900,
            "value": 44.5
          },
          {
            "t": 3960,
            "value": 55.5
          },
          {
            "t": 4020,
            "value": 44.5
          },
          {
            "t": 4080,
            "value": 55.5
          },
          {
            "t": 4140,
            "value": 44.5
          },
          {
            "t": 4200,
            "value": 55.5
          },
          {
            "t": 4260,
            "value": 44.5
          },
          {
            "t": 4320,
            "value": 55.5
          },
          {
            "t": 4380,
            "value": 44.5
          },
          {
            "t": 4440,
            "value": 55.5
          },
          {
            "t": 4500,
            "value": 44.5
          },
          {
            "t": 4560,
            "value": 55.5
          },
          {
            "t": 4620,
            "value": 44.5
          },
          {
            "t": 4680,
            "value": 55.5
          },
          {
            "t": 4740,
            "value": 44.5
          },
          {
            "t": 4800,
            "value": 55.5
          },
          {
            "t": 4860,
            "value": 44.5
          },
          {
            "t": 4920,
            "value": 55.5
          },
          {
            "t": 4980,
            "value": 44.5
          },
          {
            "t": 5040,
            "value": 55.5
          },
          {
            "t": 5100,
            "value": 44.5
          },
          {
            "t": 5160,
            "value": 55.5
          },
          {
            "t": 5220,
            "value": 44.5
          },
          {
            "t": 5280,
            "value": 55.5
          },
          {
            "t": 5340,
            "value": 44.5
          },
          {
            "t": 5400,
            "value": 55.5
          },
          {
            "t": 5460,
            "value": 44.5
          },
          {
            "t": 5520,
            "value": 55.5
          },
          {
            "t": 5580,
            "value": 44.5
          },
          {
            "t": 5640,
            "value": 55.5
          },
          {
            "t": 5700,
            "value": 44.5
          },
          {
            "t": 5760,
            "value": 55.5
          },
          {
            "t": 5820,
            "value": 44.5
          },
          {
            "t": 5880,
            "value": 55.5
          },
          {
            "t": 5940,
            "value": 44.5
          },
          {
            "t": 6000,
            "value": 55.5
          },
          {
            "t": 6060,
            "value": 44.5
          },
          {
            "t": 6120,
            "value": 55.5
          },
          {
            "t": 6180,
            "value": 44.5
          },
          {
            "t": 6240,
            "value": 55.5
          },
          {
            "t": 6300,
            "value": 44.5
          },
          {
            "t": 6360,
            "value": 55.5
          },
          {
            "t": 6420,
            "value": 44.5
          },
          {
            "t": 6480,
            "value": 55.5
          },
          {
            "t": 6540,
            "value": 44.5
          },
          {
            "t": 6600,
            "value": 55.5
          },
          {
            "t": 6660,
            "value": 44.5
          },
          {
            "t": 6720,
            "value": 55.5
          },
          {
            "t": 6780,
            "value": 44.5
          },
          {
            "t": 6840,
            "value": 55.5
          },
          {
            "t": 6900,
            "value": 44.5
          },
          {
            "t": 6960,
            "value": 55.5
          },
          {
            "t": 7020,
            "value": 44.5
          },
          {
            "t": 7080,
            "value": 55.5
          },
          {
            "t": 7140,
            "value": 44.5
          },
          {
            "t": 7200,
            "value": 55.5
          },
          {
            "t": 7260,
            "value": 44.5
          },
          {
            "t": 7320,
            "value": 55.5
          },
          {
            "t": 7380,
            "value": 44.5
          },
          {
            "t": 7440,
            "value": 55.5
          },
          {
            "t": 7500,
            "value": 44.5
          },
          {
            "t": 7560,
            "value": 55.5
          },
          {
            "t": 7620,
            "value": 44.5
          },
          {
            "t": 7680,
            "value": 55.5
          },
          {
            "t": 7740,
            "value": 44.5
          },
          {
            "t": 7800,
            "value": 55.5
          },
          {
            "t": 7860,
            "value": 44.5
          },
          {
            "t": 7920,
            "value": 55.5
          },
          {
            "t": 7980,
            "value": 44.5
          },
          {
            "t": 8040,
            "value": 55.5
          },
          {
            "t": 8100,
            "value": 44.5
          },
          {
            "t": 8160,
            "value": 55.5
          },
          {
            "t": 8220,
            "value": 44.5
          },
          {
            "t": 8280,
            "value": 55.5
          },
          {
            "t": 8340,
            "value": 44.5
          },
          {
            "t": 8400,
            "value": 55.5
          },
          {
            "t": 8460,
            "value": 44.5
          },
          {
            "t": 8520,
            "value": 55.5
          },
          {
            "t": 8580,
            "value": 44.5
          },
          {
            "t": 8640,
            "value": 55.5
          },
          {
            "t": 8700,
            "value": 44.5
          },
          {
            "t": 8760,
            "value": 55.5
          },
          {
            "t": 8820,
            "value": 44.5
          },
          {
            "t": 8880,
            "value": 55.5
          },
          {
            "t": 8940,
            "value": 44.5
          },
          {
            "t": 9000,
            "value": 55.5
          },
          {
            "t": 9060,
            "value": 44.5
          },
          {
            "t": 9120,
            "value": 55.5
          },
          {
            "t": 9180,
            "value": 44.5
          },
          {
            "t": 9240,
            "value": 55.5
          },
          {
            "t": 9300,
            "value": 44.5
          },
          {
            "t": 9360,
            "value": 55.5
          },
          {
            "t": 9420,
            "value": 44.5
          },
          {
            "t": 9480,
            "value": 55.5
          },
          {
            "t": 9540,
            "value": 44.5
          },
          {
            "t": 9600,
            "value": 55.5
          },
          {
            "t": 9660,
            "value": 44.5
          },
          {
            "t": 9720,
            "value": 55.5
          },
          {
            "t": 9780,
            "value": 44.5
          },
          {
            "t": 9840,
            "value": 55.5
          },
          {
            "t": 9900,
            "value": 44.5
          },
          {
            "t": 9960,
            "value": 55.5
          },
          {
            "t": 10020,
            "value": 44.5
          },
          {
            "t": 10080,
            "value": 55.5
          },
          {
            "t": 10140,
            "value": 44.5
          },
          {
            "t": 10200,
            "value": 55.5
          },
          {
            "t": 10260,
            "value": 44.5
          },
          {
            "t": 10320,
            "value": 55.5
          },
          {
            "t": 10380,
            "value": 44.5
          },
          {
            "t": 10440,
            "value": 55.5
          },
          {
            "t": 10500,
            "value": 44.5
          },
          {
            "t": 10560,
            "value": 55.5
          },
          {
            "t": 10620,
            "value": 44.5
          },
          {
            "t": 10680,
            "value": 55.5
          },
          {
            "t": 10740,
            "value": 44.5
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2280,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2520,
          "to_s": 10800,
          "equals": true
        }
      ]
    },
    {
      "name": "count_exactly_at_threshold",
      "description": "Count edge, low side. A 6-minute square wave of +/-15% about 50%: five whole cycles per window, so every rolling window holds exactly 10 crossings and xCount lands on exactly 10.000000 (the pulse integral is 600 s of area against an 1800 s window, times a count_scale of 30, all exact in IEEE-754). xHigh is strict, so ten crossings reads clear. mad is 14.7, far above its threshold, so the count test is the only thing blocking the fault.",
      "inputs": {
        "zone_dmpr_pos": [
          {
            "t": 0,
            "value": 65.0
          },
          {
            "t": 180,
            "value": 35.0
          },
          {
            "t": 360,
            "value": 65.0
          },
          {
            "t": 540,
            "value": 35.0
          },
          {
            "t": 720,
            "value": 65.0
          },
          {
            "t": 900,
            "value": 35.0
          },
          {
            "t": 1080,
            "value": 65.0
          },
          {
            "t": 1260,
            "value": 35.0
          },
          {
            "t": 1440,
            "value": 65.0
          },
          {
            "t": 1620,
            "value": 35.0
          },
          {
            "t": 1800,
            "value": 65.0
          },
          {
            "t": 1980,
            "value": 35.0
          },
          {
            "t": 2160,
            "value": 65.0
          },
          {
            "t": 2340,
            "value": 35.0
          },
          {
            "t": 2520,
            "value": 65.0
          },
          {
            "t": 2700,
            "value": 35.0
          },
          {
            "t": 2880,
            "value": 65.0
          },
          {
            "t": 3060,
            "value": 35.0
          },
          {
            "t": 3240,
            "value": 65.0
          },
          {
            "t": 3420,
            "value": 35.0
          },
          {
            "t": 3600,
            "value": 65.0
          },
          {
            "t": 3780,
            "value": 35.0
          },
          {
            "t": 3960,
            "value": 65.0
          },
          {
            "t": 4140,
            "value": 35.0
          },
          {
            "t": 4320,
            "value": 65.0
          },
          {
            "t": 4500,
            "value": 35.0
          },
          {
            "t": 4680,
            "value": 65.0
          },
          {
            "t": 4860,
            "value": 35.0
          },
          {
            "t": 5040,
            "value": 65.0
          },
          {
            "t": 5220,
            "value": 35.0
          },
          {
            "t": 5400,
            "value": 65.0
          },
          {
            "t": 5580,
            "value": 35.0
          },
          {
            "t": 5760,
            "value": 65.0
          },
          {
            "t": 5940,
            "value": 35.0
          },
          {
            "t": 6120,
            "value": 65.0
          },
          {
            "t": 6300,
            "value": 35.0
          },
          {
            "t": 6480,
            "value": 65.0
          },
          {
            "t": 6660,
            "value": 35.0
          },
          {
            "t": 6840,
            "value": 65.0
          },
          {
            "t": 7020,
            "value": 35.0
          },
          {
            "t": 7200,
            "value": 65.0
          },
          {
            "t": 7380,
            "value": 35.0
          },
          {
            "t": 7560,
            "value": 65.0
          },
          {
            "t": 7740,
            "value": 35.0
          },
          {
            "t": 7920,
            "value": 65.0
          },
          {
            "t": 8100,
            "value": 35.0
          },
          {
            "t": 8280,
            "value": 65.0
          },
          {
            "t": 8460,
            "value": 35.0
          },
          {
            "t": 8640,
            "value": 65.0
          },
          {
            "t": 8820,
            "value": 35.0
          },
          {
            "t": 9000,
            "value": 65.0
          },
          {
            "t": 9180,
            "value": 35.0
          },
          {
            "t": 9360,
            "value": 65.0
          },
          {
            "t": 9540,
            "value": 35.0
          },
          {
            "t": 9720,
            "value": 65.0
          },
          {
            "t": 9900,
            "value": 35.0
          },
          {
            "t": 10080,
            "value": 65.0
          },
          {
            "t": 10260,
            "value": 35.0
          },
          {
            "t": 10440,
            "value": 65.0
          },
          {
            "t": 10620,
            "value": 35.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 10800,
          "equals": false
        }
      ]
    },
    {
      "name": "count_just_over_threshold",
      "description": "Count edge, high side. A 5-minute asymmetric cycle \u2014 one tick at 90%, four at 45% \u2014 puts 12 crossings in every window at mad 14.4. A symmetric wave cannot place 11 or 12 crossings on a 60 s grid (the available periods jump from 10 crossings at 360 s to 15 at 240 s), so the duty cycle is skewed instead; the mean sits at 54%, above the low value and below the high one, and each cycle still crosses it twice. yFault asserts at t=2760 s.",
      "inputs": {
        "zone_dmpr_pos": [
          {
            "t": 0,
            "value": 90.0
          },
          {
            "t": 60,
            "value": 45.0
          },
          {
            "t": 300,
            "value": 90.0
          },
          {
            "t": 360,
            "value": 45.0
          },
          {
            "t": 600,
            "value": 90.0
          },
          {
            "t": 660,
            "value": 45.0
          },
          {
            "t": 900,
            "value": 90.0
          },
          {
            "t": 960,
            "value": 45.0
          },
          {
            "t": 1200,
            "value": 90.0
          },
          {
            "t": 1260,
            "value": 45.0
          },
          {
            "t": 1500,
            "value": 90.0
          },
          {
            "t": 1560,
            "value": 45.0
          },
          {
            "t": 1800,
            "value": 90.0
          },
          {
            "t": 1860,
            "value": 45.0
          },
          {
            "t": 2100,
            "value": 90.0
          },
          {
            "t": 2160,
            "value": 45.0
          },
          {
            "t": 2400,
            "value": 90.0
          },
          {
            "t": 2460,
            "value": 45.0
          },
          {
            "t": 2700,
            "value": 90.0
          },
          {
            "t": 2760,
            "value": 45.0
          },
          {
            "t": 3000,
            "value": 90.0
          },
          {
            "t": 3060,
            "value": 45.0
          },
          {
            "t": 3300,
            "value": 90.0
          },
          {
            "t": 3360,
            "value": 45.0
          },
          {
            "t": 3600,
            "value": 90.0
          },
          {
            "t": 3660,
            "value": 45.0
          },
          {
            "t": 3900,
            "value": 90.0
          },
          {
            "t": 3960,
            "value": 45.0
          },
          {
            "t": 4200,
            "value": 90.0
          },
          {
            "t": 4260,
            "value": 45.0
          },
          {
            "t": 4500,
            "value": 90.0
          },
          {
            "t": 4560,
            "value": 45.0
          },
          {
            "t": 4800,
            "value": 90.0
          },
          {
            "t": 4860,
            "value": 45.0
          },
          {
            "t": 5100,
            "value": 90.0
          },
          {
            "t": 5160,
            "value": 45.0
          },
          {
            "t": 5400,
            "value": 90.0
          },
          {
            "t": 5460,
            "value": 45.0
          },
          {
            "t": 5700,
            "value": 90.0
          },
          {
            "t": 5760,
            "value": 45.0
          },
          {
            "t": 6000,
            "value": 90.0
          },
          {
            "t": 6060,
            "value": 45.0
          },
          {
            "t": 6300,
            "value": 90.0
          },
          {
            "t": 6360,
            "value": 45.0
          },
          {
            "t": 6600,
            "value": 90.0
          },
          {
            "t": 6660,
            "value": 45.0
          },
          {
            "t": 6900,
            "value": 90.0
          },
          {
            "t": 6960,
            "value": 45.0
          },
          {
            "t": 7200,
            "value": 90.0
          },
          {
            "t": 7260,
            "value": 45.0
          },
          {
            "t": 7500,
            "value": 90.0
          },
          {
            "t": 7560,
            "value": 45.0
          },
          {
            "t": 7800,
            "value": 90.0
          },
          {
            "t": 7860,
            "value": 45.0
          },
          {
            "t": 8100,
            "value": 90.0
          },
          {
            "t": 8160,
            "value": 45.0
          },
          {
            "t": 8400,
            "value": 90.0
          },
          {
            "t": 8460,
            "value": 45.0
          },
          {
            "t": 8700,
            "value": 90.0
          },
          {
            "t": 8760,
            "value": 45.0
          },
          {
            "t": 9000,
            "value": 90.0
          },
          {
            "t": 9060,
            "value": 45.0
          },
          {
            "t": 9300,
            "value": 90.0
          },
          {
            "t": 9360,
            "value": 45.0
          },
          {
            "t": 9600,
            "value": 90.0
          },
          {
            "t": 9660,
            "value": 45.0
          },
          {
            "t": 9900,
            "value": 90.0
          },
          {
            "t": 9960,
            "value": 45.0
          },
          {
            "t": 10200,
            "value": 90.0
          },
          {
            "t": 10260,
            "value": 45.0
          },
          {
            "t": 10500,
            "value": 90.0
          },
          {
            "t": 10560,
            "value": 45.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2640,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2880,
          "to_s": 10800,
          "equals": true
        }
      ]
    },
    {
      "name": "hunting_stops_before_delay",
      "description": "Transient: 22 minutes of hard hunting (+/-10%, 4-minute period) starting at t=1800 s, after the windows have filled, then the damper settles. The eleventh crossing lands at t=3120 s and lifts the count over the threshold just as the oscillation ends; the count falls back to exactly 10.0 at t=3720 s as the first crossing ages out of the trailing window. `both` holds for 540 s against a 900 s alarm_delay, so one disturbance rejection does not report.",
      "inputs": {
        "zone_dmpr_pos": [
          {
            "t": 0,
            "value": 50.0
          },
          {
            "t": 1800,
            "value": 60.0
          },
          {
            "t": 1920,
            "value": 40.0
          },
          {
            "t": 2040,
            "value": 60.0
          },
          {
            "t": 2160,
            "value": 40.0
          },
          {
            "t": 2280,
            "value": 60.0
          },
          {
            "t": 2400,
            "value": 40.0
          },
          {
            "t": 2520,
            "value": 60.0
          },
          {
            "t": 2640,
            "value": 40.0
          },
          {
            "t": 2760,
            "value": 60.0
          },
          {
            "t": 2880,
            "value": 40.0
          },
          {
            "t": 3000,
            "value": 60.0
          },
          {
            "t": 3120,
            "value": 50.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 10800,
          "equals": false
        }
      ]
    },
    {
      "name": "recovery_clears",
      "description": "Recovery: the same hunting starts at t=1800 s and runs for 90 minutes before a technician retunes the loop at t=7200 s. yFault asserts at t=4020 s and clears at t=7800 s \u2014 600 s after the last swing, the time five of the fifteen crossings in the window need to age out and bring the count back to exactly 10.0. Neither statistic drops when the hunting stops; both decay across the trailing window, and here the count reaches its threshold first (mad does not fall under 5.0 until t=8040 s).",
      "inputs": {
        "zone_dmpr_pos": [
          {
            "t": 0,
            "value": 50.0
          },
          {
            "t": 1800,
            "value": 60.0
          },
          {
            "t": 1920,
            "value": 40.0
          },
          {
            "t": 2040,
            "value": 60.0
          },
          {
            "t": 2160,
            "value": 40.0
          },
          {
            "t": 2280,
            "value": 60.0
          },
          {
            "t": 2400,
            "value": 40.0
          },
          {
            "t": 2520,
            "value": 60.0
          },
          {
            "t": 2640,
            "value": 40.0
          },
          {
            "t": 2760,
            "value": 60.0
          },
          {
            "t": 2880,
            "value": 40.0
          },
          {
            "t": 3000,
            "value": 60.0
          },
          {
            "t": 3120,
            "value": 40.0
          },
          {
            "t": 3240,
            "value": 60.0
          },
          {
            "t": 3360,
            "value": 40.0
          },
          {
            "t": 3480,
            "value": 60.0
          },
          {
            "t": 3600,
            "value": 40.0
          },
          {
            "t": 3720,
            "value": 60.0
          },
          {
            "t": 3840,
            "value": 40.0
          },
          {
            "t": 3960,
            "value": 60.0
          },
          {
            "t": 4080,
            "value": 40.0
          },
          {
            "t": 4200,
            "value": 60.0
          },
          {
            "t": 4320,
            "value": 40.0
          },
          {
            "t": 4440,
            "value": 60.0
          },
          {
            "t": 4560,
            "value": 40.0
          },
          {
            "t": 4680,
            "value": 60.0
          },
          {
            "t": 4800,
            "value": 40.0
          },
          {
            "t": 4920,
            "value": 60.0
          },
          {
            "t": 5040,
            "value": 40.0
          },
          {
            "t": 5160,
            "value": 60.0
          },
          {
            "t": 5280,
            "value": 40.0
          },
          {
            "t": 5400,
            "value": 60.0
          },
          {
            "t": 5520,
            "value": 40.0
          },
          {
            "t": 5640,
            "value": 60.0
          },
          {
            "t": 5760,
            "value": 40.0
          },
          {
            "t": 5880,
            "value": 60.0
          },
          {
            "t": 6000,
            "value": 40.0
          },
          {
            "t": 6120,
            "value": 60.0
          },
          {
            "t": 6240,
            "value": 40.0
          },
          {
            "t": 6360,
            "value": 60.0
          },
          {
            "t": 6480,
            "value": 40.0
          },
          {
            "t": 6600,
            "value": 60.0
          },
          {
            "t": 6720,
            "value": 40.0
          },
          {
            "t": 6840,
            "value": 60.0
          },
          {
            "t": 6960,
            "value": 40.0
          },
          {
            "t": 7080,
            "value": 60.0
          },
          {
            "t": 7200,
            "value": 50.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3900,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 4140,
          "to_s": 7620,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 7860,
          "to_s": 10800,
          "equals": false
        }
      ]
    },
    {
      "name": "warmup_burst_asserts",
      "description": "The warm-up artifact, and why the host NO_EVAL precondition is load-bearing. Ten minutes of tick-rate +/-20% swinging at engine start produces 8 crossings, and 8 crossings per 30 minutes is the reference's own NO_FAULT vector. But while the window is filling, xRate divides by elapsed time rather than by eval_window, so those 8 crossings read as a count of 24 at t=600 s and stay above 10 until t=1440 s. mad is inflated the same way. Both hold long enough for yFault to assert at t=1140 s; it clears at t=1440 s as the divisor grows, and the settled count after the window fills is 8. alarm_delay (900 s) is half of eval_window (1800 s) and cannot cover this \u2014 only the host gate can.",
      "inputs": {
        "zone_dmpr_pos": [
          {
            "t": 0,
            "value": 70.0
          },
          {
            "t": 60,
            "value": 30.0
          },
          {
            "t": 120,
            "value": 70.0
          },
          {
            "t": 180,
            "value": 30.0
          },
          {
            "t": 240,
            "value": 70.0
          },
          {
            "t": 300,
            "value": 30.0
          },
          {
            "t": 360,
            "value": 70.0
          },
          {
            "t": 420,
            "value": 30.0
          },
          {
            "t": 480,
            "value": 70.0
          },
          {
            "t": 540,
            "value": 30.0
          },
          {
            "t": 600,
            "value": 50.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1020,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 1200,
          "to_s": 1320,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 1500,
          "to_s": 10800,
          "equals": false
        }
      ]
    }
  ]
}

VAV-0006 — Reheat waste during cooling season

Statusverified — engine e2ff2f8, cxf:fnv1a128:f9e05984e4f2c96f769f4460a41807b7, 2026-08-17
Severity3
Methodrule
Phase2
CategoryCRITICAL_WASTE
ConfidenceHIGH
EstimationDIRECT_MEASUREMENT
G36
ClustersCLU-05
Suppresses
Suppressed by
RelatedAHU-0019, VAV-0001, VAV-0003
Playbooksvav-min-flow-reheat
SourceHVAC FDD Reference v1.0 §10, VAV-0006; Torabi et al. 2022; PNNL-25985 EEM-15/EEM-16
Operating statesOccupied cooling season. Outside the cooling season the rule reads false because the question is meaningless, not because the box is healthy — the host reports NO_EVAL there, from the same OAT the rule consumes.

Preconditions (host-enforced): Supply fan running and the box under occupied control; a night-setback reheat cycle is not this fault. oat must be the site outdoor air temperature, fresh and shaded — a sun-baked wall sensor reads warm enough to put the building in a cooling season the weather is not in. zone_dmpr_pos should be position feedback where the box provides it; on command-only boxes the damper term reads intent rather than blade position, and a blade stuck open under a minimum-flow command will read as this fault.

Points: rht_vlv_cmd, oat, zone_dmpr_pos

Outputs:

  • yFault — True once the reheat valve has stayed above reheat_active_threshold with the damper below damper_at_minimum_margin and OAT above cooling_season_oat, continuously for eval_duration plus alarm_delay

Parameters:

NameDefaultUnitCXF pathDescription
reheat_active_threshold10.0%rhtOn.tReheat valve command above which the coil counts as actively consuming energy
cooling_season_oat18.0°CwarmOut.tOutdoor air temperature above which the building is in cooling season (65 °F)
damper_at_minimum_margin35.0%dmprMin.tDamper position below which the box is passing minimum flow rather than answering a load
eval_duration3600.0shold.delayTimeContinuous duration of the reheat-at-minimum-flow condition before it counts as waste (60 min)
alarm_delay900.0spersist.delayTimeFurther persistence required after eval_duration before the alarm asserts (15 min)

Description

A reheat coil is running in July. The zone is passing the minimum airflow its configuration insists on, that air arrives at the supply temperature the chiller worked to produce, and the box heats it back up before it reaches the space — simultaneous heating and cooling seen from the zone end, which is where much of it happens in a VAV building. The damper term is what separates waste from load: reheat with the damper open and modulating is a box answering a genuine heating demand, and any finding there sits at the air handler. Reheat with the damper parked at minimum is a configuration defect that persists every occupied hour until someone changes a setpoint. The season term keeps the rule from arguing with winter. This rule is the trigger of CLU-05 (Zone Heating & Cooling Conflict); VAV-0003 and SYS-0007 are members that should clear behind it.

Detection Logic

yFault = rht_vlv_cmd > reheat_active_threshold
     AND oat > cooling_season_oat                     (cooling season)
     AND zone_dmpr_pos < damper_at_minimum_margin
     sustained continuously for eval_duration
     and then held a further alarm_delay

Block graph (rule.cxf.jsonld):

VAV-0006 block graph

Three threshold tests, two conjunctions, two delays. hold turns true only after the conjunction has been continuously true for eval_duration (60 min) and persist adds alarm_delay (15 min) on top, so a box that reheats through the afternoon alarms at 4500 s. Any break in any of the three terms drops both timers, so a damper stroke, a passing cloud on the OAT sensor, or a valve that closes for five minutes restarts the clock. All three comparisons are strict, as the reference writes them: a valve reported at exactly 10.0%, an outdoor air temperature of exactly 18.0 °C, and a damper sitting exactly on 35.0% each fail their term. Both delayOnInit flags are true, so a condition already present at load waits out the full 75 minutes rather than alarming on the first tick after a restart.

Possible Diagnoses

  1. VAV minimum airflow setpoint too high for this zone — the common case, and the one VAV-0001 confirms directly against the ventilation requirement
  2. AHU supply air temperature setpoint too low, so every box on the air handler has to temper its minimum flow (AHU-0019)
  3. SAT reset responding to a rogue zone: the reset is working, one zone is holding it at the cold end, and this box is paying for it (VAV-0002)
  4. Zone has low internal loads but a high minimum flow requirement — a corner office or a perimeter zone sized for a load that never materialized

Energy Impact

CRITICAL_WASTE, HIGH confidence, DIRECT_MEASUREMENT. The waste is on the wire: waste_kw = rht_vlv_cmd/100 × vav_rht_capacity_kw for every hour the condition holds, with no counterfactual to model — and the cooling energy spent making the air it undoes is waste on top. The reference gives 5–20% of zone thermal energy and PNNL-25985 maps the fix to EEM-15 (VAV minimum flow reduction) and EEM-16. Cooling-dominant by climate, though the multiplier is what matters: a building runs dozens to hundreds of these boxes, and EEM-15 is the highest-impact individual measure across all building types in the PNNL study at 5–16% of site energy, 7.7% nationally.

Emissions Impact

Scope 1, DIRECT_EMISSIONS, HIGH confidence; typically 500–3,000 kg CO₂e/yr per zone. The reference reports scope 1 because hot-water reheat is usually fed by a gas-fired boiler, so the waste is on-site combustion. Boxes with electric reheat coils, or hot water from a heat pump or district loop, move the same kilowatts into scope 2 — hosts should follow the heating source rather than this default. Avoided-emissions basis: marginal operating emissions rate (MOER).

Deviations

  • cooling_season is implemented as the OAT comparison the reference puts in parentheses, not as a host-supplied season flag. It is one comparison on a point the rule needs no help obtaining; consuming a host “cooling season” boolean would hide cooling_season_oat from set_param and make the rule’s behavior depend on a definition the library cannot see.
  • The reference’s heating-season vector expects NO_EVAL, and this rule reports it as a false yFault. Out of season the rule reads false because the question is meaningless, not because the box is healthy, and publishing that distinction is the host’s job via operating_states. No evaluability output is exposed, unlike AHU-0021’s yTempDeltaOk: there the condition is computed from the rule’s inputs and the host cannot check it without redoing the arithmetic, whereas here the gate is an input the host already holds.
  • The precondition OAT > cooling_season_oat is deliberately stated twice — once in preconditions, once in the block graph — because the reference states it twice. The in-graph term is what makes the rule safe to run continuously; the precondition is what tells the host when a false output means anything.
  • Two delays in series rather than one. eval_duration and AlarmDelay are separate tunables in the reference, so they stay separately tunable even though a single 4500 s delay behaves identically at the defaults (precedent: AHU-0027). A site on 15-minute trend data changes one parameter.
  • All three comparisons are strict (>, >, <), matching the reference’s own operators — no boundary reinterpretation, though the exact values are where a retuned site will sit.
  • hold.delayOnInit and persist.delayOnInit are both true (Modelica/CDL default is false), the library’s standing choice against alarming on the first tick after a controller restart.
  • Operating-state gating (occupied cooling mode) and the fan-running precondition are declared in frontmatter for host enforcement rather than encoded in the block graph, per the library’s design stance.
  • The reference’s Notes block is truncated in the source document mid-sentence. It is quoted below as far as the source runs and no further; the Torabi finding it was introducing has not been reconstructed.

Notes

Reference note, quoted as far as the source runs: “Check AHU SAT first — Torabi et al. (2022) found zone-level reheat”.

Fix order within CLU-05 follows that advice. Diagnoses 2 and 3 both live at the air handler and are cheaper to check than anything at the box: pull the supply air temperature setpoint and its reset trend (AHU-0019, AHU-0023) and check whether one zone is holding the reset at its cold end (VAV-0002). A supply air temperature raised into its reset band clears this fault across every box at once, while lowering one box’s minimum flow fixes one box; the vav-min-flow-reheat playbook’s counting rule discriminates — more than half the boxes flagged points at the air handler, one to three at zone configuration. Its step 2.3 (a summer reheat lockout) is the remote fix and step 2.1 (minimum airflow down to the ASHRAE 62.1 requirement) is the durable one; both are $0 and batchable.

The rule reads zone_dmpr_pos as evidence of what the box is doing, not what it was told. On boxes exposing only the damper command, a blade stuck open while the command sits at minimum satisfies the damper term and this rule reports waste at a box passing full flow — VAV-0004 is what separates those two.

Test Vectors

10 scenarios, clock step 300 s over 21600 s.

ScenarioDescription
normal_cooling_no_reheatReference vector: 25 °C outside, damper at 60%, reheat valve shut. The box is cooling in cooling weather — exactly what it is for.
reheat_at_minimum_flow_cooling_seasonReference vector: 28 °C outside, damper parked at 20% (its minimum), reheat valve at 40%, held for 90 min. The box is heating air the chiller just cooled, at the lowest flow it is allowed to pass. Alarms at eval_duration (3600 s) + alarm_delay (900 s) = 4500 s.
reheat_in_heating_seasonReference vector: the same 40% reheat at a 20% damper, but 5 °C outside. The season term holds yFault false — and false here means unevaluable, not healthy. The reference expects NO_EVAL for this case and the host reports it from the same OAT the rule reads; reheat in January is the reheat coil doing its job.
reheat_with_damper_openThe damper term earning its place: 40% reheat at 28 °C outside, but the damper is at 60% and modulating. The box is passing well above minimum flow, so it is answering a real heating load rather than tempering excess cold air. That is a diagnosis for the air handler, not waste at this box.
thresholds_exactly_at_edgesBoundary: reheat exactly at reheat_active_threshold (10.0%), OAT exactly at cooling_season_oat (18.0 °C), damper exactly at damper_at_minimum_margin (35.0%). All three tests are strict, so a signal sitting on any edge fails its term and nothing fires.
thresholds_just_inside_edgesBoundary from the other side: reheat 10.1%, OAT 18.1 °C, damper 34.9%. Every term is now inside its threshold by a tenth and the alarm lands on the same 4500 s schedule.
morning_reheat_burst_clearsTransient: a warm-up reheat burst on a 28 °C day at minimum flow ends when the valve closes at t=3000 s, 600 s short of eval_duration. The hold timer discards the elapsed time, so a morning burst never alarms.
damper_opens_mid_holdTransient: reheat and weather hold, but the damper opens to 60% at t=3900 s — a real load arrives — and falls back to 20% at t=4500 s. The interruption drops both timers; the alarm restarts from 4500 s and lands at 9000 s rather than 4500 s.
oat_crosses_into_cooling_seasonThe season term as a gate on live weather: reheat at 40% and the damper at minimum from t=0, but a 15 °C morning only crosses cooling_season_oat at t=3600 s (22 °C). The clock starts at the crossing, so the alarm lands at 8100 s.
reheat_waste_alarms_then_clearsRecovery: the alarm asserts at 4500 s, then a summer reheat lockout is enabled at t=10800 s and drives the valve shut. yFault drops on that tick, since TrueDelay only delays the rising edge.
vectors.json
{
  "schema": "cxf-library/vectors/v1",
  "clock": {
    "step_s": 300,
    "horizon_s": 21600
  },
  "scenarios": [
    {
      "name": "normal_cooling_no_reheat",
      "description": "Reference vector: 25 \u00b0C outside, damper at 60%, reheat valve shut. The box is cooling in cooling weather \u2014 exactly what it is for.",
      "inputs": {
        "rht_vlv_cmd": 0.0,
        "zone_dmpr_pos": 60.0,
        "oat": 25.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 21600,
          "equals": false
        }
      ]
    },
    {
      "name": "reheat_at_minimum_flow_cooling_season",
      "description": "Reference vector: 28 \u00b0C outside, damper parked at 20% (its minimum), reheat valve at 40%, held for 90 min. The box is heating air the chiller just cooled, at the lowest flow it is allowed to pass. Alarms at eval_duration (3600 s) + alarm_delay (900 s) = 4500 s.",
      "inputs": {
        "rht_vlv_cmd": 40.0,
        "zone_dmpr_pos": 20.0,
        "oat": 28.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 4200,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 4800,
          "to_s": 21600,
          "equals": true
        }
      ]
    },
    {
      "name": "reheat_in_heating_season",
      "description": "Reference vector: the same 40% reheat at a 20% damper, but 5 \u00b0C outside. The season term holds yFault false \u2014 and false here means unevaluable, not healthy. The reference expects NO_EVAL for this case and the host reports it from the same OAT the rule reads; reheat in January is the reheat coil doing its job.",
      "inputs": {
        "rht_vlv_cmd": 40.0,
        "zone_dmpr_pos": 20.0,
        "oat": 5.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 21600,
          "equals": false
        }
      ]
    },
    {
      "name": "reheat_with_damper_open",
      "description": "The damper term earning its place: 40% reheat at 28 \u00b0C outside, but the damper is at 60% and modulating. The box is passing well above minimum flow, so it is answering a real heating load rather than tempering excess cold air. That is a diagnosis for the air handler, not waste at this box.",
      "inputs": {
        "rht_vlv_cmd": 40.0,
        "zone_dmpr_pos": 60.0,
        "oat": 28.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 21600,
          "equals": false
        }
      ]
    },
    {
      "name": "thresholds_exactly_at_edges",
      "description": "Boundary: reheat exactly at reheat_active_threshold (10.0%), OAT exactly at cooling_season_oat (18.0 \u00b0C), damper exactly at damper_at_minimum_margin (35.0%). All three tests are strict, so a signal sitting on any edge fails its term and nothing fires.",
      "inputs": {
        "rht_vlv_cmd": 10.0,
        "zone_dmpr_pos": 35.0,
        "oat": 18.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 21600,
          "equals": false
        }
      ]
    },
    {
      "name": "thresholds_just_inside_edges",
      "description": "Boundary from the other side: reheat 10.1%, OAT 18.1 \u00b0C, damper 34.9%. Every term is now inside its threshold by a tenth and the alarm lands on the same 4500 s schedule.",
      "inputs": {
        "rht_vlv_cmd": 10.1,
        "zone_dmpr_pos": 34.9,
        "oat": 18.1
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 4200,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 4800,
          "to_s": 21600,
          "equals": true
        }
      ]
    },
    {
      "name": "morning_reheat_burst_clears",
      "description": "Transient: a warm-up reheat burst on a 28 \u00b0C day at minimum flow ends when the valve closes at t=3000 s, 600 s short of eval_duration. The hold timer discards the elapsed time, so a morning burst never alarms.",
      "inputs": {
        "rht_vlv_cmd": [
          {
            "t": 0,
            "value": 40.0
          },
          {
            "t": 3000,
            "value": 0.0
          }
        ],
        "zone_dmpr_pos": 20.0,
        "oat": 28.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 21600,
          "equals": false
        }
      ]
    },
    {
      "name": "damper_opens_mid_hold",
      "description": "Transient: reheat and weather hold, but the damper opens to 60% at t=3900 s \u2014 a real load arrives \u2014 and falls back to 20% at t=4500 s. The interruption drops both timers; the alarm restarts from 4500 s and lands at 9000 s rather than 4500 s.",
      "inputs": {
        "rht_vlv_cmd": 40.0,
        "zone_dmpr_pos": [
          {
            "t": 0,
            "value": 20.0
          },
          {
            "t": 3900,
            "value": 60.0
          },
          {
            "t": 4500,
            "value": 20.0
          }
        ],
        "oat": 28.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 8700,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 9300,
          "to_s": 21600,
          "equals": true
        }
      ]
    },
    {
      "name": "oat_crosses_into_cooling_season",
      "description": "The season term as a gate on live weather: reheat at 40% and the damper at minimum from t=0, but a 15 \u00b0C morning only crosses cooling_season_oat at t=3600 s (22 \u00b0C). The clock starts at the crossing, so the alarm lands at 8100 s.",
      "inputs": {
        "rht_vlv_cmd": 40.0,
        "zone_dmpr_pos": 20.0,
        "oat": [
          {
            "t": 0,
            "value": 15.0
          },
          {
            "t": 3600,
            "value": 22.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7800,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 8400,
          "to_s": 21600,
          "equals": true
        }
      ]
    },
    {
      "name": "reheat_waste_alarms_then_clears",
      "description": "Recovery: the alarm asserts at 4500 s, then a summer reheat lockout is enabled at t=10800 s and drives the valve shut. yFault drops on that tick, since TrueDelay only delays the rising edge.",
      "inputs": {
        "rht_vlv_cmd": [
          {
            "t": 0,
            "value": 40.0
          },
          {
            "t": 10800,
            "value": 0.0
          }
        ],
        "zone_dmpr_pos": 20.0,
        "oat": 28.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 4200,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 4800,
          "to_s": 10500,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 11100,
          "to_s": 21600,
          "equals": false
        }
      ]
    }
  ]
}

VAV-0007 — VAV airflow tracking CUSUM

Statusverified — engine e2ff2f8, cxf:fnv1a128:e234d75706a2e9484f7249ffa215c442, 2026-08-18
Severity3
Methodstatistical
Phase3
CategoryCOMFORT_ENERGY
ConfidenceMEDIUM
EstimationPROXY_ESTIMATION
G36
Clusters
Suppresses
Suppressed by
RelatedVAV-0002, VAV-0004, VAV-0008, VAV-0009
Playbooksstuck-actuator, vav-min-flow-reheat
SourceBushby, Castro, Schein, House (2001), NIST/CEC PIER Project 2.3, §5.1.1 — the normalized statistic (eq. 1) and the two-sided CUSUM recursion (eqs. 2-3); Bushby, Castro, Schein, House (2001), NIST/CEC PIER Project 2.3, §5.1.3 — CFMerror = CFMactual − CFMsetpoint, the fault classes it detects, and the occupied-only / first-hour-excluded computation rule; Bushby, Castro, Schein, House (2001), NIST/CEC PIER Project 2.3, §5.1.4 (point requirements), §5.1.5 (threshold-selection protocol), §5.2.2 Table 4 (normal-operation error statistics), §5.2.3 and Table 5 (k = 3 on the Iowa Energy Center data; alarm-limit ranges); Calibration method: tools/simharness/harness.py vavcal mode and tools/simharness/README.md; Library precedent: SYS-0010 (Discrete.UnitDelay sample grid and seed behaviour), SYS-0006 (sub-condition flags), HP-0001 and VAV-0001 (per-binding placeholder parameters)
Operating statesall (fan running) — the box’s flow loop is supposed to track its setpoint in every mode, and the rule gates itself on occupancy rather than on mode

Preconditions (host-enforced): AHU fan running: with no branch pressure the box cannot track anything and every zone accumulates at once. zone_airflow_sp must be the ACTIVE setpoint the damper loop is tracking (the dictionary’s own contract) — a stale or wrongly-bound setpoint produces a real, sustained, entirely fictitious bias, and CUSUM is built to find exactly that. sigma_flow_error is a COMMISSIONING parameter in L/s and does not travel between boxes: it scales with box size, so a 2000 L/s box and a 200 L/s box differ by about 10× and the shipped default is one 280 L/s box’s number (see Deviations). Commission it from normal-operation data that INCLUDES ordinary setpoint activity, per §5.1.5 — a sigma fitted on steady-state samples only makes every damper stroke look like a fault. sample_period MUST equal the host’s tick interval, and the alarm limit is a per-sample quantity: halve the tick and the same bias alarms in half the wall-clock time. Pressure-dependent boxes and boxes commissioned with the flow sensor disabled must be excluded host-side — there is no setpoint to track. Read yOccupiedOk before yFault: while it is false the accumulators are held at zero and the verdict is NO_EVAL, not a healthy box.

Points: zone_airflow, zone_airflow_sp, occ_scheduled

Outputs:

  • yFault — True while either accumulator sits above alarm_limit_h — the box’s flow error has leaned one way for long enough that the accumulated evidence exceeds the alarm limit
  • yHigh — Sub-condition flag — the positive (S) accumulator is above the limit: the box is delivering more air than asked. Diagnostic detail only; a false yHigh never means NO_EVAL
  • yLow — Sub-condition flag — the negative (T) accumulator is above the limit: the box is starving its zone. Not an evaluability output either
  • yOccupiedOk — Evaluability signal — true once occ_scheduled has held true for occupied_warmup. While false both accumulators are forced to zero and no verdict exists; false means NO_EVAL and the host must ignore yFault

Parameters:

NameDefaultUnitCXF pathDescription
mean_flow_error0.0L/smeanC.kMean flow error under normal operation, subtracted before normalizing. The source measured 0 CFM over four boxes and eight days (§5.2.2 Table 4), so the shipped default is zero; a box with a known standing offset carries it here rather than letting the offset accumulate.
sigma_flow_error2.36L/ssigmaC.kStandard deviation of the flow error under normal operation — the scale everything else is measured in. The source’s 5 CFM (§5.2.2 Table 4) converted to L/s, measured on ~280 L/s Iowa Energy Center boxes at one-minute sampling. PER-BOX COMMISSIONING VALUE: it scales with box size and with the flow sensor’s own noise, so a large box needs its own number and an unretuned default there is silently 10× too small. Floor it above the flow sensor’s resolution — the graph divides by this constant unguarded, and a zero or near-zero sigma produces an infinite or noise-dominated z.
slack_k3.01 (multiples of sigma)kC.kSlack parameter: the part of each sample’s normalized error that is written off as normal variation before anything accumulates. The source set k = 3 for its Iowa Energy Center charts (§5.2.3), so only excursions past 3 sigma build evidence at all — at the shipped sigma that is a 7.1 L/s sustained bias before the rule can ever fire.
alarm_limit_h20.01 (accumulated sigma-samples)sHigh.t, tHigh.tAlarm limit both accumulators are compared against. The source’s Table 5 gives 3 to 180 for the S side and 3 to 100 for the T side as the range that caught every injected fault with no false alarms; 20 sits near the middle of the overlap in log terms — about 7× the largest healthy excursion its Table 6 recorded, and 5× inside the tightest detection bound. Dimensionless, so it transfers across box sizes once sigma is commissioned per box. One card parameter drives both comparators and hosts must set them together; a site wanting the source’s asymmetric limits sets the two block paths separately.
sample_period60.0ssPrev.samplePeriod, tPrev.samplePeriodThe accumulators’ sample interval — one tick of feedback through Discrete.UnitDelay. MUST equal the host’s tick interval; at any other ratio the delayed accumulator value is between one and two periods old and the recursion stops being the source’s. It also sets the clock the alarm limit is denominated in: h is evidence per sample, so the same bias alarms in half the wall-clock time at half the tick. The shipped 60 s is the interval the source’s own statistics and limits were measured at (§5.2.2). One card parameter, two block paths, always set together.
occupied_warmup3600.0soccGate.delayTimeHow long occ_scheduled must hold true before the accumulators are allowed to run. The source treats the first hour of each occupied period as unoccupied so steady-state conditions can develop (§5.1.3); this is that hour, and it also covers the morning warm-up excursion that would otherwise be the day’s largest accumulation.

Description

The box tracks its airflow setpoint slightly wrong, and keeps doing it. A damper caught part way through its stroke, a differential-pressure pickup starting to foul, a flow loop tuned soft enough to sit permanently a little off target — none of these produce the 30% miss VAV-0004 waits for, and all of them are faults. This rule accumulates the error instead of thresholding it. Each sample’s flow error is normalized against the box’s own commissioned noise, the part of it that looks like ordinary variation is written off, and whatever is left is added to a running sum that grows only while the error keeps leaning the same way. Symmetric noise never builds; a small persistent bias always does. Two sums run, one per direction, so a box over-delivering and a box starving its zone arrive as different findings. This is the airflow channel of NIST’s VPACC, the first of three (VAV-0008 temperature, VAV-0009 reheat-coil ΔT).

Detection Logic

CFMerror    = zone_airflow − zone_airflow_sp
z           = (CFMerror − mean_flow_error) / sigma_flow_error
yOccupiedOk = occ_scheduled held continuously true for occupied_warmup   (false ⇒ host reports NO_EVAL)

while yOccupiedOk:   S = max(0, z − k + S_prev)     T = max(0, −z − k + T_prev)
otherwise:           S = 0                          T = 0

yHigh = S > alarm_limit_h     yLow = T > alarm_limit_h     yFault = yHigh OR yLow

Block graph (rule.cxf.jsonld):

VAV-0007 block graph

The library’s first feedback accumulator: sReset/tReset feed Discrete.UnitDelay instances whose outputs return to sSum/tSum, which the engine accepts because a unit delay’s output at tick i is its input at tick i−1 by construction and so cuts the direct-feedthrough graph. sample_period must therefore equal the host’s tick, and the delay’s y_start is left at the CDL default of 0.0 — which is the correct initial accumulator value, so this rule needs no warmup mask.

There is no persistence timer, because alarm_limit_h is the timer: time enters through accumulated evidence rather than through a dwell on a boolean. That cuts both ways on the falling edge. A rule with a TrueDelay drops the moment its condition clears; this one drains at slack_k per sample from wherever the accumulator got to, so an alarm outlives the fix by however long the sum takes to fall back through the limit — minutes for a small bias, an hour for a large one. The comparison is strict and the graph divides by sigma_flow_error unguarded, which is safe only because that divisor is a commissioned constant with a documented floor and not a live signal.

Possible Diagnoses

Per §5.1.3, the CUSUM of CFMerror detects stuck dampers, differential-pressure sensor faults, and unstable airflow control.

  1. Damper stuck or actuator slipping — the classic case, and the direction flags split it: yHigh on a box that will not close down, yLow on one that will not open up
  2. Differential-pressure flow sensor drifting or partly plugged — a fouling pickup reads low, the loop opens against it, and the measured error is small and one-signed for weeks, which is precisely the signature a threshold rule cannot see
  3. Unstable or badly retuned flow control — a loop that overshoots symmetrically leaves both sums at zero, but one that limit-cycles around an offset does not
  4. A mis-bound or stale zone_airflow_sp — a real sustained error against a number nobody is controlling to. Check the binding before the box
  5. Branch static pressure chronically short of what this box needs, in which case the box is behaving correctly and the finding belongs upstream

Energy Impact

COMFORT_ENERGY, MEDIUM confidence, PROXY_ESTIMATION. The mechanism is VAV-0004’s and so is the waste term — over-delivered air costs (zone_airflow − zone_airflow_sp) × cp × |sat − zone_temp|, assembled host-side because neither temperature is an input here; under-delivery costs unmet load and complaints instead. What differs is scale: this rule fires on biases below the threshold rule’s 30%, so each box’s number is small and the case for the work order is the count of boxes and the months of drift caught early rather than the instantaneous kilowatts. Confidence is MEDIUM because the method is validated on real VAV boxes (the source detected all three of its injected faults with no false alarms) while every parameter is per-site. Climate-neutral.

Emissions Impact

Scope 1 or 2, PROXY_EMISSIONS, MEDIUM confidence. Which applies follows what conditions the excess air: a hot-water reheat coil working against an over-delivering box is on-site combustion, while the fan moving the air and the chiller cooling it are purchased electricity. Avoided-emissions basis: marginal operating emissions rate (MOER). Per-box quantities are small; the population is not.

Deviations

  • The shipped k and h come from the source’s real-data section, not from its illustration. Figure 9’s k = 0.5, h = 5 is a synthetic demonstration on random normal data. §5.2.3 states that k was set to three for the Iowa Energy Center charts, and Table 5 publishes alarm-limit ranges per channel measured against injected faults. The airflow channel’s own numbers are what ship.
  • alarm_limit_h defaults to 20, which is a library choice inside the source’s interval. Table 5 gives 3–180 (S) and 3–100 (T); Table 6’s healthy peaks over seven zone-days are ≤ 3. Twenty is near the geometric centre of the overlap — roughly 7× the observed healthy ceiling and 5× below the tightest detection bound — and the source is explicit that its own numbers are preliminary and site-dependent.
  • One h for both directions, though the source’s ranges are asymmetric. Its S range runs to 180 and its T range to 100, and other channels’ T sides are marked “not used” entirely. A single parameter keeps the common case honest and the two block paths stay individually settable for a site that wants the split.
  • sigma_flow_error ships as a per-box placeholder in the strong sense. The 5 CFM of Table 4 was measured on four ~280 L/s boxes; flow-sensor noise scales with the box, so the default is a worked example rather than a portable constant, and left unretuned on a 2000 L/s box it makes every ordinary fluctuation a 10 sigma event. Same contract as HP-0001’s fitted baseline and VAV-0001’s ventilation requirement, and the point dictionary is where a host looks for the binding.
  • The simulation harness cannot supply this channel’s statistics, and does not pretend to. vavcal computes normal-operation mean and standard deviation for the temperature and reheat-ΔT channels only. A modelled terminal’s airflow is a solved quantity with no damper-loop dynamics and no DP-sensor noise in it, so a simulated CFMerror would report solver residuals rather than the quantity being calibrated. The airflow channel’s defaults are therefore the source’s own field measurements, and commissioning per §5.1.5 remains mandatory.
  • Occupancy is a bound point, not a host-side precondition. The library’s stance keeps gating in the host, but CUSUM’s semantics require the accumulator to be zeroed at the start of each occupied period rather than merely ignored: a frozen non-zero sum would carry last night’s history into this morning. The Reals.Switch pair selecting zero is the only way to express that in-graph. Precedent for occupancy as an ordinary input: AHU-0026, SYS-0008.
  • The first-hour exclusion is a TrueDelay with delayOnInit = true, so it is served after every occupied-period start and after a controller restart. The source’s reason is settling to steady state; it also covers the morning warm-up excursion, which would otherwise be the largest single accumulation of the day.
  • y_start is left at the CDL default of 0.0 and no warmup gate is needed — the opposite call from SYS-0010, and for a good reason rather than inconsistency. There the seed fabricated a step against a live reading; here zero is the correct initial accumulator, so the delay’s two-sample seed persistence costs at most one sample of accumulation and can never invent a fault.
  • sample_period and the alarm limit are coupled through the tick. The accumulators advance once per sample, so h is evidence per sample and time-to-alarm scales inversely with the tick rate. A host moving from 60 s to 300 s ticks keeps the same detection floor but takes five times as long, and the fix is to re-derive h, never to leave it alone and assume the rule is unchanged. Both UnitDelay instances take the value together through the list-form cxf path.
  • No persistence timer on the fault path, unlike almost every other card here. Adding one would be redundant against evidence that is already time-integrated, and would delay a finding the accumulator has already established.
  • The alarm drains rather than latching or clearing cleanly. Once the error is corrected the sum falls by k per sample and yFault stays true until it passes back below h — 23 minutes in the pinned vector. Hosts should treat the assert as the event and hold the work order, not track the falling edge.
  • Strict > on both limits, bracketed rather than pinned. The vectors walk the accumulator across h one unit per sample and assert on the samples either side, because a normalized running sum is a computed double that cannot be parked exactly on the limit — AHU-0022’s argument, and its arithmetic here is off by an ulp per sample.
  • Overlap with VAV-0004 is complementary, and neither suppresses the other. 053 catches a big fast miss on one look (30% of setpoint, sustained 20 min); this catches a small one that never clears — at the shipped parameters, roughly 7 to 60 L/s on a 200 L/s box, a band 053 is structurally blind to. A box far enough out will trip both, which is a corroboration rather than a duplicate.
  • The library’s units are the dictionary’s, so the source’s CFM figures are converted. 5 CFM → 2.36 L/s at 0.4719 L/s per CFM; k and h are dimensionless and cross unchanged. Hosts on CFM convert before binding, per SCHEMA.md.
  • points needs nothing new. §5.1.4 names room temperature, both zone setpoints, airflow setpoint, actual airflow and occupancy as points already in the local controller; this channel uses three of them and adds no instrumentation. VAV-0009’s vav_dat is the family’s only new point.
  • playbooks lists stuck-actuator first, which this batch’s assignment did not. The card was assigned vav-min-flow-reheat with the rest of the family, and it stays; but diagnoses 1 and 2 are a stuck damper and a failing flow sensor, which is stuck-actuator’s subject and VAV-0004’s binding for the same reasons. Both are recorded here because playbooks/ is single-writer.
  • Severity 3, phase 3, method: statistical, g36: null. Severity follows VAV-0004, the threshold rule on the same signal; phase 3 and the 1xx band are SCHEMA.md’s for advanced statistical rules, matching the reserved VAV-0010. VPACC predates G36 by two decades and no §5.16 clause covers terminal flow tracking.
  • The direction flags are drawn as neutral pills, not red ones. SYS-0006 and SYS-0008 draw sub-condition flags in the fault colour, which reads as three alarms leaving the graph; amber stays reserved for evaluability (yOccupiedOk). Worth reconciling library-wide rather than per card.
  • Operating states and the remaining preconditions are declared in frontmatter for host enforcement rather than encoded in the block graph, per the library’s design stance.

Notes

Commission sigma_flow_error on data that includes ordinary setpoint activity, not on a quiet afternoon. Every setpoint change produces a genuine tracking excursion while the damper strokes, and a sigma fitted without any of them makes those excursions look like evidence. The source’s own boxes, sampled every minute through real occupied days, peaked at an accumulated 3 — so on a reasonably tuned box the strokes cost nothing, but that is a fact about their boxes and their sampling interval, and it is the first thing to check when a newly deployed instance alarms on its first day.

Read the direction flags before dispatching. yLow with a damper reading full open is a starved branch or a plugged flow pickup; yHigh with a damper reading closed is an actuator off its shaft or a flow sensor reading low against a correctly positioned blade. Both are cheaper to tell apart from a day of trend than from a ladder.

Test Vectors

8 scenarios, clock step 60 s over 10800 s.

ScenarioDescription
healthy_noise_below_slackFlow error swinging to +/-7 L/s on a 200 L/s setpoint — 2.97 sigma, just inside the k = 3 slack. Neither accumulator ever leaves zero, so no amount of elapsed time produces an alarm. This is the whole point of the slack parameter.
sustained_over_delivery_alarmsA steady +14.16 L/s (6 sigma) over-delivery on a 200 L/s setpoint — 7% of setpoint, well inside VAV-0004’s 30% tracking band, so no threshold rule sees it. S climbs 3 per sample from the moment the gate opens and passes h = 20 seven samples later.
sustained_under_delivery_alarmsThe mirror case: a steady -14.16 L/s under-delivery drives T, not S, so the direction flags separate a box delivering too much air from one starving its zone.
occupancy_reset_clears_accumulationSame 6 sigma bias as sustained_over_delivery_alarms, which alone alarms at 3960 s. Occupancy drops at 3900 s with S at 18, one sample short of h. The accumulator is forced to zero, and when occupancy returns at 4200 s the rule serves the first hour again before restarting from zero — so the alarm arrives at 8160 s instead.
first_hour_excludedA 20 sigma flow error present from load. One sample past the gate would carry S to 34, yet nothing asserts for the whole first hour — the exclusion window is honoured against an error large enough to alarm immediately.
alarm_limit_bracketedA +9.44 L/s (4 sigma) bias adds exactly 1 to S per sample, walking the accumulator across h one unit at a time: silent at S = 19 (4680 s), asserting at S = 21 (4800 s). The S = 20 sample is skipped because a normalized accumulator cannot be parked exactly on the limit.
alarm_drains_after_error_clearsThe bias is corrected at 5400 s with S at 87. The alarm does not drop with it: S falls by k = 3 per sample and the rule stays asserted for a further 23 minutes. CUSUM has memory, and a host watching the falling edge is watching the accumulator drain, not the box misbehaving.
unoccupied_stays_silentThe same 20 sigma error with the box unoccupied all day. Nothing accumulates and nothing asserts — and a false yFault here means NO_EVAL, which yOccupiedOk is what tells the host.
vectors.json
{
  "schema": "cxf-library/vectors/v1",
  "clock": {
    "step_s": 60,
    "horizon_s": 10800
  },
  "scenarios": [
    {
      "name": "healthy_noise_below_slack",
      "description": "Flow error swinging to +/-7 L/s on a 200 L/s setpoint \u2014 2.97 sigma, just inside the k = 3 slack. Neither accumulator ever leaves zero, so no amount of elapsed time produces an alarm. This is the whole point of the slack parameter.",
      "inputs": {
        "zone_airflow": [
          {
            "t": 0,
            "value": 207.0
          },
          {
            "t": 600,
            "value": 193.0
          },
          {
            "t": 1800,
            "value": 206.0
          },
          {
            "t": 3600,
            "value": 193.5
          },
          {
            "t": 5400,
            "value": 207.0
          },
          {
            "t": 7200,
            "value": 193.0
          },
          {
            "t": 9000,
            "value": 206.5
          }
        ],
        "zone_airflow_sp": 200.0,
        "occ_scheduled": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 10800,
          "equals": false
        },
        {
          "output": "yHigh",
          "from_s": 0,
          "to_s": 10800,
          "equals": false
        },
        {
          "output": "yLow",
          "from_s": 0,
          "to_s": 10800,
          "equals": false
        },
        {
          "output": "yOccupiedOk",
          "from_s": 3720,
          "to_s": 10800,
          "equals": true
        }
      ]
    },
    {
      "name": "sustained_over_delivery_alarms",
      "description": "A steady +14.16 L/s (6 sigma) over-delivery on a 200 L/s setpoint \u2014 7% of setpoint, well inside VAV-0004's 30% tracking band, so no threshold rule sees it. S climbs 3 per sample from the moment the gate opens and passes h = 20 seven samples later.",
      "inputs": {
        "zone_airflow": 214.16,
        "zone_airflow_sp": 200.0,
        "occ_scheduled": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3840,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 4080,
          "to_s": 10800,
          "equals": true
        },
        {
          "output": "yHigh",
          "from_s": 4080,
          "to_s": 10800,
          "equals": true
        },
        {
          "output": "yLow",
          "from_s": 0,
          "to_s": 10800,
          "equals": false
        }
      ]
    },
    {
      "name": "sustained_under_delivery_alarms",
      "description": "The mirror case: a steady -14.16 L/s under-delivery drives T, not S, so the direction flags separate a box delivering too much air from one starving its zone.",
      "inputs": {
        "zone_airflow": 185.84,
        "zone_airflow_sp": 200.0,
        "occ_scheduled": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3840,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 4080,
          "to_s": 10800,
          "equals": true
        },
        {
          "output": "yLow",
          "from_s": 4080,
          "to_s": 10800,
          "equals": true
        },
        {
          "output": "yHigh",
          "from_s": 0,
          "to_s": 10800,
          "equals": false
        }
      ]
    },
    {
      "name": "occupancy_reset_clears_accumulation",
      "description": "Same 6 sigma bias as sustained_over_delivery_alarms, which alone alarms at 3960 s. Occupancy drops at 3900 s with S at 18, one sample short of h. The accumulator is forced to zero, and when occupancy returns at 4200 s the rule serves the first hour again before restarting from zero \u2014 so the alarm arrives at 8160 s instead.",
      "inputs": {
        "zone_airflow": 214.16,
        "zone_airflow_sp": 200.0,
        "occ_scheduled": [
          {
            "t": 0,
            "value": true
          },
          {
            "t": 3900,
            "value": false
          },
          {
            "t": 4200,
            "value": true
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 8040,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 8280,
          "to_s": 10800,
          "equals": true
        },
        {
          "output": "yOccupiedOk",
          "from_s": 3960,
          "to_s": 7680,
          "equals": false
        },
        {
          "output": "yOccupiedOk",
          "from_s": 7860,
          "to_s": 10800,
          "equals": true
        }
      ]
    },
    {
      "name": "first_hour_excluded",
      "description": "A 20 sigma flow error present from load. One sample past the gate would carry S to 34, yet nothing asserts for the whole first hour \u2014 the exclusion window is honoured against an error large enough to alarm immediately.",
      "inputs": {
        "zone_airflow": 247.2,
        "zone_airflow_sp": 200.0,
        "occ_scheduled": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3540,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 3720,
          "to_s": 10800,
          "equals": true
        },
        {
          "output": "yOccupiedOk",
          "from_s": 0,
          "to_s": 3540,
          "equals": false
        },
        {
          "output": "yOccupiedOk",
          "from_s": 3660,
          "to_s": 10800,
          "equals": true
        }
      ]
    },
    {
      "name": "alarm_limit_bracketed",
      "description": "A +9.44 L/s (4 sigma) bias adds exactly 1 to S per sample, walking the accumulator across h one unit at a time: silent at S = 19 (4680 s), asserting at S = 21 (4800 s). The S = 20 sample is skipped because a normalized accumulator cannot be parked exactly on the limit.",
      "inputs": {
        "zone_airflow": 209.44,
        "zone_airflow_sp": 200.0,
        "occ_scheduled": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 4680,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 4800,
          "to_s": 10800,
          "equals": true
        }
      ]
    },
    {
      "name": "alarm_drains_after_error_clears",
      "description": "The bias is corrected at 5400 s with S at 87. The alarm does not drop with it: S falls by k = 3 per sample and the rule stays asserted for a further 23 minutes. CUSUM has memory, and a host watching the falling edge is watching the accumulator drain, not the box misbehaving.",
      "inputs": {
        "zone_airflow": [
          {
            "t": 0,
            "value": 214.16
          },
          {
            "t": 5400,
            "value": 200.0
          }
        ],
        "zone_airflow_sp": 200.0,
        "occ_scheduled": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 4080,
          "to_s": 6660,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 6900,
          "to_s": 10800,
          "equals": false
        }
      ]
    },
    {
      "name": "unoccupied_stays_silent",
      "description": "The same 20 sigma error with the box unoccupied all day. Nothing accumulates and nothing asserts \u2014 and a false yFault here means NO_EVAL, which yOccupiedOk is what tells the host.",
      "inputs": {
        "zone_airflow": 247.2,
        "zone_airflow_sp": 200.0,
        "occ_scheduled": false
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 10800,
          "equals": false
        },
        {
          "output": "yHigh",
          "from_s": 0,
          "to_s": 10800,
          "equals": false
        },
        {
          "output": "yLow",
          "from_s": 0,
          "to_s": 10800,
          "equals": false
        },
        {
          "output": "yOccupiedOk",
          "from_s": 0,
          "to_s": 10800,
          "equals": false
        }
      ]
    }
  ]
}

VAV-0008 — Zone temperature CUSUM

Statusverified — engine e2ff2f8, cxf:fnv1a128:ffbf4740924e29e25d13a4474bfb7aab, 2026-08-18
Severity3
Methodstatistical
Phase3
CategoryCOMFORT_ENERGY
ConfidenceMEDIUM
EstimationQUALITATIVE_ONLY
G36
Clusters
Suppresses
Suppressed by
RelatedVAV-0003, VAV-0007, VAV-0009
Playbooksvav-min-flow-reheat
SourceBushby, Castro, Schein, House (2001), NIST/CEC PIER Project 2.3, §5.1.1 — the normalized statistic (eq. 1) and the two-sided CUSUM recursion (eqs. 2-3); Figure 9’s k = 0.5 / h = 5 pair, stated there as a synthetic illustration; Bushby, Castro, Schein, House (2001), NIST/CEC PIER Project 2.3, §5.1.3 — the three generic VPACC error signals, the Temperror piecewise, the faults the temperature chart is claimed to detect, and the occupied-only / first-hour-excluded computation rule; Bushby, Castro, Schein, House (2001), NIST/CEC PIER Project 2.3, §5.1.4 — VPACC’s point requirements: room temperature, both setpoints and occupancy status are already in the local VAV controller; Bushby, Castro, Schein, House (2001), NIST/CEC PIER Project 2.3, §5.1.5 — slack parameters come from normal-operation data and alarm limits from fault-injection data, collected per VAV box type; no portable values are published; Bushby, Castro, Schein, House (2001), NIST/CEC PIER Project 2.3, §5.2 — Iowa Energy Center fault-injection validation, 8:00-17:00 occupied schedule, data collected at 1-minute intervals; Harness calibration (committed method): tools/simharness/harness.py vavcal mode and tools/simharness/README.md — healthy occupied Temperror measured across 15 zones of B2B OfficeMedium-4004 for a July and a January week; Library-authored: the HVAC FDD Reference v1.0 publishes no CUSUM card, so name, severity, category and every parameter default are argued on this card; Sibling precedent: SYS-0010 (the library’s only prior Discrete.UnitDelay), SYS-0006 (statistical card with sub-condition direction flags), HP-0001 and VAV-0001 (commissioning parameters shipped as documented placeholders); Engine pin e2ff2f8: crates/oce-blocks/src/discrete.rs (UnitDelay sample grid, loop-cut contract, y_start seeding), crates/oce-graph/src/topo.rs (the emit-before sort that admits this feedback path)
Operating statesoccupied, gated in-graph rather than host-side. While occ_scheduled is false — and for exclusion_time after it goes true — both accumulators are forced to zero, which is what CUSUM semantics require and what the source specifies. yArmedOk publishes that state.

Preconditions (host-enforced): Four host obligations, then the commissioning one. (1) Bind the ACTIVE occupied-mode setpoints the zone loop is tracking, not schedule defaults, and guarantee zone_temp_sp_htg <= zone_temp_sp_clg — crossed setpoints turn the piecewise into nonsense rather than into a wider band (see Deviations). (2) The AHU serving this box must be delivering air at its setpoint; a failed air handler puts every zone on the trunk out of band at once and this rule will blame each box in turn. Suppress while the AHU is down or off schedule. (3) The zone temperature sensor must be trustworthy — a sensor drifting 1 °C produces exactly this signature with the box in perfect health, which is what VAV-0010 (reserved, neighbor-comparison drift) is for. (4) Tick on the sample_period grid: both unit delays advance on that clock, and a host ticking faster publishes an un-advanced preview of the accumulator between sample instants. Finally, slack_k, alarm_limit_h and error_sigma are commissioning values, not portable constants — §5.1.5 says so in as many words, and until a site has set them from its own data the verdict means only what the shipped illustrative pair means. yArmedOk is the in-rule evaluability output: false means NO_EVAL, not healthy.

Points: zone_temp, zone_temp_sp_htg, zone_temp_sp_clg, occ_scheduled

Outputs:

  • yFault — True while either cumulative sum is strictly above alarm_limit_h — the zone has been outside its occupied deadband, in one direction, for long enough that the accumulated normalized error passed the limit
  • yHigh — Sub-condition flag, undelayed — the S (upper) chart is above the limit: the zone has been riding above its cooling setpoint. Not an evaluability output; a false yHigh never means NO_EVAL
  • yLow — Sub-condition flag, undelayed — the T (lower) chart is above the limit: the zone has been riding below its heating setpoint. Not an evaluability output
  • yArmedOk — Evaluability flag — true only while the schedule is occupied AND exclusion_time has elapsed since it went occupied. FALSE MEANS NO_EVAL: the accumulators are held at zero and yFault is silent by construction, not by evidence

Parameters:

NameDefaultUnitCXF pathDescription
slack_k0.51 (standard deviations per sample)slackK.kSlack. The normalized error must exceed k before it accumulates at all, which is what filters normal-operation variation out of the sums. COMMISSIONING PARAMETER: §5.1.5 says slack parameters come from normal-operation data collected across VAV box types and publishes none, so 0.5 is Figure 9’s synthetic illustration and nothing stronger. One constant feeds both charts.
alarm_limit_h5.01 (accumulated standard deviations)sAlarm.t, tAlarm.tAlarm limit. Either sum strictly above it means out of control. COMMISSIONING PARAMETER on the same terms as slack_k — §5.1.5 derives alarm limits from fault-injection data; 5 is Figure 9’s pair-mate to k = 0.5. One card parameter binds both charts; a site wanting the source’s per-chart limits sets sAlarm.t and tAlarm.t individually and records the split.
error_sigma0.5°CsigmaK.kNormalization denominator, eq. 1’s sigma-hat. NOT a measured healthy standard deviation and it cannot be one — measured healthy Temperror is identically zero (see Deviations) and division by zero is undefined. It is a FLOOR: the smallest sustained deviation the site is willing to treat as noise, in degrees. With slack_k it sets the detection floor, since nothing below slack_k × error_sigma (0.25 °C at the defaults) ever accumulates.
error_mean0.0°CmeanK.kExpected Temperror under normal operation, eq. 1’s x-bar. 0.0 is measured rather than assumed: the piecewise is zero everywhere inside the deadband, and a healthy occupied zone stays there (harness vavcal, 15 zones × 2 seasons). A site whose zones habitually ride a setpoint edge can raise it, accepting that the charts then measure departure from that habit.
sample_period300.0ssPrev.samplePeriod, tPrev.samplePeriodThe CUSUM clock. Both unit delays advance on this grid and each accumulator gains one increment per period, so time-to-alarm is roughly alarm_limit_h / (z − slack_k) periods. SET IT TO THE HOST’S TICK INTERVAL and set both paths together. The source’s own Iowa Energy Center data was 1-minute (§5.2); a site trending at 60 s and leaving the limit alone alarms five times sooner than this card’s worked numbers.
exclusion_time3600.0soccArmed.delayTimeHow long after each occupied-period start the accumulators stay held at zero, so steady-state conditions can develop — the source’s first hour (§5.1.3), taken literally. Also covers a controller restart, since the delay is armed from init.

Description

A zone that misses its setpoints by a little, all day, is invisible to a threshold. Half a degree past the cooling setpoint is not worth an alarm on any one reading, and no fixed band will catch it without also catching every transient. A cumulative sum chart catches it by accumulating: the deviation is normalized, a slack term is subtracted so ordinary variation nets out, and what survives is added to a running total that only moves in one direction until the zone comes back inside its band. Small and persistent beats large and brief, which is the opposite of what every other VAV card in this library does.

Two totals run, one per direction, so the finding names which way the zone is failing — a zone stuck above its cooling setpoint and a zone stuck below its heating setpoint have almost no repairs in common. The accumulators are held at zero outside the occupied schedule and for the first hour inside it: an unoccupied zone is supposed to drift, and a zone still recovering from setback has not yet failed at anything.

Detection Logic

Temperror = max(0, zone_temp − zone_temp_sp_clg)     above the cooling setpoint
          + min(0, zone_temp − zone_temp_sp_htg)     below the heating setpoint
                                                     both zero inside the deadband

z_i       = (Temperror_i − error_mean) / error_sigma

armed_i   = occ_scheduled held continuously true for exclusion_time

S_i       = armed_i ? max(0,  z_i − slack_k + S_{i−1}) : 0
T_i       = armed_i ? max(0, −z_i − slack_k + T_{i−1}) : 0

yHigh     = S_i > alarm_limit_h        yLow = T_i > alarm_limit_h
yFault    = yHigh OR yLow

Block graph (rule.cxf.jsonld):

VAV-0008 block graph

The two accumulators are the library’s first feedback loops. Each closes through a Discrete.UnitDelay, whose output at tick i is its input at tick i−1 by construction — that is what makes the cycle legal rather than an algebraic loop, and the engine’s block-level sort admits it because a loop-breaker’s input is read only by the deferred state update. Both delays run on sample_period, which must be the host’s tick interval: between sample instants a delay holds its last value, so the accumulator recomputes from the same history instead of advancing, and the published sum is a preview.

There is no persistence timer, because the accumulation is the persistence — a disturbance large enough to add several increments still has to survive alarm_limit_h / slack_k quiet samples before the sum drains back to zero. That drain is the flip side of the sensitivity: after a repair the alarm clears about ten samples later at the defaults, not immediately. Both comparisons are strict, so a sum landing exactly on the limit reads healthy.

The occupancy reset is applied to the published sum and to the delay input together, so an unoccupied tick emits a true zero rather than a one-tick recomputation from zero — CUSUM needs the state cleared, not the output muted.

Possible Diagnoses

The source attributes this channel to damper faults, valve faults and temperature sensor faults (§5.1.3); the direction flags split them.

  1. Damper faultyHigh with a stuck or hunting damper that will not open past minimum starves the zone of cooling; yLow with a damper stuck open overcools it on primary air. Check position feedback against command before anything else.
  2. Reheat valve faultyLow where the valve cannot open (failed actuator, no hot water, air-bound coil) and the zone never reaches its heating setpoint; yHigh where it leaks or is driven open, which is VAV-0003’s fault seen from the temperature side rather than the valve side.
  3. Zone temperature sensor drift or bad placement — the sensor is wrong and the box is fine. A sensor in a supply-air stream, above a copier, or on a sunlit wall produces a permanent one-sided error that no repair to the box will clear. VAV-0010 is the rule that names this; until it ships, a sibling-zone comparison does the same job by hand.
  4. Primary air wrong or absent — supply air temperature off its setpoint, duct static starved, fan down. Every box on the trunk trips in the same direction within a few samples of each other, which is the tell.
  5. Load beyond the box’s capacity — a re-purposed or over-occupied zone whose terminal was sized for something else. A commissioning finding, not a fault, and the only one on this list that a work order cannot close.

Energy Impact

COMFORT_ENERGY, MEDIUM confidence, QUALITATIVE_ONLY. The rule reports that a zone spent hours outside its band and in which direction; it cannot say what that cost, because the cost depends entirely on which of the five diagnoses is true. A starved zone wastes almost nothing directly and delivers the discomfort the whole system exists to prevent; a zone held above its cooling setpoint by a leaking reheat coil is burning heat and then paying to remove it, which is VAV-0003’s accounting. Confidence is MEDIUM rather than LOW because the method is not speculative — the source validated VPACC against physically injected faults on instrumented boxes at the Iowa Energy Center (§5.2) — and not HIGH because the shipped slack and alarm limits are an illustration, so the false-positive rate of this parameterization is uncharacterized. Climate- neutral: a zone out of band is out of band in every climate.

Emissions Impact

Scope 1 or 2 depending on which side trips and what serves the box, QUALITATIVE_EMISSIONS, MEDIUM confidence, avoided-emissions basis N/A. A yLow finding on a hydronic box points at heating energy, Scope 1 where the boiler burns gas; a yHigh finding points at cooling and fan energy, Scope 2. The split is real and this card does not collapse it, because the direction flag that decides it is one of the two things the rule actually knows.

Deviations

  • Temperror is signed here; the source’s third branch is not. §5.1.3 writes Temperror = HSP − Temproom below the heating setpoint, a positive magnitude, so its Temperror is non-negative in both directions. Taken literally that makes eq. 3’s T chart dead on this channel — with a non-negative error and the measured x-bar of zero, −z − k is never positive and yLow could never assert. This card negates that branch (zone_temp − zone_temp_sp_htg), which leaves the magnitude identical to the source’s and puts the source’s own two-sided recursion back to work. The cost is that a zone alternating between hot and cold excursions accumulates in neither chart as fast as a magnitude signal would; that oscillation is VAV-0005’s fault, not this one’s.
  • The piecewise is built as max(0, e_clg) + min(0, e_htg) rather than as a three-way selection. For zone_temp_sp_htg <= zone_temp_sp_clg the two terms are never both non-zero and the sum reproduces all three branches exactly, in five blocks with no boolean routing. It relies on that ordering: crossed setpoints make both terms live and the sum meaningless, which is why the frontmatter demands the ordering rather than clamping it in-graph.
  • error_sigma is a floor, not a statistic. The committed harness method (vavcal) computes exactly this piecewise over occupied ticks and reports the cross-zone median mean and standard deviation; on B2B OfficeMedium-4004, 15 zones, one July and one January week, healthy Temperror came back identically zero in both seasons. That is a fact about a well-controlled simulated thermostat as much as about the piecewise, and simulation noise (order 0.01- 0.03 °C) is cleaner than any real sensor — but it does establish that there is no healthy standard deviation to divide by. So sigma ships as a documented floor in degrees, and the consequence is stated plainly: any sustained deviation above slack_k × error_sigma accumulates without limit.
  • slack_k = 0.5 and alarm_limit_h = 5 are Figure 9’s illustrative pair. §5.1.5 declines to publish production values and describes a per-box-type calibration campaign instead; Figure 9’s numbers annotate a synthetic normal-distribution example, not a VAV box. They ship because a card needs defaults, and the parameter descriptions say exactly what they are. Contrast the APAR half of the same report, which commits a full threshold table.
  • The occupancy reset is in the graph, against the library’s usual stance. Operating-state gating is normally host-side, but suppressing the output is not the same as resetting the state: a frozen non-zero accumulator would carry yesterday afternoon’s excursion across the night and alarm on the first armed tick. The source is explicit that the CUSUMs reset to zero (§5.1.3), so the reset must be where the state is. Binding occ_scheduled as an ordinary point follows AHU-0026 and SYS-0008.
  • The reset drives the published sum, not only the feedback path. Switching only the delay’s input would leave the current tick free to publish max(0, z − k) while unoccupied, so a single large unoccupied excursion could assert yFault at alarm_limit_h below 1. The Switch sits between the clamp and both consumers instead.
  • The first hour is a Logical.TrueDelay on occ_scheduled, not a host-side convention: armed goes true only after the schedule has been true continuously for exclusion_time, and falls immediately when it drops. delayOnInit = true (CDL default false) is the library’s standing choice and does real work here — a controller restarting mid-afternoon serves a fresh exclusion hour rather than arming empty accumulators into a running zone.
  • sample_period binds two CXF paths and couples the rule to the host’s tick. Discrete.UnitDelay advances on a grid derived from the first tick time; between instants it holds, so a host ticking faster than the period sees the accumulator recompute from an un-advanced history rather than climb. Ticks slower than the period degrade gracefully to a one-tick delay. Set both paths together, and set them to the tick.
  • UnitDelay seeds from y_start for up to two sample periods when the run starts off-grid (engine-verified: the block samples on when sampleTrigger with no initial() clause, so a mid-interval start stages nothing until the next true instant). At y_start = 0 — the default, left unset — that is indistinguishable from the reset state, so the quirk costs nothing here; it would matter to any future card seeding a delay non-zero.
  • No alarm delay on yFault. Every other timing-bearing card in this library ends in a Logical.TrueDelay; this one does not, because the accumulation already is the persistence test and stacking a timer on top would make the effective detection time two unrelated parameters deep. The source alarms when the sum exceeds the limit, full stop.
  • Slack, limit, mean and sigma are Reals.Sources.Constant values, not AddParameter/MultiplyByParameter gains. The memo’s sketch used AddParameter(p = −k), which would make the shipped tunable a negative number the retuner has to remember to negate. Constants keep every published parameter positive and in its natural unit, the same reason SYS-0006 squares its noise threshold in-graph rather than publishing the square.
  • One alarm_limit_h binds both charts. §5.1.5 speaks of alarm limits for the S and T sums separately, so an asymmetric site is within the source’s intent; the card exposes one parameter because symmetric is the sane default, and both CXF paths are listed so a host can split them deliberately.
  • Both comparisons are strict (>), matching “exceeds” in §5.1.1. The edge is pinned exactly rather than by bracketing, because at the shipped defaults a 0.5 °C excursion produces an increment of exactly 0.5 and the sum lands on 5.0 and then 5.5 on consecutive samples with no floating-point slack.
  • Three extra boundary outputs, and they are not the same kind. yHigh and yLow are sub-condition flags in SYS-0006’s sense — diagnostic detail, read with yFault, and a false one never means NO_EVAL. yArmedOk is an evaluability flag in AHU-0006’s sense: the occupancy gate lives inside this graph, so without it a host cannot tell a silent healthy zone from a zone nobody was watching.
  • Severity 3, category: COMFORT_ENERGY, confidence: MEDIUM and the name are library-authored. The HVAC FDD Reference has no CUSUM card to transcribe and the source assigns no severity to anything. Severity 3 matches VAV-0003, the neighbouring zone-comfort card; the category follows the fault’s character (a zone out of band costs comfort first and energy second).
  • g36: null and no G36 clause in source. G36 sequences terminal units, but this logic is 2001 statistical-process-control work that predates it, and SCHEMA.md reserves the g36 field for the 001-049 range regardless.

Notes

The engine accepted the feedback loop without complaint on the first load, which is worth recording because it was the open question for the whole family: Discrete.UnitDelay declares no feedthrough from u to y, so the connector DAG never sees a cycle and the block-level emit-before sort skips the cut input entirely. Nothing about the loop needed working around.

VAV-0010 is reserved for zone temperature sensor drift by neighbour comparison. It is deliberately not in related, because it does not co-occur with this rule — it is the rule that decides whether this one’s input can be believed, and diagnosis 3 is the whole of the overlap.

The family splits by error signal, not by fault: VAV-0007 accumulates airflow error, this card zone temperature error, VAV-0009 the reheat-coil temperature rise. A box in real trouble usually trips more than one, and which ones it trips is most of the diagnosis.

Test Vectors

11 scenarios, clock step 300 s over 36000 s.

ScenarioDescription
healthy_inside_deadband_silentZone parked mid-band all day. Temperror is identically zero (the deadband branch), so both accumulators sit at their floor and nothing ever arrives at the alarm limit.
excursion_below_slack_stays_silent0.2 degC above the cooling setpoint forever: z = 0.4 is under the slack k = 0.5, so every increment is negative and the Max clamp holds S at zero. The point of k.
excursion_exactly_at_slack_never_accumulates0.25 degC above the cooling setpoint is exactly k * sigma, so z - k is exactly zero and S never leaves the floor however long it runs. The slack edge, pinned exactly (0.25 and 0.5 are binary-exact).
small_warm_bias_accumulates_to_alarm0.3 degC above the cooling setpoint — a bias no fixed threshold in this library would report — accumulates at 0.1 per sample and reaches the alarm limit after about 50 samples. This is what CUSUM buys.
warm_bias_trips_high_side_at_alarm_limit0.5 degC above the cooling setpoint gives z = 1 and an increment of exactly 0.5, so S lands on 5.0 at t=6300 (not above the limit, strict >) and 5.5 at t=6600. Both sides of h at the accumulator level.
cold_bias_trips_low_side_at_alarm_limit0.5 degC below the heating setpoint: the heating branch of the piecewise gives a negative Temperror, the T accumulator carries it, and the same 5.0 / 5.5 edge appears on the low side while the high side stays at zero.
first_hour_of_occupancy_excludedA 2 degC excursion present from the first tick would cross the alarm limit two samples in without the exclusion; the accumulators stay clamped to zero until t=3600 and only then start, so the alarm lands at 3900.
unoccupied_never_accumulates6 degC above the cooling setpoint for ten hours with the schedule off. The Switch feeds zero into both accumulators and into their unit delays, so the rule is silent and yArmedOk says why.
occupancy_reset_clears_mid_accumulationThe same 0.5 degC bias, interrupted: S has reached 3.0 when the schedule drops at t=5400, the reset zeroes it, and the second occupied period has to serve its own exclusion hour and climb from zero again — so the alarm arrives at 13800 instead of 6600.
alarm_drains_after_zone_returns_to_deadbandWarm bias until t=7200, then the zone recovers. S drains by k per sample once the error is back inside the deadband and the alarm clears at 7500 — CUSUM forgets at the slack rate, not instantly.
setpoint_edges_are_zero_errorZone parked exactly on the heating setpoint, then exactly on the cooling setpoint, then mid-band. All three branches of the piecewise evaluate to zero at and inside the edges, so ten occupied hours produce nothing.
vectors.json
{
  "schema": "cxf-library/vectors/v1",
  "clock": {
    "step_s": 300,
    "horizon_s": 36000
  },
  "scenarios": [
    {
      "name": "healthy_inside_deadband_silent",
      "description": "Zone parked mid-band all day. Temperror is identically zero (the deadband branch), so both accumulators sit at their floor and nothing ever arrives at the alarm limit.",
      "inputs": {
        "zone_temp": 22.0,
        "zone_temp_sp_htg": 20.0,
        "zone_temp_sp_clg": 24.0,
        "occ_scheduled": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 36000,
          "equals": false
        },
        {
          "output": "yHigh",
          "from_s": 0,
          "to_s": 36000,
          "equals": false
        },
        {
          "output": "yLow",
          "from_s": 0,
          "to_s": 36000,
          "equals": false
        },
        {
          "output": "yArmedOk",
          "from_s": 0,
          "to_s": 3300,
          "equals": false
        },
        {
          "output": "yArmedOk",
          "from_s": 3600,
          "to_s": 36000,
          "equals": true
        }
      ]
    },
    {
      "name": "excursion_below_slack_stays_silent",
      "description": "0.2 degC above the cooling setpoint forever: z = 0.4 is under the slack k = 0.5, so every increment is negative and the Max clamp holds S at zero. The point of k.",
      "inputs": {
        "zone_temp": 24.2,
        "zone_temp_sp_htg": 20.0,
        "zone_temp_sp_clg": 24.0,
        "occ_scheduled": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 36000,
          "equals": false
        },
        {
          "output": "yHigh",
          "from_s": 0,
          "to_s": 36000,
          "equals": false
        },
        {
          "output": "yLow",
          "from_s": 0,
          "to_s": 36000,
          "equals": false
        }
      ]
    },
    {
      "name": "excursion_exactly_at_slack_never_accumulates",
      "description": "0.25 degC above the cooling setpoint is exactly k * sigma, so z - k is exactly zero and S never leaves the floor however long it runs. The slack edge, pinned exactly (0.25 and 0.5 are binary-exact).",
      "inputs": {
        "zone_temp": 24.25,
        "zone_temp_sp_htg": 20.0,
        "zone_temp_sp_clg": 24.0,
        "occ_scheduled": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 36000,
          "equals": false
        },
        {
          "output": "yHigh",
          "from_s": 0,
          "to_s": 36000,
          "equals": false
        },
        {
          "output": "yLow",
          "from_s": 0,
          "to_s": 36000,
          "equals": false
        }
      ]
    },
    {
      "name": "small_warm_bias_accumulates_to_alarm",
      "description": "0.3 degC above the cooling setpoint \u2014 a bias no fixed threshold in this library would report \u2014 accumulates at 0.1 per sample and reaches the alarm limit after about 50 samples. This is what CUSUM buys.",
      "inputs": {
        "zone_temp": 24.3,
        "zone_temp_sp_htg": 20.0,
        "zone_temp_sp_clg": 24.0,
        "occ_scheduled": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 17400,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 19800,
          "to_s": 36000,
          "equals": true
        },
        {
          "output": "yHigh",
          "from_s": 19800,
          "to_s": 36000,
          "equals": true
        },
        {
          "output": "yLow",
          "from_s": 0,
          "to_s": 36000,
          "equals": false
        }
      ]
    },
    {
      "name": "warm_bias_trips_high_side_at_alarm_limit",
      "description": "0.5 degC above the cooling setpoint gives z = 1 and an increment of exactly 0.5, so S lands on 5.0 at t=6300 (not above the limit, strict >) and 5.5 at t=6600. Both sides of h at the accumulator level.",
      "inputs": {
        "zone_temp": 24.5,
        "zone_temp_sp_htg": 20.0,
        "zone_temp_sp_clg": 24.0,
        "occ_scheduled": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 6300,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 6600,
          "to_s": 36000,
          "equals": true
        },
        {
          "output": "yHigh",
          "from_s": 0,
          "to_s": 6300,
          "equals": false
        },
        {
          "output": "yHigh",
          "from_s": 6600,
          "to_s": 36000,
          "equals": true
        },
        {
          "output": "yLow",
          "from_s": 0,
          "to_s": 36000,
          "equals": false
        }
      ]
    },
    {
      "name": "cold_bias_trips_low_side_at_alarm_limit",
      "description": "0.5 degC below the heating setpoint: the heating branch of the piecewise gives a negative Temperror, the T accumulator carries it, and the same 5.0 / 5.5 edge appears on the low side while the high side stays at zero.",
      "inputs": {
        "zone_temp": 19.5,
        "zone_temp_sp_htg": 20.0,
        "zone_temp_sp_clg": 24.0,
        "occ_scheduled": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 6300,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 6600,
          "to_s": 36000,
          "equals": true
        },
        {
          "output": "yLow",
          "from_s": 0,
          "to_s": 6300,
          "equals": false
        },
        {
          "output": "yLow",
          "from_s": 6600,
          "to_s": 36000,
          "equals": true
        },
        {
          "output": "yHigh",
          "from_s": 0,
          "to_s": 36000,
          "equals": false
        }
      ]
    },
    {
      "name": "first_hour_of_occupancy_excluded",
      "description": "A 2 degC excursion present from the first tick would cross the alarm limit two samples in without the exclusion; the accumulators stay clamped to zero until t=3600 and only then start, so the alarm lands at 3900.",
      "inputs": {
        "zone_temp": 26.0,
        "zone_temp_sp_htg": 20.0,
        "zone_temp_sp_clg": 24.0,
        "occ_scheduled": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 3900,
          "to_s": 36000,
          "equals": true
        },
        {
          "output": "yArmedOk",
          "from_s": 0,
          "to_s": 3300,
          "equals": false
        },
        {
          "output": "yArmedOk",
          "from_s": 3600,
          "to_s": 36000,
          "equals": true
        }
      ]
    },
    {
      "name": "unoccupied_never_accumulates",
      "description": "6 degC above the cooling setpoint for ten hours with the schedule off. The Switch feeds zero into both accumulators and into their unit delays, so the rule is silent and yArmedOk says why.",
      "inputs": {
        "zone_temp": 30.0,
        "zone_temp_sp_htg": 20.0,
        "zone_temp_sp_clg": 24.0,
        "occ_scheduled": false
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 36000,
          "equals": false
        },
        {
          "output": "yHigh",
          "from_s": 0,
          "to_s": 36000,
          "equals": false
        },
        {
          "output": "yLow",
          "from_s": 0,
          "to_s": 36000,
          "equals": false
        },
        {
          "output": "yArmedOk",
          "from_s": 0,
          "to_s": 36000,
          "equals": false
        }
      ]
    },
    {
      "name": "occupancy_reset_clears_mid_accumulation",
      "description": "The same 0.5 degC bias, interrupted: S has reached 3.0 when the schedule drops at t=5400, the reset zeroes it, and the second occupied period has to serve its own exclusion hour and climb from zero again \u2014 so the alarm arrives at 13800 instead of 6600.",
      "inputs": {
        "zone_temp": 24.5,
        "zone_temp_sp_htg": 20.0,
        "zone_temp_sp_clg": 24.0,
        "occ_scheduled": [
          {
            "t": 0,
            "value": true
          },
          {
            "t": 5400,
            "value": false
          },
          {
            "t": 7200,
            "value": true
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 13500,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 13800,
          "to_s": 36000,
          "equals": true
        },
        {
          "output": "yArmedOk",
          "from_s": 3600,
          "to_s": 5100,
          "equals": true
        },
        {
          "output": "yArmedOk",
          "from_s": 5400,
          "to_s": 10500,
          "equals": false
        },
        {
          "output": "yArmedOk",
          "from_s": 10800,
          "to_s": 36000,
          "equals": true
        }
      ]
    },
    {
      "name": "alarm_drains_after_zone_returns_to_deadband",
      "description": "Warm bias until t=7200, then the zone recovers. S drains by k per sample once the error is back inside the deadband and the alarm clears at 7500 \u2014 CUSUM forgets at the slack rate, not instantly.",
      "inputs": {
        "zone_temp": [
          {
            "t": 0,
            "value": 24.5
          },
          {
            "t": 7200,
            "value": 22.0
          }
        ],
        "zone_temp_sp_htg": 20.0,
        "zone_temp_sp_clg": 24.0,
        "occ_scheduled": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 6300,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 6600,
          "to_s": 7200,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 7500,
          "to_s": 36000,
          "equals": false
        },
        {
          "output": "yLow",
          "from_s": 0,
          "to_s": 36000,
          "equals": false
        }
      ]
    },
    {
      "name": "setpoint_edges_are_zero_error",
      "description": "Zone parked exactly on the heating setpoint, then exactly on the cooling setpoint, then mid-band. All three branches of the piecewise evaluate to zero at and inside the edges, so ten occupied hours produce nothing.",
      "inputs": {
        "zone_temp": [
          {
            "t": 0,
            "value": 20.0
          },
          {
            "t": 12000,
            "value": 24.0
          },
          {
            "t": 24000,
            "value": 22.0
          }
        ],
        "zone_temp_sp_htg": 20.0,
        "zone_temp_sp_clg": 24.0,
        "occ_scheduled": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 36000,
          "equals": false
        },
        {
          "output": "yHigh",
          "from_s": 0,
          "to_s": 36000,
          "equals": false
        },
        {
          "output": "yLow",
          "from_s": 0,
          "to_s": 36000,
          "equals": false
        }
      ]
    }
  ]
}

VAV-0009 — Reheat coil leakage CUSUM

Statusverified — engine e2ff2f8, cxf:fnv1a128:b1d17eced97fea52d58a97a364eee7bf, 2026-08-18
Severity3
Methodstatistical
Phase3
CategoryCRITICAL_WASTE
ConfidenceMEDIUM
EstimationPROXY_ESTIMATION
G36
Clusters
Suppresses
Suppressed by
RelatedVAV-0003, VAV-0007, VAV-0008, FCU-0002, FPB-0003
Playbooksvav-min-flow-reheat
SourceBushby, Castro, Schein, House (2001), NIST/CEC PIER Project 2.3, §5.1.1 — the normalized statistic (eq. 1) and the two-sided CUSUM recursion (eqs. 2-3); Bushby, Castro, Schein, House (2001), NIST/CEC PIER Project 2.3, §5.1.3 — dTerror = discharge minus supply temperature, computed only while the reheat coil is commanded off; the leaking valve/element attribution; Bushby, Castro, Schein, House (2001), NIST/CEC PIER Project 2.3, §5.1.4 — point requirements, and the entering-air ≈ supply-air workaround this card adopts where the box has no entering-air sensor; Bushby, Castro, Schein, House (2001), NIST/CEC PIER Project 2.3, §5.1.5 — slack from normal-operation data, alarm limits from fault-injection data, collected per VAV box type; Bushby, Castro, Schein, House (2001), NIST/CEC PIER Project 2.3, §5.2 — Iowa Energy Center validation: the stuck/leaking-valve studies measured discharge rises of 3.7-11.9 °F, and Table 5 reports k = 3 with the campaign’s alarm-limit ranges; Harness calibration (committed method): tools/simharness/harness.py vavcal mode and tools/simharness/README.md — healthy reheat-off dTerror measured across the B2B OfficeMedium-4004 zones, and the supply-broadcast approximation bias validated below 0.005 °C; Library-authored: the HVAC FDD Reference v1.0 publishes no CUSUM card, so name, severity, category and the shipped defaults are argued on this card; Sibling precedent: VAV-0007/VAV-0008 (this batch’s recursion and reset topology), SYS-0006 (sub-condition flags), VAV-0003 (the valve seen from the command side); Engine pin e2ff2f8: crates/oce-blocks/src/discrete.rs (UnitDelay sample grid, loop-cut contract), crates/oce-graph/src/topo.rs (the emit-before sort that admits the feedback path)
Operating statesoccupied with the reheat coil commanded off, both gated in-graph. While the schedule is unoccupied, for exclusion_time after it goes occupied, or while rht_vlv_cmd is at/above reheat_closed_threshold, the accumulators are forced to zero. yOccupiedOk and yReheatOk publish the two gates separately.

Preconditions (host-enforced): Five host obligations. (1) This is the one VPACC channel that needs a discharge-air sensor: many-but-not-all boxes have one (points/vav.points.json vav_dat), and a box without it runs the source’s reduced two-channel VPACC (VAV-0007/VAV-0008) rather than a substitute signal. (2) sat is the serving AHU’s supply-air temperature broadcast to the box, standing in for entering-air per §5.1.4’s own workaround — bind the loop actually serving this box; the committed harness method measured the approximation bias below 0.005 °C in simulation, but a long or leaky duct run raises the healthy baseline, which is what error_mean absorbs and why it is commissioned per box. (3) rht_vlv_cmd is the COMMAND, not position feedback — a leak with the valve commanded open is invisible here by construction and belongs to VAV-0003. (4) Tick on the sample_period grid; both unit delays advance on that clock. (5) error_mean, error_sigma, slack_k and alarm_limit_h are per-box-type commissioning values (§5.1.5); the shipped set is one simulated medium-office box plus the source’s Iowa campaign. yOccupiedOk and yReheatOk are evaluability flags: either false means NO_EVAL, not healthy.

Points: vav_dat, sat, rht_vlv_cmd, occ_scheduled

Outputs:

  • yFault — True while either cumulative sum is strictly above alarm_limit_h — the discharge has run away from the supply temperature, with the coil commanded shut, for long enough that the accumulated normalized error passed the limit
  • yHigh — Sub-condition flag, undelayed — the S (upper) chart is above the limit: heat is being added across a coil commanded off, the leak direction. Not an evaluability output; a false yHigh never means NO_EVAL
  • yLow — Sub-condition flag, undelayed — the T (lower) chart is above the limit: the discharge reads persistently BELOW the supply broadcast, which no leak can produce. An instrumentation finding (wrong loop bound, sensor swap or drift), not a coil finding
  • yOccupiedOk — Evaluability flag — true only while the schedule is occupied AND exclusion_time has elapsed since it went occupied. FALSE MEANS NO_EVAL: the accumulators are held at zero by construction
  • yReheatOk — Evaluability flag — true only while rht_vlv_cmd is strictly below reheat_closed_threshold. FALSE MEANS NO_EVAL: with the coil legitimately commanded open, a discharge rise is the coil doing its job and the accumulators are held at zero

Parameters:

NameDefaultUnitCXF pathDescription
error_mean0.44°CmeanC.kExpected reheat-off rise, eq. 1’s x-bar — fan heat and duct gain make a small positive dT normal, so zero would be wrong here. 0.44 °C is the committed harness measurement (vavcal, B2B OfficeMedium-4004, occupied reheat-off ticks); commission it per box, since fan type, duct run and box size all move it.
error_sigma0.39°CsigmaC.kNormalization denominator, eq. 1’s sigma-hat. Unlike VAV-0008’s floor, this channel has a real healthy spread to measure and 0.39 °C is the same harness measurement — but simulation noise is cleaner than real sensors, so treat it as a starting point, not a statistic about your box.
slack_k3.01 (standard deviations per sample)kC.kSlack. The normalized error must exceed k before anything accumulates. 3.0 is what Table 5 reports for the source’s Iowa Energy Center campaign — measured, unlike the Figure-9 illustration the sibling cards ship — and still a commissioning parameter per §5.1.5. One constant feeds both charts.
alarm_limit_h100.01 (accumulated standard deviations)sHigh.t, tHigh.tAlarm limit, sitting inside Table 5’s published range for the campaign. At the shipped defaults a 5 °C leak rise adds about 8.7 per minute and alarms in roughly 12 minutes; the drain back through the limit after repair takes as long as the slack allows. One parameter binds both charts; split sHigh.t and tHigh.t deliberately if a site wants the source’s per-chart limits.
reheat_closed_threshold1.0%rhtOff.tThe command level below which the coil counts as off and the channel is armed (strict less-than). Set it under the controller’s minimum crack; a box that never commands fully closed never evaluates this rule, and yReheatOk says so.
sample_period60.0ssPrev.samplePeriod, tPrev.samplePeriodThe CUSUM clock — both unit delays advance on this grid, one increment per period. SET IT TO THE HOST’S TICK INTERVAL and set both paths together. 60 s matches the source’s own 1-minute data (§5.2).
exclusion_time3600.0soccGate.delayTimeHow long after each occupied-period start the accumulators stay held at zero — the source’s first hour (§5.1.3), taken literally. delayOnInit = true also covers a controller restart.

Description

A reheat valve that will not quite close is the quietest fault a VAV box can have: the zone stays comfortable, the damper compensates with more cold primary air, and the only witness is a discharge temperature a degree or two above the supply air feeding the box. That rise is real heat, paid for twice — once at the boiler and again at the chiller that removes it. No single reading is worth an alarm, because fan heat and duct gain put a small legitimate rise across every box; what convicts the coil is a rise persistently above that baseline, every minute, with the valve commanded shut. A cumulative sum chart is built for exactly that shape of evidence.

Detection Logic

dTerror  = vav_dat − sat                       rise across the box, °C

z_i      = (dTerror_i − error_mean) / error_sigma

armed_i  = occ_scheduled held true for exclusion_time
           AND rht_vlv_cmd < reheat_closed_threshold

S_i      = armed_i ? max(0,  z_i − slack_k + S_{i−1}) : 0
T_i      = armed_i ? max(0, −z_i − slack_k + T_{i−1}) : 0

yHigh    = S_i > alarm_limit_h        yLow = T_i > alarm_limit_h
yFault   = yHigh OR yLow

Block graph (rule.cxf.jsonld):

VAV-0009 block graph

The recursion, the occupancy arming and the feedback-through-UnitDelay topology are VAV-0008’s, documented there; this card adds one gate. A single Logical.And of the occupancy arm and the reheat-off comparison drives both reset switches, so the accumulators hold state only while the coil is commanded off — the one condition under which a discharge rise means anything. When the coil is commanded open, the state is cleared, not frozen (see Deviations). Both threshold comparisons are strict, and there is no alarm delay: the accumulation is the persistence test.

Possible Diagnoses

§5.1.3 attributes this channel to a leaking valve or element; the gate and the direction flags narrow it further.

  1. Reheat valve passing — worn seat, debris, or actuator not driving fully closed. The classic yHigh finding: rise within the source’s measured 3.7–11.9 °F leak band, coil commanded shut. Verify at the coil: pipe surface temperature downstream of the valve tells the truth in one visit.
  2. Electric reheat element held on — welded contactor or failed SCR, the electric-box equivalent of a passing valve, same signature and a bigger safety question. FCU-0002 is the fan-coil sibling.
  3. Valve driven open by override or bad sequence — the command reads closed at the BAS but a local override or miswired output holds it open. The rule cannot tell this from a leak; the work order can.
  4. Instrumentation, not the coil (yLow, or a yHigh that survives valve isolation) — wrong AHU loop bound as sat, discharge sensor drifted or swapped with another box. A persistent negative rise is physically impossible from a leak, which is why the T chart’s finding is named as instrumentation rather than folded into the coil story.

Energy Impact

CRITICAL_WASTE, MEDIUM confidence, PROXY_ESTIMATION. Leak heat is bought at the plant and then removed by cooling the same air back down — the same double-payment as VAV-0003, seen from the temperature side instead of the command side. The rule deliberately consumes no airflow point, so it prices nothing in-rule; the proxy is (dTerror − error_mean) × airflow for a host that wants numbers, and the leak-hours count alone ranks boxes for a valve walk-down. Confidence is MEDIUM on the sibling cards’ terms: the method is validated on physically injected faults (§5.2), the shipped parameters are one simulated box plus the source’s campaign. Heating-season biased, since the leak needs a live heat source behind the valve.

Emissions Impact

Scope 1 or 2 by heat source, QUALITATIVE_EMISSIONS. A passing hydronic valve spends boiler fuel (Scope 1 where gas) plus the chiller electricity that removes it (Scope 2); an electric element is Scope 2 twice. The double-payment structure means abating this fault removes emissions on both sides at once, which is why it ranks above its comfort impact — the zone usually feels fine.

Deviations

  • Reheat-on clears the accumulators rather than holding them. The source computes dTerror only while the coil is off and says nothing about what the sums do meanwhile. Holding would carry a stale sum across a multi-hour heating call and alarm on the first armed tick after it; clearing costs the case where a slow leak is interleaved with regular legitimate reheat — but a coil regularly commanded open is VAV-0003’s jurisdiction, not a coil passing unseen. The vectors pin the choice (reheat_command_resets_accumulator).
  • One Logical.And gates both charts through the shared reset switches, collapsing the source’s occupied-only rule and reheat-off rule into a single arm condition. The two evaluability flags stay separate outputs precisely because the host cannot otherwise tell which gate silenced the rule — a box heating all day publishes yReheatOk = false for hours and that is normal.
  • error_mean/error_sigma are measured, not floored — the contrast with VAV-0008. Fan heat and duct gain give this channel a real healthy baseline; the committed vavcal method measured 0.44/0.39 °C across the OfficeMedium-4004 zones on reheat-off occupied ticks. They remain per-box commissioning values (§5.1.5): a long duct run or a series fan moves the baseline, and error_mean is where that lives.
  • slack_k = 3.0 and alarm_limit_h = 100 come from Table 5’s Iowa campaign, not Figure 9’s illustration — the one channel in the trio whose defaults are measured. Still commissioning parameters; the campaign’s boxes are not your boxes.
  • The T chart is kept and its finding renamed. A leak cannot produce a negative rise, so yLow names instrumentation (wrong sat binding, sensor drift or swap) instead of pretending to be a coil finding. Dropping the chart would have been cheaper in blocks; keeping the source’s two-sided recursion buys a free wrong-loop detector on the channel most exposed to a binding mistake.
  • sat stands in for entering-air temperature per §5.1.4’s own workaround. The bias of that approximation was validated below 0.005 °C by the committed harness method — in simulation, on one building; the real-world residual is duct gain, which error_mean absorbs by design.
  • rht_vlv_cmd is compared strictly below 1 % (Reals.LessThreshold) — command, not position, because position feedback is rare on terminal units and a lying command is diagnosis 3. The threshold is a parameter so sites whose controllers never write a clean zero can still arm the rule.
  • The shared house choices are inherited, not re-argued: in-graph reset driving the published sum and the delay input together, delayOnInit = true serving a fresh exclusion hour on restart, UnitDelay y_start seeding costless at zero, strict comparisons, no alarm delay, constants over parameterized gains. VAV-0008’s Deviations carry the full arguments.
  • Severity 3, category: CRITICAL_WASTE, confidence: MEDIUM and the name are library-authored. CRITICAL_WASTE follows VAV-0003 — same waste, same double payment — rather than the trio’s COMFORT_ENERGY, because the zone is typically comfortable while this fault runs. g36: null on the same grounds as the siblings.

Notes

The trio splits by error signal: VAV-0007 accumulates airflow tracking error, VAV-0008 zone temperature error, this card the reheat-off discharge rise. This is the only channel that needs vav_dat; a box without it runs the other two as the source’s reduced VPACC and loses only this coil’s coverage.

VAV-0003 and this card meet at the same valve from opposite sides: 052 convicts a valve commanded open while the zone is satisfied, this card a valve passing while commanded shut. Neither suppresses the other — a failed actuator can produce both in one afternoon, and seeing both is the diagnosis.

Test Vectors

8 scenarios, clock step 60 s over 10800 s.

ScenarioDescription
healthy_variation_below_slackdTerror wanders over 0.05, 0.83 and 1.60 degC — plus and minus one sigma about the commissioned 0.44 degC, then out to just inside 3 sigma.
leak_accumulates_to_alarmA 5.0 degC rise across a coil commanded shut — inside the 3.7 to 11.9 degF band the source measured on its stuck valve (Table 3). z = 11.7, so each sample banks 8.69 and the twelfth accumulating sample carries S past h = 100 at t = 4260 s.
negative_bias_accumulates_on_low_sideThe mirror direction: the discharge reads 5.0 degC BELOW the broadcast supply temperature, which no leak can produce. T accumulates, yLow rises and yHigh stays down — the direction flags are what separate a leaking coil from a broken dT channel.
unoccupied_resets_mid_accumulationS reaches 86.9 and the schedule drops at t = 4140 s. The accumulator goes to zero rather than freezing, so the return to occupancy at 4380 s costs a fresh warmup hour and a fresh climb: the alarm lands at 8640 s instead of 4260 s.
warmup_hour_runs_from_the_occupied_transitionOccupancy starts at t = 1800 s, so the excluded hour ends at 5400 s and not at 3600 s: the source’s first-hour rule is measured from the transition, not from controller start. The alarm follows twelve samples later.
reheat_command_resets_accumulatorS reaches 78.2, the coil is commanded open from 4140 s to 4440 s, and the accumulator is back at zero when the valve shuts again — the source’s own reading of its Figure 13 (the dT CUSUM is reset to zero when the valve is not full close). The climb restarts from nothing and the alarm lands at 5160 s, not the 4620 s a held accumulator would give.
alarm_clears_as_accumulator_drains_through_hAfter the alarm at 4260 s the rise falls back to 1.415 degC, inside the slack band, and S drains at 0.5 per sample from 104.3. Both sides of h at the accumulator level: still true at 4680 s (S = 100.8), false from 4860 s (S = 99.3). The comparison is strict and there is no persistence timer to unwind.
reheat_open_throughout_channel_silentA 5.0 degC rise across a coil commanded 50% open is the coil doing its job. No reheat-off sample exists all run, both accumulators stay at zero, and yReheatOk stays false so the host reports NO_EVAL rather than a healthy box.
vectors.json
{
  "schema": "cxf-library/vectors/v1",
  "clock": {
    "step_s": 60,
    "horizon_s": 10800
  },
  "scenarios": [
    {
      "name": "healthy_variation_below_slack",
      "description": "dTerror wanders over 0.05, 0.83 and 1.60 degC \u2014 plus and minus one sigma about the commissioned 0.44 degC, then out to just inside 3 sigma. |z| never clears slack_k, so neither accumulator ever leaves zero. This is what the slack parameter is for.",
      "inputs": {
        "vav_dat": [
          {
            "t": 0,
            "value": 13.83
          },
          {
            "t": 600,
            "value": 13.05
          },
          {
            "t": 1200,
            "value": 14.6
          },
          {
            "t": 1800,
            "value": 13.05
          },
          {
            "t": 2400,
            "value": 13.83
          },
          {
            "t": 3000,
            "value": 14.6
          },
          {
            "t": 3600,
            "value": 13.05
          },
          {
            "t": 4200,
            "value": 13.83
          },
          {
            "t": 4800,
            "value": 14.6
          },
          {
            "t": 5400,
            "value": 13.05
          },
          {
            "t": 6000,
            "value": 13.83
          },
          {
            "t": 6600,
            "value": 14.6
          },
          {
            "t": 7200,
            "value": 13.05
          },
          {
            "t": 7800,
            "value": 13.83
          },
          {
            "t": 8400,
            "value": 14.6
          },
          {
            "t": 9000,
            "value": 13.05
          },
          {
            "t": 9600,
            "value": 13.83
          },
          {
            "t": 10200,
            "value": 14.6
          }
        ],
        "sat": 13.0,
        "rht_vlv_cmd": 0.0,
        "occ_scheduled": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 10800,
          "equals": false
        },
        {
          "output": "yHigh",
          "from_s": 0,
          "to_s": 10800,
          "equals": false
        },
        {
          "output": "yLow",
          "from_s": 0,
          "to_s": 10800,
          "equals": false
        },
        {
          "output": "yReheatOk",
          "from_s": 0,
          "to_s": 10800,
          "equals": true
        },
        {
          "output": "yOccupiedOk",
          "from_s": 0,
          "to_s": 3540,
          "equals": false
        },
        {
          "output": "yOccupiedOk",
          "from_s": 3660,
          "to_s": 10800,
          "equals": true
        }
      ]
    },
    {
      "name": "leak_accumulates_to_alarm",
      "description": "A 5.0 degC rise across a coil commanded shut \u2014 inside the 3.7 to 11.9 degF band the source measured on its stuck valve (Table 3). z = 11.7, so each sample banks 8.69 and the twelfth accumulating sample carries S past h = 100 at t = 4260 s.",
      "inputs": {
        "vav_dat": 18.0,
        "sat": 13.0,
        "rht_vlv_cmd": 0.0,
        "occ_scheduled": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 4140,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 4320,
          "to_s": 10800,
          "equals": true
        },
        {
          "output": "yHigh",
          "from_s": 0,
          "to_s": 4140,
          "equals": false
        },
        {
          "output": "yHigh",
          "from_s": 4320,
          "to_s": 10800,
          "equals": true
        },
        {
          "output": "yLow",
          "from_s": 0,
          "to_s": 10800,
          "equals": false
        }
      ]
    },
    {
      "name": "negative_bias_accumulates_on_low_side",
      "description": "The mirror direction: the discharge reads 5.0 degC BELOW the broadcast supply temperature, which no leak can produce. T accumulates, yLow rises and yHigh stays down \u2014 the direction flags are what separate a leaking coil from a broken dT channel.",
      "inputs": {
        "vav_dat": 8.0,
        "sat": 13.0,
        "rht_vlv_cmd": 0.0,
        "occ_scheduled": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 4020,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 4200,
          "to_s": 10800,
          "equals": true
        },
        {
          "output": "yLow",
          "from_s": 0,
          "to_s": 4020,
          "equals": false
        },
        {
          "output": "yLow",
          "from_s": 4200,
          "to_s": 10800,
          "equals": true
        },
        {
          "output": "yHigh",
          "from_s": 0,
          "to_s": 10800,
          "equals": false
        }
      ]
    },
    {
      "name": "unoccupied_resets_mid_accumulation",
      "description": "S reaches 86.9 and the schedule drops at t = 4140 s. The accumulator goes to zero rather than freezing, so the return to occupancy at 4380 s costs a fresh warmup hour and a fresh climb: the alarm lands at 8640 s instead of 4260 s.",
      "inputs": {
        "vav_dat": 18.0,
        "sat": 13.0,
        "rht_vlv_cmd": 0.0,
        "occ_scheduled": [
          {
            "t": 0,
            "value": true
          },
          {
            "t": 4140,
            "value": false
          },
          {
            "t": 4380,
            "value": true
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 8520,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 8700,
          "to_s": 10800,
          "equals": true
        },
        {
          "output": "yOccupiedOk",
          "from_s": 0,
          "to_s": 3540,
          "equals": false
        },
        {
          "output": "yOccupiedOk",
          "from_s": 3660,
          "to_s": 4080,
          "equals": true
        },
        {
          "output": "yOccupiedOk",
          "from_s": 4140,
          "to_s": 7920,
          "equals": false
        },
        {
          "output": "yOccupiedOk",
          "from_s": 8040,
          "to_s": 10800,
          "equals": true
        }
      ]
    },
    {
      "name": "warmup_hour_runs_from_the_occupied_transition",
      "description": "Occupancy starts at t = 1800 s, so the excluded hour ends at 5400 s and not at 3600 s: the source's first-hour rule is measured from the transition, not from controller start. The alarm follows twelve samples later.",
      "inputs": {
        "vav_dat": 18.0,
        "sat": 13.0,
        "rht_vlv_cmd": 0.0,
        "occ_scheduled": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 1800,
            "value": true
          }
        ]
      },
      "expect": [
        {
          "output": "yOccupiedOk",
          "from_s": 0,
          "to_s": 5340,
          "equals": false
        },
        {
          "output": "yOccupiedOk",
          "from_s": 5460,
          "to_s": 10800,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5940,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 6120,
          "to_s": 10800,
          "equals": true
        }
      ]
    },
    {
      "name": "reheat_command_resets_accumulator",
      "description": "S reaches 78.2, the coil is commanded open from 4140 s to 4440 s, and the accumulator is back at zero when the valve shuts again \u2014 the source's own reading of its Figure 13 (the dT CUSUM is reset to zero when the valve is not full close). The climb restarts from nothing and the alarm lands at 5160 s, not the 4620 s a held accumulator would give.",
      "inputs": {
        "vav_dat": 18.0,
        "sat": 13.0,
        "rht_vlv_cmd": [
          {
            "t": 0,
            "value": 0.0
          },
          {
            "t": 4140,
            "value": 50.0
          },
          {
            "t": 4500,
            "value": 0.0
          }
        ],
        "occ_scheduled": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5040,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 5220,
          "to_s": 10800,
          "equals": true
        },
        {
          "output": "yReheatOk",
          "from_s": 0,
          "to_s": 4080,
          "equals": true
        },
        {
          "output": "yReheatOk",
          "from_s": 4140,
          "to_s": 4440,
          "equals": false
        },
        {
          "output": "yReheatOk",
          "from_s": 4500,
          "to_s": 10800,
          "equals": true
        }
      ]
    },
    {
      "name": "alarm_clears_as_accumulator_drains_through_h",
      "description": "After the alarm at 4260 s the rise falls back to 1.415 degC, inside the slack band, and S drains at 0.5 per sample from 104.3. Both sides of h at the accumulator level: still true at 4680 s (S = 100.8), false from 4860 s (S = 99.3). The comparison is strict and there is no persistence timer to unwind.",
      "inputs": {
        "vav_dat": [
          {
            "t": 0,
            "value": 18.0
          },
          {
            "t": 4320,
            "value": 14.415
          }
        ],
        "sat": 13.0,
        "rht_vlv_cmd": 0.0,
        "occ_scheduled": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 4140,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 4320,
          "to_s": 4680,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 4860,
          "to_s": 10800,
          "equals": false
        }
      ]
    },
    {
      "name": "reheat_open_throughout_channel_silent",
      "description": "A 5.0 degC rise across a coil commanded 50% open is the coil doing its job. No reheat-off sample exists all run, both accumulators stay at zero, and yReheatOk stays false so the host reports NO_EVAL rather than a healthy box.",
      "inputs": {
        "vav_dat": 18.0,
        "sat": 13.0,
        "rht_vlv_cmd": 50.0,
        "occ_scheduled": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 10800,
          "equals": false
        },
        {
          "output": "yHigh",
          "from_s": 0,
          "to_s": 10800,
          "equals": false
        },
        {
          "output": "yLow",
          "from_s": 0,
          "to_s": 10800,
          "equals": false
        },
        {
          "output": "yReheatOk",
          "from_s": 0,
          "to_s": 10800,
          "equals": false
        }
      ]
    }
  ]
}

VAV-0010 — Zone temperature sensor drift (neighbor-median)

Statusverified — engine e2ff2f8, cxf:fnv1a128:633c71d93a7cb6a8f5f86e6f18b81077, 2026-08-18
Severity3
Methodstatistical
Phase3
CategoryCOMFORT_ENERGY
ConfidenceLOW
EstimationQUALITATIVE_ONLY
G36
ClustersCLU-09
Suppresses
Suppressed by
RelatedSYS-0005, SYS-0009, SYS-0010, VAV-0008
Playbookssensor-drift
SourceYang, H., Cho, S., Tae, C.-S., Zaheeruddin, M. (2008). Sequential rule based algorithms for temperature sensor fault detection in air handling units. Energy Conversion and Management 49(8), 2291-2306. doi:10.1016/j.enconman.2008.01.029 — drift as a distinct sensor-fault class detected by rule-based comparison against a reference, and the isolation caveat this card inherits; Library-authored: no reference card exists for a neighbor-comparison drift rule; name, severity, thresholds and the median reference are argued here. This is the rule VAV-0008’s diagnosis 3 and faults/vav/README.md have pointed at since the CUSUM batch; Library precedent: SYS-0005 (pairwise cross-validation, ambiguous verdict — this card is its fleet-referenced descendant with a NAMED verdict), SYS-0009/SYS-0010 (single-sensor adjudication form), HP-0001 (commissioning parameters shipped as documented placeholders); points/vav.points.json zone_temp_neighbor_median — the self-excluding median contract (population >= ~5 zones, divergent-purpose zones excluded) this rule stands on
Operating statesoccupied, host-enforced. Unoccupied setback diverges zones legitimately — different masses recover at different rates — so the comparison means something only while every zone is holding an occupied setpoint. This rule is stateless, so the gate is the host’s standard job (contrast the VAV-0007/0008/0009 accumulators, which had to reset in-graph).

Preconditions (host-enforced): The median contract does the heavy lifting — read points/vav.points.json zone_temp_neighbor_median before instantiating: the median EXCLUDES this zone (a drifting sensor must not pull its own reference), covers >= ~5 sibling zones on the same AHU, and omits zones whose setpoints legitimately diverge (server rooms, vestibules, unconditioned buffers). drift_threshold must exceed the site’s occupied SETPOINT SPREAD plus normal zone-to-zone scatter: zones commanded to different setpoints differ by design, and a threshold inside that spread alarms on the design. Delivery quality is resolved before this rule runs — a held-over stale value reads as divergence, and the rule is right about the number and wrong about the sensor (SYS-0005’s caveat, inherited). Per the sensor-health family’s standing constraint, no other card may list this rule in its suppresses.

Points: zone_temp, zone_temp_neighbor_median

Outputs:

  • yFault — True while this zone’s temperature has stayed more than drift_threshold from its neighbor median, in either direction, continuously for persist_time. The verdict names THIS zone’s sensor — see adjudicates
  • yHigh — Sub-condition flag, undelayed — the zone reads above the median by more than drift_threshold right now. Not an evaluability output; false never means NO_EVAL
  • yLow — Sub-condition flag, undelayed — the zone reads below the median by more than drift_threshold. Same kind as yHigh

Parameters:

NameDefaultUnitCXF pathDescription
drift_threshold3.0°ChighCmp.t, lowCmp.tDivergence from the neighbor median beyond which this zone is the outlier. COMMISSIONING PLACEHOLDER: it must clear the site’s occupied setpoint spread plus honest zone-to-zone scatter, which no source publishes as a portable number. 3.0 is deliberately wider than SYS-0005’s 2.0 pair band — a median of different rooms is a looser reference than a co-located pair. One value feeds both directions through the in-graph negation.
persist_time7200.0spersist.delayTimeContinuous divergence required before the alarm asserts (2 h). Drift is permanent, so latency is cheap and false positives are not: two hours rides out lunch loads, solar swings, and a propped-open door. delayOnInit = true serves the full persistence on a controller restart.

Description

A zone temperature sensor that drifts takes its whole zone with it: the box dutifully conditions the room to a wrong number, the occupants adjust the setpoint to fight it, and every VAV diagnostic on that zone inherits the lie. A single drifted sensor is invisible from inside its own control loop — the loop closes on the sensor, so the trend looks healthy. What exposes it is the rest of the fleet: sibling zones on the same air handler see the same supply air and roughly the same weather, so a zone that reads persistently far from its neighbors’ median is the outlier, and the median — unlike SYS-0005’s two-sensor pair — says which sensor to distrust.

Detection Logic

err     = zone_temp − zone_temp_neighbor_median

yHigh   = err  > drift_threshold          this zone reads above the fleet
yLow    = −err > drift_threshold          below the fleet (in-graph negation)

yFault  = (yHigh OR yLow) continuously for persist_time

Block graph (rule.cxf.jsonld):

VAV-0010 block graph

Six blocks, stateless. The median is computed by the host per the point contract — self-excluding, so a drifting sensor cannot drag its own reference toward itself. Trunk-level events are free immunity: a failed AHU or a building-wide swing moves the zone and the median together, and the subtraction sees only the residual (median_moves_with_zone pins it). The Or sits before the single persistence delay, so a sensor that flips from reading high to reading low without ever closing is one continuous fault. Both comparisons are strict; equality at the threshold reads healthy.

Possible Diagnoses

  1. Sensor drift or failed calibration — the finding this card names. Verify with a handheld reference at the thermostat; recalibrate or replace, per the sensor-drift playbook.
  2. Bad placement rather than bad electronics — a sensor over a copier, in supply-air wash, or on a sun-struck wall drifts with the source, not with age. Same signature, different work order: move it.
  3. A genuinely divergent zone that belongs out of the population — a space repurposed since commissioning (new server load, new wall). The fix is the median population list, not the sensor.
  4. The zone really is out of control — starved airflow or a failed box holds the space off setpoint while the sensor tells the truth. The VAV family’s own rules (VAV-0004, VAV-0008) fire alongside this one in that case; this card alone, with the box quiet, points at the sensor.

Energy Impact

COMFORT_ENERGY, LOW confidence, QUALITATIVE_ONLY. The drifted sensor itself consumes nothing; the waste is whatever the wrong number drives — typically over-conditioning of one zone and setpoint-fighting by its occupants — and that is counted by the downstream rules this card adjudicates. Confidence is LOW on SYS-0005’s grounds: the mechanism is literature-backed but the shipped thresholds are placeholders with no fault-injection validation behind them yet. Climate-neutral.

Emissions Impact

Scope 1 or 2 by what the false reading drives (reheat vs cooling), QUALITATIVE_EMISSIONS. No direct term; abatement rides the downstream fix.

Deviations

  • The median names the verdict; the pair could not. SYS-0005 ships verdict: ambiguous because two sensors that disagree carry no majority. Five or more siblings do: this card adjudicates zone_temp as invalid_while_active, the single-sensor form SYS-0009/0010 established. The cost is the median contract itself — population size and composition are host obligations the graph cannot check.
  • drift_threshold 3.0 °C and persist_time 7200 s are library defaults, not literature values. Yang et al. (2008) validate pairwise comparison thresholds on co-located AHU sensors; no source publishes a neighbor-median band. Shipped wider (3.0 vs the pair’s 2.0) and slower (2 h single delay vs the pair’s chained 60 + 30 min) because rooms are a looser reference than a shared duct; both retune at commissioning against the measured occupied spread.
  • One delay, not SYS-0005’s chained two. The reference specified that card’s drift_duration + AlarmDelay split, so it was transcribed; this card has no source to honor and one persist_time is one fewer number to commission wrong.
  • The negation is in-graph (MultiplyByParameter · k = −1) so the one published threshold stays positive and feeds both comparators — the same reason VAV-0008 keeps its constants positive rather than shipping a negative the retuner must remember.
  • Host-side occupancy gating, against the batch-19 precedent and for the house default. The CUSUM trio gated in-graph because accumulators need their STATE reset; this rule is stateless, so operating_states: occupied does the job with zero blocks. The persistence delay does run through unoccupied hours — a 3 a.m. assertion is possible and the host discards it as NO_EVAL, which is suppression of output, not state, and therefore safe here.
  • delayOnInit = true (CDL default false), the library’s standing choice, does real work: a controller restart serves the full two hours before re-alarming rather than re-asserting into a zone that may have been fixed.
  • Severity 3, category: COMFORT_ENERGY, name are library-authored; mirrored from SYS-0005, the nearest shipped relative. g36: null — G36 has no fleet-relative sensor check.

Notes

This closes the reservation VAV-0008’s Notes and the family README have carried since batch 19: the drift rule that decides whether the CUSUM cards’ zone_temp input can be believed. Run it beside them — a zone that trips VAV-0008 and this card is a sensor problem wearing a comfort problem’s clothes.

Test Vectors

8 scenarios, clock step 60 s over 10800 s.

ScenarioDescription
zones_agreeThe zone reads 0.4 degC over its neighbor median — inside any sane drift_threshold. Nothing asserts for three hours.
reads_high_drift_alarmsA 3.5 degC standing excess over the median: yHigh asserts undelayed from the first tick, and yFault exactly at persist_time (delayOnInit = true, divergence present from init).
reads_low_drift_alarmsThe mirror direction, 3.5 degC under the median through the in-graph negation: yLow undelayed, yFault at persist_time.
threshold_equality_is_silentExactly drift_threshold apart on each side in turn (+3.0 then -3.0). Both comparisons are strict, so equality reads healthy and nothing ever accumulates toward an alarm.
transient_never_alarmsA one-hour 4.5 degC excursion — a real disturbance, not drift. yHigh reports it live; the excursion ends before persist_time and yFault stays silent.
direction_flip_keeps_persistenceDivergence flips sign at one hour without ever closing. The Or sits before the delay, so the persistence survives the flip and yFault still asserts at persist_time from the start — a sensor that is simply wrong, whichever way, is one fault.
median_moves_with_zoneA building-wide swing: the zone climbs 5 degC and the neighbor median climbs with it. The subtraction sees only the small residual, which is the point of a fleet-relative reference — trunk-level events move both terms.
recovery_resets_delayDivergence holds for 100 minutes, closes for 10, then returns. TrueDelay starts over on the false gap, so the second run has not reached persist_time by the horizon: no alarm without CONTINUOUS divergence.
vectors.json
{
  "schema": "cxf-library/vectors/v1",
  "clock": {
    "step_s": 60,
    "horizon_s": 10800
  },
  "scenarios": [
    {
      "name": "zones_agree",
      "description": "The zone reads 0.4 degC over its neighbor median \u2014 inside any sane drift_threshold. Nothing asserts for three hours.",
      "inputs": {
        "zone_temp": 22.4,
        "zone_temp_neighbor_median": 22.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 10800,
          "equals": false
        },
        {
          "output": "yHigh",
          "from_s": 0,
          "to_s": 10800,
          "equals": false
        },
        {
          "output": "yLow",
          "from_s": 0,
          "to_s": 10800,
          "equals": false
        }
      ]
    },
    {
      "name": "reads_high_drift_alarms",
      "description": "A 3.5 degC standing excess over the median: yHigh asserts undelayed from the first tick, and yFault exactly at persist_time (delayOnInit = true, divergence present from init).",
      "inputs": {
        "zone_temp": 25.5,
        "zone_temp_neighbor_median": 22.0
      },
      "expect": [
        {
          "output": "yHigh",
          "from_s": 0,
          "to_s": 10800,
          "equals": true
        },
        {
          "output": "yLow",
          "from_s": 0,
          "to_s": 10800,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7140,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 7200,
          "to_s": 10800,
          "equals": true
        }
      ]
    },
    {
      "name": "reads_low_drift_alarms",
      "description": "The mirror direction, 3.5 degC under the median through the in-graph negation: yLow undelayed, yFault at persist_time.",
      "inputs": {
        "zone_temp": 18.5,
        "zone_temp_neighbor_median": 22.0
      },
      "expect": [
        {
          "output": "yLow",
          "from_s": 0,
          "to_s": 10800,
          "equals": true
        },
        {
          "output": "yHigh",
          "from_s": 0,
          "to_s": 10800,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7140,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 7200,
          "to_s": 10800,
          "equals": true
        }
      ]
    },
    {
      "name": "threshold_equality_is_silent",
      "description": "Exactly drift_threshold apart on each side in turn (+3.0 then -3.0). Both comparisons are strict, so equality reads healthy and nothing ever accumulates toward an alarm.",
      "inputs": {
        "zone_temp": [
          {
            "t": 0,
            "value": 25.0
          },
          {
            "t": 5400,
            "value": 19.0
          }
        ],
        "zone_temp_neighbor_median": 22.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 10800,
          "equals": false
        },
        {
          "output": "yHigh",
          "from_s": 0,
          "to_s": 10800,
          "equals": false
        },
        {
          "output": "yLow",
          "from_s": 0,
          "to_s": 10800,
          "equals": false
        }
      ]
    },
    {
      "name": "transient_never_alarms",
      "description": "A one-hour 4.5 degC excursion \u2014 a real disturbance, not drift. yHigh reports it live; the excursion ends before persist_time and yFault stays silent.",
      "inputs": {
        "zone_temp": [
          {
            "t": 0,
            "value": 26.5
          },
          {
            "t": 3600,
            "value": 22.0
          }
        ],
        "zone_temp_neighbor_median": 22.0
      },
      "expect": [
        {
          "output": "yHigh",
          "from_s": 0,
          "to_s": 3540,
          "equals": true
        },
        {
          "output": "yHigh",
          "from_s": 3600,
          "to_s": 10800,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 10800,
          "equals": false
        }
      ]
    },
    {
      "name": "direction_flip_keeps_persistence",
      "description": "Divergence flips sign at one hour without ever closing. The Or sits before the delay, so the persistence survives the flip and yFault still asserts at persist_time from the start \u2014 a sensor that is simply wrong, whichever way, is one fault.",
      "inputs": {
        "zone_temp": [
          {
            "t": 0,
            "value": 26.0
          },
          {
            "t": 3600,
            "value": 18.0
          }
        ],
        "zone_temp_neighbor_median": 22.0
      },
      "expect": [
        {
          "output": "yHigh",
          "from_s": 0,
          "to_s": 3540,
          "equals": true
        },
        {
          "output": "yHigh",
          "from_s": 3600,
          "to_s": 10800,
          "equals": false
        },
        {
          "output": "yLow",
          "from_s": 0,
          "to_s": 3540,
          "equals": false
        },
        {
          "output": "yLow",
          "from_s": 3600,
          "to_s": 10800,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7140,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 7200,
          "to_s": 10800,
          "equals": true
        }
      ]
    },
    {
      "name": "median_moves_with_zone",
      "description": "A building-wide swing: the zone climbs 5 degC and the neighbor median climbs with it. The subtraction sees only the small residual, which is the point of a fleet-relative reference \u2014 trunk-level events move both terms.",
      "inputs": {
        "zone_temp": [
          {
            "t": 0,
            "value": 23.0
          },
          {
            "t": 5400,
            "value": 28.0
          }
        ],
        "zone_temp_neighbor_median": [
          {
            "t": 0,
            "value": 22.8
          },
          {
            "t": 5400,
            "value": 27.6
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 10800,
          "equals": false
        },
        {
          "output": "yHigh",
          "from_s": 0,
          "to_s": 10800,
          "equals": false
        },
        {
          "output": "yLow",
          "from_s": 0,
          "to_s": 10800,
          "equals": false
        }
      ]
    },
    {
      "name": "recovery_resets_delay",
      "description": "Divergence holds for 100 minutes, closes for 10, then returns. TrueDelay starts over on the false gap, so the second run has not reached persist_time by the horizon: no alarm without CONTINUOUS divergence.",
      "inputs": {
        "zone_temp": [
          {
            "t": 0,
            "value": 26.0
          },
          {
            "t": 6000,
            "value": 22.0
          },
          {
            "t": 6600,
            "value": 26.0
          }
        ],
        "zone_temp_neighbor_median": 22.0
      },
      "expect": [
        {
          "output": "yHigh",
          "from_s": 0,
          "to_s": 5940,
          "equals": true
        },
        {
          "output": "yHigh",
          "from_s": 6000,
          "to_s": 6540,
          "equals": false
        },
        {
          "output": "yHigh",
          "from_s": 6600,
          "to_s": 10800,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 10800,
          "equals": false
        }
      ]
    }
  ]
}

VFD Fault Rules

Variable frequency drive fault detection rules (VFD-*). Source grounding: HVAC FDD Reference v1.0 ch.15, PNNL VFD O&M guidance, NIST’s generic faulty-regulation work, and verified library hunting precedents (see each card’s Deviations section). Application-agnostic: the same five rules serve fan, pump, and other process drives, with the process-variable pair bound per application.

Point dictionary: points/vfd.points.json.

Index

IDNameSevMethodStatus
VFD-0001Command vs feedback deviation2ruleverified
VFD-0002At minimum speed with load unsatisfied3ruleverified
VFD-0003At maximum speed with load unsatisfied3ruleverified
VFD-0004VFD process-loop hunting3ruleverified
VFD-0005VFD not in remote automatic control2ruleverified

VFD-0001/0002 severity and method follow the reference’s ch.15 cards. The three library-authored expansion rules classify their adopted thresholds and source adaptations on-card.

Relationships

  • All five rules share the vfd-pump-faults playbook.
  • VFD-0001 suppresses the speed-limit and hunting rules when speed feedback is not trustworthy. VFD-0005 suppresses those same-drive automatic-loop rules while remote automatic authority is absent.
  • No drive-control cluster is declared: mode, tracking, capacity, and tuning findings are peer symptoms without one causal trigger whose correction should clear the rest.

VFD-0001 — Command vs feedback deviation

Statusverified — engine e2ff2f8, cxf:fnv1a128:5c7f261d8eb5790babf4c55afa671f06, 2026-08-17
Severity2
Methodrule
Phase2
CategoryPROTECTIVE
ConfidenceMEDIUM
EstimationQUALITATIVE_ONLY
G36
Clusters
SuppressesVFD-0002, VFD-0003, VFD-0004
Suppressed by
RelatedVFD-0002, VFD-0003, VFD-0004, VFD-0005, PMP-0006
Playbooksvfd-pump-faults
SourceHVAC FDD Reference v1.0 §15, VFD-0001; Ali et al. 2020; Engineering best practice
Operating statesdrive commanded to run above its minimum speed

Preconditions (host-enforced): vfd_speed_cmd and vfd_speed must be the same drive’s command and feedback, both scaled 0-100% of rated speed. The rule does no unit conversion: a site trending feedback in Hz against a percent command reads as a permanent 40-point deviation on a 60 Hz drive. Both points must be fresh — a stale feedback value held at its last reading is a communication fault (diagnosis 4), which this rule reports as a drive fault, the right alarm for the wrong reason. Where the site trends both a drive-reported speed and a tachometer, prefer the drive-reported value, since diagnosis 5 is the tachometer itself. Command evaluability is signalled in-rule by yCmdOk: when it is false the verdict is NO_EVAL, not healthy, and in particular a drive commanded off is not evaluated at all.

Points: vfd_speed_cmd, vfd_speed

Outputs:

  • yFault — True while the drive is commanded above min_cmd_for_eval and its feedback has stayed more than speed_error_threshold away from the command, for deviation_duration plus alarm_delay
  • yCmdOk — Evaluability signal — true when vfd_speed_cmd exceeds min_cmd_for_eval; false means NO_EVAL and the host must ignore yFault

Parameters:

NameDefaultUnitCXF pathDescription
speed_error_threshold5.0%devHigh.tCommand-versus-feedback tolerance, in points of rated speed, in either direction
min_cmd_for_eval20.0%cmdOk.tSpeed command below which the drive is not obliged to track and the comparison is not evaluable. ADOPTED — the reference states no such parameter; the default is VFD-0002’s min_speed
deviation_duration300.0strack.delayTimeContinuous deviation required before it counts as sustained rather than a drive ramping to a new command (5 min)
alarm_delay300.0spersist.delayTimeFurther persistence required after deviation_duration before the alarm asserts (5 min)

Description

A variable frequency drive is asked for a speed and reports back the speed it is running. When those two numbers separate and stay separated, something between the control loop and the shaft has stopped working — the drive derating itself on a hot heatsink, a motor loading up on a failing bearing, a belt slipping so the driven equipment never reaches the speed the motor does, or a command that never arrived. The rule is deliberately indifferent to which: two points, one subtraction and a tolerance, which is why the same rule covers fan and pump drives without change. What it buys is early warning on a component whose failures are progressive — a derating drive or a slipping belt deteriorates for weeks before it stops — so the alarm is about the equipment, not about energy.

Detection Logic

deviation = |vfd_speed_cmd − vfd_speed|

yCmdOk = vfd_speed_cmd > min_cmd_for_eval        (false ⇒ host reports NO_EVAL)
yFault = (deviation > speed_error_threshold AND yCmdOk)
         sustained for deviation_duration, then held a further alarm_delay

Block graph (rule.cxf.jsonld):

VFD-0001 block graph

err and absErr form the unsigned deviation, so the test is symmetric: a drive falling short of its command and a drive overrunning it are both faults and both alarm. Belt slip and a failing tachometer sit on opposite sides of that same comparison.

vfd_speed_cmd fans out a second time into cmdOk, the evaluability branch, whose output is both the boundary output yCmdOk and the second input of gate. A drive commanded below its minimum speed therefore holds yFault down — and that false means unknown, not healthy. Below its own minimum a drive is under no obligation to track: a loop output of 10% on a drive whose minimum is 20% leaves the machine stopped, and the resulting 10-point “deviation” is correct behavior.

track and persist are two delays in series (the AHU-0027 pattern, also used by VAV-0004): track is the reference’s sustained for deviation_duration, persist its separate AlarmDelay, ten minutes total at the defaults and independently tunable. Any moment of tracking drops both timers and discards the accumulated time, so the alarm always describes one continuous deviation. Both comparisons are strict — a deviation of exactly 5.0 points is not a fault, a command of exactly 20.0% is not evaluable.

Possible Diagnoses

  1. VFD internal fault — overcurrent, overvoltage, or overtemperature, the last of which shows first as quiet derating rather than a trip
  2. Motor bearing failure, loading the drive until it can no longer hold commanded speed
  3. Belt slip on fan applications — the motor reaches its speed and the driven equipment does not, so which of the two the feedback reports decides whether this rule can see it at all
  4. Communication fault between the BAS and the drive, leaving the drive on its last received command or a local setpoint
  5. Speed sensor or tachometer failure — the drive is fine and the measurement is wrong, which is why the point dictionary prefers the drive-reported speed where both are trended

Energy Impact

PROTECTIVE, MEDIUM confidence, QUALITATIVE_ONLY. There is no per-fault energy model and the reference does not offer one; it points to the Energy Impact Reference §4.4 framework, which applies only once the failing component is known. The direction of the waste depends on the cause: a derating drive makes a pressure-controlled loop work longer for the same result, a slipping belt turns shaft power into heat in the belt, and a failed tachometer costs nothing until someone acts on its reading. Neutral climate sensitivity. The number worth quoting to an owner is the repair — the playbook puts a drive replacement at $500-$2,000 depending on motor horsepower.

Emissions Impact

Scope 2, QUALITATIVE_EMISSIONS, MEDIUM confidence, basis N/A. VFD-driven equipment is electric, so whatever additional draw the fault produces is purchased electricity. The reference declines to give a range — “protective; indirect via motor/VFD damage” — and that is the honest reading: the emissions consequence is dominated by the replacement hardware and the runtime of whatever the drive serves, neither of which this rule measures.

Deviations

  • min_cmd_for_eval is an adopted addition; the reference has no such parameter, only the bare deviation test. Without the gate the rule misfires wherever a drive is commanded below the speed it can physically hold: the drive sits stopped, the command sits at 10%, and the rule reads a 10-point fault on a machine behaving as designed. The 20.0% default is VFD-0002’s min_speed — same chapter, same family, same quantity — and hosts retune it to the drive’s actual minimum. Exposed as yCmdOk per SCHEMA.md so the host can tell NO_EVAL from healthy.
  • The gate creates a blind spot, and not a small one. A drive running while commanded off — a welded contactor, a hand-off-auto switch left in hand, the stuck last-command case of diagnosis 4 — has a command of 0% and is never evaluated at any positive min_cmd_for_eval. The check that would catch it is a run-status-versus-command comparison at equipment level, which this library has no drive rule for; AHU-0018 and RTU-0006 ask the nearest question (a fan running outside its schedule) and catch only the after-hours version. Read yCmdOk = false as this rule standing down, not as an idle drive being fine.
  • Two delays in series rather than one. The reference lists deviation_duration and AlarmDelay as separate tunables for the same condition, so both are kept and chained. A single 600 s delay would be indistinguishable as shipped, but a site wanting a two-minute deviation window and a ten-minute alarm hold can have it. Precedent: VAV-0004, whose reference tunables have the identical shape.
  • Strict > at the deviation threshold. The reference writes > too, so nothing is lost, and CDL Reals has no GreaterEqual to express the inclusive form anyway. A deviation of exactly 5.0 points reads healthy; the disagreement is measure-zero on a real-valued signal and both sides are pinned.
  • suppresses: [VFD-0002, VFD-0003, VFD-0004] is an authored relationship, not the reference’s. All three rules infer operating limits or motion from the same vfd_speed feedback; a drive not tracking its command cannot support those premises. Each card carries the matching suppressed_by. Suppression must be instance-scoped to the same physical drive.
  • Both points are percent of rated speed and the rule converts nothing. The point dictionary declares % for both; a drive trended in Hz must be scaled before binding. Stated in the preconditions because the failure mode is a permanent, plausible-looking deviation rather than an obvious error.
  • delayOnInit = true on both delays (CDL default is false): a drive already deviating when the controller starts waits out the full ten minutes rather than alarming on the first tick.
  • Frontmatter clusters is empty — the reference defines no cluster containing a VFD rule, and this card does not edit the cluster set. g36 is null: a research-backed 050-range rule sourced to Ali et al. 2020 and engineering best practice rather than to a G36 clause.
  • The reference publishes no test vectors for this card; every scenario in vectors.json is library-authored.

Notes

Read yFault and yCmdOk together. An alarming drive can go quiet because the loop backed its command below the evaluation floor rather than because it recovered — both outputs fall on the same tick, and only the pair distinguishes that from a genuine recovery. Step 3.1 of the vfd-pump-faults playbook, verifying that the VFD output tracks the command within 5%, is this rule’s threshold read as an acceptance test: the same number that raises the alarm closes the work order. VFD-0002 asks a different question of the same drive — not whether it follows its command, but whether the command has run out of room. Clear this one first.

Test Vectors

11 scenarios, clock step 60 s over 2400 s.

ScenarioDescription
drive_tracking_commandHealthy drive: commanded to 60%, running at 58%. A 2-point offset is inside the 5% tolerance and the drive is well above the evaluation floor, so the rule is live and silent.
feedback_below_commandCommanded to 60%, running at 40%: the drive is 20 points short of what it was asked for. Both delays run from t=0 under delayOnInit, so the alarm lands 600 s in (deviation_duration + alarm_delay).
feedback_above_commandThe mirror case: commanded to 40%, running at 60%. Abs makes the test symmetric, so a drive overrunning its command alarms exactly as a drive falling short does.
deviation_exactly_at_thresholdBoundary: 60% commanded against 55% feedback is a deviation of exactly speed_error_threshold (5.0). CDL Reals has no GreaterEqual, so the comparison is strict and exactly 5 clears.
deviation_just_above_thresholdBoundary from the other side: 54.9% feedback against the same command is a 5.1-point deviation, and the alarm lands after the two delays.
command_below_run_floorCommanded to 10% — below the drive’s own minimum speed — with the drive stopped. The 10-point deviation clears the threshold, but a drive is not obliged to track a command below its minimum, so yCmdOk is false and the verdict is NO_EVAL rather than a fault.
command_exactly_at_run_floorEvaluability boundary: the command sits exactly on min_cmd_for_eval (20.0%). Strict > again, so exactly 20 is not evaluable.
command_just_above_run_floorEvaluability boundary from the other side: 20.1% commanded with the drive stopped. The rule becomes evaluable and the standing 20.1-point deviation alarms after the two delays.
deviation_shorter_than_both_delaysAn 8-minute deviation: long enough to mature track (5 min) but only 3 of the further 5 minutes persist needs. No alarm. With a single 300 s delay in place of the chain this scenario would fire at 300 s, which is what makes it the regression test for the two-delay structure.
drive_recovers_after_alarmRecovery: the alarm asserts at 600 s and the drive comes back to within 1 point of its command at t=1200 s. Both delays drop on that tick — TrueDelay only delays the rising edge.
command_drops_below_floor_after_alarmThe evaluability release: a deviating drive alarms at 600 s, then the loop backs the command down to 10% at t=1200 s. yCmdOk goes false and yFault follows — the host must read that pair as NO_EVAL, not as a drive that fixed itself.
vectors.json
{
  "schema": "cxf-library/vectors/v1",
  "clock": {
    "step_s": 60,
    "horizon_s": 2400
  },
  "scenarios": [
    {
      "name": "drive_tracking_command",
      "description": "Healthy drive: commanded to 60%, running at 58%. A 2-point offset is inside the 5% tolerance and the drive is well above the evaluation floor, so the rule is live and silent.",
      "inputs": {
        "vfd_speed_cmd": 60.0,
        "vfd_speed": 58.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yCmdOk",
          "from_s": 0,
          "to_s": 2400,
          "equals": true
        }
      ]
    },
    {
      "name": "feedback_below_command",
      "description": "Commanded to 60%, running at 40%: the drive is 20 points short of what it was asked for. Both delays run from t=0 under delayOnInit, so the alarm lands 600 s in (deviation_duration + alarm_delay).",
      "inputs": {
        "vfd_speed_cmd": 60.0,
        "vfd_speed": 40.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 540,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 660,
          "to_s": 2400,
          "equals": true
        },
        {
          "output": "yCmdOk",
          "from_s": 0,
          "to_s": 2400,
          "equals": true
        }
      ]
    },
    {
      "name": "feedback_above_command",
      "description": "The mirror case: commanded to 40%, running at 60%. Abs makes the test symmetric, so a drive overrunning its command alarms exactly as a drive falling short does.",
      "inputs": {
        "vfd_speed_cmd": 40.0,
        "vfd_speed": 60.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 540,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 660,
          "to_s": 2400,
          "equals": true
        },
        {
          "output": "yCmdOk",
          "from_s": 0,
          "to_s": 2400,
          "equals": true
        }
      ]
    },
    {
      "name": "deviation_exactly_at_threshold",
      "description": "Boundary: 60% commanded against 55% feedback is a deviation of exactly speed_error_threshold (5.0). CDL Reals has no GreaterEqual, so the comparison is strict and exactly 5 clears.",
      "inputs": {
        "vfd_speed_cmd": 60.0,
        "vfd_speed": 55.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yCmdOk",
          "from_s": 0,
          "to_s": 2400,
          "equals": true
        }
      ]
    },
    {
      "name": "deviation_just_above_threshold",
      "description": "Boundary from the other side: 54.9% feedback against the same command is a 5.1-point deviation, and the alarm lands after the two delays.",
      "inputs": {
        "vfd_speed_cmd": 60.0,
        "vfd_speed": 54.9
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 540,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 660,
          "to_s": 2400,
          "equals": true
        },
        {
          "output": "yCmdOk",
          "from_s": 0,
          "to_s": 2400,
          "equals": true
        }
      ]
    },
    {
      "name": "command_below_run_floor",
      "description": "Commanded to 10% \u2014 below the drive's own minimum speed \u2014 with the drive stopped. The 10-point deviation clears the threshold, but a drive is not obliged to track a command below its minimum, so yCmdOk is false and the verdict is NO_EVAL rather than a fault.",
      "inputs": {
        "vfd_speed_cmd": 10.0,
        "vfd_speed": 0.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yCmdOk",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        }
      ]
    },
    {
      "name": "command_exactly_at_run_floor",
      "description": "Evaluability boundary: the command sits exactly on min_cmd_for_eval (20.0%). Strict `>` again, so exactly 20 is not evaluable.",
      "inputs": {
        "vfd_speed_cmd": 20.0,
        "vfd_speed": 0.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yCmdOk",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        }
      ]
    },
    {
      "name": "command_just_above_run_floor",
      "description": "Evaluability boundary from the other side: 20.1% commanded with the drive stopped. The rule becomes evaluable and the standing 20.1-point deviation alarms after the two delays.",
      "inputs": {
        "vfd_speed_cmd": 20.1,
        "vfd_speed": 0.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 540,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 660,
          "to_s": 2400,
          "equals": true
        },
        {
          "output": "yCmdOk",
          "from_s": 0,
          "to_s": 2400,
          "equals": true
        }
      ]
    },
    {
      "name": "deviation_shorter_than_both_delays",
      "description": "An 8-minute deviation: long enough to mature `track` (5 min) but only 3 of the further 5 minutes `persist` needs. No alarm. With a single 300 s delay in place of the chain this scenario would fire at 300 s, which is what makes it the regression test for the two-delay structure.",
      "inputs": {
        "vfd_speed_cmd": 60.0,
        "vfd_speed": [
          {
            "t": 0,
            "value": 40.0
          },
          {
            "t": 480,
            "value": 60.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yCmdOk",
          "from_s": 0,
          "to_s": 2400,
          "equals": true
        }
      ]
    },
    {
      "name": "drive_recovers_after_alarm",
      "description": "Recovery: the alarm asserts at 600 s and the drive comes back to within 1 point of its command at t=1200 s. Both delays drop on that tick \u2014 TrueDelay only delays the rising edge.",
      "inputs": {
        "vfd_speed_cmd": 60.0,
        "vfd_speed": [
          {
            "t": 0,
            "value": 40.0
          },
          {
            "t": 1200,
            "value": 59.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 540,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 660,
          "to_s": 1140,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 1260,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yCmdOk",
          "from_s": 0,
          "to_s": 2400,
          "equals": true
        }
      ]
    },
    {
      "name": "command_drops_below_floor_after_alarm",
      "description": "The evaluability release: a deviating drive alarms at 600 s, then the loop backs the command down to 10% at t=1200 s. yCmdOk goes false and yFault follows \u2014 the host must read that pair as NO_EVAL, not as a drive that fixed itself.",
      "inputs": {
        "vfd_speed_cmd": [
          {
            "t": 0,
            "value": 60.0
          },
          {
            "t": 1200,
            "value": 10.0
          }
        ],
        "vfd_speed": 40.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 540,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 660,
          "to_s": 1140,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 1260,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yCmdOk",
          "from_s": 0,
          "to_s": 1140,
          "equals": true
        },
        {
          "output": "yCmdOk",
          "from_s": 1200,
          "to_s": 2400,
          "equals": false
        }
      ]
    }
  ]
}

VFD-0002 — At minimum speed with load unsatisfied

Statusverified — engine e2ff2f8, cxf:fnv1a128:eb1ff89459aa410286eaaad01914e3fa, 2026-08-17
Severity3
Methodrule
Phase2
CategoryCOMFORT_ENERGY
ConfidenceLOW
EstimationQUALITATIVE_ONLY
G36
Clusters
Suppresses
Suppressed byVFD-0001, VFD-0005
RelatedVFD-0001, VFD-0003, VFD-0004, VFD-0005
Playbooksvfd-pump-faults
SourceHVAC FDD Reference v1.0 §15, VFD-0002; Engineering best practice
Operating statesdrive enabled and its control loop active

Preconditions (host-enforced): The drive must be enabled and its loop in automatic. A drive stopped, in hand, or overridden sits at or below minimum speed with the process variable wherever the building left it, which is this rule’s exact signature and none of its meaning; the host owns that exclusion, and VFD-0005 suppresses this rule while remote automatic control is absent. vfd_process_value and vfd_process_sp must come from the same loop in the same units, and pv_error_threshold must have been retuned into those units — the shipped 10.0 is a placeholder, not a site value (see Deviations). vfd_speed is the drive’s own feedback, so a drive that is not tracking its command undermines the minimum-speed premise: VFD-0001 (see suppressed_by) silences this rule while that is true. A loop whose setpoint is being reset by a trim-and-respond sequence must have a settled setpoint bound here, since a setpoint moving faster than the loop can follow produces a standing error at any speed.

Points: vfd_speed, vfd_process_value, vfd_process_sp

Outputs:

  • yFault — True while the drive has stayed at or below min_speed + speed_tolerance with the process variable more than pv_error_threshold from setpoint, continuously for at least sustained_duration

Parameters:

NameDefaultUnitCXF pathDescription
min_speed20.0%minSpd.kThe drive’s configured minimum speed, in points of rated speed
speed_tolerance3.0%tol.kHow far above the minimum the feedback may sit and still count as parked at minimum
pv_error_threshold10.01pvOff.tAbsolute deviation of the process variable from setpoint, IN THE LOOP’S OWN UNITS, above which the load counts as unsatisfied. PER-LOOP SITE CONFIGURATION — the shipped 10.0 is a placeholder carried over from the reference’s “10%” and means nothing until it is set in the units the host binds
sustained_duration900.0spersist.delayTimeContinuous violation required before the alarm asserts (15 min). ADOPTED — the reference’s tunables line is truncated and publishes no value

Description

A drive sitting on its minimum speed is a loop with no downward room left. That is normal at light load — the whole point of a minimum is to keep the machine above the speed where it stops cooling itself — and it becomes a finding only when the process variable it is supposed to control is nowhere near setpoint at the same time. Then the loop is pinned against a limit while the thing it controls is wrong, and no amount of further control action will fix it. The direction of the miss names the fault: below setpoint the loop wants more and the drive will not give it (obstruction, undersized machine, torque limit); above setpoint the minimum is set too high for the load, which is the more common finding on a lightly loaded pump. The distinction is one subtraction away in the host, from points it already has.

Detection Logic

speed_floor = min_speed + speed_tolerance        (20.0 + 3.0 = 23.0 %)
at_min      = vfd_speed < speed_floor
pv_error    = |vfd_process_value − vfd_process_sp|

yFault = (at_min AND pv_error > pv_error_threshold)
         sustained continuously for sustained_duration

Block graph (rule.cxf.jsonld):

VFD-0002 block graph

minSpd and tol carry the reference’s two speed tunables as constants and speedFloor adds them, so the composite trip point is assembled in the graph rather than folded into a single threshold. That costs two blocks and buys independent retuning: a site with a 30% minimum changes minSpd.k alone and the tolerance keeps its own meaning (VAV-0001 assembles its trip point the same way). atMin is a Reals.Less against that sum.

err, absErr and pvOff form the load term. Taking the absolute value before the comparison is what makes the rule symmetric, and the symmetry is load-bearing rather than incidental — over-delivery at minimum speed is a real and distinct finding.

both conjoins the two terms and a single persist measures the duration: the reference states one sustained for sustained_duration over the whole conjunction, unlike VFD-0001’s separately listed deviation window and alarm delay. Any moment where either term releases drops the timer and discards the accumulated time, so the alarm always describes one continuous episode. Both comparisons are strict, which is where this rule departs from the reference’s <= (see Deviations): a drive at exactly 23.0% is not at minimum, and a process variable exactly 10.0 units off setpoint is not unsatisfied.

Possible Diagnoses

  1. Minimum speed set too low — the reference’s first diagnosis, which applies when the process variable is below setpoint; read the other way, a minimum set too high is what produces the over-delivery case
  2. Mechanical obstruction reducing output: a closed isolation valve, a blocked strainer, a clogged filter bank, or a damper someone shut
  3. System undersized for the actual load — a design finding rather than an operating one, and usually seasonal
  4. Sensor error on the process variable — the loop is satisfying a setpoint the measurement is misreporting, and this rule cannot tell that from a real miss
  5. VFD torque limit reached: the drive is holding speed down to protect itself, which reads as minimum speed from outside

Energy Impact

COMFORT_ENERGY, LOW confidence, QUALITATIVE_ONLY. The reference offers no model and this card does not invent one — the waste depends entirely on which diagnosis holds. An obstruction spends the full minimum draw to deliver less than it should; an oversized minimum on a lightly loaded pump is continuous over-pumping, and the case where the cube law pays back on repair (the vfd-pump-faults playbook notes that dropping pump speed by 20% drops pump power by 49%); a sensor error spends energy chasing a number that was never wrong. Comfort is the primary impact in the under-delivery direction, which is what COMFORT_ENERGY records. Neutral climate sensitivity.

Emissions Impact

Scope 2, QUALITATIVE_EMISSIONS, LOW confidence, basis N/A. VFD-driven equipment is electric, so the whole consequence is purchased electricity, and the reference’s own range is “context-dependent; equipment undersized or obstructed”. The one case worth quantifying after diagnosis is the oversized minimum, where the avoided draw is continuous and computable from the cube law once the corrected minimum is known.

Deviations

  • pv_error_threshold ships a placeholder default with no site authority. The reference writes it as “10%” while its equation is an absolute difference; the point dictionary is canonical and resolves it in the loop’s own units. So pvOff.t = 10.0 means ten units of whatever the host binds — reasonable on a duct-static loop in pascals, and a rule disabled outright on one in inches of water. Hosts MUST set it per loop. Reading it as a percentage instead would need a division by setpoint and a setpoint-evaluability gate (VAV-0004’s shape), which the reference’s equation does not ask for.
  • sustained_duration has no published default. The reference’s tunables line for this card ends mid-sentence in both the chapter extract and the full text. This card adopts 900 s, the value the reference publishes for VAV-0004’s structurally identical tracking_duration — a loop failing to reach setpoint, sustained. VFD-0001’s 5-minute deviation_duration was rejected as a drive-response window, too short for a loop answering a load step. Hosts should retune to their loop’s time constant.
  • <= becomes strict <. The reference writes vfd_speed <= min_speed + speed_tolerance; CDL Reals has no LessEqual, so a drive reporting exactly 23.0% reads as off-minimum. The disagreement is measure-zero, and the composite boundary is pinned from three sides because the trip point is a sum of two tunables that a host may move.
  • The floor is assembled in the graph, not folded into a threshold. A single LessThreshold with t = 23.0 would be one block instead of four and behave identically as shipped, but it would collapse two of the reference’s four tunables into one number and force a host retuning the minimum to recompute the sum by hand.
  • Canonical point names replace the reference’s process_variable and setpoint with vfd_process_value and vfd_process_sp per points/vfd.points.json, which marks both provisional and deliberately untyped — the semantic tags belong on the application-specific point (duct static, differential pressure, supply temperature) the host binds underneath.
  • No evaluability output. Both terms are direct comparisons on bound inputs, with no computed data-quality condition the host cannot see for itself. Contrast VFD-0001’s yCmdOk and ERV-0001’s yTempDeltaOk, which are derived. The exclusions that matter here — drive disabled, loop in hand, setpoint still ramping — are operating-state gating and live in frontmatter.
  • suppressed_by: [VFD-0001, VFD-0005] is authored, not the reference’s. This rule infers from vfd_speed that an automatic loop is pinned at its floor. Bad command/feedback tracking breaks the speed premise, while local or bypass operation breaks the automatic-loop premise. Both suppressors carry matching relationships and must be instance-scoped to this drive.
  • A stopped drive satisfies the speed term trivially, and nothing in the graph stops it. Feedback of 0% is below any positive floor, so a stopped machine under an off-setpoint loop alarms — and since a stopped machine is usually why the loop is off setpoint, the alarm is near-guaranteed. The reference’s equation has the same property and lists no run status; the exclusion is a host precondition, and the behaviour is pinned as a vector so any future in-graph run gate has to rewrite it deliberately.
  • The over-delivery direction is a deliberate keep. The reference’s absolute value admits it and this card implements it rather than narrowing to under-delivery, because “minimum set too high” is a genuine and common finding the same three points already detect. Hosts wanting only under-delivery read the sign from vfd_process_value and vfd_process_sp directly.
  • persist.delayOnInit = true (CDL default is false): a drive already pinned at minimum with an unsatisfied load when the controller starts waits out the full 15 minutes rather than alarming on the first tick.
  • The reference’s playbook Applies-To line does not name this card — it lists only VFD-0001 and the two future PMP rules. The family README assigns the playbook to both VFD rules and the frontmatter follows it; playbooks/vfd-pump-faults.md carries VFD-0002 as an explicitly marked library addition.
  • Frontmatter clusters is empty and g36 is null: no cluster in the reference contains a VFD rule, and this is a research-backed 050-range card sourced to engineering best practice. The reference publishes no test vectors, so every scenario in vectors.json is library-authored.

Notes

Diagnosis order in practice starts with the cheapest disambiguation, which is the sign of the error rather than anything in the field. Below setpoint: check for obstruction before touching the minimum, because raising the minimum on an obstructed loop hides the fault and pays for it forever. Above setpoint: the minimum is the first thing to look at, and lowering it to what the drive and the driven equipment can actually tolerate is a BAS change with no capital cost. In both directions, confirm the process-variable sensor against a second reading before acting — diagnosis 4 costs nothing to rule out and invalidates everything downstream of it.

Test Vectors

13 scenarios, clock step 60 s over 3600 s.

ScenarioDescription
at_minimum_with_load_satisfiedThe drive is parked at its 20% minimum and the process variable is within 5 units of setpoint. This is normal light-load operation — the loop has nothing left to ask for and does not need it.
at_minimum_with_load_unsatisfiedThe motivating case: the drive sits at 20% while the process variable stays 15 units below setpoint. The loop cannot slow down any further to help itself and cannot speed up to close the gap. Both terms hold from t=0, so delayOnInit puts the alarm at 900 s.
modulating_above_minimumThe same 15-unit setpoint miss with the drive at 60%. The loop still has headroom, so the miss is a tuning or capacity question rather than this fault — the minimum-speed term is what makes it actionable.
speed_exactly_at_floorBoundary on the composite floor: feedback at exactly min_speed + speed_tolerance (20.0 + 3.0 = 23.0) with the load unsatisfied. The reference writes <=; CDL Reals has no LessEqual, so the graph uses strict < and exactly 23.0 clears.
speed_just_below_floorOne epsilon inside the floor: 22.9% with the same unsatisfied load. The speed term passes and the alarm lands after sustained_duration.
speed_just_above_floorOne epsilon outside the floor: 23.1% with the same unsatisfied load, pinning the third side of the composite boundary. A drive that has started to modulate up is no longer stuck at minimum.
pv_error_exactly_at_thresholdBoundary on the load term: the process variable sits exactly pv_error_threshold (10.0 units) below setpoint at minimum speed. Strict >, so exactly 10 is not a fault.
pv_error_just_above_thresholdBoundary from the other side: 10.1 units below setpoint at minimum speed, and the alarm lands after sustained_duration.
pv_above_setpoint_at_minimumThe over-delivery direction: at minimum speed the process variable runs 15 units above setpoint. The reference’s absolute value makes this a fault too, and it is diagnosis 1 read backwards — the minimum is set too high for the load, so the loop cannot stop overshooting.
transient_load_excursionA 10-minute excursion 15 units below setpoint at minimum speed, ending 5 minutes short of sustained_duration — the load step a loop at minimum takes a while to answer. The timer resets and no alarm is raised.
drive_modulates_up_after_alarmRecovery through the speed term: the alarm asserts at 900 s, then the drive leaves minimum for 45% at t=1800 s (the loop was released, or the obstruction cleared). yFault drops on that tick even though the process variable is still short of setpoint.
load_satisfied_after_alarmRecovery through the load term: the alarm asserts at 900 s, then the process variable climbs to within 1 unit of setpoint at t=1800 s while the drive stays at minimum. The condition is a conjunction, so either term releasing clears it.
stopped_drive_reads_as_at_minimumThe known hole, pinned so it cannot change silently: a stopped drive (feedback 0%) satisfies speed < min_speed + speed_tolerance trivially, so a stopped pump under an unsatisfied loop alarms. The reference’s equation has the same property. Suppressing it is the host’s job — see the card’s preconditions — and this vector is what a future in-graph run gate would have to change.
vectors.json
{
  "schema": "cxf-library/vectors/v1",
  "clock": {
    "step_s": 60,
    "horizon_s": 3600
  },
  "scenarios": [
    {
      "name": "at_minimum_with_load_satisfied",
      "description": "The drive is parked at its 20% minimum and the process variable is within 5 units of setpoint. This is normal light-load operation \u2014 the loop has nothing left to ask for and does not need it.",
      "inputs": {
        "vfd_speed": 20.0,
        "vfd_process_value": 100.0,
        "vfd_process_sp": 105.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "at_minimum_with_load_unsatisfied",
      "description": "The motivating case: the drive sits at 20% while the process variable stays 15 units below setpoint. The loop cannot slow down any further to help itself and cannot speed up to close the gap. Both terms hold from t=0, so delayOnInit puts the alarm at 900 s.",
      "inputs": {
        "vfd_speed": 20.0,
        "vfd_process_value": 90.0,
        "vfd_process_sp": 105.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 840,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 960,
          "to_s": 3600,
          "equals": true
        }
      ]
    },
    {
      "name": "modulating_above_minimum",
      "description": "The same 15-unit setpoint miss with the drive at 60%. The loop still has headroom, so the miss is a tuning or capacity question rather than this fault \u2014 the minimum-speed term is what makes it actionable.",
      "inputs": {
        "vfd_speed": 60.0,
        "vfd_process_value": 90.0,
        "vfd_process_sp": 105.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "speed_exactly_at_floor",
      "description": "Boundary on the composite floor: feedback at exactly min_speed + speed_tolerance (20.0 + 3.0 = 23.0) with the load unsatisfied. The reference writes `<=`; CDL Reals has no LessEqual, so the graph uses strict `<` and exactly 23.0 clears.",
      "inputs": {
        "vfd_speed": 23.0,
        "vfd_process_value": 80.0,
        "vfd_process_sp": 105.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "speed_just_below_floor",
      "description": "One epsilon inside the floor: 22.9% with the same unsatisfied load. The speed term passes and the alarm lands after sustained_duration.",
      "inputs": {
        "vfd_speed": 22.9,
        "vfd_process_value": 80.0,
        "vfd_process_sp": 105.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 840,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 960,
          "to_s": 3600,
          "equals": true
        }
      ]
    },
    {
      "name": "speed_just_above_floor",
      "description": "One epsilon outside the floor: 23.1% with the same unsatisfied load, pinning the third side of the composite boundary. A drive that has started to modulate up is no longer stuck at minimum.",
      "inputs": {
        "vfd_speed": 23.1,
        "vfd_process_value": 80.0,
        "vfd_process_sp": 105.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "pv_error_exactly_at_threshold",
      "description": "Boundary on the load term: the process variable sits exactly pv_error_threshold (10.0 units) below setpoint at minimum speed. Strict `>`, so exactly 10 is not a fault.",
      "inputs": {
        "vfd_speed": 20.0,
        "vfd_process_value": 95.0,
        "vfd_process_sp": 105.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "pv_error_just_above_threshold",
      "description": "Boundary from the other side: 10.1 units below setpoint at minimum speed, and the alarm lands after sustained_duration.",
      "inputs": {
        "vfd_speed": 20.0,
        "vfd_process_value": 94.9,
        "vfd_process_sp": 105.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 840,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 960,
          "to_s": 3600,
          "equals": true
        }
      ]
    },
    {
      "name": "pv_above_setpoint_at_minimum",
      "description": "The over-delivery direction: at minimum speed the process variable runs 15 units above setpoint. The reference's absolute value makes this a fault too, and it is diagnosis 1 read backwards \u2014 the minimum is set too high for the load, so the loop cannot stop overshooting.",
      "inputs": {
        "vfd_speed": 20.0,
        "vfd_process_value": 120.0,
        "vfd_process_sp": 105.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 840,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 960,
          "to_s": 3600,
          "equals": true
        }
      ]
    },
    {
      "name": "transient_load_excursion",
      "description": "A 10-minute excursion 15 units below setpoint at minimum speed, ending 5 minutes short of sustained_duration \u2014 the load step a loop at minimum takes a while to answer. The timer resets and no alarm is raised.",
      "inputs": {
        "vfd_speed": 20.0,
        "vfd_process_value": [
          {
            "t": 0,
            "value": 90.0
          },
          {
            "t": 600,
            "value": 100.0
          }
        ],
        "vfd_process_sp": 105.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "drive_modulates_up_after_alarm",
      "description": "Recovery through the speed term: the alarm asserts at 900 s, then the drive leaves minimum for 45% at t=1800 s (the loop was released, or the obstruction cleared). yFault drops on that tick even though the process variable is still short of setpoint.",
      "inputs": {
        "vfd_speed": [
          {
            "t": 0,
            "value": 20.0
          },
          {
            "t": 1800,
            "value": 45.0
          }
        ],
        "vfd_process_value": 90.0,
        "vfd_process_sp": 105.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 840,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 960,
          "to_s": 1740,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 1860,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "load_satisfied_after_alarm",
      "description": "Recovery through the load term: the alarm asserts at 900 s, then the process variable climbs to within 1 unit of setpoint at t=1800 s while the drive stays at minimum. The condition is a conjunction, so either term releasing clears it.",
      "inputs": {
        "vfd_speed": 20.0,
        "vfd_process_value": [
          {
            "t": 0,
            "value": 90.0
          },
          {
            "t": 1800,
            "value": 104.0
          }
        ],
        "vfd_process_sp": 105.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 840,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 960,
          "to_s": 1740,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 1860,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "stopped_drive_reads_as_at_minimum",
      "description": "The known hole, pinned so it cannot change silently: a stopped drive (feedback 0%) satisfies `speed < min_speed + speed_tolerance` trivially, so a stopped pump under an unsatisfied loop alarms. The reference's equation has the same property. Suppressing it is the host's job \u2014 see the card's preconditions \u2014 and this vector is what a future in-graph run gate would have to change.",
      "inputs": {
        "vfd_speed": 0.0,
        "vfd_process_value": 90.0,
        "vfd_process_sp": 105.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 840,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 960,
          "to_s": 3600,
          "equals": true
        }
      ]
    }
  ]
}

VFD-0003 — At maximum speed with load unsatisfied

Statusverified — engine e2ff2f8, cxf:fnv1a128:172305490314de625490108d7740b3b6, 2026-08-20
Severity3
Methodrule
Phase2
CategoryCOMFORT_ENERGY
ConfidenceMEDIUM
EstimationQUALITATIVE_ONLY
G36
Clusters
Suppresses
Suppressed byVFD-0001, VFD-0005
RelatedVFD-0002, VFD-0004
Playbooksvfd-pump-faults
SourcePNNL Variable Speed Drives O&M Best Practice — output frequency/speed and the controlled process variable are core drive operating evidence; manufacturer and application limits remain site-specific; NIST, Automatically Detecting Faulty Regulation in HVAC Controls (2013) — generic failure-to-regulate and actuator-limit fault classes; Library-authored high-limit complement to VFD-0002, reusing its assembled speed boundary, symmetric process-error test, and 900 s persistence convention
Operating statesdrive enabled, under remote automatic process control, and not intentionally capacity-limited

Preconditions (host-enforced): The final drive enable and active-loop states are host gates; a stopped drive is NO_EVAL, not healthy. The drive must be accepting remote automatic control, with approved hand/bypass, commissioning, smoke/purge, lead/lag transition, and functional-test intervals excluded. VFD-0005 suppresses this rule while automatic control is absent. vfd_speed must be trustworthy normalized feedback; VFD-0001 suppresses this rule during command/feedback tracking failure. The host must exclude intentional torque, current, safety, demand, or equipment-capacity limits. vfd_process_value and vfd_process_sp must be the same loop in the same units, the setpoint must be settled for that loop’s time constant, and pv_error_threshold must be configured in those units. When any gate is unmet the verdict is NO_EVAL, not healthy.

Points: vfd_speed, vfd_process_value, vfd_process_sp

Outputs:

  • yFault — True while speed is strictly above max_speed minus speed_tolerance and either directional process error has persisted for sustained_duration
  • yProcessHigh — Immediate raw diagnostic; true when process value is more than pv_error_threshold above setpoint, whether or not speed is at maximum
  • yProcessLow — Immediate raw diagnostic; true when process value is more than pv_error_threshold below setpoint, whether or not speed is at maximum

Parameters:

NameDefaultUnitCXF pathDescription
max_speed100.0%maxSpd.kADOPTED_TUNABLE: normalized maximum-speed reference. Configure to the drive/application limit when it is not 100%.
speed_tolerance3.0%tol.kLIBRARY_PRECEDENT: margin below maximum that still counts as pinned at the high limit; inherited from VFD-0002’s assembled limit shape.
pv_error_threshold10.01processHigh.t, processLow.tNO_PORTABLE_DEFAULT: absolute directional error in the bound loop’s own units. The executable 10.0 is a placeholder; deployment is blocked until it is set for that loop.
sustained_duration900.0spersist.delayTimeLIBRARY_PRECEDENT: continuous high-limit and process-error conjunction required before alarm (15 min), reused from VFD-0002.

Description

A process loop has spent its last increment of actuator authority: the drive is near its configured maximum, yet the quantity it regulates remains materially away from setpoint. That signature does not identify one component. It can mean an obstructed air or water path, undersized equipment, a drive held below its reported limit by current or torque protection, a bad process sensor, an incorrect control direction, or a maximum that was commissioned too low. What it does identify is the useful decision boundary: more ordinary loop output is no longer available, so waiting for the controller to recover is not a fix.

Detection Logic

speed_ceiling = max_speed - speed_tolerance
at_max        = vfd_speed > speed_ceiling
process_high  = vfd_process_value - vfd_process_sp > pv_error_threshold
process_low   = vfd_process_sp - vfd_process_value > pv_error_threshold

yProcessHigh = process_high
yProcessLow  = process_low
yFault       = TrueDelay(at_max AND (process_high OR process_low),
                         sustained_duration)

Block graph (rule.cxf.jsonld):

VFD-0003 block graph

The high and low branches are separate rather than an absolute-value block so the graph can expose the error direction without pretending to know the loop’s control sign. They are mutually exclusive at a positive threshold and remain raw: either may be true while the drive is at mid-speed. Only their OR is gated by atMax and delayed. Any release of the speed or process branch clears the fault and resets the complete 900-second timer.

Possible Diagnoses

  1. Air- or water-side obstruction: dirty filter/coil, closed damper or valve, blocked strainer, or restricted duct/pipe.
  2. Equipment or distribution system undersized for the present load.
  3. Drive current, torque, safety, demand, or application limit below the normalized maximum assumed by the BAS.
  4. Process sensor bias, setpoint/unit mismatch, or reversed control action.
  5. Mechanical degradation in the driven equipment, belt, coupling, or impeller.
  6. Maximum-speed parameter or reset sequence commissioned incorrectly.

Energy Impact

COMFORT_ENERGY, MEDIUM confidence, QUALITATIVE_ONLY. A saturated drive can spend long periods near its largest electrical draw without meeting demand, but the three inputs cannot distinguish useful peak-load operation from wasted work behind an obstruction. Use the playbook to establish the cause before applying fan/pump laws or claiming savings.

Emissions Impact

Scope 2, qualitative. VFD-driven equipment is electric; any avoidable high-speed runtime becomes purchased-electricity emissions. No avoided-emissions number is defensible without measured power and a diagnosed correction.

Deviations

  • This is a library-authored high-limit complement, not a transcribed PNNL or NIST equation. Those sources support monitoring drive output and regulated process behavior; the exact 100%, 3-point, 10-unit, and 900-second choices are classified above rather than presented as published universal limits.
  • pv_error_threshold has no portable default. The shipped 10.0 exists so the CXF graph is executable. A deployment that has not replaced it in the same units as the bound PV/setpoint is invalid.
  • Both comparisons are strict. Exactly 97% is clear at the defaults and exactly +/-10 loop units leaves both direction flags false. The vectors pin every boundary from both sides.
  • Direction does not imply underdelivery. yProcessHigh can be the bad side of one loop and the desired response of another. It is a diagnostic sign, not a cause label.
  • Automatic mode is suppressed explicitly. VFD-0005 suppresses this rule because a local/hand or bypassed drive invalidates the automatic-loop saturation inference; VFD-0001 does the same when feedback cannot establish where the drive actually is. Suppression must be instance-scoped to the same physical drive.
  • The graph has no enable input. A stopped drive produces a raw process direction but cannot satisfy at_max; the host still reports NO_EVAL because the operating premise is absent.
  • persist.delayOnInit = true, so a violation present at engine start waits the full 15 minutes.
  • No simulation validation is claimed. The current harness has no actual drive feedback paired with that same loop’s PV and setpoint; joining the pump-flow proxy to boiler temperature would be cross-loop fabrication.

Notes

Read the sign before visiting the field, then read the loop definition. A high PV at maximum speed may be correct action for a reverse-acting loop, evidence of overdelivery, or proof that the drive limit is unrelated to the bound process. The generic rule deliberately stops at that boundary. Resolve VFD-0005 and VFD-0001 first, verify loop units and direction, then check configured drive limits and the mechanical path.

Test Vectors

20 scenarios, clock step 60 s over 3600 s.

ScenarioDescription
healthy_mid_speed_satisfiedAt 60% speed with PV one loop unit above setpoint, neither saturation nor process-error direction is active.
maximum_speed_satisfiedThe drive can sit at 100% without fault when the controlled process is within its configured error band.
maximum_speed_process_highAt 100% speed with PV 15 loop units above setpoint, the high diagnostic is immediate and the combined fault waits 15 minutes.
maximum_speed_process_lowThe symmetric direction: PV 15 loop units below setpoint activates only yProcessLow and reaches the same persisted fault.
speed_exactly_at_ceilingmax_speed - speed_tolerance is 97%; the graph uses strict greater-than, so exactly 97% is not at maximum even though the raw high-error diagnostic is true.
speed_just_beyond_ceilingAt 97.1% the speed term passes; a 10.1-unit PV miss brackets both assembled strict boundaries from the faulting side.
pv_error_exactly_at_threshold_highA positive error of exactly 10.0 loop units is not high under the strict comparator, even at 100% speed.
pv_error_exactly_at_threshold_lowThe negative-error boundary is symmetric: PV exactly 10.0 units below setpoint does not activate yProcessLow.
violation_shorter_than_durationA 14-minute high-process episode ends one sample before the 15-minute persistence can mature and never alarms.
speed_release_resets_timerSix hundred seconds at maximum, one sample below the ceiling, then a fresh violation proves the conjunction timer resets rather than accumulating disjoint episodes.
process_recovery_resets_timerThe same reset through the process branch: a one-sample return inside the allowance band discards elapsed persistence before the miss returns.
initialization_violation_waitsA violation present at engine initialization waits the full delay because persist.delayOnInit is true.
stopped_drive_raw_behavior_host_gatedWith the drive stopped, the raw process-low diagnostic remains true but the high-speed term is false. The required enabled/auto/active-loop exclusion is host-side.
mid_speed_raw_direction_switchAt 50% speed the fault remains false while the raw process diagnostic switches from high to low at t=1800 s, proving the secondary outputs are not saturation-gated.
both_just_beyond_low_boundariesThe low-error mirror brackets both strict thresholds from the active side: speed 97.1% and PV 10.1 units below setpoint.
violation_ends_at_delay_boundaryThe candidate releases at the staged 900-second boundary before that tick is evaluated. No assertion is made on the boundary tick, and no fault appears.
two_short_opposite_direction_episodesA short high-error episode and a separate low-error episode are divided by recovery. Direction changes do not let the shared persistence timer accumulate disjoint intervals.
recovery_after_alarmA matured high-direction fault clears immediately when PV returns to setpoint at t=1800 s.
speed_threshold_chatter_resets_delaySpeed alternates on opposite sides of the 97% ceiling every minute while PV remains high. The candidate repeatedly resets and never survives 900 seconds.
process_threshold_chatter_resets_delayPV alternates just inside and outside the high-error threshold every minute at maximum speed; the strict candidate never persists long enough to fault.
vectors.json
{
  "schema": "cxf-library/vectors/v1",
  "clock": {
    "step_s": 60,
    "horizon_s": 3600
  },
  "scenarios": [
    {
      "name": "healthy_mid_speed_satisfied",
      "description": "At 60% speed with PV one loop unit above setpoint, neither saturation nor process-error direction is active.",
      "inputs": {
        "vfd_speed": 60.0,
        "vfd_process_value": 101.0,
        "vfd_process_sp": 100.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        },
        {
          "output": "yProcessHigh",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        },
        {
          "output": "yProcessLow",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "maximum_speed_satisfied",
      "description": "The drive can sit at 100% without fault when the controlled process is within its configured error band.",
      "inputs": {
        "vfd_speed": 100.0,
        "vfd_process_value": 105.0,
        "vfd_process_sp": 100.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        },
        {
          "output": "yProcessHigh",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        },
        {
          "output": "yProcessLow",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "maximum_speed_process_high",
      "description": "At 100% speed with PV 15 loop units above setpoint, the high diagnostic is immediate and the combined fault waits 15 minutes.",
      "inputs": {
        "vfd_speed": 100.0,
        "vfd_process_value": 115.0,
        "vfd_process_sp": 100.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 840,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 960,
          "to_s": 3600,
          "equals": true
        },
        {
          "output": "yProcessHigh",
          "from_s": 0,
          "to_s": 3600,
          "equals": true
        },
        {
          "output": "yProcessLow",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "maximum_speed_process_low",
      "description": "The symmetric direction: PV 15 loop units below setpoint activates only yProcessLow and reaches the same persisted fault.",
      "inputs": {
        "vfd_speed": 100.0,
        "vfd_process_value": 85.0,
        "vfd_process_sp": 100.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 840,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 960,
          "to_s": 3600,
          "equals": true
        },
        {
          "output": "yProcessHigh",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        },
        {
          "output": "yProcessLow",
          "from_s": 0,
          "to_s": 3600,
          "equals": true
        }
      ]
    },
    {
      "name": "speed_exactly_at_ceiling",
      "description": "max_speed - speed_tolerance is 97%; the graph uses strict greater-than, so exactly 97% is not at maximum even though the raw high-error diagnostic is true.",
      "inputs": {
        "vfd_speed": 97.0,
        "vfd_process_value": 115.0,
        "vfd_process_sp": 100.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        },
        {
          "output": "yProcessHigh",
          "from_s": 0,
          "to_s": 3600,
          "equals": true
        },
        {
          "output": "yProcessLow",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "speed_just_beyond_ceiling",
      "description": "At 97.1% the speed term passes; a 10.1-unit PV miss brackets both assembled strict boundaries from the faulting side.",
      "inputs": {
        "vfd_speed": 97.1,
        "vfd_process_value": 110.1,
        "vfd_process_sp": 100.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 840,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 960,
          "to_s": 3600,
          "equals": true
        },
        {
          "output": "yProcessHigh",
          "from_s": 0,
          "to_s": 3600,
          "equals": true
        },
        {
          "output": "yProcessLow",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "pv_error_exactly_at_threshold_high",
      "description": "A positive error of exactly 10.0 loop units is not high under the strict comparator, even at 100% speed.",
      "inputs": {
        "vfd_speed": 100.0,
        "vfd_process_value": 110.0,
        "vfd_process_sp": 100.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        },
        {
          "output": "yProcessHigh",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        },
        {
          "output": "yProcessLow",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "pv_error_exactly_at_threshold_low",
      "description": "The negative-error boundary is symmetric: PV exactly 10.0 units below setpoint does not activate yProcessLow.",
      "inputs": {
        "vfd_speed": 100.0,
        "vfd_process_value": 90.0,
        "vfd_process_sp": 100.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        },
        {
          "output": "yProcessHigh",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        },
        {
          "output": "yProcessLow",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "violation_shorter_than_duration",
      "description": "A 14-minute high-process episode ends one sample before the 15-minute persistence can mature and never alarms.",
      "inputs": {
        "vfd_speed": 100.0,
        "vfd_process_value": [
          {
            "t": 0,
            "value": 115.0
          },
          {
            "t": 840,
            "value": 100.0
          }
        ],
        "vfd_process_sp": 100.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 840,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 960,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "speed_release_resets_timer",
      "description": "Six hundred seconds at maximum, one sample below the ceiling, then a fresh violation proves the conjunction timer resets rather than accumulating disjoint episodes.",
      "inputs": {
        "vfd_speed": [
          {
            "t": 0,
            "value": 100.0
          },
          {
            "t": 600,
            "value": 90.0
          },
          {
            "t": 660,
            "value": 100.0
          }
        ],
        "vfd_process_value": 115.0,
        "vfd_process_sp": 100.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1500,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 1620,
          "to_s": 3600,
          "equals": true
        }
      ]
    },
    {
      "name": "process_recovery_resets_timer",
      "description": "The same reset through the process branch: a one-sample return inside the allowance band discards elapsed persistence before the miss returns.",
      "inputs": {
        "vfd_speed": 100.0,
        "vfd_process_value": [
          {
            "t": 0,
            "value": 85.0
          },
          {
            "t": 600,
            "value": 100.0
          },
          {
            "t": 660,
            "value": 85.0
          }
        ],
        "vfd_process_sp": 100.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1500,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 1620,
          "to_s": 3600,
          "equals": true
        }
      ]
    },
    {
      "name": "initialization_violation_waits",
      "description": "A violation present at engine initialization waits the full delay because persist.delayOnInit is true.",
      "inputs": {
        "vfd_speed": 98.0,
        "vfd_process_value": 80.0,
        "vfd_process_sp": 100.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 840,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 960,
          "to_s": 3600,
          "equals": true
        }
      ]
    },
    {
      "name": "stopped_drive_raw_behavior_host_gated",
      "description": "With the drive stopped, the raw process-low diagnostic remains true but the high-speed term is false. The required enabled/auto/active-loop exclusion is host-side.",
      "inputs": {
        "vfd_speed": 0.0,
        "vfd_process_value": 80.0,
        "vfd_process_sp": 100.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        },
        {
          "output": "yProcessHigh",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        },
        {
          "output": "yProcessLow",
          "from_s": 0,
          "to_s": 3600,
          "equals": true
        }
      ]
    },
    {
      "name": "mid_speed_raw_direction_switch",
      "description": "At 50% speed the fault remains false while the raw process diagnostic switches from high to low at t=1800 s, proving the secondary outputs are not saturation-gated.",
      "inputs": {
        "vfd_speed": 50.0,
        "vfd_process_value": [
          {
            "t": 0,
            "value": 115.0
          },
          {
            "t": 1800,
            "value": 85.0
          }
        ],
        "vfd_process_sp": 100.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        },
        {
          "output": "yProcessHigh",
          "from_s": 0,
          "to_s": 1740,
          "equals": true
        },
        {
          "output": "yProcessHigh",
          "from_s": 1860,
          "to_s": 3600,
          "equals": false
        },
        {
          "output": "yProcessLow",
          "from_s": 0,
          "to_s": 1740,
          "equals": false
        },
        {
          "output": "yProcessLow",
          "from_s": 1860,
          "to_s": 3600,
          "equals": true
        }
      ]
    },
    {
      "name": "both_just_beyond_low_boundaries",
      "description": "The low-error mirror brackets both strict thresholds from the active side: speed 97.1% and PV 10.1 units below setpoint.",
      "inputs": {
        "vfd_speed": 97.1,
        "vfd_process_value": 89.9,
        "vfd_process_sp": 100.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 840,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 960,
          "to_s": 3600,
          "equals": true
        },
        {
          "output": "yProcessHigh",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        },
        {
          "output": "yProcessLow",
          "from_s": 0,
          "to_s": 3600,
          "equals": true
        }
      ]
    },
    {
      "name": "violation_ends_at_delay_boundary",
      "description": "The candidate releases at the staged 900-second boundary before that tick is evaluated. No assertion is made on the boundary tick, and no fault appears.",
      "inputs": {
        "vfd_speed": 100.0,
        "vfd_process_value": [
          {
            "t": 0,
            "value": 115.0
          },
          {
            "t": 900,
            "value": 100.0
          }
        ],
        "vfd_process_sp": 100.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 840,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 960,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "two_short_opposite_direction_episodes",
      "description": "A short high-error episode and a separate low-error episode are divided by recovery. Direction changes do not let the shared persistence timer accumulate disjoint intervals.",
      "inputs": {
        "vfd_speed": 100.0,
        "vfd_process_value": [
          {
            "t": 0,
            "value": 115.0
          },
          {
            "t": 600,
            "value": 100.0
          },
          {
            "t": 720,
            "value": 85.0
          },
          {
            "t": 1320,
            "value": 100.0
          }
        ],
        "vfd_process_sp": 100.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "recovery_after_alarm",
      "description": "A matured high-direction fault clears immediately when PV returns to setpoint at t=1800 s.",
      "inputs": {
        "vfd_speed": 100.0,
        "vfd_process_value": [
          {
            "t": 0,
            "value": 115.0
          },
          {
            "t": 1800,
            "value": 100.0
          }
        ],
        "vfd_process_sp": 100.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 840,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 960,
          "to_s": 1740,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 1860,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "speed_threshold_chatter_resets_delay",
      "description": "Speed alternates on opposite sides of the 97% ceiling every minute while PV remains high. The candidate repeatedly resets and never survives 900 seconds.",
      "inputs": {
        "vfd_speed": [
          {
            "t": 0,
            "value": 97.1
          },
          {
            "t": 60,
            "value": 96.9
          },
          {
            "t": 120,
            "value": 97.1
          },
          {
            "t": 180,
            "value": 96.9
          },
          {
            "t": 240,
            "value": 97.1
          },
          {
            "t": 300,
            "value": 96.9
          },
          {
            "t": 360,
            "value": 97.1
          },
          {
            "t": 420,
            "value": 96.9
          },
          {
            "t": 480,
            "value": 97.1
          },
          {
            "t": 540,
            "value": 96.9
          },
          {
            "t": 600,
            "value": 97.1
          },
          {
            "t": 660,
            "value": 96.9
          },
          {
            "t": 720,
            "value": 97.1
          },
          {
            "t": 780,
            "value": 96.9
          },
          {
            "t": 840,
            "value": 97.1
          },
          {
            "t": 900,
            "value": 96.9
          },
          {
            "t": 960,
            "value": 97.1
          },
          {
            "t": 1020,
            "value": 96.9
          },
          {
            "t": 1080,
            "value": 97.1
          },
          {
            "t": 1140,
            "value": 96.9
          },
          {
            "t": 1200,
            "value": 97.1
          },
          {
            "t": 1260,
            "value": 96.9
          },
          {
            "t": 1320,
            "value": 97.1
          },
          {
            "t": 1380,
            "value": 96.9
          },
          {
            "t": 1440,
            "value": 97.1
          },
          {
            "t": 1500,
            "value": 96.9
          },
          {
            "t": 1560,
            "value": 97.1
          },
          {
            "t": 1620,
            "value": 96.9
          },
          {
            "t": 1680,
            "value": 97.1
          },
          {
            "t": 1740,
            "value": 96.9
          },
          {
            "t": 1800,
            "value": 97.1
          },
          {
            "t": 1860,
            "value": 96.9
          },
          {
            "t": 1920,
            "value": 97.1
          },
          {
            "t": 1980,
            "value": 96.9
          },
          {
            "t": 2040,
            "value": 97.1
          },
          {
            "t": 2100,
            "value": 96.9
          },
          {
            "t": 2160,
            "value": 97.1
          },
          {
            "t": 2220,
            "value": 96.9
          },
          {
            "t": 2280,
            "value": 97.1
          },
          {
            "t": 2340,
            "value": 96.9
          },
          {
            "t": 2400,
            "value": 97.1
          },
          {
            "t": 2460,
            "value": 96.9
          },
          {
            "t": 2520,
            "value": 97.1
          },
          {
            "t": 2580,
            "value": 96.9
          },
          {
            "t": 2640,
            "value": 97.1
          },
          {
            "t": 2700,
            "value": 96.9
          },
          {
            "t": 2760,
            "value": 97.1
          },
          {
            "t": 2820,
            "value": 96.9
          },
          {
            "t": 2880,
            "value": 97.1
          },
          {
            "t": 2940,
            "value": 96.9
          },
          {
            "t": 3000,
            "value": 97.1
          },
          {
            "t": 3060,
            "value": 96.9
          },
          {
            "t": 3120,
            "value": 97.1
          },
          {
            "t": 3180,
            "value": 96.9
          },
          {
            "t": 3240,
            "value": 97.1
          },
          {
            "t": 3300,
            "value": 96.9
          },
          {
            "t": 3360,
            "value": 97.1
          },
          {
            "t": 3420,
            "value": 96.9
          },
          {
            "t": 3480,
            "value": 97.1
          },
          {
            "t": 3540,
            "value": 96.9
          }
        ],
        "vfd_process_value": 115.0,
        "vfd_process_sp": 100.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "process_threshold_chatter_resets_delay",
      "description": "PV alternates just inside and outside the high-error threshold every minute at maximum speed; the strict candidate never persists long enough to fault.",
      "inputs": {
        "vfd_speed": 100.0,
        "vfd_process_value": [
          {
            "t": 0,
            "value": 110.1
          },
          {
            "t": 60,
            "value": 109.9
          },
          {
            "t": 120,
            "value": 110.1
          },
          {
            "t": 180,
            "value": 109.9
          },
          {
            "t": 240,
            "value": 110.1
          },
          {
            "t": 300,
            "value": 109.9
          },
          {
            "t": 360,
            "value": 110.1
          },
          {
            "t": 420,
            "value": 109.9
          },
          {
            "t": 480,
            "value": 110.1
          },
          {
            "t": 540,
            "value": 109.9
          },
          {
            "t": 600,
            "value": 110.1
          },
          {
            "t": 660,
            "value": 109.9
          },
          {
            "t": 720,
            "value": 110.1
          },
          {
            "t": 780,
            "value": 109.9
          },
          {
            "t": 840,
            "value": 110.1
          },
          {
            "t": 900,
            "value": 109.9
          },
          {
            "t": 960,
            "value": 110.1
          },
          {
            "t": 1020,
            "value": 109.9
          },
          {
            "t": 1080,
            "value": 110.1
          },
          {
            "t": 1140,
            "value": 109.9
          },
          {
            "t": 1200,
            "value": 110.1
          },
          {
            "t": 1260,
            "value": 109.9
          },
          {
            "t": 1320,
            "value": 110.1
          },
          {
            "t": 1380,
            "value": 109.9
          },
          {
            "t": 1440,
            "value": 110.1
          },
          {
            "t": 1500,
            "value": 109.9
          },
          {
            "t": 1560,
            "value": 110.1
          },
          {
            "t": 1620,
            "value": 109.9
          },
          {
            "t": 1680,
            "value": 110.1
          },
          {
            "t": 1740,
            "value": 109.9
          },
          {
            "t": 1800,
            "value": 110.1
          },
          {
            "t": 1860,
            "value": 109.9
          },
          {
            "t": 1920,
            "value": 110.1
          },
          {
            "t": 1980,
            "value": 109.9
          },
          {
            "t": 2040,
            "value": 110.1
          },
          {
            "t": 2100,
            "value": 109.9
          },
          {
            "t": 2160,
            "value": 110.1
          },
          {
            "t": 2220,
            "value": 109.9
          },
          {
            "t": 2280,
            "value": 110.1
          },
          {
            "t": 2340,
            "value": 109.9
          },
          {
            "t": 2400,
            "value": 110.1
          },
          {
            "t": 2460,
            "value": 109.9
          },
          {
            "t": 2520,
            "value": 110.1
          },
          {
            "t": 2580,
            "value": 109.9
          },
          {
            "t": 2640,
            "value": 110.1
          },
          {
            "t": 2700,
            "value": 109.9
          },
          {
            "t": 2760,
            "value": 110.1
          },
          {
            "t": 2820,
            "value": 109.9
          },
          {
            "t": 2880,
            "value": 110.1
          },
          {
            "t": 2940,
            "value": 109.9
          },
          {
            "t": 3000,
            "value": 110.1
          },
          {
            "t": 3060,
            "value": 109.9
          },
          {
            "t": 3120,
            "value": 110.1
          },
          {
            "t": 3180,
            "value": 109.9
          },
          {
            "t": 3240,
            "value": 110.1
          },
          {
            "t": 3300,
            "value": 109.9
          },
          {
            "t": 3360,
            "value": 110.1
          },
          {
            "t": 3420,
            "value": 109.9
          },
          {
            "t": 3480,
            "value": 110.1
          },
          {
            "t": 3540,
            "value": 109.9
          }
        ],
        "vfd_process_sp": 100.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    }
  ]
}

VFD-0004 — VFD process-loop hunting

Statusverified — engine e2ff2f8, cxf:fnv1a128:2589d83e4938cac060cbe7919a919495, 2026-08-20
Severity3
Methodrule
Phase2
CategoryEFFICIENCY_LOSS
ConfidenceMEDIUM
EstimationQUALITATIVE_ONLY
G36
Clusters
Suppresses
Suppressed byVFD-0001, VFD-0005
RelatedVFD-0002, VFD-0003
Playbooksvfd-pump-faults
SourceNIST, Automatically Detecting Faulty Regulation in HVAC Controls (2013), pp. 412 and 416-419 — setpoint allowance bands, oscillatory regulation, regulating-variable hunting, reset exclusions, and field-tuned alarm parameters; Library precedent VAV-0005 — verified rolling-mean crossing count plus rolling MAD hunting topology and its sampling/count-scale contract; Library precedent AHU-0022 — moving-average/MAD implementation and warm-up treatment; its short/long onset ratio is not reused because it self-clears sustained hunting; PNNL Variable Speed Drives O&M Best Practice — trend drive output speed/frequency with the controlled process variable
Operating statesdrive and one identified process loop in settled remote automatic operation

Preconditions (host-enforced): The drive must be enabled and accepting remote automatic control; VFD-0005 suppresses the rule during local/hand or bypass operation, and VFD-0001 suppresses it while speed feedback cannot establish actual drive motion. Exclude startup, shutdown, smoke/purge, lead/lag changeover, commissioning, manual tuning tests, safety/current/torque/demand limits, and real load or setpoint-reset transitions until the loop is settled. vfd_process_value and vfd_process_sp must be the same loop in the same units, and pv_allowance_band must be configured in those units. The host must report NO_EVAL for the first evaluation_window after valid operation begins. Use a fixed tick in [14.3 s, 150 s) at the defaults, 60 s recommended, with count_scale = evaluation_window / tick; irregular timing and change-of-value deadbands invalidate the counts. When any gate is unmet the verdict is NO_EVAL, not healthy.

Points: vfd_speed, vfd_process_value, vfd_process_sp

Outputs:

  • yFault — True after both the material speed-hunting and unstable-process subconditions remain active continuously for alarm_persistence
  • ySpeedHunting — Immediate diagnostic; true when rolling speed mean-crossing count and rolling speed MAD both exceed their strict limits
  • yProcessUnstable — Immediate diagnostic; true when process-error mean crossings exceed the count limit and rolling mean absolute error from setpoint exceeds pv_allowance_band

Parameters:

NameDefaultUnitCXF pathDescription
evaluation_window900.0smuSpeed.delta, crossRate.delta, speedMad.delta, muProcess.delta, processCrossRate.delta, pvMae.deltaADOPTED_TUNABLE informed by AHU-0022’s 15-minute scatter window. Drives all six rolling means and must be changed on every listed path together.
max_crossings_per_window6.01crossHigh.t, processCrossHigh.tADOPTED_TUNABLE: maximum allowed rolling mean crossings for each lane. The strict comparisons mean six clears and seven faults; one physical cycle normally yields two crossings.
min_speed_mad5.0%amplitudeHigh.tADOPTED_TUNABLE: rolling mean absolute speed deviation above which motion is material. Five MAD points equal a 10-point peak-to-peak square wave; waveform conversion differs for a sine.
pv_allowance_band10.01pvBandHigh.tNO_PORTABLE_DEFAULT: rolling mean absolute process error in the bound loop’s own units. The executable 10.0 is an adoption-blocking placeholder.
count_scale15.01crossCount.k, processCrossCount.kDERIVED: evaluation_window / fixed host tick = 900/60. Both event-count lanes must be retuned together whenever the window or tick changes.
alarm_persistence300.0spersist.delayTimeADOPTED_TUNABLE: continuous overlap of both instability diagnostics required before alarm (5 min).

Description

The drive and its process are moving together, but not settling. A poorly tuned loop, noisy process signal, conflicting reset sequence, mechanical backlash, or an application limit can make speed repeatedly overshoot its own center while the controlled quantity repeatedly moves and remains materially away from setpoint. The rule requires evidence in both places. Speed oscillation with a stable process is useful tuning evidence but not this combined fault; process oscillation behind a steady drive points somewhere else.

Detection Logic

speed_mean   = MovingAverage(vfd_speed, evaluation_window)
speed_count  = MovingAverage(Change(vfd_speed > speed_mean), window)
               * count_scale
speed_mad    = MovingAverage(abs(vfd_speed - speed_mean), window)
ySpeedHunting = speed_count > max_crossings_per_window
             AND speed_mad > min_speed_mad

process_err   = vfd_process_value - vfd_process_sp
process_mean  = MovingAverage(process_err, evaluation_window)
process_count = MovingAverage(Change(process_err > process_mean), window)
                * count_scale
process_mae   = MovingAverage(abs(process_err), evaluation_window)
yProcessUnstable = process_count > max_crossings_per_window
                AND process_mae > pv_allowance_band

yFault = TrueDelay(ySpeedHunting AND yProcessUnstable, alarm_persistence)

Each Boolean Change pulse is converted to integer and then real before its rolling average, as shown in the CXF graph.

Block graph (rule.cxf.jsonld):

VFD-0004 block graph

Both diagnostics are immediate rolling subconditions; only their overlap is delayed. Stable operation clears them by aging old pulses and deviations out of the trailing windows. A fresh engine instance resets the rings, edge histories, and persistence timer.

Possible Diagnoses

  1. PID gain too high or integral time too short for the driven system.
  2. Noisy, quantized, or intermittently connected process sensor.
  3. Conflicting control loops or reset sequences acting on the same drive.
  4. Mechanical backlash, sticking damper/valve, unstable pump system, or rapidly changing system resistance.
  5. A real load disturbance, staging event, or setpoint reset that the host did not exclude.
  6. Drive current/torque/application limits interacting with the BAS loop.

Energy Impact

EFFICIENCY_LOSS, MEDIUM confidence, QUALITATIVE_ONLY. The loss is application dependent: extra cube-law fan/pump work on high excursions, process energy that overshoots and is corrected, and wear from continuous speed changes. The rule has no power point or application model, so it reports hunting duration rather than an invented savings percentage.

Emissions Impact

Scope 2, qualitative. Any avoidable motor and process energy is purchased electricity. Convert measured incremental energy after diagnosis; the rolling statistics themselves have no emissions factor.

Deviations

  • Mean crossings substitute for literal direction reversals. This is the verified VAV-0005 implementation: a periodic cycle produces two of either, while drift with ripple can differ. The parameter is therefore named max_crossings_per_window, not minimum_reversals.
  • The roadmap’s 10% speed excursion becomes 5% MAD. For a square wave, 10 points peak-to-peak is +/-5 and has MAD 5. A 10-point peak-to-peak sine has MAD about 3.2, so waveform shape matters. Computed-statistic boundaries are bracketed in vectors rather than asserted on exact equality ticks.
  • The process lane is an adaptation, not NIST’s two-sided CUSUM. It counts crossings of process error around its rolling mean and independently requires mean absolute error from the true setpoint. This rejects stable offset but intentionally admits a one-sided oscillatory offset; the dedicated vector pins that broader behavior.
  • The same crossing limit is applied to both lanes. NIST publishes no VFD-specific value of six. It is one adopted, jointly tuned concept and both strict comparisons use > 6.
  • pv_allowance_band has no portable default. The executable 10.0 must be replaced in the loop’s own units before deployment.
  • Event counts are tick-coupled. A one-tick pulse has area equal to the preceding tick interval; count_scale = evaluation_window / dt converts its moving average back to a count. Irregular ticks bias individual events.
  • The legal sampling interval is narrow. The 64-checkpoint ring requires dt >= 900/63 = 14.3 s, while strict count > 6 requires dt < 900/6 = 150 s. At the current replay harness’s 300-second cadence this rule can never alarm. Sixty seconds is the only cadence exercised here.
  • Warm-up is host-gated. Partial-window divisors extrapolate the observed event pace, so a startup burst can raise raw diagnostics despite delayOnInit=true; the first 900 seconds are NO_EVAL.
  • Moving averages are continuous-time integrals, not sample statistics, and event history is half-open: an event exactly one window old has aged out.
  • Change-of-value and sub-tick blindness remain. A wide COV deadband hides crossings; an even number of reversals between samples can return to the same observed value and disappear entirely.
  • Suppressions are instance-scoped. VFD-0001 and VFD-0005 invalidate this drive’s speed/automatic-loop premise. Their raw states should gate evaluation immediately; waiting for another rule’s delayed alarm creates a race.
  • No simulation FPR is claimed. The harness has neither a genuine VFD speed/PV/ setpoint triplet nor a legal hunting cadence.

Notes

The diagnostic split is the investigation order. If only ySpeedHunting is true, inspect speed command, feedback, limits, and mechanical response before retuning the process loop. If only yProcessUnstable is true, the disturbance or sensor is not being driven by measured VFD motion. When both are true, first exclude a legitimate reset/load step, then compare their phase: process motion leading speed suggests real load or noise; speed leading process suggests tuning or actuator behavior.

Test Vectors

18 scenarios, clock step 60 s over 7200 s.

ScenarioDescription
constant_speed_first_sample_artifactConstant speed and satisfied PV remain clear. The speed-mean crossing branch has a zero-area initialization pulse and one harmless fill artifact, neither of which can satisfy both speed criteria.
load_step_monotonic_recoveryAfter a settled baseline, speed moves once from 50% to 60% while PV carries a 20-unit error for only five minutes. The error area is below pv_allowance_band times the window and the speed never repeatedly crosses its center.
fast_shallow_ditherA four-minute speed cycle crosses often but stays at +/-4%; its settled speed MAD is below the strict 5-point materiality threshold. PV is satisfied.
speed_mad_just_below_thresholdA +/-4.9% square speed wave brackets the computed materiality boundary from the nonfaulting side. Rolling MAD is not asserted at an exact equality edge; the process branch is independently active.
speed_mad_just_above_thresholdA +/-5.1% square wave brackets the materiality boundary from the active side. Fast crossings and unstable process error allow the combined fault to mature.
speed_hunting_process_stableMaterial fast speed motion is present, but PV stays on setpoint; the speed diagnostic can guide tuning without producing the combined fault.
slow_deep_speed_motionA deep ten-minute speed cycle has large MAD but only three mean crossings per 900-second window, below the strict count limit. PV remains unsatisfied, isolating the speed-frequency rejection.
process_unstable_speed_stablePV alternates 12 units around setpoint while speed is fixed. The process diagnostic is active after warm-up, but the rule refuses to call it VFD hunting without material speed crossings.
count_exactly_allowedA 900-second repeating speed pattern produces six mean crossings per full window with material MAD. The strict count > 6 comparison keeps ySpeedHunting and the combined fault clear while the process branch is active.
one_crossing_over_limitA repeating seven-crossing pattern is one event above the allowed six, with material speed MAD and unstable process error; all raw criteria and the delayed fault remain active after warm-up.
pv_mae_exactly_thresholdWith established speed hunting, a constant absolute PV error of exactly 10.0 loop units leaves the strict process-MAE comparison false and blocks the fault.
pv_mae_just_over_thresholdA 10.1-unit mean absolute PV error brackets the process boundary from the active side while the speed branch is materially hunting.
persistent_both_error_directionsMaterial speed hunting paired with PV alternating above and below setpoint proves the process statistic is symmetric and the combined fault persists.
one_sided_oscillatory_process_offsetPV oscillates between 20 and 30 units above setpoint while speed hunts. Crossings around the rolling process-error mean plus MAE from the real setpoint intentionally classify this one-sided oscillatory offset as unstable; this is broader than NIST’s two-sided allowance-band CUSUM.
window_aging_clearsThe loop hunts through t=3600 s and then speed settles while PV remains 11 units off setpoint. The process diagnostic stays active; speed crossings and MAD must age out before the fault clears.
warmup_burst_raw_behaviorEight tick-rate speed changes during the partial-window fill can be extrapolated above the rolling count threshold while PV is off setpoint. The raw result is pinned; hosts must report NO_EVAL during the first evaluation window.
setpoint_step_excluded_raw_behaviorA setpoint step at t=1800 followed by sustained materially reversing speed raises the raw diagnostics and fault. Deployment must instead report NO_EVAL until reset and disturbance response are settled.
subtick_hunting_aliases_clearA host sampling once per minute cannot see an even number of reversals that occur and return between samples; the staged trace is therefore constant and every output stays clear. This pins the Nyquist limitation, not healthy plant behavior.
vectors.json
{
  "schema": "cxf-library/vectors/v1",
  "clock": {
    "step_s": 60,
    "horizon_s": 7200
  },
  "scenarios": [
    {
      "name": "constant_speed_first_sample_artifact",
      "description": "Constant speed and satisfied PV remain clear. The speed-mean crossing branch has a zero-area initialization pulse and one harmless fill artifact, neither of which can satisfy both speed criteria.",
      "inputs": {
        "vfd_speed": 50.0,
        "vfd_process_value": 100.0,
        "vfd_process_sp": 100.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        },
        {
          "output": "ySpeedHunting",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        },
        {
          "output": "yProcessUnstable",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        }
      ]
    },
    {
      "name": "load_step_monotonic_recovery",
      "description": "After a settled baseline, speed moves once from 50% to 60% while PV carries a 20-unit error for only five minutes. The error area is below pv_allowance_band times the window and the speed never repeatedly crosses its center.",
      "inputs": {
        "vfd_speed": [
          {
            "t": 0,
            "value": 50.0
          },
          {
            "t": 1800,
            "value": 60.0
          }
        ],
        "vfd_process_value": [
          {
            "t": 0,
            "value": 100.0
          },
          {
            "t": 1800,
            "value": 120.0
          },
          {
            "t": 2100,
            "value": 100.0
          }
        ],
        "vfd_process_sp": 100.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        },
        {
          "output": "ySpeedHunting",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        },
        {
          "output": "yProcessUnstable",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        }
      ]
    },
    {
      "name": "fast_shallow_dither",
      "description": "A four-minute speed cycle crosses often but stays at +/-4%; its settled speed MAD is below the strict 5-point materiality threshold. PV is satisfied.",
      "inputs": {
        "vfd_speed": [
          {
            "t": 0,
            "value": 54.0
          },
          {
            "t": 120,
            "value": 46.0
          },
          {
            "t": 240,
            "value": 54.0
          },
          {
            "t": 360,
            "value": 46.0
          },
          {
            "t": 480,
            "value": 54.0
          },
          {
            "t": 600,
            "value": 46.0
          },
          {
            "t": 720,
            "value": 54.0
          },
          {
            "t": 840,
            "value": 46.0
          },
          {
            "t": 960,
            "value": 54.0
          },
          {
            "t": 1080,
            "value": 46.0
          },
          {
            "t": 1200,
            "value": 54.0
          },
          {
            "t": 1320,
            "value": 46.0
          },
          {
            "t": 1440,
            "value": 54.0
          },
          {
            "t": 1560,
            "value": 46.0
          },
          {
            "t": 1680,
            "value": 54.0
          },
          {
            "t": 1800,
            "value": 46.0
          },
          {
            "t": 1920,
            "value": 54.0
          },
          {
            "t": 2040,
            "value": 46.0
          },
          {
            "t": 2160,
            "value": 54.0
          },
          {
            "t": 2280,
            "value": 46.0
          },
          {
            "t": 2400,
            "value": 54.0
          },
          {
            "t": 2520,
            "value": 46.0
          },
          {
            "t": 2640,
            "value": 54.0
          },
          {
            "t": 2760,
            "value": 46.0
          },
          {
            "t": 2880,
            "value": 54.0
          },
          {
            "t": 3000,
            "value": 46.0
          },
          {
            "t": 3120,
            "value": 54.0
          },
          {
            "t": 3240,
            "value": 46.0
          },
          {
            "t": 3360,
            "value": 54.0
          },
          {
            "t": 3480,
            "value": 46.0
          },
          {
            "t": 3600,
            "value": 54.0
          },
          {
            "t": 3720,
            "value": 46.0
          },
          {
            "t": 3840,
            "value": 54.0
          },
          {
            "t": 3960,
            "value": 46.0
          },
          {
            "t": 4080,
            "value": 54.0
          },
          {
            "t": 4200,
            "value": 46.0
          },
          {
            "t": 4320,
            "value": 54.0
          },
          {
            "t": 4440,
            "value": 46.0
          },
          {
            "t": 4560,
            "value": 54.0
          },
          {
            "t": 4680,
            "value": 46.0
          },
          {
            "t": 4800,
            "value": 54.0
          },
          {
            "t": 4920,
            "value": 46.0
          },
          {
            "t": 5040,
            "value": 54.0
          },
          {
            "t": 5160,
            "value": 46.0
          },
          {
            "t": 5280,
            "value": 54.0
          },
          {
            "t": 5400,
            "value": 46.0
          },
          {
            "t": 5520,
            "value": 54.0
          },
          {
            "t": 5640,
            "value": 46.0
          },
          {
            "t": 5760,
            "value": 54.0
          },
          {
            "t": 5880,
            "value": 46.0
          },
          {
            "t": 6000,
            "value": 54.0
          },
          {
            "t": 6120,
            "value": 46.0
          },
          {
            "t": 6240,
            "value": 54.0
          },
          {
            "t": 6360,
            "value": 46.0
          },
          {
            "t": 6480,
            "value": 54.0
          },
          {
            "t": 6600,
            "value": 46.0
          },
          {
            "t": 6720,
            "value": 54.0
          },
          {
            "t": 6840,
            "value": 46.0
          },
          {
            "t": 6960,
            "value": 54.0
          },
          {
            "t": 7080,
            "value": 46.0
          }
        ],
        "vfd_process_value": 100.0,
        "vfd_process_sp": 100.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        },
        {
          "output": "ySpeedHunting",
          "from_s": 1800,
          "to_s": 7200,
          "equals": false
        },
        {
          "output": "yProcessUnstable",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        }
      ]
    },
    {
      "name": "speed_mad_just_below_threshold",
      "description": "A +/-4.9% square speed wave brackets the computed materiality boundary from the nonfaulting side. Rolling MAD is not asserted at an exact equality edge; the process branch is independently active.",
      "inputs": {
        "vfd_speed": [
          {
            "t": 0,
            "value": 54.9
          },
          {
            "t": 120,
            "value": 45.1
          },
          {
            "t": 240,
            "value": 54.9
          },
          {
            "t": 360,
            "value": 45.1
          },
          {
            "t": 480,
            "value": 54.9
          },
          {
            "t": 600,
            "value": 45.1
          },
          {
            "t": 720,
            "value": 54.9
          },
          {
            "t": 840,
            "value": 45.1
          },
          {
            "t": 960,
            "value": 54.9
          },
          {
            "t": 1080,
            "value": 45.1
          },
          {
            "t": 1200,
            "value": 54.9
          },
          {
            "t": 1320,
            "value": 45.1
          },
          {
            "t": 1440,
            "value": 54.9
          },
          {
            "t": 1560,
            "value": 45.1
          },
          {
            "t": 1680,
            "value": 54.9
          },
          {
            "t": 1800,
            "value": 45.1
          },
          {
            "t": 1920,
            "value": 54.9
          },
          {
            "t": 2040,
            "value": 45.1
          },
          {
            "t": 2160,
            "value": 54.9
          },
          {
            "t": 2280,
            "value": 45.1
          },
          {
            "t": 2400,
            "value": 54.9
          },
          {
            "t": 2520,
            "value": 45.1
          },
          {
            "t": 2640,
            "value": 54.9
          },
          {
            "t": 2760,
            "value": 45.1
          },
          {
            "t": 2880,
            "value": 54.9
          },
          {
            "t": 3000,
            "value": 45.1
          },
          {
            "t": 3120,
            "value": 54.9
          },
          {
            "t": 3240,
            "value": 45.1
          },
          {
            "t": 3360,
            "value": 54.9
          },
          {
            "t": 3480,
            "value": 45.1
          },
          {
            "t": 3600,
            "value": 54.9
          },
          {
            "t": 3720,
            "value": 45.1
          },
          {
            "t": 3840,
            "value": 54.9
          },
          {
            "t": 3960,
            "value": 45.1
          },
          {
            "t": 4080,
            "value": 54.9
          },
          {
            "t": 4200,
            "value": 45.1
          },
          {
            "t": 4320,
            "value": 54.9
          },
          {
            "t": 4440,
            "value": 45.1
          },
          {
            "t": 4560,
            "value": 54.9
          },
          {
            "t": 4680,
            "value": 45.1
          },
          {
            "t": 4800,
            "value": 54.9
          },
          {
            "t": 4920,
            "value": 45.1
          },
          {
            "t": 5040,
            "value": 54.9
          },
          {
            "t": 5160,
            "value": 45.1
          },
          {
            "t": 5280,
            "value": 54.9
          },
          {
            "t": 5400,
            "value": 45.1
          },
          {
            "t": 5520,
            "value": 54.9
          },
          {
            "t": 5640,
            "value": 45.1
          },
          {
            "t": 5760,
            "value": 54.9
          },
          {
            "t": 5880,
            "value": 45.1
          },
          {
            "t": 6000,
            "value": 54.9
          },
          {
            "t": 6120,
            "value": 45.1
          },
          {
            "t": 6240,
            "value": 54.9
          },
          {
            "t": 6360,
            "value": 45.1
          },
          {
            "t": 6480,
            "value": 54.9
          },
          {
            "t": 6600,
            "value": 45.1
          },
          {
            "t": 6720,
            "value": 54.9
          },
          {
            "t": 6840,
            "value": 45.1
          },
          {
            "t": 6960,
            "value": 54.9
          },
          {
            "t": 7080,
            "value": 45.1
          }
        ],
        "vfd_process_value": [
          {
            "t": 0,
            "value": 112.0
          },
          {
            "t": 120,
            "value": 88.0
          },
          {
            "t": 240,
            "value": 112.0
          },
          {
            "t": 360,
            "value": 88.0
          },
          {
            "t": 480,
            "value": 112.0
          },
          {
            "t": 600,
            "value": 88.0
          },
          {
            "t": 720,
            "value": 112.0
          },
          {
            "t": 840,
            "value": 88.0
          },
          {
            "t": 960,
            "value": 112.0
          },
          {
            "t": 1080,
            "value": 88.0
          },
          {
            "t": 1200,
            "value": 112.0
          },
          {
            "t": 1320,
            "value": 88.0
          },
          {
            "t": 1440,
            "value": 112.0
          },
          {
            "t": 1560,
            "value": 88.0
          },
          {
            "t": 1680,
            "value": 112.0
          },
          {
            "t": 1800,
            "value": 88.0
          },
          {
            "t": 1920,
            "value": 112.0
          },
          {
            "t": 2040,
            "value": 88.0
          },
          {
            "t": 2160,
            "value": 112.0
          },
          {
            "t": 2280,
            "value": 88.0
          },
          {
            "t": 2400,
            "value": 112.0
          },
          {
            "t": 2520,
            "value": 88.0
          },
          {
            "t": 2640,
            "value": 112.0
          },
          {
            "t": 2760,
            "value": 88.0
          },
          {
            "t": 2880,
            "value": 112.0
          },
          {
            "t": 3000,
            "value": 88.0
          },
          {
            "t": 3120,
            "value": 112.0
          },
          {
            "t": 3240,
            "value": 88.0
          },
          {
            "t": 3360,
            "value": 112.0
          },
          {
            "t": 3480,
            "value": 88.0
          },
          {
            "t": 3600,
            "value": 112.0
          },
          {
            "t": 3720,
            "value": 88.0
          },
          {
            "t": 3840,
            "value": 112.0
          },
          {
            "t": 3960,
            "value": 88.0
          },
          {
            "t": 4080,
            "value": 112.0
          },
          {
            "t": 4200,
            "value": 88.0
          },
          {
            "t": 4320,
            "value": 112.0
          },
          {
            "t": 4440,
            "value": 88.0
          },
          {
            "t": 4560,
            "value": 112.0
          },
          {
            "t": 4680,
            "value": 88.0
          },
          {
            "t": 4800,
            "value": 112.0
          },
          {
            "t": 4920,
            "value": 88.0
          },
          {
            "t": 5040,
            "value": 112.0
          },
          {
            "t": 5160,
            "value": 88.0
          },
          {
            "t": 5280,
            "value": 112.0
          },
          {
            "t": 5400,
            "value": 88.0
          },
          {
            "t": 5520,
            "value": 112.0
          },
          {
            "t": 5640,
            "value": 88.0
          },
          {
            "t": 5760,
            "value": 112.0
          },
          {
            "t": 5880,
            "value": 88.0
          },
          {
            "t": 6000,
            "value": 112.0
          },
          {
            "t": 6120,
            "value": 88.0
          },
          {
            "t": 6240,
            "value": 112.0
          },
          {
            "t": 6360,
            "value": 88.0
          },
          {
            "t": 6480,
            "value": 112.0
          },
          {
            "t": 6600,
            "value": 88.0
          },
          {
            "t": 6720,
            "value": 112.0
          },
          {
            "t": 6840,
            "value": 88.0
          },
          {
            "t": 6960,
            "value": 112.0
          },
          {
            "t": 7080,
            "value": 88.0
          }
        ],
        "vfd_process_sp": 100.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        },
        {
          "output": "ySpeedHunting",
          "from_s": 1800,
          "to_s": 7200,
          "equals": false
        },
        {
          "output": "yProcessUnstable",
          "from_s": 1800,
          "to_s": 7200,
          "equals": true
        }
      ]
    },
    {
      "name": "speed_mad_just_above_threshold",
      "description": "A +/-5.1% square wave brackets the materiality boundary from the active side. Fast crossings and unstable process error allow the combined fault to mature.",
      "inputs": {
        "vfd_speed": [
          {
            "t": 0,
            "value": 55.1
          },
          {
            "t": 120,
            "value": 44.9
          },
          {
            "t": 240,
            "value": 55.1
          },
          {
            "t": 360,
            "value": 44.9
          },
          {
            "t": 480,
            "value": 55.1
          },
          {
            "t": 600,
            "value": 44.9
          },
          {
            "t": 720,
            "value": 55.1
          },
          {
            "t": 840,
            "value": 44.9
          },
          {
            "t": 960,
            "value": 55.1
          },
          {
            "t": 1080,
            "value": 44.9
          },
          {
            "t": 1200,
            "value": 55.1
          },
          {
            "t": 1320,
            "value": 44.9
          },
          {
            "t": 1440,
            "value": 55.1
          },
          {
            "t": 1560,
            "value": 44.9
          },
          {
            "t": 1680,
            "value": 55.1
          },
          {
            "t": 1800,
            "value": 44.9
          },
          {
            "t": 1920,
            "value": 55.1
          },
          {
            "t": 2040,
            "value": 44.9
          },
          {
            "t": 2160,
            "value": 55.1
          },
          {
            "t": 2280,
            "value": 44.9
          },
          {
            "t": 2400,
            "value": 55.1
          },
          {
            "t": 2520,
            "value": 44.9
          },
          {
            "t": 2640,
            "value": 55.1
          },
          {
            "t": 2760,
            "value": 44.9
          },
          {
            "t": 2880,
            "value": 55.1
          },
          {
            "t": 3000,
            "value": 44.9
          },
          {
            "t": 3120,
            "value": 55.1
          },
          {
            "t": 3240,
            "value": 44.9
          },
          {
            "t": 3360,
            "value": 55.1
          },
          {
            "t": 3480,
            "value": 44.9
          },
          {
            "t": 3600,
            "value": 55.1
          },
          {
            "t": 3720,
            "value": 44.9
          },
          {
            "t": 3840,
            "value": 55.1
          },
          {
            "t": 3960,
            "value": 44.9
          },
          {
            "t": 4080,
            "value": 55.1
          },
          {
            "t": 4200,
            "value": 44.9
          },
          {
            "t": 4320,
            "value": 55.1
          },
          {
            "t": 4440,
            "value": 44.9
          },
          {
            "t": 4560,
            "value": 55.1
          },
          {
            "t": 4680,
            "value": 44.9
          },
          {
            "t": 4800,
            "value": 55.1
          },
          {
            "t": 4920,
            "value": 44.9
          },
          {
            "t": 5040,
            "value": 55.1
          },
          {
            "t": 5160,
            "value": 44.9
          },
          {
            "t": 5280,
            "value": 55.1
          },
          {
            "t": 5400,
            "value": 44.9
          },
          {
            "t": 5520,
            "value": 55.1
          },
          {
            "t": 5640,
            "value": 44.9
          },
          {
            "t": 5760,
            "value": 55.1
          },
          {
            "t": 5880,
            "value": 44.9
          },
          {
            "t": 6000,
            "value": 55.1
          },
          {
            "t": 6120,
            "value": 44.9
          },
          {
            "t": 6240,
            "value": 55.1
          },
          {
            "t": 6360,
            "value": 44.9
          },
          {
            "t": 6480,
            "value": 55.1
          },
          {
            "t": 6600,
            "value": 44.9
          },
          {
            "t": 6720,
            "value": 55.1
          },
          {
            "t": 6840,
            "value": 44.9
          },
          {
            "t": 6960,
            "value": 55.1
          },
          {
            "t": 7080,
            "value": 44.9
          }
        ],
        "vfd_process_value": [
          {
            "t": 0,
            "value": 112.0
          },
          {
            "t": 120,
            "value": 88.0
          },
          {
            "t": 240,
            "value": 112.0
          },
          {
            "t": 360,
            "value": 88.0
          },
          {
            "t": 480,
            "value": 112.0
          },
          {
            "t": 600,
            "value": 88.0
          },
          {
            "t": 720,
            "value": 112.0
          },
          {
            "t": 840,
            "value": 88.0
          },
          {
            "t": 960,
            "value": 112.0
          },
          {
            "t": 1080,
            "value": 88.0
          },
          {
            "t": 1200,
            "value": 112.0
          },
          {
            "t": 1320,
            "value": 88.0
          },
          {
            "t": 1440,
            "value": 112.0
          },
          {
            "t": 1560,
            "value": 88.0
          },
          {
            "t": 1680,
            "value": 112.0
          },
          {
            "t": 1800,
            "value": 88.0
          },
          {
            "t": 1920,
            "value": 112.0
          },
          {
            "t": 2040,
            "value": 88.0
          },
          {
            "t": 2160,
            "value": 112.0
          },
          {
            "t": 2280,
            "value": 88.0
          },
          {
            "t": 2400,
            "value": 112.0
          },
          {
            "t": 2520,
            "value": 88.0
          },
          {
            "t": 2640,
            "value": 112.0
          },
          {
            "t": 2760,
            "value": 88.0
          },
          {
            "t": 2880,
            "value": 112.0
          },
          {
            "t": 3000,
            "value": 88.0
          },
          {
            "t": 3120,
            "value": 112.0
          },
          {
            "t": 3240,
            "value": 88.0
          },
          {
            "t": 3360,
            "value": 112.0
          },
          {
            "t": 3480,
            "value": 88.0
          },
          {
            "t": 3600,
            "value": 112.0
          },
          {
            "t": 3720,
            "value": 88.0
          },
          {
            "t": 3840,
            "value": 112.0
          },
          {
            "t": 3960,
            "value": 88.0
          },
          {
            "t": 4080,
            "value": 112.0
          },
          {
            "t": 4200,
            "value": 88.0
          },
          {
            "t": 4320,
            "value": 112.0
          },
          {
            "t": 4440,
            "value": 88.0
          },
          {
            "t": 4560,
            "value": 112.0
          },
          {
            "t": 4680,
            "value": 88.0
          },
          {
            "t": 4800,
            "value": 112.0
          },
          {
            "t": 4920,
            "value": 88.0
          },
          {
            "t": 5040,
            "value": 112.0
          },
          {
            "t": 5160,
            "value": 88.0
          },
          {
            "t": 5280,
            "value": 112.0
          },
          {
            "t": 5400,
            "value": 88.0
          },
          {
            "t": 5520,
            "value": 112.0
          },
          {
            "t": 5640,
            "value": 88.0
          },
          {
            "t": 5760,
            "value": 112.0
          },
          {
            "t": 5880,
            "value": 88.0
          },
          {
            "t": 6000,
            "value": 112.0
          },
          {
            "t": 6120,
            "value": 88.0
          },
          {
            "t": 6240,
            "value": 112.0
          },
          {
            "t": 6360,
            "value": 88.0
          },
          {
            "t": 6480,
            "value": 112.0
          },
          {
            "t": 6600,
            "value": 88.0
          },
          {
            "t": 6720,
            "value": 112.0
          },
          {
            "t": 6840,
            "value": 88.0
          },
          {
            "t": 6960,
            "value": 112.0
          },
          {
            "t": 7080,
            "value": 88.0
          }
        ],
        "vfd_process_sp": 100.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 2400,
          "to_s": 7200,
          "equals": true
        },
        {
          "output": "ySpeedHunting",
          "from_s": 1800,
          "to_s": 7200,
          "equals": true
        },
        {
          "output": "yProcessUnstable",
          "from_s": 1800,
          "to_s": 7200,
          "equals": true
        }
      ]
    },
    {
      "name": "speed_hunting_process_stable",
      "description": "Material fast speed motion is present, but PV stays on setpoint; the speed diagnostic can guide tuning without producing the combined fault.",
      "inputs": {
        "vfd_speed": [
          {
            "t": 0,
            "value": 60.0
          },
          {
            "t": 120,
            "value": 40.0
          },
          {
            "t": 240,
            "value": 60.0
          },
          {
            "t": 360,
            "value": 40.0
          },
          {
            "t": 480,
            "value": 60.0
          },
          {
            "t": 600,
            "value": 40.0
          },
          {
            "t": 720,
            "value": 60.0
          },
          {
            "t": 840,
            "value": 40.0
          },
          {
            "t": 960,
            "value": 60.0
          },
          {
            "t": 1080,
            "value": 40.0
          },
          {
            "t": 1200,
            "value": 60.0
          },
          {
            "t": 1320,
            "value": 40.0
          },
          {
            "t": 1440,
            "value": 60.0
          },
          {
            "t": 1560,
            "value": 40.0
          },
          {
            "t": 1680,
            "value": 60.0
          },
          {
            "t": 1800,
            "value": 40.0
          },
          {
            "t": 1920,
            "value": 60.0
          },
          {
            "t": 2040,
            "value": 40.0
          },
          {
            "t": 2160,
            "value": 60.0
          },
          {
            "t": 2280,
            "value": 40.0
          },
          {
            "t": 2400,
            "value": 60.0
          },
          {
            "t": 2520,
            "value": 40.0
          },
          {
            "t": 2640,
            "value": 60.0
          },
          {
            "t": 2760,
            "value": 40.0
          },
          {
            "t": 2880,
            "value": 60.0
          },
          {
            "t": 3000,
            "value": 40.0
          },
          {
            "t": 3120,
            "value": 60.0
          },
          {
            "t": 3240,
            "value": 40.0
          },
          {
            "t": 3360,
            "value": 60.0
          },
          {
            "t": 3480,
            "value": 40.0
          },
          {
            "t": 3600,
            "value": 60.0
          },
          {
            "t": 3720,
            "value": 40.0
          },
          {
            "t": 3840,
            "value": 60.0
          },
          {
            "t": 3960,
            "value": 40.0
          },
          {
            "t": 4080,
            "value": 60.0
          },
          {
            "t": 4200,
            "value": 40.0
          },
          {
            "t": 4320,
            "value": 60.0
          },
          {
            "t": 4440,
            "value": 40.0
          },
          {
            "t": 4560,
            "value": 60.0
          },
          {
            "t": 4680,
            "value": 40.0
          },
          {
            "t": 4800,
            "value": 60.0
          },
          {
            "t": 4920,
            "value": 40.0
          },
          {
            "t": 5040,
            "value": 60.0
          },
          {
            "t": 5160,
            "value": 40.0
          },
          {
            "t": 5280,
            "value": 60.0
          },
          {
            "t": 5400,
            "value": 40.0
          },
          {
            "t": 5520,
            "value": 60.0
          },
          {
            "t": 5640,
            "value": 40.0
          },
          {
            "t": 5760,
            "value": 60.0
          },
          {
            "t": 5880,
            "value": 40.0
          },
          {
            "t": 6000,
            "value": 60.0
          },
          {
            "t": 6120,
            "value": 40.0
          },
          {
            "t": 6240,
            "value": 60.0
          },
          {
            "t": 6360,
            "value": 40.0
          },
          {
            "t": 6480,
            "value": 60.0
          },
          {
            "t": 6600,
            "value": 40.0
          },
          {
            "t": 6720,
            "value": 60.0
          },
          {
            "t": 6840,
            "value": 40.0
          },
          {
            "t": 6960,
            "value": 60.0
          },
          {
            "t": 7080,
            "value": 40.0
          }
        ],
        "vfd_process_value": 100.0,
        "vfd_process_sp": 100.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        },
        {
          "output": "ySpeedHunting",
          "from_s": 1800,
          "to_s": 7200,
          "equals": true
        },
        {
          "output": "yProcessUnstable",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        }
      ]
    },
    {
      "name": "slow_deep_speed_motion",
      "description": "A deep ten-minute speed cycle has large MAD but only three mean crossings per 900-second window, below the strict count limit. PV remains unsatisfied, isolating the speed-frequency rejection.",
      "inputs": {
        "vfd_speed": [
          {
            "t": 0,
            "value": 65.0
          },
          {
            "t": 300,
            "value": 35.0
          },
          {
            "t": 600,
            "value": 65.0
          },
          {
            "t": 900,
            "value": 35.0
          },
          {
            "t": 1200,
            "value": 65.0
          },
          {
            "t": 1500,
            "value": 35.0
          },
          {
            "t": 1800,
            "value": 65.0
          },
          {
            "t": 2100,
            "value": 35.0
          },
          {
            "t": 2400,
            "value": 65.0
          },
          {
            "t": 2700,
            "value": 35.0
          },
          {
            "t": 3000,
            "value": 65.0
          },
          {
            "t": 3300,
            "value": 35.0
          },
          {
            "t": 3600,
            "value": 65.0
          },
          {
            "t": 3900,
            "value": 35.0
          },
          {
            "t": 4200,
            "value": 65.0
          },
          {
            "t": 4500,
            "value": 35.0
          },
          {
            "t": 4800,
            "value": 65.0
          },
          {
            "t": 5100,
            "value": 35.0
          },
          {
            "t": 5400,
            "value": 65.0
          },
          {
            "t": 5700,
            "value": 35.0
          },
          {
            "t": 6000,
            "value": 65.0
          },
          {
            "t": 6300,
            "value": 35.0
          },
          {
            "t": 6600,
            "value": 65.0
          },
          {
            "t": 6900,
            "value": 35.0
          }
        ],
        "vfd_process_value": [
          {
            "t": 0,
            "value": 112.0
          },
          {
            "t": 120,
            "value": 88.0
          },
          {
            "t": 240,
            "value": 112.0
          },
          {
            "t": 360,
            "value": 88.0
          },
          {
            "t": 480,
            "value": 112.0
          },
          {
            "t": 600,
            "value": 88.0
          },
          {
            "t": 720,
            "value": 112.0
          },
          {
            "t": 840,
            "value": 88.0
          },
          {
            "t": 960,
            "value": 112.0
          },
          {
            "t": 1080,
            "value": 88.0
          },
          {
            "t": 1200,
            "value": 112.0
          },
          {
            "t": 1320,
            "value": 88.0
          },
          {
            "t": 1440,
            "value": 112.0
          },
          {
            "t": 1560,
            "value": 88.0
          },
          {
            "t": 1680,
            "value": 112.0
          },
          {
            "t": 1800,
            "value": 88.0
          },
          {
            "t": 1920,
            "value": 112.0
          },
          {
            "t": 2040,
            "value": 88.0
          },
          {
            "t": 2160,
            "value": 112.0
          },
          {
            "t": 2280,
            "value": 88.0
          },
          {
            "t": 2400,
            "value": 112.0
          },
          {
            "t": 2520,
            "value": 88.0
          },
          {
            "t": 2640,
            "value": 112.0
          },
          {
            "t": 2760,
            "value": 88.0
          },
          {
            "t": 2880,
            "value": 112.0
          },
          {
            "t": 3000,
            "value": 88.0
          },
          {
            "t": 3120,
            "value": 112.0
          },
          {
            "t": 3240,
            "value": 88.0
          },
          {
            "t": 3360,
            "value": 112.0
          },
          {
            "t": 3480,
            "value": 88.0
          },
          {
            "t": 3600,
            "value": 112.0
          },
          {
            "t": 3720,
            "value": 88.0
          },
          {
            "t": 3840,
            "value": 112.0
          },
          {
            "t": 3960,
            "value": 88.0
          },
          {
            "t": 4080,
            "value": 112.0
          },
          {
            "t": 4200,
            "value": 88.0
          },
          {
            "t": 4320,
            "value": 112.0
          },
          {
            "t": 4440,
            "value": 88.0
          },
          {
            "t": 4560,
            "value": 112.0
          },
          {
            "t": 4680,
            "value": 88.0
          },
          {
            "t": 4800,
            "value": 112.0
          },
          {
            "t": 4920,
            "value": 88.0
          },
          {
            "t": 5040,
            "value": 112.0
          },
          {
            "t": 5160,
            "value": 88.0
          },
          {
            "t": 5280,
            "value": 112.0
          },
          {
            "t": 5400,
            "value": 88.0
          },
          {
            "t": 5520,
            "value": 112.0
          },
          {
            "t": 5640,
            "value": 88.0
          },
          {
            "t": 5760,
            "value": 112.0
          },
          {
            "t": 5880,
            "value": 88.0
          },
          {
            "t": 6000,
            "value": 112.0
          },
          {
            "t": 6120,
            "value": 88.0
          },
          {
            "t": 6240,
            "value": 112.0
          },
          {
            "t": 6360,
            "value": 88.0
          },
          {
            "t": 6480,
            "value": 112.0
          },
          {
            "t": 6600,
            "value": 88.0
          },
          {
            "t": 6720,
            "value": 112.0
          },
          {
            "t": 6840,
            "value": 88.0
          },
          {
            "t": 6960,
            "value": 112.0
          },
          {
            "t": 7080,
            "value": 88.0
          }
        ],
        "vfd_process_sp": 100.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        },
        {
          "output": "ySpeedHunting",
          "from_s": 1800,
          "to_s": 7200,
          "equals": false
        },
        {
          "output": "yProcessUnstable",
          "from_s": 1800,
          "to_s": 7200,
          "equals": true
        }
      ]
    },
    {
      "name": "process_unstable_speed_stable",
      "description": "PV alternates 12 units around setpoint while speed is fixed. The process diagnostic is active after warm-up, but the rule refuses to call it VFD hunting without material speed crossings.",
      "inputs": {
        "vfd_speed": 50.0,
        "vfd_process_value": [
          {
            "t": 0,
            "value": 112.0
          },
          {
            "t": 120,
            "value": 88.0
          },
          {
            "t": 240,
            "value": 112.0
          },
          {
            "t": 360,
            "value": 88.0
          },
          {
            "t": 480,
            "value": 112.0
          },
          {
            "t": 600,
            "value": 88.0
          },
          {
            "t": 720,
            "value": 112.0
          },
          {
            "t": 840,
            "value": 88.0
          },
          {
            "t": 960,
            "value": 112.0
          },
          {
            "t": 1080,
            "value": 88.0
          },
          {
            "t": 1200,
            "value": 112.0
          },
          {
            "t": 1320,
            "value": 88.0
          },
          {
            "t": 1440,
            "value": 112.0
          },
          {
            "t": 1560,
            "value": 88.0
          },
          {
            "t": 1680,
            "value": 112.0
          },
          {
            "t": 1800,
            "value": 88.0
          },
          {
            "t": 1920,
            "value": 112.0
          },
          {
            "t": 2040,
            "value": 88.0
          },
          {
            "t": 2160,
            "value": 112.0
          },
          {
            "t": 2280,
            "value": 88.0
          },
          {
            "t": 2400,
            "value": 112.0
          },
          {
            "t": 2520,
            "value": 88.0
          },
          {
            "t": 2640,
            "value": 112.0
          },
          {
            "t": 2760,
            "value": 88.0
          },
          {
            "t": 2880,
            "value": 112.0
          },
          {
            "t": 3000,
            "value": 88.0
          },
          {
            "t": 3120,
            "value": 112.0
          },
          {
            "t": 3240,
            "value": 88.0
          },
          {
            "t": 3360,
            "value": 112.0
          },
          {
            "t": 3480,
            "value": 88.0
          },
          {
            "t": 3600,
            "value": 112.0
          },
          {
            "t": 3720,
            "value": 88.0
          },
          {
            "t": 3840,
            "value": 112.0
          },
          {
            "t": 3960,
            "value": 88.0
          },
          {
            "t": 4080,
            "value": 112.0
          },
          {
            "t": 4200,
            "value": 88.0
          },
          {
            "t": 4320,
            "value": 112.0
          },
          {
            "t": 4440,
            "value": 88.0
          },
          {
            "t": 4560,
            "value": 112.0
          },
          {
            "t": 4680,
            "value": 88.0
          },
          {
            "t": 4800,
            "value": 112.0
          },
          {
            "t": 4920,
            "value": 88.0
          },
          {
            "t": 5040,
            "value": 112.0
          },
          {
            "t": 5160,
            "value": 88.0
          },
          {
            "t": 5280,
            "value": 112.0
          },
          {
            "t": 5400,
            "value": 88.0
          },
          {
            "t": 5520,
            "value": 112.0
          },
          {
            "t": 5640,
            "value": 88.0
          },
          {
            "t": 5760,
            "value": 112.0
          },
          {
            "t": 5880,
            "value": 88.0
          },
          {
            "t": 6000,
            "value": 112.0
          },
          {
            "t": 6120,
            "value": 88.0
          },
          {
            "t": 6240,
            "value": 112.0
          },
          {
            "t": 6360,
            "value": 88.0
          },
          {
            "t": 6480,
            "value": 112.0
          },
          {
            "t": 6600,
            "value": 88.0
          },
          {
            "t": 6720,
            "value": 112.0
          },
          {
            "t": 6840,
            "value": 88.0
          },
          {
            "t": 6960,
            "value": 112.0
          },
          {
            "t": 7080,
            "value": 88.0
          }
        ],
        "vfd_process_sp": 100.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        },
        {
          "output": "ySpeedHunting",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        },
        {
          "output": "yProcessUnstable",
          "from_s": 1800,
          "to_s": 7200,
          "equals": true
        }
      ]
    },
    {
      "name": "count_exactly_allowed",
      "description": "A 900-second repeating speed pattern produces six mean crossings per full window with material MAD. The strict count > 6 comparison keeps ySpeedHunting and the combined fault clear while the process branch is active.",
      "inputs": {
        "vfd_speed": [
          {
            "t": 0,
            "value": 60.0
          },
          {
            "t": 150,
            "value": 40.0
          },
          {
            "t": 300,
            "value": 60.0
          },
          {
            "t": 450,
            "value": 40.0
          },
          {
            "t": 600,
            "value": 60.0
          },
          {
            "t": 750,
            "value": 40.0
          },
          {
            "t": 900,
            "value": 60.0
          },
          {
            "t": 1050,
            "value": 40.0
          },
          {
            "t": 1200,
            "value": 60.0
          },
          {
            "t": 1350,
            "value": 40.0
          },
          {
            "t": 1500,
            "value": 60.0
          },
          {
            "t": 1650,
            "value": 40.0
          },
          {
            "t": 1800,
            "value": 60.0
          },
          {
            "t": 1950,
            "value": 40.0
          },
          {
            "t": 2100,
            "value": 60.0
          },
          {
            "t": 2250,
            "value": 40.0
          },
          {
            "t": 2400,
            "value": 60.0
          },
          {
            "t": 2550,
            "value": 40.0
          },
          {
            "t": 2700,
            "value": 60.0
          },
          {
            "t": 2850,
            "value": 40.0
          },
          {
            "t": 3000,
            "value": 60.0
          },
          {
            "t": 3150,
            "value": 40.0
          },
          {
            "t": 3300,
            "value": 60.0
          },
          {
            "t": 3450,
            "value": 40.0
          },
          {
            "t": 3600,
            "value": 60.0
          },
          {
            "t": 3750,
            "value": 40.0
          },
          {
            "t": 3900,
            "value": 60.0
          },
          {
            "t": 4050,
            "value": 40.0
          },
          {
            "t": 4200,
            "value": 60.0
          },
          {
            "t": 4350,
            "value": 40.0
          },
          {
            "t": 4500,
            "value": 60.0
          },
          {
            "t": 4650,
            "value": 40.0
          },
          {
            "t": 4800,
            "value": 60.0
          },
          {
            "t": 4950,
            "value": 40.0
          },
          {
            "t": 5100,
            "value": 60.0
          },
          {
            "t": 5250,
            "value": 40.0
          },
          {
            "t": 5400,
            "value": 60.0
          },
          {
            "t": 5550,
            "value": 40.0
          },
          {
            "t": 5700,
            "value": 60.0
          },
          {
            "t": 5850,
            "value": 40.0
          },
          {
            "t": 6000,
            "value": 60.0
          },
          {
            "t": 6150,
            "value": 40.0
          },
          {
            "t": 6300,
            "value": 60.0
          },
          {
            "t": 6450,
            "value": 40.0
          },
          {
            "t": 6600,
            "value": 60.0
          },
          {
            "t": 6750,
            "value": 40.0
          },
          {
            "t": 6900,
            "value": 60.0
          },
          {
            "t": 7050,
            "value": 40.0
          }
        ],
        "vfd_process_value": [
          {
            "t": 0,
            "value": 112.0
          },
          {
            "t": 120,
            "value": 88.0
          },
          {
            "t": 240,
            "value": 112.0
          },
          {
            "t": 360,
            "value": 88.0
          },
          {
            "t": 480,
            "value": 112.0
          },
          {
            "t": 600,
            "value": 88.0
          },
          {
            "t": 720,
            "value": 112.0
          },
          {
            "t": 840,
            "value": 88.0
          },
          {
            "t": 960,
            "value": 112.0
          },
          {
            "t": 1080,
            "value": 88.0
          },
          {
            "t": 1200,
            "value": 112.0
          },
          {
            "t": 1320,
            "value": 88.0
          },
          {
            "t": 1440,
            "value": 112.0
          },
          {
            "t": 1560,
            "value": 88.0
          },
          {
            "t": 1680,
            "value": 112.0
          },
          {
            "t": 1800,
            "value": 88.0
          },
          {
            "t": 1920,
            "value": 112.0
          },
          {
            "t": 2040,
            "value": 88.0
          },
          {
            "t": 2160,
            "value": 112.0
          },
          {
            "t": 2280,
            "value": 88.0
          },
          {
            "t": 2400,
            "value": 112.0
          },
          {
            "t": 2520,
            "value": 88.0
          },
          {
            "t": 2640,
            "value": 112.0
          },
          {
            "t": 2760,
            "value": 88.0
          },
          {
            "t": 2880,
            "value": 112.0
          },
          {
            "t": 3000,
            "value": 88.0
          },
          {
            "t": 3120,
            "value": 112.0
          },
          {
            "t": 3240,
            "value": 88.0
          },
          {
            "t": 3360,
            "value": 112.0
          },
          {
            "t": 3480,
            "value": 88.0
          },
          {
            "t": 3600,
            "value": 112.0
          },
          {
            "t": 3720,
            "value": 88.0
          },
          {
            "t": 3840,
            "value": 112.0
          },
          {
            "t": 3960,
            "value": 88.0
          },
          {
            "t": 4080,
            "value": 112.0
          },
          {
            "t": 4200,
            "value": 88.0
          },
          {
            "t": 4320,
            "value": 112.0
          },
          {
            "t": 4440,
            "value": 88.0
          },
          {
            "t": 4560,
            "value": 112.0
          },
          {
            "t": 4680,
            "value": 88.0
          },
          {
            "t": 4800,
            "value": 112.0
          },
          {
            "t": 4920,
            "value": 88.0
          },
          {
            "t": 5040,
            "value": 112.0
          },
          {
            "t": 5160,
            "value": 88.0
          },
          {
            "t": 5280,
            "value": 112.0
          },
          {
            "t": 5400,
            "value": 88.0
          },
          {
            "t": 5520,
            "value": 112.0
          },
          {
            "t": 5640,
            "value": 88.0
          },
          {
            "t": 5760,
            "value": 112.0
          },
          {
            "t": 5880,
            "value": 88.0
          },
          {
            "t": 6000,
            "value": 112.0
          },
          {
            "t": 6120,
            "value": 88.0
          },
          {
            "t": 6240,
            "value": 112.0
          },
          {
            "t": 6360,
            "value": 88.0
          },
          {
            "t": 6480,
            "value": 112.0
          },
          {
            "t": 6600,
            "value": 88.0
          },
          {
            "t": 6720,
            "value": 112.0
          },
          {
            "t": 6840,
            "value": 88.0
          },
          {
            "t": 6960,
            "value": 112.0
          },
          {
            "t": 7080,
            "value": 88.0
          }
        ],
        "vfd_process_sp": 100.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        },
        {
          "output": "ySpeedHunting",
          "from_s": 1800,
          "to_s": 7200,
          "equals": false
        },
        {
          "output": "yProcessUnstable",
          "from_s": 1800,
          "to_s": 7200,
          "equals": true
        }
      ]
    },
    {
      "name": "one_crossing_over_limit",
      "description": "A repeating seven-crossing pattern is one event above the allowed six, with material speed MAD and unstable process error; all raw criteria and the delayed fault remain active after warm-up.",
      "inputs": {
        "vfd_speed": [
          {
            "t": 0,
            "value": 60.0
          },
          {
            "t": 120,
            "value": 40.0
          },
          {
            "t": 240,
            "value": 60.0
          },
          {
            "t": 360,
            "value": 40.0
          },
          {
            "t": 480,
            "value": 60.0
          },
          {
            "t": 600,
            "value": 40.0
          },
          {
            "t": 720,
            "value": 60.0
          },
          {
            "t": 900,
            "value": 40.0
          },
          {
            "t": 1020,
            "value": 60.0
          },
          {
            "t": 1140,
            "value": 40.0
          },
          {
            "t": 1260,
            "value": 60.0
          },
          {
            "t": 1380,
            "value": 40.0
          },
          {
            "t": 1500,
            "value": 60.0
          },
          {
            "t": 1620,
            "value": 40.0
          },
          {
            "t": 1800,
            "value": 60.0
          },
          {
            "t": 1920,
            "value": 40.0
          },
          {
            "t": 2040,
            "value": 60.0
          },
          {
            "t": 2160,
            "value": 40.0
          },
          {
            "t": 2280,
            "value": 60.0
          },
          {
            "t": 2400,
            "value": 40.0
          },
          {
            "t": 2520,
            "value": 60.0
          },
          {
            "t": 2700,
            "value": 40.0
          },
          {
            "t": 2820,
            "value": 60.0
          },
          {
            "t": 2940,
            "value": 40.0
          },
          {
            "t": 3060,
            "value": 60.0
          },
          {
            "t": 3180,
            "value": 40.0
          },
          {
            "t": 3300,
            "value": 60.0
          },
          {
            "t": 3420,
            "value": 40.0
          },
          {
            "t": 3600,
            "value": 60.0
          },
          {
            "t": 3720,
            "value": 40.0
          },
          {
            "t": 3840,
            "value": 60.0
          },
          {
            "t": 3960,
            "value": 40.0
          },
          {
            "t": 4080,
            "value": 60.0
          },
          {
            "t": 4200,
            "value": 40.0
          },
          {
            "t": 4320,
            "value": 60.0
          },
          {
            "t": 4500,
            "value": 40.0
          },
          {
            "t": 4620,
            "value": 60.0
          },
          {
            "t": 4740,
            "value": 40.0
          },
          {
            "t": 4860,
            "value": 60.0
          },
          {
            "t": 4980,
            "value": 40.0
          },
          {
            "t": 5100,
            "value": 60.0
          },
          {
            "t": 5220,
            "value": 40.0
          },
          {
            "t": 5400,
            "value": 60.0
          },
          {
            "t": 5520,
            "value": 40.0
          },
          {
            "t": 5640,
            "value": 60.0
          },
          {
            "t": 5760,
            "value": 40.0
          },
          {
            "t": 5880,
            "value": 60.0
          },
          {
            "t": 6000,
            "value": 40.0
          },
          {
            "t": 6120,
            "value": 60.0
          },
          {
            "t": 6300,
            "value": 40.0
          },
          {
            "t": 6420,
            "value": 60.0
          },
          {
            "t": 6540,
            "value": 40.0
          },
          {
            "t": 6660,
            "value": 60.0
          },
          {
            "t": 6780,
            "value": 40.0
          },
          {
            "t": 6900,
            "value": 60.0
          },
          {
            "t": 7020,
            "value": 40.0
          }
        ],
        "vfd_process_value": [
          {
            "t": 0,
            "value": 112.0
          },
          {
            "t": 120,
            "value": 88.0
          },
          {
            "t": 240,
            "value": 112.0
          },
          {
            "t": 360,
            "value": 88.0
          },
          {
            "t": 480,
            "value": 112.0
          },
          {
            "t": 600,
            "value": 88.0
          },
          {
            "t": 720,
            "value": 112.0
          },
          {
            "t": 840,
            "value": 88.0
          },
          {
            "t": 960,
            "value": 112.0
          },
          {
            "t": 1080,
            "value": 88.0
          },
          {
            "t": 1200,
            "value": 112.0
          },
          {
            "t": 1320,
            "value": 88.0
          },
          {
            "t": 1440,
            "value": 112.0
          },
          {
            "t": 1560,
            "value": 88.0
          },
          {
            "t": 1680,
            "value": 112.0
          },
          {
            "t": 1800,
            "value": 88.0
          },
          {
            "t": 1920,
            "value": 112.0
          },
          {
            "t": 2040,
            "value": 88.0
          },
          {
            "t": 2160,
            "value": 112.0
          },
          {
            "t": 2280,
            "value": 88.0
          },
          {
            "t": 2400,
            "value": 112.0
          },
          {
            "t": 2520,
            "value": 88.0
          },
          {
            "t": 2640,
            "value": 112.0
          },
          {
            "t": 2760,
            "value": 88.0
          },
          {
            "t": 2880,
            "value": 112.0
          },
          {
            "t": 3000,
            "value": 88.0
          },
          {
            "t": 3120,
            "value": 112.0
          },
          {
            "t": 3240,
            "value": 88.0
          },
          {
            "t": 3360,
            "value": 112.0
          },
          {
            "t": 3480,
            "value": 88.0
          },
          {
            "t": 3600,
            "value": 112.0
          },
          {
            "t": 3720,
            "value": 88.0
          },
          {
            "t": 3840,
            "value": 112.0
          },
          {
            "t": 3960,
            "value": 88.0
          },
          {
            "t": 4080,
            "value": 112.0
          },
          {
            "t": 4200,
            "value": 88.0
          },
          {
            "t": 4320,
            "value": 112.0
          },
          {
            "t": 4440,
            "value": 88.0
          },
          {
            "t": 4560,
            "value": 112.0
          },
          {
            "t": 4680,
            "value": 88.0
          },
          {
            "t": 4800,
            "value": 112.0
          },
          {
            "t": 4920,
            "value": 88.0
          },
          {
            "t": 5040,
            "value": 112.0
          },
          {
            "t": 5160,
            "value": 88.0
          },
          {
            "t": 5280,
            "value": 112.0
          },
          {
            "t": 5400,
            "value": 88.0
          },
          {
            "t": 5520,
            "value": 112.0
          },
          {
            "t": 5640,
            "value": 88.0
          },
          {
            "t": 5760,
            "value": 112.0
          },
          {
            "t": 5880,
            "value": 88.0
          },
          {
            "t": 6000,
            "value": 112.0
          },
          {
            "t": 6120,
            "value": 88.0
          },
          {
            "t": 6240,
            "value": 112.0
          },
          {
            "t": 6360,
            "value": 88.0
          },
          {
            "t": 6480,
            "value": 112.0
          },
          {
            "t": 6600,
            "value": 88.0
          },
          {
            "t": 6720,
            "value": 112.0
          },
          {
            "t": 6840,
            "value": 88.0
          },
          {
            "t": 6960,
            "value": 112.0
          },
          {
            "t": 7080,
            "value": 88.0
          }
        ],
        "vfd_process_sp": 100.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 2400,
          "to_s": 6900,
          "equals": true
        },
        {
          "output": "ySpeedHunting",
          "from_s": 1800,
          "to_s": 6900,
          "equals": true
        },
        {
          "output": "yProcessUnstable",
          "from_s": 1800,
          "to_s": 6900,
          "equals": true
        }
      ]
    },
    {
      "name": "pv_mae_exactly_threshold",
      "description": "With established speed hunting, a constant absolute PV error of exactly 10.0 loop units leaves the strict process-MAE comparison false and blocks the fault.",
      "inputs": {
        "vfd_speed": [
          {
            "t": 0,
            "value": 60.0
          },
          {
            "t": 120,
            "value": 40.0
          },
          {
            "t": 240,
            "value": 60.0
          },
          {
            "t": 360,
            "value": 40.0
          },
          {
            "t": 480,
            "value": 60.0
          },
          {
            "t": 600,
            "value": 40.0
          },
          {
            "t": 720,
            "value": 60.0
          },
          {
            "t": 840,
            "value": 40.0
          },
          {
            "t": 960,
            "value": 60.0
          },
          {
            "t": 1080,
            "value": 40.0
          },
          {
            "t": 1200,
            "value": 60.0
          },
          {
            "t": 1320,
            "value": 40.0
          },
          {
            "t": 1440,
            "value": 60.0
          },
          {
            "t": 1560,
            "value": 40.0
          },
          {
            "t": 1680,
            "value": 60.0
          },
          {
            "t": 1800,
            "value": 40.0
          },
          {
            "t": 1920,
            "value": 60.0
          },
          {
            "t": 2040,
            "value": 40.0
          },
          {
            "t": 2160,
            "value": 60.0
          },
          {
            "t": 2280,
            "value": 40.0
          },
          {
            "t": 2400,
            "value": 60.0
          },
          {
            "t": 2520,
            "value": 40.0
          },
          {
            "t": 2640,
            "value": 60.0
          },
          {
            "t": 2760,
            "value": 40.0
          },
          {
            "t": 2880,
            "value": 60.0
          },
          {
            "t": 3000,
            "value": 40.0
          },
          {
            "t": 3120,
            "value": 60.0
          },
          {
            "t": 3240,
            "value": 40.0
          },
          {
            "t": 3360,
            "value": 60.0
          },
          {
            "t": 3480,
            "value": 40.0
          },
          {
            "t": 3600,
            "value": 60.0
          },
          {
            "t": 3720,
            "value": 40.0
          },
          {
            "t": 3840,
            "value": 60.0
          },
          {
            "t": 3960,
            "value": 40.0
          },
          {
            "t": 4080,
            "value": 60.0
          },
          {
            "t": 4200,
            "value": 40.0
          },
          {
            "t": 4320,
            "value": 60.0
          },
          {
            "t": 4440,
            "value": 40.0
          },
          {
            "t": 4560,
            "value": 60.0
          },
          {
            "t": 4680,
            "value": 40.0
          },
          {
            "t": 4800,
            "value": 60.0
          },
          {
            "t": 4920,
            "value": 40.0
          },
          {
            "t": 5040,
            "value": 60.0
          },
          {
            "t": 5160,
            "value": 40.0
          },
          {
            "t": 5280,
            "value": 60.0
          },
          {
            "t": 5400,
            "value": 40.0
          },
          {
            "t": 5520,
            "value": 60.0
          },
          {
            "t": 5640,
            "value": 40.0
          },
          {
            "t": 5760,
            "value": 60.0
          },
          {
            "t": 5880,
            "value": 40.0
          },
          {
            "t": 6000,
            "value": 60.0
          },
          {
            "t": 6120,
            "value": 40.0
          },
          {
            "t": 6240,
            "value": 60.0
          },
          {
            "t": 6360,
            "value": 40.0
          },
          {
            "t": 6480,
            "value": 60.0
          },
          {
            "t": 6600,
            "value": 40.0
          },
          {
            "t": 6720,
            "value": 60.0
          },
          {
            "t": 6840,
            "value": 40.0
          },
          {
            "t": 6960,
            "value": 60.0
          },
          {
            "t": 7080,
            "value": 40.0
          }
        ],
        "vfd_process_value": [
          {
            "t": 0,
            "value": 110.0
          },
          {
            "t": 120,
            "value": 90.0
          },
          {
            "t": 240,
            "value": 110.0
          },
          {
            "t": 360,
            "value": 90.0
          },
          {
            "t": 480,
            "value": 110.0
          },
          {
            "t": 600,
            "value": 90.0
          },
          {
            "t": 720,
            "value": 110.0
          },
          {
            "t": 840,
            "value": 90.0
          },
          {
            "t": 960,
            "value": 110.0
          },
          {
            "t": 1080,
            "value": 90.0
          },
          {
            "t": 1200,
            "value": 110.0
          },
          {
            "t": 1320,
            "value": 90.0
          },
          {
            "t": 1440,
            "value": 110.0
          },
          {
            "t": 1560,
            "value": 90.0
          },
          {
            "t": 1680,
            "value": 110.0
          },
          {
            "t": 1800,
            "value": 90.0
          },
          {
            "t": 1920,
            "value": 110.0
          },
          {
            "t": 2040,
            "value": 90.0
          },
          {
            "t": 2160,
            "value": 110.0
          },
          {
            "t": 2280,
            "value": 90.0
          },
          {
            "t": 2400,
            "value": 110.0
          },
          {
            "t": 2520,
            "value": 90.0
          },
          {
            "t": 2640,
            "value": 110.0
          },
          {
            "t": 2760,
            "value": 90.0
          },
          {
            "t": 2880,
            "value": 110.0
          },
          {
            "t": 3000,
            "value": 90.0
          },
          {
            "t": 3120,
            "value": 110.0
          },
          {
            "t": 3240,
            "value": 90.0
          },
          {
            "t": 3360,
            "value": 110.0
          },
          {
            "t": 3480,
            "value": 90.0
          },
          {
            "t": 3600,
            "value": 110.0
          },
          {
            "t": 3720,
            "value": 90.0
          },
          {
            "t": 3840,
            "value": 110.0
          },
          {
            "t": 3960,
            "value": 90.0
          },
          {
            "t": 4080,
            "value": 110.0
          },
          {
            "t": 4200,
            "value": 90.0
          },
          {
            "t": 4320,
            "value": 110.0
          },
          {
            "t": 4440,
            "value": 90.0
          },
          {
            "t": 4560,
            "value": 110.0
          },
          {
            "t": 4680,
            "value": 90.0
          },
          {
            "t": 4800,
            "value": 110.0
          },
          {
            "t": 4920,
            "value": 90.0
          },
          {
            "t": 5040,
            "value": 110.0
          },
          {
            "t": 5160,
            "value": 90.0
          },
          {
            "t": 5280,
            "value": 110.0
          },
          {
            "t": 5400,
            "value": 90.0
          },
          {
            "t": 5520,
            "value": 110.0
          },
          {
            "t": 5640,
            "value": 90.0
          },
          {
            "t": 5760,
            "value": 110.0
          },
          {
            "t": 5880,
            "value": 90.0
          },
          {
            "t": 6000,
            "value": 110.0
          },
          {
            "t": 6120,
            "value": 90.0
          },
          {
            "t": 6240,
            "value": 110.0
          },
          {
            "t": 6360,
            "value": 90.0
          },
          {
            "t": 6480,
            "value": 110.0
          },
          {
            "t": 6600,
            "value": 90.0
          },
          {
            "t": 6720,
            "value": 110.0
          },
          {
            "t": 6840,
            "value": 90.0
          },
          {
            "t": 6960,
            "value": 110.0
          },
          {
            "t": 7080,
            "value": 90.0
          }
        ],
        "vfd_process_sp": 100.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        },
        {
          "output": "ySpeedHunting",
          "from_s": 1800,
          "to_s": 7200,
          "equals": true
        },
        {
          "output": "yProcessUnstable",
          "from_s": 1800,
          "to_s": 7200,
          "equals": false
        }
      ]
    },
    {
      "name": "pv_mae_just_over_threshold",
      "description": "A 10.1-unit mean absolute PV error brackets the process boundary from the active side while the speed branch is materially hunting.",
      "inputs": {
        "vfd_speed": [
          {
            "t": 0,
            "value": 60.0
          },
          {
            "t": 120,
            "value": 40.0
          },
          {
            "t": 240,
            "value": 60.0
          },
          {
            "t": 360,
            "value": 40.0
          },
          {
            "t": 480,
            "value": 60.0
          },
          {
            "t": 600,
            "value": 40.0
          },
          {
            "t": 720,
            "value": 60.0
          },
          {
            "t": 840,
            "value": 40.0
          },
          {
            "t": 960,
            "value": 60.0
          },
          {
            "t": 1080,
            "value": 40.0
          },
          {
            "t": 1200,
            "value": 60.0
          },
          {
            "t": 1320,
            "value": 40.0
          },
          {
            "t": 1440,
            "value": 60.0
          },
          {
            "t": 1560,
            "value": 40.0
          },
          {
            "t": 1680,
            "value": 60.0
          },
          {
            "t": 1800,
            "value": 40.0
          },
          {
            "t": 1920,
            "value": 60.0
          },
          {
            "t": 2040,
            "value": 40.0
          },
          {
            "t": 2160,
            "value": 60.0
          },
          {
            "t": 2280,
            "value": 40.0
          },
          {
            "t": 2400,
            "value": 60.0
          },
          {
            "t": 2520,
            "value": 40.0
          },
          {
            "t": 2640,
            "value": 60.0
          },
          {
            "t": 2760,
            "value": 40.0
          },
          {
            "t": 2880,
            "value": 60.0
          },
          {
            "t": 3000,
            "value": 40.0
          },
          {
            "t": 3120,
            "value": 60.0
          },
          {
            "t": 3240,
            "value": 40.0
          },
          {
            "t": 3360,
            "value": 60.0
          },
          {
            "t": 3480,
            "value": 40.0
          },
          {
            "t": 3600,
            "value": 60.0
          },
          {
            "t": 3720,
            "value": 40.0
          },
          {
            "t": 3840,
            "value": 60.0
          },
          {
            "t": 3960,
            "value": 40.0
          },
          {
            "t": 4080,
            "value": 60.0
          },
          {
            "t": 4200,
            "value": 40.0
          },
          {
            "t": 4320,
            "value": 60.0
          },
          {
            "t": 4440,
            "value": 40.0
          },
          {
            "t": 4560,
            "value": 60.0
          },
          {
            "t": 4680,
            "value": 40.0
          },
          {
            "t": 4800,
            "value": 60.0
          },
          {
            "t": 4920,
            "value": 40.0
          },
          {
            "t": 5040,
            "value": 60.0
          },
          {
            "t": 5160,
            "value": 40.0
          },
          {
            "t": 5280,
            "value": 60.0
          },
          {
            "t": 5400,
            "value": 40.0
          },
          {
            "t": 5520,
            "value": 60.0
          },
          {
            "t": 5640,
            "value": 40.0
          },
          {
            "t": 5760,
            "value": 60.0
          },
          {
            "t": 5880,
            "value": 40.0
          },
          {
            "t": 6000,
            "value": 60.0
          },
          {
            "t": 6120,
            "value": 40.0
          },
          {
            "t": 6240,
            "value": 60.0
          },
          {
            "t": 6360,
            "value": 40.0
          },
          {
            "t": 6480,
            "value": 60.0
          },
          {
            "t": 6600,
            "value": 40.0
          },
          {
            "t": 6720,
            "value": 60.0
          },
          {
            "t": 6840,
            "value": 40.0
          },
          {
            "t": 6960,
            "value": 60.0
          },
          {
            "t": 7080,
            "value": 40.0
          }
        ],
        "vfd_process_value": [
          {
            "t": 0,
            "value": 110.1
          },
          {
            "t": 120,
            "value": 89.9
          },
          {
            "t": 240,
            "value": 110.1
          },
          {
            "t": 360,
            "value": 89.9
          },
          {
            "t": 480,
            "value": 110.1
          },
          {
            "t": 600,
            "value": 89.9
          },
          {
            "t": 720,
            "value": 110.1
          },
          {
            "t": 840,
            "value": 89.9
          },
          {
            "t": 960,
            "value": 110.1
          },
          {
            "t": 1080,
            "value": 89.9
          },
          {
            "t": 1200,
            "value": 110.1
          },
          {
            "t": 1320,
            "value": 89.9
          },
          {
            "t": 1440,
            "value": 110.1
          },
          {
            "t": 1560,
            "value": 89.9
          },
          {
            "t": 1680,
            "value": 110.1
          },
          {
            "t": 1800,
            "value": 89.9
          },
          {
            "t": 1920,
            "value": 110.1
          },
          {
            "t": 2040,
            "value": 89.9
          },
          {
            "t": 2160,
            "value": 110.1
          },
          {
            "t": 2280,
            "value": 89.9
          },
          {
            "t": 2400,
            "value": 110.1
          },
          {
            "t": 2520,
            "value": 89.9
          },
          {
            "t": 2640,
            "value": 110.1
          },
          {
            "t": 2760,
            "value": 89.9
          },
          {
            "t": 2880,
            "value": 110.1
          },
          {
            "t": 3000,
            "value": 89.9
          },
          {
            "t": 3120,
            "value": 110.1
          },
          {
            "t": 3240,
            "value": 89.9
          },
          {
            "t": 3360,
            "value": 110.1
          },
          {
            "t": 3480,
            "value": 89.9
          },
          {
            "t": 3600,
            "value": 110.1
          },
          {
            "t": 3720,
            "value": 89.9
          },
          {
            "t": 3840,
            "value": 110.1
          },
          {
            "t": 3960,
            "value": 89.9
          },
          {
            "t": 4080,
            "value": 110.1
          },
          {
            "t": 4200,
            "value": 89.9
          },
          {
            "t": 4320,
            "value": 110.1
          },
          {
            "t": 4440,
            "value": 89.9
          },
          {
            "t": 4560,
            "value": 110.1
          },
          {
            "t": 4680,
            "value": 89.9
          },
          {
            "t": 4800,
            "value": 110.1
          },
          {
            "t": 4920,
            "value": 89.9
          },
          {
            "t": 5040,
            "value": 110.1
          },
          {
            "t": 5160,
            "value": 89.9
          },
          {
            "t": 5280,
            "value": 110.1
          },
          {
            "t": 5400,
            "value": 89.9
          },
          {
            "t": 5520,
            "value": 110.1
          },
          {
            "t": 5640,
            "value": 89.9
          },
          {
            "t": 5760,
            "value": 110.1
          },
          {
            "t": 5880,
            "value": 89.9
          },
          {
            "t": 6000,
            "value": 110.1
          },
          {
            "t": 6120,
            "value": 89.9
          },
          {
            "t": 6240,
            "value": 110.1
          },
          {
            "t": 6360,
            "value": 89.9
          },
          {
            "t": 6480,
            "value": 110.1
          },
          {
            "t": 6600,
            "value": 89.9
          },
          {
            "t": 6720,
            "value": 110.1
          },
          {
            "t": 6840,
            "value": 89.9
          },
          {
            "t": 6960,
            "value": 110.1
          },
          {
            "t": 7080,
            "value": 89.9
          }
        ],
        "vfd_process_sp": 100.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 2400,
          "to_s": 7200,
          "equals": true
        },
        {
          "output": "ySpeedHunting",
          "from_s": 1800,
          "to_s": 7200,
          "equals": true
        },
        {
          "output": "yProcessUnstable",
          "from_s": 1800,
          "to_s": 7200,
          "equals": true
        }
      ]
    },
    {
      "name": "persistent_both_error_directions",
      "description": "Material speed hunting paired with PV alternating above and below setpoint proves the process statistic is symmetric and the combined fault persists.",
      "inputs": {
        "vfd_speed": [
          {
            "t": 0,
            "value": 60.0
          },
          {
            "t": 120,
            "value": 40.0
          },
          {
            "t": 240,
            "value": 60.0
          },
          {
            "t": 360,
            "value": 40.0
          },
          {
            "t": 480,
            "value": 60.0
          },
          {
            "t": 600,
            "value": 40.0
          },
          {
            "t": 720,
            "value": 60.0
          },
          {
            "t": 840,
            "value": 40.0
          },
          {
            "t": 960,
            "value": 60.0
          },
          {
            "t": 1080,
            "value": 40.0
          },
          {
            "t": 1200,
            "value": 60.0
          },
          {
            "t": 1320,
            "value": 40.0
          },
          {
            "t": 1440,
            "value": 60.0
          },
          {
            "t": 1560,
            "value": 40.0
          },
          {
            "t": 1680,
            "value": 60.0
          },
          {
            "t": 1800,
            "value": 40.0
          },
          {
            "t": 1920,
            "value": 60.0
          },
          {
            "t": 2040,
            "value": 40.0
          },
          {
            "t": 2160,
            "value": 60.0
          },
          {
            "t": 2280,
            "value": 40.0
          },
          {
            "t": 2400,
            "value": 60.0
          },
          {
            "t": 2520,
            "value": 40.0
          },
          {
            "t": 2640,
            "value": 60.0
          },
          {
            "t": 2760,
            "value": 40.0
          },
          {
            "t": 2880,
            "value": 60.0
          },
          {
            "t": 3000,
            "value": 40.0
          },
          {
            "t": 3120,
            "value": 60.0
          },
          {
            "t": 3240,
            "value": 40.0
          },
          {
            "t": 3360,
            "value": 60.0
          },
          {
            "t": 3480,
            "value": 40.0
          },
          {
            "t": 3600,
            "value": 60.0
          },
          {
            "t": 3720,
            "value": 40.0
          },
          {
            "t": 3840,
            "value": 60.0
          },
          {
            "t": 3960,
            "value": 40.0
          },
          {
            "t": 4080,
            "value": 60.0
          },
          {
            "t": 4200,
            "value": 40.0
          },
          {
            "t": 4320,
            "value": 60.0
          },
          {
            "t": 4440,
            "value": 40.0
          },
          {
            "t": 4560,
            "value": 60.0
          },
          {
            "t": 4680,
            "value": 40.0
          },
          {
            "t": 4800,
            "value": 60.0
          },
          {
            "t": 4920,
            "value": 40.0
          },
          {
            "t": 5040,
            "value": 60.0
          },
          {
            "t": 5160,
            "value": 40.0
          },
          {
            "t": 5280,
            "value": 60.0
          },
          {
            "t": 5400,
            "value": 40.0
          },
          {
            "t": 5520,
            "value": 60.0
          },
          {
            "t": 5640,
            "value": 40.0
          },
          {
            "t": 5760,
            "value": 60.0
          },
          {
            "t": 5880,
            "value": 40.0
          },
          {
            "t": 6000,
            "value": 60.0
          },
          {
            "t": 6120,
            "value": 40.0
          },
          {
            "t": 6240,
            "value": 60.0
          },
          {
            "t": 6360,
            "value": 40.0
          },
          {
            "t": 6480,
            "value": 60.0
          },
          {
            "t": 6600,
            "value": 40.0
          },
          {
            "t": 6720,
            "value": 60.0
          },
          {
            "t": 6840,
            "value": 40.0
          },
          {
            "t": 6960,
            "value": 60.0
          },
          {
            "t": 7080,
            "value": 40.0
          }
        ],
        "vfd_process_value": [
          {
            "t": 0,
            "value": 112.0
          },
          {
            "t": 120,
            "value": 88.0
          },
          {
            "t": 240,
            "value": 112.0
          },
          {
            "t": 360,
            "value": 88.0
          },
          {
            "t": 480,
            "value": 112.0
          },
          {
            "t": 600,
            "value": 88.0
          },
          {
            "t": 720,
            "value": 112.0
          },
          {
            "t": 840,
            "value": 88.0
          },
          {
            "t": 960,
            "value": 112.0
          },
          {
            "t": 1080,
            "value": 88.0
          },
          {
            "t": 1200,
            "value": 112.0
          },
          {
            "t": 1320,
            "value": 88.0
          },
          {
            "t": 1440,
            "value": 112.0
          },
          {
            "t": 1560,
            "value": 88.0
          },
          {
            "t": 1680,
            "value": 112.0
          },
          {
            "t": 1800,
            "value": 88.0
          },
          {
            "t": 1920,
            "value": 112.0
          },
          {
            "t": 2040,
            "value": 88.0
          },
          {
            "t": 2160,
            "value": 112.0
          },
          {
            "t": 2280,
            "value": 88.0
          },
          {
            "t": 2400,
            "value": 112.0
          },
          {
            "t": 2520,
            "value": 88.0
          },
          {
            "t": 2640,
            "value": 112.0
          },
          {
            "t": 2760,
            "value": 88.0
          },
          {
            "t": 2880,
            "value": 112.0
          },
          {
            "t": 3000,
            "value": 88.0
          },
          {
            "t": 3120,
            "value": 112.0
          },
          {
            "t": 3240,
            "value": 88.0
          },
          {
            "t": 3360,
            "value": 112.0
          },
          {
            "t": 3480,
            "value": 88.0
          },
          {
            "t": 3600,
            "value": 112.0
          },
          {
            "t": 3720,
            "value": 88.0
          },
          {
            "t": 3840,
            "value": 112.0
          },
          {
            "t": 3960,
            "value": 88.0
          },
          {
            "t": 4080,
            "value": 112.0
          },
          {
            "t": 4200,
            "value": 88.0
          },
          {
            "t": 4320,
            "value": 112.0
          },
          {
            "t": 4440,
            "value": 88.0
          },
          {
            "t": 4560,
            "value": 112.0
          },
          {
            "t": 4680,
            "value": 88.0
          },
          {
            "t": 4800,
            "value": 112.0
          },
          {
            "t": 4920,
            "value": 88.0
          },
          {
            "t": 5040,
            "value": 112.0
          },
          {
            "t": 5160,
            "value": 88.0
          },
          {
            "t": 5280,
            "value": 112.0
          },
          {
            "t": 5400,
            "value": 88.0
          },
          {
            "t": 5520,
            "value": 112.0
          },
          {
            "t": 5640,
            "value": 88.0
          },
          {
            "t": 5760,
            "value": 112.0
          },
          {
            "t": 5880,
            "value": 88.0
          },
          {
            "t": 6000,
            "value": 112.0
          },
          {
            "t": 6120,
            "value": 88.0
          },
          {
            "t": 6240,
            "value": 112.0
          },
          {
            "t": 6360,
            "value": 88.0
          },
          {
            "t": 6480,
            "value": 112.0
          },
          {
            "t": 6600,
            "value": 88.0
          },
          {
            "t": 6720,
            "value": 112.0
          },
          {
            "t": 6840,
            "value": 88.0
          },
          {
            "t": 6960,
            "value": 112.0
          },
          {
            "t": 7080,
            "value": 88.0
          }
        ],
        "vfd_process_sp": 100.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 2400,
          "to_s": 7200,
          "equals": true
        },
        {
          "output": "ySpeedHunting",
          "from_s": 1800,
          "to_s": 7200,
          "equals": true
        },
        {
          "output": "yProcessUnstable",
          "from_s": 1800,
          "to_s": 7200,
          "equals": true
        }
      ]
    },
    {
      "name": "one_sided_oscillatory_process_offset",
      "description": "PV oscillates between 20 and 30 units above setpoint while speed hunts. Crossings around the rolling process-error mean plus MAE from the real setpoint intentionally classify this one-sided oscillatory offset as unstable; this is broader than NIST's two-sided allowance-band CUSUM.",
      "inputs": {
        "vfd_speed": [
          {
            "t": 0,
            "value": 60.0
          },
          {
            "t": 120,
            "value": 40.0
          },
          {
            "t": 240,
            "value": 60.0
          },
          {
            "t": 360,
            "value": 40.0
          },
          {
            "t": 480,
            "value": 60.0
          },
          {
            "t": 600,
            "value": 40.0
          },
          {
            "t": 720,
            "value": 60.0
          },
          {
            "t": 840,
            "value": 40.0
          },
          {
            "t": 960,
            "value": 60.0
          },
          {
            "t": 1080,
            "value": 40.0
          },
          {
            "t": 1200,
            "value": 60.0
          },
          {
            "t": 1320,
            "value": 40.0
          },
          {
            "t": 1440,
            "value": 60.0
          },
          {
            "t": 1560,
            "value": 40.0
          },
          {
            "t": 1680,
            "value": 60.0
          },
          {
            "t": 1800,
            "value": 40.0
          },
          {
            "t": 1920,
            "value": 60.0
          },
          {
            "t": 2040,
            "value": 40.0
          },
          {
            "t": 2160,
            "value": 60.0
          },
          {
            "t": 2280,
            "value": 40.0
          },
          {
            "t": 2400,
            "value": 60.0
          },
          {
            "t": 2520,
            "value": 40.0
          },
          {
            "t": 2640,
            "value": 60.0
          },
          {
            "t": 2760,
            "value": 40.0
          },
          {
            "t": 2880,
            "value": 60.0
          },
          {
            "t": 3000,
            "value": 40.0
          },
          {
            "t": 3120,
            "value": 60.0
          },
          {
            "t": 3240,
            "value": 40.0
          },
          {
            "t": 3360,
            "value": 60.0
          },
          {
            "t": 3480,
            "value": 40.0
          },
          {
            "t": 3600,
            "value": 60.0
          },
          {
            "t": 3720,
            "value": 40.0
          },
          {
            "t": 3840,
            "value": 60.0
          },
          {
            "t": 3960,
            "value": 40.0
          },
          {
            "t": 4080,
            "value": 60.0
          },
          {
            "t": 4200,
            "value": 40.0
          },
          {
            "t": 4320,
            "value": 60.0
          },
          {
            "t": 4440,
            "value": 40.0
          },
          {
            "t": 4560,
            "value": 60.0
          },
          {
            "t": 4680,
            "value": 40.0
          },
          {
            "t": 4800,
            "value": 60.0
          },
          {
            "t": 4920,
            "value": 40.0
          },
          {
            "t": 5040,
            "value": 60.0
          },
          {
            "t": 5160,
            "value": 40.0
          },
          {
            "t": 5280,
            "value": 60.0
          },
          {
            "t": 5400,
            "value": 40.0
          },
          {
            "t": 5520,
            "value": 60.0
          },
          {
            "t": 5640,
            "value": 40.0
          },
          {
            "t": 5760,
            "value": 60.0
          },
          {
            "t": 5880,
            "value": 40.0
          },
          {
            "t": 6000,
            "value": 60.0
          },
          {
            "t": 6120,
            "value": 40.0
          },
          {
            "t": 6240,
            "value": 60.0
          },
          {
            "t": 6360,
            "value": 40.0
          },
          {
            "t": 6480,
            "value": 60.0
          },
          {
            "t": 6600,
            "value": 40.0
          },
          {
            "t": 6720,
            "value": 60.0
          },
          {
            "t": 6840,
            "value": 40.0
          },
          {
            "t": 6960,
            "value": 60.0
          },
          {
            "t": 7080,
            "value": 40.0
          }
        ],
        "vfd_process_value": [
          {
            "t": 0,
            "value": 130.0
          },
          {
            "t": 120,
            "value": 120.0
          },
          {
            "t": 240,
            "value": 130.0
          },
          {
            "t": 360,
            "value": 120.0
          },
          {
            "t": 480,
            "value": 130.0
          },
          {
            "t": 600,
            "value": 120.0
          },
          {
            "t": 720,
            "value": 130.0
          },
          {
            "t": 840,
            "value": 120.0
          },
          {
            "t": 960,
            "value": 130.0
          },
          {
            "t": 1080,
            "value": 120.0
          },
          {
            "t": 1200,
            "value": 130.0
          },
          {
            "t": 1320,
            "value": 120.0
          },
          {
            "t": 1440,
            "value": 130.0
          },
          {
            "t": 1560,
            "value": 120.0
          },
          {
            "t": 1680,
            "value": 130.0
          },
          {
            "t": 1800,
            "value": 120.0
          },
          {
            "t": 1920,
            "value": 130.0
          },
          {
            "t": 2040,
            "value": 120.0
          },
          {
            "t": 2160,
            "value": 130.0
          },
          {
            "t": 2280,
            "value": 120.0
          },
          {
            "t": 2400,
            "value": 130.0
          },
          {
            "t": 2520,
            "value": 120.0
          },
          {
            "t": 2640,
            "value": 130.0
          },
          {
            "t": 2760,
            "value": 120.0
          },
          {
            "t": 2880,
            "value": 130.0
          },
          {
            "t": 3000,
            "value": 120.0
          },
          {
            "t": 3120,
            "value": 130.0
          },
          {
            "t": 3240,
            "value": 120.0
          },
          {
            "t": 3360,
            "value": 130.0
          },
          {
            "t": 3480,
            "value": 120.0
          },
          {
            "t": 3600,
            "value": 130.0
          },
          {
            "t": 3720,
            "value": 120.0
          },
          {
            "t": 3840,
            "value": 130.0
          },
          {
            "t": 3960,
            "value": 120.0
          },
          {
            "t": 4080,
            "value": 130.0
          },
          {
            "t": 4200,
            "value": 120.0
          },
          {
            "t": 4320,
            "value": 130.0
          },
          {
            "t": 4440,
            "value": 120.0
          },
          {
            "t": 4560,
            "value": 130.0
          },
          {
            "t": 4680,
            "value": 120.0
          },
          {
            "t": 4800,
            "value": 130.0
          },
          {
            "t": 4920,
            "value": 120.0
          },
          {
            "t": 5040,
            "value": 130.0
          },
          {
            "t": 5160,
            "value": 120.0
          },
          {
            "t": 5280,
            "value": 130.0
          },
          {
            "t": 5400,
            "value": 120.0
          },
          {
            "t": 5520,
            "value": 130.0
          },
          {
            "t": 5640,
            "value": 120.0
          },
          {
            "t": 5760,
            "value": 130.0
          },
          {
            "t": 5880,
            "value": 120.0
          },
          {
            "t": 6000,
            "value": 130.0
          },
          {
            "t": 6120,
            "value": 120.0
          },
          {
            "t": 6240,
            "value": 130.0
          },
          {
            "t": 6360,
            "value": 120.0
          },
          {
            "t": 6480,
            "value": 130.0
          },
          {
            "t": 6600,
            "value": 120.0
          },
          {
            "t": 6720,
            "value": 130.0
          },
          {
            "t": 6840,
            "value": 120.0
          },
          {
            "t": 6960,
            "value": 130.0
          },
          {
            "t": 7080,
            "value": 120.0
          }
        ],
        "vfd_process_sp": 100.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 2400,
          "to_s": 7200,
          "equals": true
        },
        {
          "output": "ySpeedHunting",
          "from_s": 1800,
          "to_s": 7200,
          "equals": true
        },
        {
          "output": "yProcessUnstable",
          "from_s": 1800,
          "to_s": 7200,
          "equals": true
        }
      ]
    },
    {
      "name": "window_aging_clears",
      "description": "The loop hunts through t=3600 s and then speed settles while PV remains 11 units off setpoint. The process diagnostic stays active; speed crossings and MAD must age out before the fault clears.",
      "inputs": {
        "vfd_speed": [
          {
            "t": 0,
            "value": 60.0
          },
          {
            "t": 120,
            "value": 40.0
          },
          {
            "t": 240,
            "value": 60.0
          },
          {
            "t": 360,
            "value": 40.0
          },
          {
            "t": 480,
            "value": 60.0
          },
          {
            "t": 600,
            "value": 40.0
          },
          {
            "t": 720,
            "value": 60.0
          },
          {
            "t": 840,
            "value": 40.0
          },
          {
            "t": 960,
            "value": 60.0
          },
          {
            "t": 1080,
            "value": 40.0
          },
          {
            "t": 1200,
            "value": 60.0
          },
          {
            "t": 1320,
            "value": 40.0
          },
          {
            "t": 1440,
            "value": 60.0
          },
          {
            "t": 1560,
            "value": 40.0
          },
          {
            "t": 1680,
            "value": 60.0
          },
          {
            "t": 1800,
            "value": 40.0
          },
          {
            "t": 1920,
            "value": 60.0
          },
          {
            "t": 2040,
            "value": 40.0
          },
          {
            "t": 2160,
            "value": 60.0
          },
          {
            "t": 2280,
            "value": 40.0
          },
          {
            "t": 2400,
            "value": 60.0
          },
          {
            "t": 2520,
            "value": 40.0
          },
          {
            "t": 2640,
            "value": 60.0
          },
          {
            "t": 2760,
            "value": 40.0
          },
          {
            "t": 2880,
            "value": 60.0
          },
          {
            "t": 3000,
            "value": 40.0
          },
          {
            "t": 3120,
            "value": 60.0
          },
          {
            "t": 3240,
            "value": 40.0
          },
          {
            "t": 3360,
            "value": 60.0
          },
          {
            "t": 3480,
            "value": 40.0
          },
          {
            "t": 3600,
            "value": 50.0
          }
        ],
        "vfd_process_value": [
          {
            "t": 0,
            "value": 112.0
          },
          {
            "t": 120,
            "value": 88.0
          },
          {
            "t": 240,
            "value": 112.0
          },
          {
            "t": 360,
            "value": 88.0
          },
          {
            "t": 480,
            "value": 112.0
          },
          {
            "t": 600,
            "value": 88.0
          },
          {
            "t": 720,
            "value": 112.0
          },
          {
            "t": 840,
            "value": 88.0
          },
          {
            "t": 960,
            "value": 112.0
          },
          {
            "t": 1080,
            "value": 88.0
          },
          {
            "t": 1200,
            "value": 112.0
          },
          {
            "t": 1320,
            "value": 88.0
          },
          {
            "t": 1440,
            "value": 112.0
          },
          {
            "t": 1560,
            "value": 88.0
          },
          {
            "t": 1680,
            "value": 112.0
          },
          {
            "t": 1800,
            "value": 88.0
          },
          {
            "t": 1920,
            "value": 112.0
          },
          {
            "t": 2040,
            "value": 88.0
          },
          {
            "t": 2160,
            "value": 112.0
          },
          {
            "t": 2280,
            "value": 88.0
          },
          {
            "t": 2400,
            "value": 112.0
          },
          {
            "t": 2520,
            "value": 88.0
          },
          {
            "t": 2640,
            "value": 112.0
          },
          {
            "t": 2760,
            "value": 88.0
          },
          {
            "t": 2880,
            "value": 112.0
          },
          {
            "t": 3000,
            "value": 88.0
          },
          {
            "t": 3120,
            "value": 112.0
          },
          {
            "t": 3240,
            "value": 88.0
          },
          {
            "t": 3360,
            "value": 112.0
          },
          {
            "t": 3480,
            "value": 88.0
          },
          {
            "t": 3600,
            "value": 112.0
          },
          {
            "t": 3720,
            "value": 88.0
          },
          {
            "t": 3840,
            "value": 112.0
          },
          {
            "t": 3960,
            "value": 88.0
          },
          {
            "t": 4080,
            "value": 112.0
          },
          {
            "t": 4200,
            "value": 88.0
          },
          {
            "t": 4320,
            "value": 112.0
          },
          {
            "t": 4440,
            "value": 88.0
          },
          {
            "t": 4560,
            "value": 112.0
          },
          {
            "t": 4680,
            "value": 88.0
          },
          {
            "t": 4800,
            "value": 112.0
          },
          {
            "t": 4920,
            "value": 88.0
          },
          {
            "t": 5040,
            "value": 112.0
          },
          {
            "t": 5160,
            "value": 88.0
          },
          {
            "t": 5280,
            "value": 112.0
          },
          {
            "t": 5400,
            "value": 88.0
          },
          {
            "t": 5520,
            "value": 112.0
          },
          {
            "t": 5640,
            "value": 88.0
          },
          {
            "t": 5760,
            "value": 112.0
          },
          {
            "t": 5880,
            "value": 88.0
          },
          {
            "t": 6000,
            "value": 112.0
          },
          {
            "t": 6120,
            "value": 88.0
          },
          {
            "t": 6240,
            "value": 112.0
          },
          {
            "t": 6360,
            "value": 88.0
          },
          {
            "t": 6480,
            "value": 112.0
          },
          {
            "t": 6600,
            "value": 88.0
          },
          {
            "t": 6720,
            "value": 112.0
          },
          {
            "t": 6840,
            "value": 88.0
          },
          {
            "t": 6960,
            "value": 112.0
          },
          {
            "t": 7080,
            "value": 88.0
          }
        ],
        "vfd_process_sp": 100.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 2400,
          "to_s": 3480,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 5400,
          "to_s": 7200,
          "equals": false
        },
        {
          "output": "ySpeedHunting",
          "from_s": 1800,
          "to_s": 3480,
          "equals": true
        },
        {
          "output": "ySpeedHunting",
          "from_s": 5400,
          "to_s": 7200,
          "equals": false
        },
        {
          "output": "yProcessUnstable",
          "from_s": 1800,
          "to_s": 7200,
          "equals": true
        }
      ]
    },
    {
      "name": "warmup_burst_raw_behavior",
      "description": "Eight tick-rate speed changes during the partial-window fill can be extrapolated above the rolling count threshold while PV is off setpoint. The raw result is pinned; hosts must report NO_EVAL during the first evaluation window.",
      "inputs": {
        "vfd_speed": [
          {
            "t": 0,
            "value": 70.0
          },
          {
            "t": 60,
            "value": 30.0
          },
          {
            "t": 120,
            "value": 70.0
          },
          {
            "t": 180,
            "value": 30.0
          },
          {
            "t": 240,
            "value": 70.0
          },
          {
            "t": 300,
            "value": 30.0
          },
          {
            "t": 360,
            "value": 70.0
          },
          {
            "t": 420,
            "value": 30.0
          },
          {
            "t": 480,
            "value": 50.0
          }
        ],
        "vfd_process_value": [
          {
            "t": 0,
            "value": 112.0
          },
          {
            "t": 120,
            "value": 88.0
          },
          {
            "t": 240,
            "value": 112.0
          },
          {
            "t": 360,
            "value": 88.0
          },
          {
            "t": 480,
            "value": 112.0
          },
          {
            "t": 600,
            "value": 88.0
          },
          {
            "t": 720,
            "value": 112.0
          },
          {
            "t": 840,
            "value": 88.0
          },
          {
            "t": 960,
            "value": 112.0
          },
          {
            "t": 1080,
            "value": 88.0
          },
          {
            "t": 1200,
            "value": 112.0
          },
          {
            "t": 1320,
            "value": 88.0
          },
          {
            "t": 1440,
            "value": 112.0
          },
          {
            "t": 1560,
            "value": 88.0
          },
          {
            "t": 1680,
            "value": 112.0
          },
          {
            "t": 1800,
            "value": 88.0
          },
          {
            "t": 1920,
            "value": 112.0
          },
          {
            "t": 2040,
            "value": 88.0
          },
          {
            "t": 2160,
            "value": 112.0
          },
          {
            "t": 2280,
            "value": 88.0
          },
          {
            "t": 2400,
            "value": 112.0
          },
          {
            "t": 2520,
            "value": 88.0
          },
          {
            "t": 2640,
            "value": 112.0
          },
          {
            "t": 2760,
            "value": 88.0
          },
          {
            "t": 2880,
            "value": 112.0
          },
          {
            "t": 3000,
            "value": 88.0
          },
          {
            "t": 3120,
            "value": 112.0
          },
          {
            "t": 3240,
            "value": 88.0
          },
          {
            "t": 3360,
            "value": 112.0
          },
          {
            "t": 3480,
            "value": 88.0
          },
          {
            "t": 3600,
            "value": 112.0
          },
          {
            "t": 3720,
            "value": 88.0
          },
          {
            "t": 3840,
            "value": 112.0
          },
          {
            "t": 3960,
            "value": 88.0
          },
          {
            "t": 4080,
            "value": 112.0
          },
          {
            "t": 4200,
            "value": 88.0
          },
          {
            "t": 4320,
            "value": 112.0
          },
          {
            "t": 4440,
            "value": 88.0
          },
          {
            "t": 4560,
            "value": 112.0
          },
          {
            "t": 4680,
            "value": 88.0
          },
          {
            "t": 4800,
            "value": 112.0
          },
          {
            "t": 4920,
            "value": 88.0
          },
          {
            "t": 5040,
            "value": 112.0
          },
          {
            "t": 5160,
            "value": 88.0
          },
          {
            "t": 5280,
            "value": 112.0
          },
          {
            "t": 5400,
            "value": 88.0
          },
          {
            "t": 5520,
            "value": 112.0
          },
          {
            "t": 5640,
            "value": 88.0
          },
          {
            "t": 5760,
            "value": 112.0
          },
          {
            "t": 5880,
            "value": 88.0
          },
          {
            "t": 6000,
            "value": 112.0
          },
          {
            "t": 6120,
            "value": 88.0
          },
          {
            "t": 6240,
            "value": 112.0
          },
          {
            "t": 6360,
            "value": 88.0
          },
          {
            "t": 6480,
            "value": 112.0
          },
          {
            "t": 6600,
            "value": 88.0
          },
          {
            "t": 6720,
            "value": 112.0
          },
          {
            "t": 6840,
            "value": 88.0
          },
          {
            "t": 6960,
            "value": 112.0
          },
          {
            "t": 7080,
            "value": 88.0
          }
        ],
        "vfd_process_sp": 100.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 600,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 720,
          "to_s": 840,
          "equals": true
        },
        {
          "output": "ySpeedHunting",
          "from_s": 300,
          "to_s": 840,
          "equals": true
        },
        {
          "output": "yProcessUnstable",
          "from_s": 420,
          "to_s": 840,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 3000,
          "to_s": 7200,
          "equals": false
        },
        {
          "output": "ySpeedHunting",
          "from_s": 3000,
          "to_s": 7200,
          "equals": false
        },
        {
          "output": "yProcessUnstable",
          "from_s": 1800,
          "to_s": 7200,
          "equals": true
        }
      ]
    },
    {
      "name": "setpoint_step_excluded_raw_behavior",
      "description": "A setpoint step at t=1800 followed by sustained materially reversing speed raises the raw diagnostics and fault. Deployment must instead report NO_EVAL until reset and disturbance response are settled.",
      "inputs": {
        "vfd_speed": [
          {
            "t": 0,
            "value": 50.0
          },
          {
            "t": 1800,
            "value": 60.0
          },
          {
            "t": 1920,
            "value": 40.0
          },
          {
            "t": 2040,
            "value": 60.0
          },
          {
            "t": 2160,
            "value": 40.0
          },
          {
            "t": 2280,
            "value": 60.0
          },
          {
            "t": 2400,
            "value": 40.0
          },
          {
            "t": 2520,
            "value": 60.0
          },
          {
            "t": 2640,
            "value": 40.0
          },
          {
            "t": 2760,
            "value": 60.0
          },
          {
            "t": 2880,
            "value": 40.0
          },
          {
            "t": 3000,
            "value": 60.0
          },
          {
            "t": 3120,
            "value": 40.0
          },
          {
            "t": 3240,
            "value": 60.0
          },
          {
            "t": 3360,
            "value": 40.0
          },
          {
            "t": 3480,
            "value": 60.0
          },
          {
            "t": 3600,
            "value": 40.0
          },
          {
            "t": 3720,
            "value": 60.0
          },
          {
            "t": 3840,
            "value": 40.0
          },
          {
            "t": 3960,
            "value": 60.0
          },
          {
            "t": 4080,
            "value": 40.0
          },
          {
            "t": 4200,
            "value": 60.0
          },
          {
            "t": 4320,
            "value": 40.0
          },
          {
            "t": 4440,
            "value": 60.0
          },
          {
            "t": 4560,
            "value": 40.0
          },
          {
            "t": 4680,
            "value": 60.0
          },
          {
            "t": 4800,
            "value": 40.0
          },
          {
            "t": 4920,
            "value": 60.0
          },
          {
            "t": 5040,
            "value": 40.0
          },
          {
            "t": 5160,
            "value": 60.0
          },
          {
            "t": 5280,
            "value": 40.0
          },
          {
            "t": 5400,
            "value": 60.0
          },
          {
            "t": 5520,
            "value": 40.0
          },
          {
            "t": 5640,
            "value": 60.0
          },
          {
            "t": 5760,
            "value": 40.0
          },
          {
            "t": 5880,
            "value": 60.0
          },
          {
            "t": 6000,
            "value": 40.0
          },
          {
            "t": 6120,
            "value": 60.0
          },
          {
            "t": 6240,
            "value": 40.0
          },
          {
            "t": 6360,
            "value": 60.0
          },
          {
            "t": 6480,
            "value": 40.0
          },
          {
            "t": 6600,
            "value": 60.0
          },
          {
            "t": 6720,
            "value": 40.0
          },
          {
            "t": 6840,
            "value": 60.0
          },
          {
            "t": 6960,
            "value": 40.0
          },
          {
            "t": 7080,
            "value": 60.0
          }
        ],
        "vfd_process_value": [
          {
            "t": 0,
            "value": 100.0
          },
          {
            "t": 1800,
            "value": 124.0
          },
          {
            "t": 1920,
            "value": 100.0
          },
          {
            "t": 2040,
            "value": 124.0
          },
          {
            "t": 2160,
            "value": 100.0
          },
          {
            "t": 2280,
            "value": 124.0
          },
          {
            "t": 2400,
            "value": 100.0
          },
          {
            "t": 2520,
            "value": 124.0
          },
          {
            "t": 2640,
            "value": 100.0
          },
          {
            "t": 2760,
            "value": 124.0
          },
          {
            "t": 2880,
            "value": 100.0
          },
          {
            "t": 3000,
            "value": 124.0
          },
          {
            "t": 3120,
            "value": 100.0
          },
          {
            "t": 3240,
            "value": 124.0
          },
          {
            "t": 3360,
            "value": 100.0
          },
          {
            "t": 3480,
            "value": 124.0
          },
          {
            "t": 3600,
            "value": 100.0
          },
          {
            "t": 3720,
            "value": 124.0
          },
          {
            "t": 3840,
            "value": 100.0
          },
          {
            "t": 3960,
            "value": 124.0
          },
          {
            "t": 4080,
            "value": 100.0
          },
          {
            "t": 4200,
            "value": 124.0
          },
          {
            "t": 4320,
            "value": 100.0
          },
          {
            "t": 4440,
            "value": 124.0
          },
          {
            "t": 4560,
            "value": 100.0
          },
          {
            "t": 4680,
            "value": 124.0
          },
          {
            "t": 4800,
            "value": 100.0
          },
          {
            "t": 4920,
            "value": 124.0
          },
          {
            "t": 5040,
            "value": 100.0
          },
          {
            "t": 5160,
            "value": 124.0
          },
          {
            "t": 5280,
            "value": 100.0
          },
          {
            "t": 5400,
            "value": 124.0
          },
          {
            "t": 5520,
            "value": 100.0
          },
          {
            "t": 5640,
            "value": 124.0
          },
          {
            "t": 5760,
            "value": 100.0
          },
          {
            "t": 5880,
            "value": 124.0
          },
          {
            "t": 6000,
            "value": 100.0
          },
          {
            "t": 6120,
            "value": 124.0
          },
          {
            "t": 6240,
            "value": 100.0
          },
          {
            "t": 6360,
            "value": 124.0
          },
          {
            "t": 6480,
            "value": 100.0
          },
          {
            "t": 6600,
            "value": 124.0
          },
          {
            "t": 6720,
            "value": 100.0
          },
          {
            "t": 6840,
            "value": 124.0
          },
          {
            "t": 6960,
            "value": 100.0
          },
          {
            "t": 7080,
            "value": 124.0
          }
        ],
        "vfd_process_sp": [
          {
            "t": 0,
            "value": 100.0
          },
          {
            "t": 1800,
            "value": 112.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 4200,
          "to_s": 7200,
          "equals": true
        },
        {
          "output": "ySpeedHunting",
          "from_s": 3600,
          "to_s": 7200,
          "equals": true
        },
        {
          "output": "yProcessUnstable",
          "from_s": 3600,
          "to_s": 7200,
          "equals": true
        }
      ]
    },
    {
      "name": "subtick_hunting_aliases_clear",
      "description": "A host sampling once per minute cannot see an even number of reversals that occur and return between samples; the staged trace is therefore constant and every output stays clear. This pins the Nyquist limitation, not healthy plant behavior.",
      "inputs": {
        "vfd_speed": 50.0,
        "vfd_process_value": 100.0,
        "vfd_process_sp": 100.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        },
        {
          "output": "ySpeedHunting",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        },
        {
          "output": "yProcessUnstable",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        }
      ]
    }
  ]
}

VFD-0005 — VFD not in remote automatic control

Statusverified — engine e2ff2f8, cxf:fnv1a128:a947f42d0ecc74fcad7c622f071d65fe, 2026-08-20
Severity2
Methodrule
Phase2
CategoryEXCESS_CONSUMPTION
ConfidenceHIGH
EstimationQUALITATIVE_ONLY
G36
Clusters
SuppressesVFD-0002, VFD-0003, VFD-0004
Suppressed by
RelatedVFD-0001, PMP-0006
Playbooksvfd-pump-faults
SourcePNNL Variable Speed Drives O&M Best Practice — BAS-connected drive operation and the consequence that active bypass supplies full voltage to the motor; Library-authored operating-mode watchdog distinguishing final enable, verified remote-auto authority, and confirmed active bypass; Brick 1.4.4 VFD_Enable_Command and Manual_Auto_Status; ASHRAE 223 artifact 1.0.0-ppr.2.1 binary command/operating-mode property patterns (see points/vfd.points.json)
Operating statesfinal drive enable commanded while remote automatic control is expected

Preconditions (host-enforced): Remote BAS automatic operation must be expected for this exact interval. Exclude approved maintenance or emergency bypass, fire/smoke operation, commissioning, functional tests, manual tuning, and authorized local operation. vfd_enable_cmd must be the final drive-level request downstream of normal schedule and interlocks; a system enable several sequence layers upstream is insufficient. vfd_auto_status must prove the commissioned source/authority is remote BAS auto, not merely local PID auto, keypad auto, enabled, ready, or remotely on. vfd_bypass_status must prove the active motor power path, not bypass availability, readiness, or command. Unknown multi-state values and stale telemetry are NO_EVAL, not false. Suppression and evaluation must be scoped to the same drive instance.

Points: vfd_enable_cmd, vfd_auto_status, vfd_bypass_status

Outputs:

  • yFault — True after an enabled drive remains not-remote-auto or actively bypassed for mode_settle_time
  • yNotAuto — Immediate enable-gated diagnostic; true while the final enable is on and verified remote-auto status is false
  • yBypass — Immediate enable-gated diagnostic; true while the final enable is on and active bypass is proven

Parameters:

NameDefaultUnitCXF pathDescription
mode_settle_time300.0spersist.delayTimeADOPTED_TUNABLE: continuous enabled nonautomatic/bypass state required before alarm (5 min). No PNNL or manufacturer-universal default is claimed.

Description

The BAS expects to regulate a running motor, but the drive is not accepting that authority or the motor has been transferred around the drive. Either state can defeat pressure/temperature reset, force an unintended fixed or full-line speed, and make otherwise useful command, saturation, and hunting diagnostics meaningless. The finding is operational rather than electrical: it says where control authority is, not why the operator or drive put it there.

Detection Logic

not_auto = NOT vfd_auto_status

yNotAuto = vfd_enable_cmd AND not_auto
yBypass  = vfd_enable_cmd AND vfd_bypass_status
yFault   = TrueDelay(yNotAuto OR yBypass, mode_settle_time)

The CXF uses an equivalent factored candidate, vfd_enable_cmd AND (not_auto OR vfd_bypass_status), so the final enable has one meaning throughout.

Block graph (rule.cxf.jsonld):

VFD-0005 block graph

The direction outputs are immediate and enable-gated; they normally precede the fault by five minutes. A direct handoff from local mode to bypass does not reset the timer because the candidate OR never becomes false. Clearing enable or the last active mode branch drops every applicable output immediately.

Possible Diagnoses

  1. HOA/keypad selector left in hand/local after service.
  2. Drive command source configured for keypad, terminals, fieldbus, or internal PID instead of the commissioned BAS source.
  3. Active bypass contactor or integrated bypass mode left engaged.
  4. BAS mode mapping inverted or bound to an availability/command point rather than authoritative status.
  5. Normal emergency, fire/smoke, commissioning, or maintenance operation that the host failed to exclude.

Energy Impact

EXCESS_CONSUMPTION, HIGH confidence in the state when direct commissioned telemetry is present, QUALITATIVE_ONLY for magnitude. Bypass commonly applies full line voltage and local mode may defeat resets, but neither Boolean reports motor kW or load. Downgrade confidence or report NO_EVAL when the point binding cannot prove control authority and active power path.

Emissions Impact

Scope 2, qualitative. Any additional runtime or speed becomes purchased motor electricity. Use metered drive/motor energy during the verified state to size emissions; do not infer full-load kW from bypass alone.

Deviations

  • This is a library-authored watchdog. PNNL supports BAS drive monitoring and documents bypass consequences but publishes neither this Boolean equation nor a universal five-minute delay.
  • Automatic is not synonymous with remote BAS authority. Brick’s exact Manual_Auto_Status class does not distinguish local automatic control from remote BAS control, so vfd_auto_status remains provisional and deployment must verify the value mapping.
  • Brick has no exact bypass-status class in 1.4.4. Bypass_Command is not used because a request is not active-path proof. The point is provisional and may be backed by authoritative drive mode or bypass-contactor telemetry.
  • Diagnostics are enable-gated. Both flags are false while disabled; that means not applicable, not proof of healthy auto mode or an open bypass path.
  • One shared delay follows the OR. A local-to-bypass handoff without a clean automatic tick preserves accumulated time. That is intentional because remote authority never returned.
  • Approved operation is host-gated. Maintenance/emergency bypass has the same raw signature and the vectors pin that it alarms unless the host reports NO_EVAL.
  • Suppression is broader than the roadmap’s VFD-0004 example. VFD-0002 and VFD-0003 also require an active automatic loop, so this rule suppresses all three same-drive inferences. Raw mode gates should apply immediately rather than waiting for this rule’s delayed yFault.
  • persist.delayOnInit = true, so a drive already in local/bypass at engine start waits the full configured settle time.
  • No replay validation is claimed: the harness exposes no real auto-authority or bypass-path telemetry, and neither may be synthesized from speed.

Notes

Check this rule before tuning or chasing capacity. If yNotAuto is true, confirm the selector position and configured command source; if yBypass is true, confirm the contactor/power path and why it transferred. Restore authority only through the site’s approved sequence and safety procedure. Once the mode finding is resolved, VFD-0001 through VFD-0004 become interpretable again.

Test Vectors

17 scenarios, clock step 60 s over 1800 s.

ScenarioDescription
boolean_matrix_enable_false_auto_false_bypass_falseTruth-table case: enable=false, auto=false, bypass=false. Direction outputs are gated by the final enable command, and only an enabled nonautomatic/bypassed drive can mature the timer.
boolean_matrix_enable_false_auto_false_bypass_trueTruth-table case: enable=false, auto=false, bypass=true. Direction outputs are gated by the final enable command, and only an enabled nonautomatic/bypassed drive can mature the timer.
boolean_matrix_enable_false_auto_true_bypass_falseTruth-table case: enable=false, auto=true, bypass=false. Direction outputs are gated by the final enable command, and only an enabled nonautomatic/bypassed drive can mature the timer.
boolean_matrix_enable_false_auto_true_bypass_trueTruth-table case: enable=false, auto=true, bypass=true. Direction outputs are gated by the final enable command, and only an enabled nonautomatic/bypassed drive can mature the timer.
boolean_matrix_enable_true_auto_false_bypass_falseTruth-table case: enable=true, auto=false, bypass=false. Direction outputs are gated by the final enable command, and only an enabled nonautomatic/bypassed drive can mature the timer.
boolean_matrix_enable_true_auto_false_bypass_trueTruth-table case: enable=true, auto=false, bypass=true. Direction outputs are gated by the final enable command, and only an enabled nonautomatic/bypassed drive can mature the timer.
boolean_matrix_enable_true_auto_true_bypass_falseTruth-table case: enable=true, auto=true, bypass=false. Direction outputs are gated by the final enable command, and only an enabled nonautomatic/bypassed drive can mature the timer.
boolean_matrix_enable_true_auto_true_bypass_trueTruth-table case: enable=true, auto=true, bypass=true. Direction outputs are gated by the final enable command, and only an enabled nonautomatic/bypassed drive can mature the timer.
bypass_exactly_settle_timeBypass is active from t=600 through just before t=900. The staged release is applied before the 300-second boundary tick, so no fault asserts.
local_mode_transient_shorter_than_settleThe enabled drive leaves remote auto for four minutes, then returns before the 300-second settle timer matures.
transition_into_and_out_of_bypassBypass enters at t=600 s, persists long enough to alarm, and clears immediately when bypass proof drops at t=1200 s.
direct_local_to_bypass_switch_keeps_candidate_trueAn enabled drive changes directly from not-auto to bypass at t=600 s. Direction flags swap, but the OR candidate never releases and the already-matured fault remains active.
enable_release_clears_fault_and_diagnosticsA startup-local drive alarms after settling; removing the final enable at t=900 s drops yFault and both enable-gated direction outputs immediately.
auto_status_chatter_resets_timerThe enabled drive alternates auto/not-auto every minute. yNotAuto follows the raw mode, but no episode survives the five-minute settle timer.
enable_drop_resets_timerA not-auto drive is enabled for four minutes, disabled long enough to clear all flags, then re-enabled. Only the fresh second interval may mature the full timer.
simultaneous_branches_recover_one_at_a_timeBoth direction flags start active. Restoring auto at t=600 clears only yNotAuto; bypass keeps the candidate and fault active until bypass itself clears at t=1200.
approved_bypass_raw_behavior_host_gatedApproved maintenance bypass has the same raw values as an accidental bypass and therefore alarms after settling. The host must report NO_EVAL during the approved interval.
vectors.json
{
  "schema": "cxf-library/vectors/v1",
  "clock": {
    "step_s": 60,
    "horizon_s": 1800
  },
  "scenarios": [
    {
      "name": "boolean_matrix_enable_false_auto_false_bypass_false",
      "description": "Truth-table case: enable=false, auto=false, bypass=false. Direction outputs are gated by the final enable command, and only an enabled nonautomatic/bypassed drive can mature the timer.",
      "inputs": {
        "vfd_enable_cmd": false,
        "vfd_auto_status": false,
        "vfd_bypass_status": false
      },
      "expect": [
        {
          "output": "yNotAuto",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yBypass",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        }
      ]
    },
    {
      "name": "boolean_matrix_enable_false_auto_false_bypass_true",
      "description": "Truth-table case: enable=false, auto=false, bypass=true. Direction outputs are gated by the final enable command, and only an enabled nonautomatic/bypassed drive can mature the timer.",
      "inputs": {
        "vfd_enable_cmd": false,
        "vfd_auto_status": false,
        "vfd_bypass_status": true
      },
      "expect": [
        {
          "output": "yNotAuto",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yBypass",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        }
      ]
    },
    {
      "name": "boolean_matrix_enable_false_auto_true_bypass_false",
      "description": "Truth-table case: enable=false, auto=true, bypass=false. Direction outputs are gated by the final enable command, and only an enabled nonautomatic/bypassed drive can mature the timer.",
      "inputs": {
        "vfd_enable_cmd": false,
        "vfd_auto_status": true,
        "vfd_bypass_status": false
      },
      "expect": [
        {
          "output": "yNotAuto",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yBypass",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        }
      ]
    },
    {
      "name": "boolean_matrix_enable_false_auto_true_bypass_true",
      "description": "Truth-table case: enable=false, auto=true, bypass=true. Direction outputs are gated by the final enable command, and only an enabled nonautomatic/bypassed drive can mature the timer.",
      "inputs": {
        "vfd_enable_cmd": false,
        "vfd_auto_status": true,
        "vfd_bypass_status": true
      },
      "expect": [
        {
          "output": "yNotAuto",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yBypass",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        }
      ]
    },
    {
      "name": "boolean_matrix_enable_true_auto_false_bypass_false",
      "description": "Truth-table case: enable=true, auto=false, bypass=false. Direction outputs are gated by the final enable command, and only an enabled nonautomatic/bypassed drive can mature the timer.",
      "inputs": {
        "vfd_enable_cmd": true,
        "vfd_auto_status": false,
        "vfd_bypass_status": false
      },
      "expect": [
        {
          "output": "yNotAuto",
          "from_s": 0,
          "to_s": 1800,
          "equals": true
        },
        {
          "output": "yBypass",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 240,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 360,
          "to_s": 1800,
          "equals": true
        }
      ]
    },
    {
      "name": "boolean_matrix_enable_true_auto_false_bypass_true",
      "description": "Truth-table case: enable=true, auto=false, bypass=true. Direction outputs are gated by the final enable command, and only an enabled nonautomatic/bypassed drive can mature the timer.",
      "inputs": {
        "vfd_enable_cmd": true,
        "vfd_auto_status": false,
        "vfd_bypass_status": true
      },
      "expect": [
        {
          "output": "yNotAuto",
          "from_s": 0,
          "to_s": 1800,
          "equals": true
        },
        {
          "output": "yBypass",
          "from_s": 0,
          "to_s": 1800,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 240,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 360,
          "to_s": 1800,
          "equals": true
        }
      ]
    },
    {
      "name": "boolean_matrix_enable_true_auto_true_bypass_false",
      "description": "Truth-table case: enable=true, auto=true, bypass=false. Direction outputs are gated by the final enable command, and only an enabled nonautomatic/bypassed drive can mature the timer.",
      "inputs": {
        "vfd_enable_cmd": true,
        "vfd_auto_status": true,
        "vfd_bypass_status": false
      },
      "expect": [
        {
          "output": "yNotAuto",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yBypass",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        }
      ]
    },
    {
      "name": "boolean_matrix_enable_true_auto_true_bypass_true",
      "description": "Truth-table case: enable=true, auto=true, bypass=true. Direction outputs are gated by the final enable command, and only an enabled nonautomatic/bypassed drive can mature the timer.",
      "inputs": {
        "vfd_enable_cmd": true,
        "vfd_auto_status": true,
        "vfd_bypass_status": true
      },
      "expect": [
        {
          "output": "yNotAuto",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yBypass",
          "from_s": 0,
          "to_s": 1800,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 240,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 360,
          "to_s": 1800,
          "equals": true
        }
      ]
    },
    {
      "name": "bypass_exactly_settle_time",
      "description": "Bypass is active from t=600 through just before t=900. The staged release is applied before the 300-second boundary tick, so no fault asserts.",
      "inputs": {
        "vfd_enable_cmd": true,
        "vfd_auto_status": true,
        "vfd_bypass_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 600,
            "value": true
          },
          {
            "t": 900,
            "value": false
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 840,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 960,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yBypass",
          "from_s": 660,
          "to_s": 840,
          "equals": true
        },
        {
          "output": "yBypass",
          "from_s": 960,
          "to_s": 1800,
          "equals": false
        }
      ]
    },
    {
      "name": "local_mode_transient_shorter_than_settle",
      "description": "The enabled drive leaves remote auto for four minutes, then returns before the 300-second settle timer matures.",
      "inputs": {
        "vfd_enable_cmd": true,
        "vfd_auto_status": [
          {
            "t": 0,
            "value": true
          },
          {
            "t": 600,
            "value": false
          },
          {
            "t": 840,
            "value": true
          }
        ],
        "vfd_bypass_status": false
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yNotAuto",
          "from_s": 0,
          "to_s": 540,
          "equals": false
        },
        {
          "output": "yNotAuto",
          "from_s": 660,
          "to_s": 780,
          "equals": true
        },
        {
          "output": "yNotAuto",
          "from_s": 900,
          "to_s": 1800,
          "equals": false
        }
      ]
    },
    {
      "name": "transition_into_and_out_of_bypass",
      "description": "Bypass enters at t=600 s, persists long enough to alarm, and clears immediately when bypass proof drops at t=1200 s.",
      "inputs": {
        "vfd_enable_cmd": true,
        "vfd_auto_status": true,
        "vfd_bypass_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 600,
            "value": true
          },
          {
            "t": 1200,
            "value": false
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 840,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 960,
          "to_s": 1140,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 1260,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yBypass",
          "from_s": 0,
          "to_s": 540,
          "equals": false
        },
        {
          "output": "yBypass",
          "from_s": 660,
          "to_s": 1140,
          "equals": true
        },
        {
          "output": "yBypass",
          "from_s": 1260,
          "to_s": 1800,
          "equals": false
        }
      ]
    },
    {
      "name": "direct_local_to_bypass_switch_keeps_candidate_true",
      "description": "An enabled drive changes directly from not-auto to bypass at t=600 s. Direction flags swap, but the OR candidate never releases and the already-matured fault remains active.",
      "inputs": {
        "vfd_enable_cmd": true,
        "vfd_auto_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 600,
            "value": true
          }
        ],
        "vfd_bypass_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 600,
            "value": true
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 240,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 360,
          "to_s": 1800,
          "equals": true
        },
        {
          "output": "yNotAuto",
          "from_s": 0,
          "to_s": 540,
          "equals": true
        },
        {
          "output": "yNotAuto",
          "from_s": 660,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yBypass",
          "from_s": 0,
          "to_s": 540,
          "equals": false
        },
        {
          "output": "yBypass",
          "from_s": 660,
          "to_s": 1800,
          "equals": true
        }
      ]
    },
    {
      "name": "enable_release_clears_fault_and_diagnostics",
      "description": "A startup-local drive alarms after settling; removing the final enable at t=900 s drops yFault and both enable-gated direction outputs immediately.",
      "inputs": {
        "vfd_enable_cmd": [
          {
            "t": 0,
            "value": true
          },
          {
            "t": 900,
            "value": false
          }
        ],
        "vfd_auto_status": false,
        "vfd_bypass_status": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 240,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 360,
          "to_s": 840,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 960,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yNotAuto",
          "from_s": 0,
          "to_s": 840,
          "equals": true
        },
        {
          "output": "yNotAuto",
          "from_s": 960,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yBypass",
          "from_s": 0,
          "to_s": 840,
          "equals": true
        },
        {
          "output": "yBypass",
          "from_s": 960,
          "to_s": 1800,
          "equals": false
        }
      ]
    },
    {
      "name": "auto_status_chatter_resets_timer",
      "description": "The enabled drive alternates auto/not-auto every minute. yNotAuto follows the raw mode, but no episode survives the five-minute settle timer.",
      "inputs": {
        "vfd_enable_cmd": true,
        "vfd_auto_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 60,
            "value": true
          },
          {
            "t": 120,
            "value": false
          },
          {
            "t": 180,
            "value": true
          },
          {
            "t": 240,
            "value": false
          },
          {
            "t": 300,
            "value": true
          },
          {
            "t": 360,
            "value": false
          },
          {
            "t": 420,
            "value": true
          },
          {
            "t": 480,
            "value": false
          },
          {
            "t": 540,
            "value": true
          },
          {
            "t": 600,
            "value": false
          },
          {
            "t": 660,
            "value": true
          },
          {
            "t": 720,
            "value": false
          },
          {
            "t": 780,
            "value": true
          },
          {
            "t": 840,
            "value": false
          },
          {
            "t": 900,
            "value": true
          },
          {
            "t": 960,
            "value": false
          },
          {
            "t": 1020,
            "value": true
          },
          {
            "t": 1080,
            "value": false
          },
          {
            "t": 1140,
            "value": true
          },
          {
            "t": 1200,
            "value": false
          },
          {
            "t": 1260,
            "value": true
          },
          {
            "t": 1320,
            "value": false
          },
          {
            "t": 1380,
            "value": true
          },
          {
            "t": 1440,
            "value": false
          },
          {
            "t": 1500,
            "value": true
          },
          {
            "t": 1560,
            "value": false
          },
          {
            "t": 1620,
            "value": true
          },
          {
            "t": 1680,
            "value": false
          },
          {
            "t": 1740,
            "value": true
          }
        ],
        "vfd_bypass_status": false
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        }
      ]
    },
    {
      "name": "enable_drop_resets_timer",
      "description": "A not-auto drive is enabled for four minutes, disabled long enough to clear all flags, then re-enabled. Only the fresh second interval may mature the full timer.",
      "inputs": {
        "vfd_enable_cmd": [
          {
            "t": 0,
            "value": true
          },
          {
            "t": 240,
            "value": false
          },
          {
            "t": 600,
            "value": true
          }
        ],
        "vfd_auto_status": false,
        "vfd_bypass_status": false
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 840,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 960,
          "to_s": 1800,
          "equals": true
        },
        {
          "output": "yNotAuto",
          "from_s": 300,
          "to_s": 540,
          "equals": false
        }
      ]
    },
    {
      "name": "simultaneous_branches_recover_one_at_a_time",
      "description": "Both direction flags start active. Restoring auto at t=600 clears only yNotAuto; bypass keeps the candidate and fault active until bypass itself clears at t=1200.",
      "inputs": {
        "vfd_enable_cmd": true,
        "vfd_auto_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 600,
            "value": true
          }
        ],
        "vfd_bypass_status": [
          {
            "t": 0,
            "value": true
          },
          {
            "t": 1200,
            "value": false
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 360,
          "to_s": 1140,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 1260,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yNotAuto",
          "from_s": 0,
          "to_s": 540,
          "equals": true
        },
        {
          "output": "yNotAuto",
          "from_s": 660,
          "to_s": 1800,
          "equals": false
        },
        {
          "output": "yBypass",
          "from_s": 0,
          "to_s": 1140,
          "equals": true
        },
        {
          "output": "yBypass",
          "from_s": 1260,
          "to_s": 1800,
          "equals": false
        }
      ]
    },
    {
      "name": "approved_bypass_raw_behavior_host_gated",
      "description": "Approved maintenance bypass has the same raw values as an accidental bypass and therefore alarms after settling. The host must report NO_EVAL during the approved interval.",
      "inputs": {
        "vfd_enable_cmd": true,
        "vfd_auto_status": true,
        "vfd_bypass_status": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 240,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 360,
          "to_s": 1800,
          "equals": true
        },
        {
          "output": "yBypass",
          "from_s": 0,
          "to_s": 1800,
          "equals": true
        }
      ]
    }
  ]
}

Fault Code Map

One row per rule, from faults/registry.json. Legacy ID is the rule’s pre-renumbering code.

IDNameFamilyMethodStatusLegacy ID
AHU-0001Duct static pressure too low at full fan speedAHUruleverifiedAHU-FC-001
AHU-0002Mixed air temperature too lowAHUruleverifiedAHU-FC-002
AHU-0003Mixed air temperature too highAHUruleverifiedAHU-FC-003
AHU-0004Excessive operating state changes per hourAHUruleverifiedAHU-FC-004
AHU-0005SAT too low vs MAT in heatingAHUruleverifiedAHU-FC-005
AHU-0006OA fraction deviationAHUruleverifiedAHU-FC-006
AHU-0007SAT too low at full heatingAHUruleverifiedAHU-FC-007
AHU-0008SAT ≠ MAT in economizer modeAHUruleverifiedAHU-FC-008
AHU-0009OAT too high for free coolingAHUruleverifiedAHU-FC-009
AHU-0010OAT ≠ MAT in mechanical + economizer coolingAHUruleverifiedAHU-FC-010
AHU-0011OAT too low for mechanical coolingAHUruleverifiedAHU-FC-011
AHU-0012SAT too high vs MAT in coolingAHUruleverifiedAHU-FC-012
AHU-0013SAT too high at full coolingAHUruleverifiedAHU-FC-013
AHU-0014Inactive cooling coil temperature dropAHUruleverifiedAHU-FC-014
AHU-0015Inactive heating coil temperature riseAHUruleverifiedAHU-FC-015
AHU-0016Simultaneous heating and coolingAHUruleverifiedAHU-FC-050
AHU-0017Economizer not operational when favorableAHUruleverifiedAHU-FC-051
AHU-0018Unoccupied override — equipment running during off-hoursAHUruleverifiedAHU-FC-052
AHU-0019Supply air temperature setpoint too low (over-cooling)AHUruleverifiedAHU-FC-053
AHU-0020Stuck or failed actuatorAHUruleverifiedAHU-FC-054
AHU-0021Excess outdoor air during occupied hoursAHUruleverifiedAHU-FC-055
AHU-0022Supply air temperature hunting / oscillationAHUstatisticalverifiedAHU-FC-056
AHU-0023Supply air temperature reset not functioningAHUstatisticalverifiedAHU-FC-057
AHU-0024Duct static pressure reset not functioningAHUstatisticalverifiedAHU-FC-058
AHU-0025Heating or cooling coil lockout not active by OATAHUruleverifiedAHU-FC-059
AHU-0026Outdoor air damper not closed during unoccupied periodsAHUruleverifiedAHU-FC-060
AHU-0027Manual override detected on critical control pointAHUruleverifiedAHU-FC-061
AHU-0028Mixing box damper fault — MAT outside the OAT/RAT envelopeAHUruleverifiedAHU-FC-062
AHU-0029AHU operating mode mismatchAHUruleverifiedAHU-FC-063
AHU-0030Excess outdoor air during heating modeAHUruleverifiedAHU-FC-064
AHU-0031Supply fan operating at excessive static pressureAHUruleverifiedAHU-FC-065
AHU-0032SAT too high vs RAT in coolingAHUruleverifiedAHU-FC-066
AHU-0033Supply air temperature tracking errorAHUruleverifiedAHU-FC-067
AHU-0034Economizing past changeoverAHUruleverifiedAHU-FC-068
AHU-0035Supply air temperature too high for the zone populationAHUruleverified
AHU-0036Duct static pressure too low for the zone populationAHUruleverified
AHU-0037Economizing when it should not (damper position)AHUruleverified
AHU-0038Cooling coil valve-position creep (fouling / authority loss)AHUstatisticalverified
AHU-0039Supply fan proof-of-operation failureAHUruleverified
CHW-0001Chiller efficiency (kW/ton) degradationCHWstatisticalverifiedCHW-FC-050
CHW-0002CHWST reset not functioningCHWstatisticalverifiedCHW-FC-051
CHW-0003CHW loop DP reset not functioningCHWstatisticalverifiedCHW-FC-052
CHW-0004Chilled water low delta-T syndromeCHWruleverifiedCHW-FC-053
CHW-0005Chiller condenser approach highCHWruleverifiedCHW-FC-054
CHW-0006Chiller evaporator approach highCHWruleverified
CHW-0007Chilled-water supply temperature tracking failureCHWruleverified
CHW-0008Chiller proof-of-operation failureCHWruleverified
CHW-0009Chiller short-cyclingCHWruleverified
ERV-0001Sensible effectiveness degradationERVstatisticalverifiedERV-FC-050
ERV-0002Frost protection not engagingERVruleverifiedERV-FC-051
ERV-0003Frost protection active above release conditionsERVruleverified
ERV-0004Recovery device proof-of-operation failureERVruleverified
ERV-0005Supply/exhaust airflow imbalanceERVruleverified
FCU-0001Excessive operating state changesFCUruleverifiedFCU-FC-001
FCU-0002SAT too low in full heatingFCUruleverifiedFCU-FC-002
FCU-0003SAT too high in full coolingFCUruleverifiedFCU-FC-003
FCU-0004Inactive cooling coil temperature drop (leak)FCUruleverifiedFCU-FC-004
FCU-0005Inactive heating coil temperature rise (leak)FCUruleverifiedFCU-FC-005
FCU-0006FCU fan proof-of-operation failureFCUruleverified
FCU-0007Simultaneous heating and cooling commandsFCUruleverified
FPB-0001Terminal fan proof-of-operation failureFPBruleverified
FPB-0002Primary airflow tracking failureFPBruleverified
FPB-0003Reheat valve closed with unintended temperature riseFPBruleverified
FPB-0004Terminal fan airflow degradationFPBstatisticalverified
FPB-0005Primary airflow sensor disagreementFPBmetaverified
FPB-0006Reheat-coil heat-transfer degradationFPBstatisticalverified
HP-0001COP degradation vs baselineHPstatisticalverifiedHP-FC-050
HP-0002Defrost cycle anomalyHPruleverifiedHP-FC-051
HP-0003Reversing valve faultHPruleverifiedHP-FC-052
HP-0004Refrigerant undercharge — superheat/subcooling divergenceHPruleverifiedHP-FC-053
HP-0005Refrigerant overcharge — high liquid subcoolingHPruleverifiedHP-FC-054
HP-0006Reversing-valve internal bypass leakageHPruleverifiedHP-FC-055
HP-0007Heat-pump compressor proof-of-operation failureHPruleverified
HP-0008Auxiliary heat active above lockout with compressor runningHPruleverified
HW-0001Boiler short-cyclingHWruleverifiedHW-FC-050
HW-0002Boiler efficiency degradationHWstatisticalverifiedHW-FC-051
HW-0003Boiler or HW pump operating above OAT lockout temperatureHWruleverifiedHW-FC-052
HW-0004HW loop low delta-THWruleverifiedHW-FC-053
HW-0005HW loop DP too high (pump speed vs mild OAT)HWruleverifiedHW-FC-054
HW-0006HW loop DP reset not functioningHWstatisticalverifiedHW-FC-055
HW-0007HW supply temperature too high at low loadHWruleverifiedHW-FC-056
HW-0008HWS temperature reset not functioningHWstatisticalverifiedHW-FC-057
HW-0009Boiler proof-of-operation failureHWruleverified
HW-0010Hot-water supply temperature tracking failureHWruleverified
HW-0011Hot-water temperature-control huntingHWruleverified
HW-0012Excess boiler stages at low plant loadHWruleverified
HX-0001Hydronic heat-exchanger effectiveness degradationHXstatisticalverified
HX-0002Heat exchanger active with one-side flow missingHXruleverified
HX-0003Heat transfer persists with control valve commanded closedHXruleverified
PMP-0001Pump commanded on, no flow detectedPMPruleverifiedPMP-FC-050
PMP-0002Pump deadheading (high DP, low/no flow)PMPruleverifiedPMP-FC-051
PMP-0003Pump proof-of-operation failurePMPruleverified
PMP-0004Pump short-cyclingPMPruleverified
PMP-0005Flow through stopped pumpPMPruleverified
PMP-0006Pump input-power degradationPMPstatisticalverified
RTU-0001Compressor short-cyclingRTUruleverifiedRTU-FC-050
RTU-0002Evaporator coil fouling — reduced temperature splitRTUstatisticalverifiedRTU-FC-051
RTU-0003Discharge and mixed air temperature inconsistency (AFDD0)RTUruleverifiedRTU-FC-052
RTU-0004Economizer not modulating properlyRTUruleverifiedRTU-FC-053
RTU-0005Excess outdoor air intakeRTUruleverifiedRTU-FC-054
RTU-0006Insufficient ventilation airRTUruleverifiedRTU-FC-055
RTU-0007Condenser airflow restriction (fouling)RTUstatisticalverifiedRTU-FC-100
RTU-0008Refrigerant undercharge — superheat/subcooling divergenceRTUruleverified
RTU-0009Refrigerant overcharge — high liquid subcoolingRTUruleverified
RTU-0010RTU supply-fan proof-of-operation failureRTUruleverified
RTU-0011RTU supply-air temperature tracking failureRTUruleverified
SYS-0001CHW flow with no cooling demandSYSruleverifiedSYS-FC-050
SYS-0002HW flow with no heating demandSYSruleverifiedSYS-FC-051
SYS-0003Lighting on with no occupancySYSruleverifiedSYS-FC-052
SYS-0004Exhaust fan running during unoccupied hoursSYSruleverifiedSYS-FC-053
SYS-0005Sensor drift via cross-validation (paired sensors)SYSruleverifiedSYS-FC-054
SYS-0006Virtual sensor drift detectionSYSstatisticalverifiedSYS-FC-055
SYS-0007Zone heating active during summer / warm weatherSYSruleverifiedSYS-FC-056
SYS-0008Exhaust fan schedule misalignment with AHUSYSruleverifiedSYS-FC-057
SYS-0009Sensor flatline while equipment activeSYSruleverifiedSYS-FC-058
SYS-0010Sensor spike / rate-of-change violationSYSruleverifiedSYS-FC-059
TOWER-0001Tower approach high at fan capacityTOWERruleverifiedTOWER-FC-050
TOWER-0002Tower range collapseTOWERruleverifiedTOWER-FC-051
TOWER-0003Tower fan short-cyclingTOWERruleverifiedTOWER-FC-052
TOWER-0004Tower fan proof-of-operation failureTOWERruleverified
TOWER-0005Condenser water overcooling with fan energyTOWERruleverified
TOWER-0006Cooling-tower basin freeze-protection failureTOWERruleverified
VAV-0001VAV minimum airflow setpoint too highVAVruleverifiedVAV-FC-050
VAV-0002Rogue zone driving AHU resetVAVruleverifiedVAV-FC-051
VAV-0003Reheat valve open with zone satisfiedVAVruleverifiedVAV-FC-052
VAV-0004VAV airflow tracking errorVAVruleverifiedVAV-FC-053
VAV-0005VAV damper hunting or oscillationVAVruleverifiedVAV-FC-054
VAV-0006Reheat waste during cooling seasonVAVruleverifiedVAV-FC-055
VAV-0007VAV airflow tracking CUSUMVAVstatisticalverifiedVAV-FC-101
VAV-0008Zone temperature CUSUMVAVstatisticalverifiedVAV-FC-102
VAV-0009Reheat coil leakage CUSUMVAVstatisticalverifiedVAV-FC-103
VAV-0010Zone temperature sensor drift (neighbor-median)VAVstatisticalverified
VFD-0001Command vs feedback deviationVFDruleverifiedVFD-FC-050
VFD-0002At minimum speed with load unsatisfiedVFDruleverifiedVFD-FC-051
VFD-0003At maximum speed with load unsatisfiedVFDruleverified
VFD-0004VFD process-loop huntingVFDruleverified
VFD-0005VFD not in remote automatic controlVFDruleverified

Fault Clusters

Clusters group faults that share a root cause. The trigger is the fault that usually fires first; members refine the diagnosis.

CLU-01 — Simultaneous Heating & Cooling

TriggerAHU-0016
MembersAHU-0004, AHU-0005, AHU-0012, AHU-0025, AHU-0029, FCU-0001, AHU-0032
Playbooksimultaneous-hc
Prevalencecommon
Energy impact15-40% of AHU energy

CLU-02 — Missing Reset Strategy

TriggerAHU-0023
MembersAHU-0024, AHU-0019, AHU-0031, AHU-0035
Playbookmissing-reset
Prevalence74% of buildings (PNNL 151-building study)
Energy impact5-20% of site energy

CLU-03 — Economizer Failure

TriggerAHU-0017
MembersAHU-0009, AHU-0011, RTU-0004
Playbookeconomizer-failure
Prevalence54% of RTUs (Cowan 2004)
Energy impact5-20% of cooling energy

CLU-04 — After-Hours Operation

TriggerAHU-0018
MembersSYS-0003, SYS-0004, AHU-0026
Playbookafter-hours-operation
Prevalence~15%
Energy impact3-16% of site energy

CLU-05 — Zone Heating & Cooling Conflict

TriggerVAV-0006
MembersVAV-0003, SYS-0007
Playbookvav-min-flow-reheat
Prevalencecommon
Energy impact5-20% of zone energy

CLU-06 — Chilled Water Plant Inefficiency

TriggerCHW-0001
MembersCHW-0002, CHW-0003, CHW-0004, CHW-0005
Playbookchiller-efficiency
Energy impact5-15% of chiller energy

CLU-07 — Unnecessary Plant Operation

TriggerSYS-0001
MembersSYS-0002
Playbookunnecessary-plant-operation
Energy impact100% of plant energy while active

CLU-08 — Schedule Dysfunction

TriggerAHU-0018
MembersSYS-0008, SYS-0004
Playbookexhaust-fan-schedule-misalignment
Prevalence~15%
Energy impact3-16% of site energy

CLU-09 — Sensor Integrity Failure

TriggerSYS-0005
MembersAHU-0028, AHU-0002, AHU-0003, SYS-0006, SYS-0009, SYS-0010, VAV-0010
Playbooksensor-drift
Prevalence~15%
Energy impactprevents false alarms; sensor-dependent

CLU-10 — Condenser-Side Degradation

TriggerTOWER-0001
MembersTOWER-0002, CHW-0005
Playbookcooling-tower-performance
Prevalenceunpublished (library-authored family)
Energy impact~2-4% chiller power per degC of added lift (BEE 2006 §3)

Tower heat-rejection capability loss and chiller condenser fouling share causes (scale, fouling, airflow/waterflow deficiency) and one investigation path: the tower-side vs tube-side split. Fixing the condenser-side root typically clears the chiller efficiency symptom (CLU-06 overlap via CHW-0005).

Remediation Playbooks

Playbook: After-Hours Operation

Applies toAHU-0018, AHU-0026, AHU-0027, SYS-0003, SYS-0004, CLU-04, CLU-08
Fix complexityRemote fix (95%) · On-site (5%)
Typical time15–30 min remote
Typical cost$0
Energy impactEEM-04: 3–9% site energy. EEM-16 (deadbands/setbacks): 3–16% site energy, top measure nationally at 7.7%. PNNL-27338 AIRCx flags fans running during more than 30% of unoccupied hours, or duct static pressure above 0.2 in. w.g. during unoccupied periods.

Adapted from HVAC FDD Reference v1.0, Remediation Playbooks (pp. 157–159).

Step 1 — Verify the fault

  1. Pull a 7-day trend of supply fan run status overlaid with the occupancy schedule; confirm the equipment runs during unoccupied periods.
  2. Check for legitimate after-hours reasons: morning warmup / night cooldown (normal but should be time-limited to 1–2 hours) and tenant override requests (should be time-limited, never indefinite).
  3. PNNL-27338 checks: unoccupied fan runtime > 30% of total unoccupied hours, or duct static pressure > 0.2 in. w.g. during unoccupied hours.
  4. Check the BAS time zone setting — daylight saving mismatches are a common culprit.
  5. Quantify the waste: unoccupied runtime hours × fan kW × (1 + thermal conditioning penalty). The thermal penalty is typically 1.5–3× the fan energy because the AHU is also conditioning outdoor air unnecessarily.

Step 2 — Remote fix

  1. Correct the occupancy schedule if it’s wrong — the most common fix.
  2. Remove stuck overrides: check the BACnet priority array on the fan command and release any manual override (a top retro-commissioning finding, PNNL-27338).
  3. If morning warmup / evening cooldown runs too long: limit pre-conditioning to 1–2 hours maximum, ideally with an optimal start algorithm (PNNL EEM-27/28 starts the AHU just in time to reach setpoint by occupancy).
  4. Set after-hours override time limits: each override event auto-expires after at most 2 hours; ensure an override can never run indefinitely.
  5. Verify holiday schedules are current — missed holidays are a common source of after-hours waste.
  6. Building-wide issues: fix the master schedule and verify all air handlers, exhaust fans, and lighting reference the same schedule.
  7. Enable night setback temperatures: widen the unoccupied deadband to ~13 °C heating / ~29 °C cooling (55/85 °F, or site-appropriate values) — EEM-16 alone saves 3–16% of site energy depending on building type.

Step 3 — On-site service (rare)

  1. If the fan runs despite a correct off command: check for a welded/stuck contactor or relay, a hardwired VFD run-signal bypass, or a hand-off-auto switch left in Hand.
  2. If an occupancy sensor falsely detects occupancy: check location and sensitivity — PIR sensors trigger from HVAC air currents, rodents, or direct sunlight on the lens.

Step 4 — Confirm resolution

  1. Monitor fan status during 3 consecutive unoccupied periods.
  2. All after-hours faults should clear within 24–48 hours.
  3. Verify lighting (SYS-0003) and exhaust fans (SYS-0004) also shut down on the corrected schedule — these often share the same root cause.

Playbook: Chilled-Water Plant Diagnosis

Applies toCHW-0001 through CHW-0009, CLU-06
Fix complexityRemote controls review · On-site service · Capital repair
Typical time2–8 h initial diagnosis; longer for baseline confirmation or tube/compressor work
Typical costControls-only to $1,000–$3,000 tube cleaning / $5,000+ compressor service
Energy impactEEM-11 (CHW temperature reset): 0.5–2% site energy. EEM-10 (CHW DP reset): 0.5–2%. EEM-26 (tower controls): 1–6% electricity. A 10% kW/ton degradation on a 500-ton plant can cost $5,000–$15,000/yr, but proof/cycling/tracking findings must be sized separately.

Adapted from HVAC FDD Reference v1.0, Remediation Playbooks (pp. 161–163), with library-authored command/proof, tracking, and cycling triage.

Step 1 — Establish commanded versus running machines (CHW-0008)

  1. List each chiller separately: final BAS stage command, independent run proof, local/remote mode, active lockout, and timestamp freshness. Plant enable or a fleet-OR status is not enough.
  2. For yFailToStart, follow the sequence in order: lead/lag selection, isolation valves, chilled/condenser-water pumps and flow proof, oil system, starter/drive, and chiller safeties. Do not bypass anti-recycle protection.
  3. For yUnexpectedRun, check local/manual mode, service overrides, a second controller, welded/stuck outputs, and whether the command was bound upstream of the machine’s real control owner.
  4. Verify the configured 300/120-second proof windows exceed this machine’s normal start and stop sequences plus point-delivery latency.

Step 2 — Confirm flow, permissives, and measurement boundaries

  1. Confirm evaporator and condenser flow are established for each running machine and minimum-flow interlocks are satisfied.
  2. Verify isolation and bypass valve positions, strainers, pump proof, and branch/header topology before diagnosing the refrigerant circuit.
  3. Confirm chiller_load, temperatures, and power/tons belong to the intended machine. On parallel plants, do not read a mixed-header value onto each chiller without proving that boundary is the controlled target.
  4. Rule out sensor, scaling, timestamp, flow-meter, and power-meter error.

Step 3 — Inspect CHWST tracking direction (CHW-0007)

  1. Compare the individual evaporator leaving-water temperature with the final active target delivered to that controller. A common header is acceptable only when the staged machines genuinely regulate that same point.
  2. Exclude startup pull-down, reset ramps, staging transfer, ice-making, and current/lift/surge/freeze/demand limiting before interpreting the alarm.
  3. yTooWarm: check capacity, flow, fouling, refrigerant, permissives, sensor bias, and whether the setpoint actually reached the local controller.
  4. yTooCold: check aggressive staging, local-loop tuning, reset delivery, sensor bias, and a machine controlling a different target than the BAS.
  5. A CHW-0008 fail-to-start direction makes tracking non-evaluable; an unexpected-running machine can still have meaningful tracking evidence.

Step 4 — Review starts, timers, and staging (CHW-0009)

  1. Trend per-machine proof at a cadence that resolves the shortest OFF/ON dwell. At the defaults, use a fixed 60-second evaluator and count_scale=evaluation_window/tick.
  2. Compare each start with load, CHWST/setpoint, plant enable, lead/lag selection, minimum on/off timers, and lockout/safety history.
  3. Look for low-load inability to turn down, narrow deadbands, insufficient loop volume/storage, unstable proof, or safety trip/auto-reset cycling.
  4. Apply the manufacturer’s starts-per-hour and minimum on/off limits. The library’s three-start threshold is a commissioning placeholder.

Step 5 — Then evaluate efficiency, reset, delta-T, and approach

  1. Review CHW-0001 kW/ton against its per-machine fitted baseline and verify the fit period, tons conversion, load domain, and meter boundary.
  2. Check CHWST reset (CHW-0002) and loop DP reset (CHW-0003). A setpoint locked too low increases lift; a poor DP sequence can waste pump energy.
  3. Review low delta-T (CHW-0004) with coil valves, bypass/decoupler flow, staging, and return-water temperature. Do not attribute a plant/header signature to one chiller without branch evidence.
  4. Compare condenser (CHW-0005) and evaporator (CHW-0006) approaches with their design/commissioned values, load floor, refrigerant P-T provenance, water temperatures, and flow.
  5. Check tower fan staging, condenser-water reset, and pump operation. Higher condensing lift can explain both approach and kW/ton degradation.

Step 6 — On-site service after controls and sensors are cleared

  1. High condenser approach: inspect condenser flow and strainer, clean tubes, verify water treatment, and purge non-condensables as applicable.
  2. High evaporator approach: inspect chilled-water flow/strainer and clean evaporator tubes.
  3. Normal approaches with high kW/ton: leak-test and verify refrigerant charge, compressor/VFD current, power quality, oil system, and mechanical condition.
  4. For persistent proof or cycling faults, inspect starter/drive histories, contacts, compressor protections, run-proof wiring, and local controller event logs before replacing equipment.

Step 7 — Confirm resolution

  1. Verify command and independent proof agree through normal start/stop cycles.
  2. Observe at least one full CHWST tracking delay after startup and staging; the active machine should remain within its commissioned band.
  3. Observe at least one complete cycling window with manufacturer-compliant start count and minimum on/off times.
  4. Confirm flow, delta-T, setpoint reset, and both approach temperatures are in their commissioned domains.
  5. Monitor kW/ton on a disjoint post-repair period; do not refit the baseline on the faulty interval merely to make CHW-0001 clear.

Playbook: Cooling Tower Performance, Control, and Freeze Protection

Applies toTOWER-0001 (approach high), TOWER-0002 (range collapse), TOWER-0003 (fan short-cycling), TOWER-0004 (fan proof), TOWER-0005 (overcooling with fan energy), TOWER-0006 (basin freeze protection), CHW-0005 (condenser approach high)
Fix complexityRemote controls review or qualified on-site tower/electrical service
Typical time15–30 min remote triage; 1–4 h on-site after safe access is established
Typical costSite-specific; freeze/electrical protective findings are not safely reducible to a generic service-cost range
Energy impactDegradation raises chiller lift; overcooling and unexpected fan operation can waste fan electricity. Freeze-protection findings are asset-protection alarms, not savings opportunities.

Library-authored playbook. Mechanisms and safety constraints are grounded in DOE FEMP/PNNL tower O&M guidance, BEE chiller guidance, NREL tower-control discussion, and SPX/EVAPCO manufacturer material. Numeric rule thresholds remain commissioning or site/OEM values as stated on each card.

Freeze/electrical safety: follow the site and OEM freeze plan before field inspection. Do not bypass low-water cutoff, thermostat, over-temperature, vibration, fire, or other interlocks. Do not force a contactor or manually energize an unverified immersion heater. Confirm water level and electrical isolation using qualified personnel and the site’s lockout/tagout procedure.

Step 1 — Verify identity, applicability, and mode

  1. Confirm point topology first: tower leaving water is the cold stream going to the chiller condenser; tower entering water is the warm chiller return.
  2. Confirm every fan command, proof, speed, and outlet temperature belongs to the same tower object/cell. Do not pair a fleet OR or common header with one arbitrarily selected fan.
  3. Confirm normal automatic ownership. Exclude maintenance, hand/local, tests, free cooling/waterside economizer, storage charging, emergency heat rejection, drain-down, deicing, or another approved low-water mode.
  4. For TOWER-0006, establish that this is a wet tower with water intentionally present, the monitored heater/equivalent is part of the active freeze plan, basin level and low-water cutoff are healthy, and the configured basin limit and response time come from that site’s OEM plan. Otherwise report NO_EVAL.
  5. Check freshness, time alignment, scaling, and sensor calibration. A command echo is not run proof; a heater contactor proves less than measured current, and current proves less than delivered basin heat.

Step 2 — Select the diagnostic branch

A. No fan proof or unexpected fan operation — TOWER-0004

  1. Read yFailToStart versus yUnexpectedRun; the repairs differ.
  2. Failure to start: check VFD/starter faults, disconnect/overload, vibration or OEM interlock, motor/belt/gearbox, final cell-stage command, and proof source.
  3. Unexpected run: check local/manual mode, service override, second controller, stuck output/contactor, VFD internal command, and normal coast-down timing.
  4. Compare with TOWER-0003. Cycling plus intermittent proof often points to a drive/overload/control issue before it points to tower thermal performance.

B. Overcooling while fan is loaded — TOWER-0005

  1. Confirm the active setpoint is the final target for the same cold outlet.
  2. Read yOvercooled and yFanLoaded separately. Cold water with the fan off is normal free convection and must not be dispatched as waste.
  3. Check stale/local setpoints, VFD minimum speed, cell-stage deadbands, fan hand/override, sensor bias, and isolation/bypass valve sequence.
  4. Review setpoint, mode, fan-state, or cell-count changes before the alarm; add a site holdoff longer than the normal response if required.
  5. Do not assume colder condenser water always wastes whole-plant energy. Tune tower fan versus chiller lift from measured plant performance.

C. Basin freeze protection — TOWER-0006

  1. Treat yHeaterFailToRun as electrical/command-proof evidence and yLowBasinTemp as thermal evidence. A proven heater cannot mask cold water.
  2. From a safe state, verify basin water level, low-water cutoff, local thermostat/controller state, disconnect/breaker/fuses, contactor, current or power proof, and representative submerged temperature.
  3. Confirm the sensor is away from the heater plume and not in a dry or stagnant pocket. Confirm local OAT represents the tower exposure.
  4. Remember that a basin heater protects the basin/discharge area only; it does not by itself protect exposed piping, pumps, or heat exchangers.
  5. Command false with status true is outside the current graph but can indicate an uncontrolled or dry heater hazard. Follow the OEM/site safety workflow immediately; do not treat it as a harmless energy-only condition.

D. Performance degradation — TOWER-0001, TOWER-0002, CHW-0005

  1. For approach high, confirm full fan capacity and compare matched-load, matched-wet-bulb history against the commissioned design approach.
  2. For range collapse, check condenser-water flow and actual heat rejection first; excess flow or an unloaded loop mimics tower failure.
  3. For chiller condenser approach high with normal tower approach, investigate condenser tubes, flow, refrigerant-side non-condensables, and saturation- temperature derivation before cleaning tower fill.
  4. If tower approach is high at capacity, inspect fill, spray/nozzle distribution, louvers, drift eliminators, air recirculation, fan delivery, and water-treatment records.

E. Fan short-cycling — TOWER-0003

  1. Confirm independent motor starts and a complete warmed-up trailing hour.
  2. Review leaving-water deadband, cell-stage sequence, and minimum on/off times.
  3. If sequence corrections do not resolve it, inspect VFD/starter, overload, belt/gearbox, vibration switches, and proof chatter.

Step 3 — Correct remotely where authorized

  1. Remove only approved stale overrides and restore normal automatic ownership.
  2. Correct wrong point/setpoint mapping before changing physical controls.
  3. Tune fan minimum, cell staging, deadband, and minimum on/off timers against measured response and manufacturer limits.
  4. Never remotely defeat a protective interlock or raise/lower a freeze limit merely to clear an alarm.

Step 4 — Confirm resolution

  1. Fan command and independent proof should agree after their configured direction-specific delays; cycling should remain below its warmed-up limit.
  2. During normal mechanical heat rejection, leaving water should recover inside its active setpoint allowance without unnecessary loaded-fan persistence.
  3. For degradation, compare approach/range and chiller kW/ton at matched load, flow, and weather over the next suitable operating period.
  4. For freeze protection, follow the OEM/site commissioning procedure and verify water level, all safeties, independent electrical proof, and representative basin temperature. Do not close the finding solely because the graph cleared.

Playbook: Economizer Failure

Applies toAHU-0006, AHU-0009, AHU-0011, AHU-0017, AHU-0021, AHU-0030, AHU-0034, RTU-0004, RTU-0005, RTU-0006, CLU-03
Fix complexityRemote fix (40%) · Remote + on-site (40%) · On-site only (20%)
Typical time30 min remote / 1–3 h on-site
Typical cost$0 remote / $100–$400 linkage repair / $500–$1,200 actuator
Energy impactEEM-06: 0–7% site energy. EEM-23 (RTU advanced controls): 3–11% electricity. Cowan (2004): 54% of RTU economizers have at least one fault; disconnected linkages are the single most common failure mode.

Adapted from HVAC FDD Reference v1.0, Remediation Playbooks (pp. 156–157).

Step 1 — Verify the fault

  1. Wait for favorable conditions (outdoor air cooler than return air and above the low-limit cutoff). Is the OA damper stuck at minimum? Is mechanical cooling active (cooling valve open or compressor running)? Both true = confirmed waste.
  2. Compare the OAT sensor to a nearby weather station — a sensor reading high can lock out the economizer. PNNL-27338 AIRCx computes outdoor air fraction OAF = (MAT − RAT)/(OAT − RAT), reliable only when |OAT − RAT| > 5 °F.
  3. Check both failure modes: (1) economizer not activating when favorable, and (2) economizer staying open when OAT is above the lockout — the second brings in excess hot outdoor air.

Step 2 — Remote fix

  1. Check the economizer enable/disable flag in the BAS; enable if off.
  2. Check the high-limit setpoint. Fixed dry-bulb: ASHRAE 90.1 high-limit by climate — 75 °F zones 1A–3A, 70 °F zones 4A–5A, 65 °F zones 5B–8. Differential: free cooling should enable whenever OA is cooler than RA. Raise a too-low setpoint.
  3. Remove seasonal lockouts blocking free cooling during mild weather.
  4. If the OAT sensor has drifted, apply a calibration offset as a temporary fix.
  5. RTUs with integrated economizers: verify DX staging allows the economizer to run with and without mechanical cooling — non-integrated operation loses significant free-cooling opportunity.

Step 3 — On-site service

  1. Manually command the OA damper to 100% and watch it physically move.
  2. If it doesn’t move: check actuator power/air supply; check the linkage — disconnected rod ends are the most common RTU economizer failure (rod-end pop-off, bent crank arm, stripped set screw, broken plastic clip). Replace linkage components ($50–$150).
  3. If the actuator has failed, replace it ($300–$800).
  4. RTUs: verify barometric relief / power exhaust works — inadequate relief creates positive pressure that prevents the damper opening fully.
  5. Inspect damper blade seals — worn seals cause excess outdoor air when commanded closed (RTU-0005).

Step 4 — Confirm resolution

  1. Wait for the next favorable free-cooling period.
  2. Verify the OA damper modulates between minimum and 100% as conditions change, and mechanical cooling drops during favorable periods.
  3. The fault should clear within 24 hours.
  4. Multi-RTU sites: survey all units — if one RTU has an economizer problem, 30–50% of others on the same roof likely do too.

Playbook: ERV Delivery and Effectiveness

Applies toERV-0001, ERV-0002, ERV-0003, ERV-0004, ERV-0005
Fix complexityOn-site service required
Typical time2–4 h
Typical cost$200–$1,000 (cleaning) / $2,000–$5,000 (wheel or core replacement)
Energy impactPNNL EEM-37 (optimized heat recovery wheel): significant heating and cooling energy savings in cold and hot climates. A wheel operating at 50% of rated effectiveness is recovering only half the available energy — the other half is wasted conditioning that outdoor air brings in.

Adapted from HVAC FDD Reference v1.0, Remediation Playbooks (pp. 166–167).

Step 1 — Confirm mode and frost state

  1. Confirm the ERV is scheduled to run and both air streams should be moving. Exclude smoke, purge, commissioning, manual test, and maintenance modes.
  2. Read the actual frost-sequence state and compare its configured engagement and release settings with OAT. Verify OAT against a second local sensor.
  3. For ERV-0002/0003, command the frost sequence through one safe transition and confirm the flag means active, not enabled or available.
  4. Suspend ERV-0005 during frost strategies that intentionally unbalance the core paths; otherwise verify its measurements are at a whole-unit boundary where the intentional core-path offset is not being diagnosed.

Step 2 — Prove the active recovery device

  1. On wheels and runaround loops, compare the final recovery command with an independent rotation, speed, current, flow, or work proof.
  2. A wheel motor current switch proves the motor, not the belt or wheel; inspect the belt/coupling whenever ERV-0001 is active with motor proof present.
  3. Release HOA/local and software overrides. Record drive or pump fault history before resetting it. Passive plate cores skip this step.
  4. Confirm any automatic wheel-jog/exercise command is included in the final command binding or excluded from evaluation.

Step 3 — Compare the two air streams

  1. Confirm supply and exhaust flow sensors belong to the same ERV, use L/s, have nonnegative polarity, and share an averaging interval.
  2. Compare the measured offset with the design pressure strategy. Normalize an intentional offset before treating the residual as imbalance.
  3. Inspect filters, outdoor/exhaust openings, dampers, fan belts/speeds, and the recovery core on both streams. Use ERV-0005’s direction only as evidence of which measured flow is higher; it does not isolate restriction or sensor cause.

Step 4 — Verify temperature effectiveness

  1. Calculate the sensible effectiveness during conditions with adequate temperature difference between outdoor and indoor air — minimum |OAT − RAT| > 10 °F for a reliable measurement.
  2. Compare it to the commissioning baseline or the manufacturer’s rated effectiveness.
  3. If effectiveness has dropped below 50% of the rated value, degradation is confirmed.
  4. Check whether the effectiveness drop is seasonal — some units perform differently in heating vs. cooling mode.

Step 5 — On-site service

  1. Inspect and clean the enthalpy wheel or plate core: accumulated dust and particulate reduces heat transfer surface area. Use low-pressure compressed air or a vacuum on plate cores; for enthalpy wheels, follow the manufacturer’s cleaning procedure.
  2. For enthalpy wheel units, verify the wheel itself spins — a failed drive motor, belt, or coupling leaves it stationary. Check belt tension, alignment, rotation proof, speed, and motor amperage.
  3. Check the seal and purge section for cross-contamination between the airstreams.
  4. If the core is permanently fouled or physically damaged, replace it ($2,000–$5,000).
  5. Verify that the bypass damper (if equipped) is not stuck in the bypass position, which would route air around the recovery core entirely.

Step 6 — Confirm resolution

  1. Command active equipment through stop→start→stop and confirm both proof directions clear inside their configured windows.
  2. Confirm frost mode engages and releases at the commissioned boundaries.
  3. Rebalance/normalize both streams and verify ERV-0005 stays clear through a full operating transition.
  4. Recalculate effectiveness. Target: return to within 15 percentage points of the commissioning or rated value.

Playbook: Exhaust Fan Schedule Misalignment

Applies toSYS-0004 (exhaust running unoccupied), SYS-0008 (exhaust/AHU misalignment), CLU-08
Fix complexityRemote fix (90%) · On-site (10%)
Typical time15–30 min remote
Typical cost$0 remote
Energy impactEEM-07: 0.5–3% site energy. ~35% prevalence per PNNL 151-building study. The waste is twofold: direct fan energy plus the conditioning penalty from exhausting conditioned air and drawing in unconditioned outdoor air. An exhaust fan running during unoccupied hours in winter pulls heated air out of the building while drawing freezing air in through cracks, dramatically increasing heating energy.

Adapted from HVAC FDD Reference v1.0, Remediation Playbooks (pp. 173–175).

Step 1 — Verify the fault

  1. For exhaust running unoccupied (SYS-0004):
    • Pull a 7-day trend of exhaust fan status overlaid with the building occupancy schedule.
    • Confirm the exhaust fan is running during unoccupied periods for more than 15 minutes.
    • Check whether the run is a legitimate demand override (e.g., lab exhaust, parking garage, kitchen hood).
  2. For exhaust/AHU misalignment (SYS-0008):
    • Compare exhaust fan status to supply fan status over 7 days.
    • Condition 1: exhaust ON but supply OFF — this creates negative building pressure, pulling in unconditioned air through the envelope.
    • Condition 2: supply ON but exhaust OFF during occupied hours — this creates positive pressure and can cause IAQ issues.
  3. Quantify the waste: exhaust fan energy = fan_rated_kW × (speed/100)³. Add the conditioning penalty: for each CFM of exhausted air, the building must condition the same volume of makeup air.

Step 2 — Remote fix

  1. Synchronize the exhaust fan schedule with the AHU schedule:
    • Both should reference the same master occupancy schedule.
    • If the exhaust fan is on a separate controller or timer, reprogram it to match.
  2. Release any manual overrides on the exhaust fan command.
  3. For exhaust fans controlled by standalone timers or switches (not integrated into the BAS):
    • Reprogram the timer to match the building schedule.
    • Better: connect the exhaust fan to the BAS via an interlock relay so it follows the supply fan status automatically.
  4. Verify that holiday schedules are synchronized between the AHU and exhaust systems.
  5. For demand-controlled exhaust (e.g., garage CO sensors, kitchen hoods): verify the demand sensor is working correctly and not falsely triggering the fan.

Step 3 — On-site service (rare)

  1. If the exhaust fan runs despite receiving the correct off command:
    • Check the contactor or relay for welding (stuck closed).
    • Check for a hand-off-auto switch left in hand (manual on) position.
    • Check for a hardwired interlock with another system (e.g., fire alarm override) keeping the fan running.
  2. If the exhaust fan VFD has a fault keeping the fan running at a fixed speed, clear the VFD fault and restore normal BAS control.

Step 4 — Confirm resolution

  1. Monitor exhaust fan status vs. supply fan status for 3 consecutive occupied/unoccupied cycles.
  2. Exhaust fans should start within 5 minutes of the supply fan and stop within 5 minutes of it shutting down.
  3. During unoccupied hours, exhaust fans should remain off unless a legitimate demand override is active.
  4. The fault should clear within 24–48 hours.

Playbook: Fan-Powered Terminal Faults

Applies toFPB-0001 through FPB-0006
Fix complexityRemote (45%) · On-site (55%)
Typical time10–30 min remote; 30 min–3 h on-site
Typical cost$0 remote to site-specific fan, controller, damper, sensor, actuator, or valve repair
Energy impactTerminal-fan waste, excess primary airflow, and hydronic reheat leakage; low airflow/fan failure may instead be delivery risk

Baseline readiness and diagnosis matrix

Before trusting FPB-0004/0005/0006, record each expected/reference model’s known-good fit period, version, inputs, validation error, same-path/location scope, freshness, in-domain signal, and update/freeze policy. Never fit on the current fault episode or let a sensor reference consume the sensor it judges.

FindingFirst distinction
FPB-0001 fan prooffinal command versus same-fan independent proof
FPB-0002 primary trackingAHU-fed primary flow versus active primary setpoint
FPB-0004 fan-path degradationproven fan but actual same-path flow below known-good expectation
FPB-0005 sensor disagreementmeasurement versus independent same-stream reference; neither member is automatically guilty
FPB-0003 valve leakagevalve commanded shut but coil-local temperature rises
FPB-0006 poor heat transfernear-full valve but coil-local rise is below valid expectation

Step 1 — Verify the fault

  1. Identify series versus parallel topology from drawings and a walk-down. Do not infer subtype from a point name.
  2. Confirm schedule, mode, final active airflow setpoint, AHU fan/static availability, overrides, freeze/condensate/smoke states, and maintenance.
  3. For FPB-0001 compare the final same-fan command with independent proof. A series fan may run continuously occupied; a parallel fan may be off normally.
  4. For FPB-0002 confirm the flow point is AHU-fed primary inlet flow, not total discharge or induced branch flow; verify units and K-factor.
  5. For FPB-0003/0006 confirm hydronic heat is available, airflow crosses the coil, and both temperatures are immediately around it. PFPU measurements must stay inside the fan/reheat branch before mixing.
  6. For FPB-0004/0005/0006 verify expected/reference readiness and same-path scope before interpreting residual direction.

Step 2 — Remote triage

  1. Release only documented BAS overrides and compare command, proof, airflow, setpoint, valve command, and coil temperatures on a common timestamp.
  2. Check terminal controller mode, downloaded constants, airflow calibration, and the serving AHU’s fan/static-reset state.
  3. Trend one representative operating transition. Do not force a parallel fan on in a mode that intentionally leaves it off, and do not treat setpoint-ramp error as settled tracking.
  4. If the valve reads shut but coil rise persists, inspect any available pipe temperatures and hot-water differential pressure before dispatch.
  5. Compare actual/expected model inputs at the fault interval; an out-of-domain baseline is a configuration finding, not equipment degradation.

Step 3 — On-site service

Only qualified HVAC/electrical/hydronic personnel following site lockout/tagout and manufacturer procedures may open panels, approach rotating equipment, or service valves. Never bypass smoke, freeze, condensate, electrical, or other safeties to make a point agree.

  1. Fan: inspect HOA/local ownership, relay/contactor, ECM/VFD faults, wheel, bearing, belt/coupling, and the independent proof device.
  2. Primary airflow: inspect pickup tubing/ring, inlet obstruction, damper blade, linkage/actuator, controller K-factor, and available inlet static pressure.
  3. Reheat: verify actuator stroke and linkage, valve close-off, seat debris, manual bypass/three-way piping, and unintended gravity circulation. Do not force a valve against freeze protection or a live safety sequence.
  4. Reposition or replace mislocated sensors; a mixed PFPU discharge sensor is not a casual substitute for a branch-local coil outlet sensor.

Step 4 — Confirm resolution

  1. Under safe, representative automatic operation, confirm final fan command and proof agree through an allowed transition.
  2. Confirm primary flow settles inside its commissioned band after the active target and serving AHU pressure settle.
  3. With the hydronic valve legitimately shut and residual heat expired, confirm coil-local rise remains below threshold while branch airflow is proven.
  4. Observe at least one normal occupied/heating transition for the actual subtype and verify the rule does not reassert.

Playbook: Fan Coil Unit Faults

Applies toFCU-0001 (cycling), FCU-0002 (SAT low heating), FCU-0003 (SAT high cooling), FCU-0004 (cooling leak), FCU-0005 (heating leak), FCU-0006 (fan proof), FCU-0007 (simultaneous commands)
Fix complexityRemote fix (cycling/leak detection) · On-site (valve replacement)
Typical time15 min remote / 1–2 h on-site per unit
Typical cost$0 remote / $150–$600 per valve replacement
Energy impactEEM-03 (fix leaking valves): 0.5–5% site energy in cold climates. FCU faults are insidious — each unit wastes a small amount, but hotels and apartments may have hundreds of FCUs. Leaking valves (FCU-0004/FCU-0005) are classified as CRITICAL_WASTE because they represent energy being added and removed simultaneously at the zone level.

Adapted from HVAC FDD Reference v1.0, Remediation Playbooks (pp. 171–172).

Step 1 — Verify the fault

  1. Fan proof (FCU-0006): confirm this is an active fan coil, then compare the final fan command with independent current, airflow, speed, rotation, or auxiliary proof. Check occupancy/mode ownership, condensate and freeze interlocks, service state, and local hand control before condemning the fan.
  2. Simultaneous commands (FCU-0007): trend both physical valve commands from the same FCU. Verify the deployed thresholds/duration, then exclude intentional dehumidification/reheat, freeze protection, valve exercise, and commissioning before treating the overlap as a control conflict.
  3. Excessive cycling (FCU-0001): count operating state transitions per hour — more than 7/hr indicates a problem. The most common cause is a narrow deadband between the heating and cooling setpoints.
  4. SAT deviations (FCU-0002/FCU-0003): confirm SAT is below setpoint at full heating (FC-002) or above setpoint at full cooling (FC-003). Rule out plant-side issues first — is the HW/CHW supply temperature adequate?
  5. Leaking valves (FCU-0004/FCU-0005): with the valve commanded to 0%, measure the temperature drop (cooling) or rise (heating) across the coil. Any measurable temperature change when the valve is commanded closed confirms a leak. In multi-story buildings, check gravity circulation: hot water can thermosiphon through vertically oriented coils even with the valve closed.

Step 2 — Remote fix

  1. Cycling: widen the deadband between the heating and cooling setpoints to at least 2 °F (1 °C). Check for sensor noise causing mode oscillation — apply a software filter or averaging if available.
  2. SAT deviations: verify that the central plant is providing adequate supply temperatures. If the CHW supply is too warm or the HW supply too cold, the FCU coils cannot produce the expected output regardless of valve position.
  3. Leaking valves: if the leak is small, a temporary workaround is a seasonal lockout that disables the leaking coil’s valve entirely during the opposite season (e.g. lock out the heating valve in summer). This eliminates the simultaneous heating and cooling effect while the valve awaits replacement.
  4. Simultaneous commands: after confirming no intentional cooling-plus-reheat requirement, correct overlapping loop deadbands, priority-array overrides, mode transitions, or the ordinary heating/cooling interlock. Do not defeat humidity, freeze, condensate, or equipment-protection logic.

Step 3 — On-site service

Only qualified personnel may open panels or approach rotating fans. Apply site lockout/tagout and verify absence of hazardous energy; never bypass condensate, freeze, or electrical protection to clear an FDD finding.

  1. SAT deviations with adequate plant supply: inspect the coil for fouling or air locks. Bleed air from the coil piping. Clean the coil surface.
  2. Leaking valves: the valve seat is worn or the valve body is corroded. Replace the valve ($150–$600 depending on size and type).
  3. Gravity circulation: install a check valve on the coil piping to prevent thermosiphon flow, or reorient the coil piping to eliminate the vertical loop.
  4. Hotels and apartments with hundreds of FCUs: prioritize by measuring the waste at each unit — waste_kW = |temp_change| × airflow × cp_air. Replace the worst offenders first.
  5. Fan proof: verify the proof switch before replacing equipment, then inspect the motor/ECM, wheel, bearings, contactor, and wiring under LOTO.
  6. Simultaneous commands: if command logic is correct, verify each actuator and valve position independently; use FCU-0004/0005 temperature evidence to distinguish command conflict from a valve that leaks when commanded closed.

Step 4 — Confirm resolution

  1. After repair, verify the valve closes to zero leakage: no measurable temperature change across the coil when the valve is at 0%.
  2. For cycling: verify state transitions drop below 7/hr.
  3. For fan proof, use an OEM/site-approved safe stop/start test and confirm final command and independent status agree within both configured windows.
  4. For FCU-0007, verify no above-threshold overlap during ordinary modes and confirm any designed dehumidification/reheat mode still operates correctly.
  5. Monitor for 48 hours before closing the fault.

Playbook: Heat Pump Faults

Applies toHP-0001 (COP degradation), HP-0002 (defrost anomaly), HP-0003 (reversing valve), HP-0004 (undercharge), HP-0005 (overcharge), HP-0006 (valve internal leakage), HP-0007 (compressor proof), HP-0008 (auxiliary heat above lockout)
Fix complexityOn-site service required
Typical time2–6 h on-site
Typical cost$200–$1,500 (refrigerant/defrost) / $500–$3,000 (reversing valve) / $3,000–$8,000 (compressor)
Energy impactHP-0001: 5–25% compressor energy waste from COP degradation. HP-0002: 3–10% heating energy from excessive defrost. HP-0003: 20–50% of mode energy when running in the wrong mode — this is a critical fault. Barandier (2023) found refrigerant undercharge is the most frequent heat pump fault.

Adapted from HVAC FDD Reference v1.0, Remediation Playbooks (pp. 169–170).

Step 1 — Verify the fault

  1. Compressor proof (HP-0007): first compare the final command with independent run proof for the same compressor or explicitly documented compressor group. Review OEM lockouts, defrost state, anti-cycle logic, and safety status. If any of those states can withhold operation, they must be reflected in the final command or make the rule NO_EVAL; do not use an upstream thermostat demand or fleet request as the command.
  2. Auxiliary heat above lockout (HP-0008): prove the point represents an explicitly classified auxiliary source actively producing space heat, not availability, demand, crankcase/base-pan heat, or defrost heat. Verify heating and defrost/emergency state, the installed lockout/balance point or dual-fuel switchover, compressor proof, and whether concurrent operation is actually prohibited by the OEM/site strategy.
  3. COP degradation (HP-0001): calculate measured COP as thermal output divided by electrical input and compare it to the baseline regression model (COP vs. OAT). A 15% or greater drop below the baseline curve indicates degradation. Evaluate heating and cooling modes separately — degradation may appear in only one mode. Ensure the baseline R² > 0.6 before trusting the comparison.
  4. Defrost anomaly (HP-0002): count defrost cycles per hour — more than 4/hr is excessive. Check individual defrost duration — more than 15 minutes per cycle is abnormal. Check for defrost initiating when OAT is above 7 °C (45 °F); defrost should not be needed at mild temperatures.
  5. Reversing valve (HP-0003): after a mode change command, wait 10 minutes for the system to settle. In cooling mode, SAT should be well below RAT — if SAT > RAT, the valve has not switched. In heating mode, SAT should be well above RAT — if SAT < RAT, the valve has not switched. This is a Severity 2 (high) fault: the unit is actively working against its intended purpose.

Step 2 — Remote triage

  1. Confirm command and proof timestamps are fresh, aligned, and scoped to the same physical compressor. An aggregate OR can hide a failed lag compressor.
  2. Review controller and VFD/OEM fault histories, local/remote state, defrost, pressure and temperature safeties, anti-cycle timing, and recent service.
  3. Correct only verified BAS binding or sequence defects. Never bypass smoke, freeze, condensate, high/low-pressure, electrical, or OEM safeties, and do not repeatedly reset a compressor lockout.
  4. For HP-0008, inspect thermostat/OEM staging, site OAT lockout, recovery and demand-response modes, and OAT sensor quality. Never disable backup heat until load, equipment safety, and the installed sequence have been verified.

Step 3 — On-site service

Only qualified HVAC/refrigeration personnel may open electrical panels, enter OEM service mode, or work on a refrigerant circuit. Follow the manufacturer’s procedure, lockout/tagout requirements, and applicable refrigerant-recovery rules before approaching capacitors, contactors, motors, or compressors.

  1. Compressor proof:
    1. Verify the final output at the controller and the independent proof at the same compressor without forcing or bypassing an interlock.
    2. Inspect approved terminals, contactors, overloads, current/speed proof, and wiring under the manufacturer’s de-energized test procedure.
    3. Diagnose any active OEM safety or lockout before attempting a single manufacturer-authorized reset.
  2. COP degradation:
    1. Check refrigerant charge — undercharge is the most common fault per Barandier (2023). Measure subcooling and superheat.
    2. Check for refrigerant overcharge — also degrades COP, but less common.
    3. Inspect the condenser and evaporator coils for fouling (see the rtu-compressor-refrigerant playbook).
    4. Check compressor amp draw against nameplate — elevated amps suggest mechanical degradation.
    5. Check for non-condensable gases in the refrigerant circuit.
  3. Defrost anomaly:
    1. Inspect the outdoor coil for heavy ice or frost buildup.
    2. Check the defrost temperature sensor — a failed sensor can trigger continuous defrost.
    3. Check the defrost control board for fault codes.
    4. If the unit uses time-temperature defrost, verify the timer settings match the manufacturer’s recommendation.
  4. Reversing valve:
    1. Check the reversing valve solenoid — listen for a click when the mode changes. No click indicates a failed solenoid ($100–$300 to replace).
    2. Check the wiring between the thermostat/controller and the reversing valve solenoid.
    3. If the solenoid energizes but the valve doesn’t shift, the valve body is stuck. Low refrigerant charge can prevent the valve from shifting — check charge first.
    4. If the valve body has failed, replace the reversing valve ($500–$2,000 plus refrigerant recovery).
  5. Auxiliary heat: with the unit under normal OEM control, verify the auxiliary contactor/fuel valve and independent production proof, OAT input, and configured lockout/switchover. Qualified personnel should correct only the confirmed sensor, staging, or configuration defect; do not bypass high/low-pressure, electrical, temperature, or defrost safeties.

Step 4 — Confirm resolution

  1. Compressor proof: through a normal OEM-controlled cycle, verify command and independent proof agree after the commissioned pickup/dropout allowances. Do not force a compressor start or bypass anti-cycle and safety logic.
  2. COP: monitor measured COP for 7 days — it should return to within 10% of the baseline curve.
  3. Defrost: monitor defrost frequency and duration for 48 hours. Target: fewer than 4 cycles/hr, less than 10 minutes each.
  4. Reversing valve: use manufacturer-approved operation to verify multiple mode changes and confirm SAT responds correctly each time.
  5. Auxiliary heat: monitor normal heating through representative OAT/load conditions. Confirm required backup heat remains available below the commissioned strategy and prohibited concurrent operation stays clear above it.

Playbook: Hot Water Plant Faults

Applies toHW-0001 through HW-0012
First objectiveProve point identity, plant state, safety/limit state, and finding direction before changing control
Typical scopeRemote trend/sequence review, followed by qualified controls, boiler, burner, or hydronic service as evidence requires
Impact postureQualitative unless the site has aligned fuel, electrical, useful-load, and commissioned counterfactual data

The first three rules originate in the HVAC FDD Reference boiler family. HW-0004..0008 are PNNL-grounded loop-side additions, HW-0009 is a command/proof adaptation, and HW-0010..0012 apply NIST regulation concepts, LBNL boiler-plant data contracts, and verified library graph precedents. Do not treat one rule’s shipped threshold as a universal boiler setting.

Step 1 — Establish a safe, coherent record

  1. Identify the exact plant, header, boiler fleet, pump(s), controlled outlet, and final active setpoint. Distinguish the plant/header target from each boiler’s local leaving-water target and any upstream reset request.
  2. Prove modes and signals independently: final enable/command, firing proof, circulation/flow, stage count, firing feedback, HWS/HWR, active setpoint, OAT, and any load/capacity derivation used by the finding.
  3. Check timestamps, units, range, sensor placement, calibration, and stale or held values. A common-header OR/max is not a per-boiler measurement.
  4. Mark startup, setback recovery, reset ramps, load steps, lead/lag transfers, rotation, stage overlap, exercise, maintenance, freeze protection, emergency redundancy, manual tuning, and emissions/demand limits as NO_EVAL where the card requires it.
  5. Read the boiler/burner controller and safety contacts before changing BAS logic. Never force or bypass flame safeguard, purge, ignition, high-limit, low-water, fuel-pressure, combustion-air, minimum-flow, venting, freeze, or emissions interlocks. Use qualified burner/boiler personnel for that work.

Step 2 — Resolve operating and proof contradictions

Warm-weather operation — HW-0003

  1. Verify that OAT represents the plant and that DHW, freeze, process, or other legitimate heat modes are excluded.
  2. Compare the site’s lockout and hysteresis with its design criteria and active sequence. Do not copy a generic lockout temperature into another plant.
  3. If the sequence is correct but the plant operates, trace the final enable, local hand mode, interposing relays, and lead pump/boiler authority.

Command/proof mismatch — HW-0009

  1. Read yFailToStart versus yUnexpectedRun first.
  2. For failure to start, prove the boiler is actually called to fire rather than enabled-and-satisfied, then read the burner lockout and permissive chain.
  3. For unexpected run, inspect Hand/Off/Auto state, local aquastat authority, relays/contacts, and the status source.
  4. Never increase proof timers to hide an ignition or safety trip; commission them only against the listed burner sequence.

Step 3 — Investigate distribution, setpoint, and tracking

Delta-T and DP — HW-0004, HW-0005, HW-0006

  1. Confirm supply/return direction and that flow, pump speed, DP, and setpoint belong to the same distribution loop.
  2. Inspect bypasses, decouplers, three-way valves, valve authority, sensor taps, minimum-flow paths, and simultaneous pump operation before retuning DP.
  3. Compare actual reset behavior with the final active DP target. Adjust reset only after proving the served valve/flow feedback is representative.

HWS reset and high temperature — HW-0007, HW-0008

  1. Confirm that distribution-side HWS—not a boiler-primary outlet—is compared with the intended reset sequence.
  2. Review reset endpoints against emitter requirements, boiler minimum-return constraints, mixing/buffer topology, and current design conditions.
  3. High supply temperature can be appropriate during warm-up or for legacy emitters; establish the operating state before lowering a target.

HWS tracking — HW-0010

  1. Read yTooCold versus yTooHot, and verify firing plus circulation were continuous after all excluded transitions settled.
  2. Compare the final plant/header target, measured header, each active boiler’s local target/outlet, and mixing-valve position. This separates plant control authority from capacity and mixing problems.
  3. Investigate sensor/proof/flow issues in parallel with capacity, fouling, fuel, and application limits; do not assume control tuning is first.

Step 4 — Investigate cycling, hunting, and staging

Boiler starts — HW-0001

  1. Verify the edge count is one boiler’s firing proof at a legal cadence.
  2. Trend demand, firing rate, stage requests, minimum on/off timers, flow, and HWS together. Look for oversizing, narrow differential, minimum-fire/load mismatch, lost flow, or a sequence that repeatedly transfers load.
  3. Buffering, sequence, and plant-design changes require hydronic and manufacturer review; do not defeat minimum-flow or safety limits.

Regulation hunting — HW-0011

  1. If only yFiringRateHunting is true, inspect modulation feedback, minimum fire, signal quantization, and staging continuity before changing PI gains.
  2. If only yTemperatureUnstable is true, inspect the sensor, flow, load, final setpoint, mixing loop, and competing controllers.
  3. If both are true, first exclude a real transition. Then compare phase and timing to distinguish load/sensor motion from controller-driven motion.
  4. Change PID or lead/lag tuning only with qualified controls/burner staff and a rollback plan; observe several plant response times after each change.

Excess stages at low load — HW-0012

  1. Stop if yLoadOk is false. Audit the useful-load numerator, commissioned eligible-fleet capacity, fleet membership, and timestamps.
  2. Prove the count represents firing comparable units—not enabled/available equipment—and that rotation, overlap, redundancy, or exercise is not active.
  3. Compare the finding with the commissioned staging map, boiler sizes, turndown, minimum flow, venting, emissions, and minimum run-time constraints.
  4. Stage fewer boilers only after those obligations are satisfied. Unequal or modular fleets may need a capacity-weighted state model instead of a count.

These findings are related but do not form a causal cluster. Over-staging can cause cycling or hunting, poor tuning can provoke stage changes, and a capacity limit can cause tracking error; none reliably occurs first and their repairs differ.

Step 5 — Evaluate boiler efficiency — HW-0002

  1. Verify the fitted baseline, fuel heating-value convention, aligned useful thermal output, and firing-rate range before interpreting residuals.
  2. Use a qualified combustion technician and the manufacturer procedure to measure combustion, draft, O2/CO, flue temperature, and burner operation. This library does not prescribe generic combustion targets.
  3. Inspect fireside/waterside heat-transfer surfaces, fuel train, burner, venting, condensate path where applicable, and water quality based on the measured evidence.
  4. Refit or revalidate the model only on a disjoint known-good period after the physical/control condition is resolved.

Step 6 — Confirm resolution

  1. Re-establish all card preconditions and allow the stated warm-up, rolling window, and persistence intervals to complete.
  2. Confirm directional and evaluability outputs, not only yFault. A cleared fault during NO_EVAL is not proof of repair.
  3. Verify the intended sequence through representative load and stage changes without safety or comfort regression.
  4. Quantify savings only from aligned measured fuel/power and useful load against a documented counterfactual; fault hours alone are not energy.

Playbook: Hydronic Heat-Exchanger Faults

Applies toHX-0001 through HX-0003
Fix complexityRemote validation (30%) · qualified on-site hydronic service (70%)
Typical time20–60 min remote; 1–6 h on-site, longer for cleaning/isolation work
Typical costSite-specific sensor/actuator repair through exchanger cleaning or replacement
Energy impactLost transfer raises upstream heating/cooling energy; unintended transfer wastes plant and pumping energy and may defeat isolation

Step 1 — Prove the topology and evidence

  1. Identify one physical exchanger and trace both inlet/outlet pairs from drawings and a walk-down. Keep primary and secondary identities fixed.
  2. Confirm every temperature and flow has that same equipment scope, timestamp basis, and unit. Reject common-header or fleet totals.
  3. Record each side’s fluid, glycol concentration where applicable, density and heat-capacity source, and validity range.
  4. Verify derived effectiveness/heat transfer inputs, energy-balance tolerance, expected-model version/readiness/domain, and training/commissioning period. Never fit a clean expectation on the episode being judged.
  5. Confirm automatic mode, stable setpoints/flows/valves, maintenance state, safety/protective sequences, and the commissioned re-warm interval after a direction, pump, valve, or setpoint change.

Step 2 — Separate the signatures

  1. For HX-0002, verify exchange_cmd is the final state that means both branches should flow. Availability or an upstream plant request is not enough. Compare the missing side with its pump command/proof, isolation valves, strainer DP, air/pressure state, and meter quality.
  2. For HX-0001, compare actual and expected effectiveness only inside the frozen model’s domain. Check the two independently calculated side heat rates before blaming the exchanger.
  3. For HX-0003, confirm the named valve is intended to isolate the whole path and its final command is closed. Allow commissioned transport/thermal soak, then inspect actual position if available, residual branch flow, bypasses, check valves, and gravity circulation.

Step 3 — Remote triage

  1. Release only documented BAS overrides. Do not defeat freeze protection, minimum-flow, pressure, or other protective sequences.
  2. Trend all four temperatures, both flows, final command/valve command, signed heat transfer, actual/expected effectiveness, and readiness/domain flags at a common fixed cadence.
  3. Compare sensor offsets during a legitimate no-transfer equalization period only when the system can be placed there safely.
  4. Inspect model inputs and fluid-property configuration. A bad density, glycol concentration, point sign, or connection swap can manufacture both low effectiveness and false energy imbalance.

Step 4 — Qualified on-site service

Only qualified hydronic/HVAC personnel following site lockout/tagout, pressure/temperature isolation, drain/fill, chemical-handling, and manufacturer procedures may open equipment, stroke valves locally, clean plates/tubes, or service pumps. Never isolate a required safety path or open a hot/pressurized system to test a diagnostic.

  1. Inspect the missing-flow side for pump/coupling failure, closed isolation, actuator/linkage failure, clogged strainer, air lock, fouling, failed check valve, low system pressure, or a bad meter.
  2. Inspect low effectiveness for plate/tube fouling, scaling, blocked channels, gasket/internal bypass leakage, incorrect piping, degraded glycol, and sensor placement/calibration.
  3. Inspect unintended transfer for a passing valve seat, undersized actuator or insufficient close-off rating, linkage failure, manual bypass, parallel open path, failed check valve, or thermosiphon flow.
  4. Use chemical or mechanical cleaning only under the exchanger and water- treatment manufacturer’s procedures; preserve corrosion and freeze control.

Step 5 — Confirm resolution

  1. Revalidate all point scaling, side identity, timestamps, and energy balance.
  2. Observe settled automatic operation in the applicable heating and/or cooling direction and confirm both branch flows when exchange is finally commanded.
  3. Re-establish or deliberately preserve the clean baseline under a documented change-control policy after cleaning/replacement; do not let an online model silently learn a fault.
  4. With the isolation valve legitimately closed and soak expired, confirm transfer remains within the commissioned no-load uncertainty envelope.
  5. Observe for at least one full persistence interval after settling and verify the finding does not reassert.

Playbook: Low Chilled Water Delta-T

Applies toCHW-0004, related to CHW-0001; HW-0004 — library-authored HW-side addition, the reference’s row names only the CHW rule; AHU-0038 (coil-side hydraulic creep, same discriminators)
Fix complexityRemote + on-site
Typical time2–8 h (investigation across multiple air handler coils)
Typical cost$0–$2,000 (depends on root cause)
Energy impactLow delta-T is one of the most common and costly chilled water plant problems. It forces additional pumps and chillers to run to meet load, increasing pump energy by 30–100% and reducing plant COP. PNNL-27338 detects low delta-T for both CHW and HW systems when the supply-return differential falls below threshold at moderate loads.

Adapted from HVAC FDD Reference v1.0, Remediation Playbooks (pp. 163–164).

Step 1 — Verify the fault

  1. Calculate the plant delta-T: chilled water return temperature minus supply temperature.
  2. Compare to the design delta-T (typically 10–14 °F / 5.5–7.8 °C).
  3. If delta-T is less than 50% of design when the plant is above 30% load, low delta-T is confirmed — one of the most common and costly chilled water plant problems.
  4. Check whether the low delta-T is plant-wide or concentrated at specific AHUs. Trending return water temperature at each AHU isolates the worst offenders.

Step 2 — Remote fix

  1. Check for three-way valve bypass: where three-way valves exist on air handler coils, water can bypass the coil without picking up heat. Converting to two-way valves is a capital project but high-value.
  2. Check air handler chilled water valve control — valves should modulate smoothly between 0% and 100%. If valves are stuck partially open on multiple air handlers, that explains the low delta-T.
  3. Check chiller staging: too many chillers running at light load dilutes the plant delta-T. Adjust staging thresholds so fewer chillers handle the load at higher efficiency.
  4. Check CHW differential pressure reset (EEM-10): if DP is set too high, valves throttle excessively and water bypasses through the coils too quickly to transfer full heat. Resetting DP based on the most-open valve position saves 0.5–2% site energy.

Step 3 — On-site service

  1. Inspect air handler coils for fouling — dirty coils reduce heat transfer, which directly lowers the temperature differential. Clean coils at $200–$500 per coil.
  2. Check chilled water valve sizing — oversized valves tend to hunt and never fully load the coils. The worst offenders may need valve replacement.
  3. Check for air trapped in the chilled water piping — air locks reduce water flow through coils. Bleed air at high points in the piping system.

Step 4 — Confirm resolution

  1. Monitor plant delta-T over 2 weeks.
  2. Target: delta-T at or above 75% of design when the plant is above 50% load.
  3. The fault should clear and pump energy should decrease as fewer pumps are needed to move the same cooling capacity.

Playbook: Missing Reset Strategy (SAT / DSP)

Applies toAHU-0023 (SAT reset), AHU-0024 (DSP reset), AHU-0019 (SAT SP too low), AHU-0031 (excess static pressure), CLU-02; CHW-0002 (CHWST reset), CHW-0003 (CHW DP reset), HW-0006 (HW DP reset), HW-0008 (HWS temp reset) — plant-side additions by this library, the reference’s row names only the AHU rules
Fix complexityRemote fix (90%) · Controller upgrade (10%)
Typical time1–4 h remote (sequence programming)
Typical cost$0 remote / $2,000–$5,000 if controller upgrade needed
Energy impactEEM-05 (SAT reset): 1–4.4% site energy, 2.5% national median. EEM-12 (DSP reset): 1–3% site energy via cubed fan law. Combined, absent in 74% of buildings (PNNL 151-building study).

Adapted from HVAC FDD Reference v1.0, Remediation Playbooks (pp. 154–156).

Step 1 — Verify the fault

  1. Plot the SAT setpoint vs. outdoor air temperature over 7 days. A flat setpoint means no reset; one that varies without correlating to zone demand means misconfigured reset logic. PNNL-27338 AIRCx threshold: if MAX(sat_stpt) − MIN(sat_stpt) < 2 °F over the window, no reset is detected.
  2. Plot the DSP setpoint vs. the highest zone damper position. Flat setpoint with dampers well below 100% means no reset (AIRCx: MAX − MIN < 0.25 in. w.g.). Good operation: most zone dampers 50–75% open; bad: all near 100% or all near 0%.
  3. Check whether zone-level demand requests reach the AHU controller — if no requests arrive, the issue is at the zone level or the comms path.

Step 2 — Remote fix

  1. If the reset sequence exists but is disabled: re-enable it; verify all VAV boxes send requests to the correct air handler.
  2. If no SAT reset exists, program trim-and-respond per G36 §5.16.2: start 18 °C (65 °F); trim up +0.2 °F per interval when zones satisfied; respond down −0.5 °F per zone cooling request; range 13–18 °C (55–65 °F).
  3. If no DSP reset exists, program trim-and-respond per G36 §5.16.1: start 0.5 in. w.g.; trim −0.03 in. per interval when dampers not maxed; respond +0.06 in. per zone airflow request; max = design static, min 0.2 in. w.g.
  4. If trim-and-respond is active but ineffective: check trim interval (~2 min SAT, ~1 min DSP); ensure respond magnitude exceeds trim magnitude; check request thresholds at the VAV boxes.
  5. High-SAT heuristics (PNNL-27338): >60% of zone dampers above 90% open → SAT too high, lower it; >25% of zones with reheat valves above 50% → SAT too low.

Step 3 — On-site service (rarely needed)

  1. If the AHU controller doesn’t support trim-and-respond logic, upgrade the controller ($2,000–$5,000 including labor) — uncommon outside legacy pneumatic or first-generation digital controls.
  2. Verify zone controllers communicate with the AHU controller; check trunk cabling, repeaters, protocol converters (e.g. MSTP-to-IP routers).

Step 4 — Confirm resolution

  1. Monitor setpoints over 7 days after programming.
  2. SAT setpoint should modulate up during low demand, down during high demand; DSP setpoint should decrease when zone dampers are mostly closed.
  3. The fault should clear within one evaluation window (typically 7 days).
  4. Expected savings: ~2.5% of site energy from SAT reset alone (PNNL national median); DSP reset adds 1–3% via the cubed fan law (a 20% fan-speed reduction yields ~49% fan-power reduction).

Playbook: Proof-of-Operation Failure

Applies toAHU-0039 (supply fan), PMP-0003 (pump), HW-0009 (boiler), ERV-0004 (active recovery device), FCU-0006 (fan), FPB-0001 (terminal fan), RTU-0010 (supply fan), HP-0007 (compressor)
Fix complexityRemote (40%) · On-site (60%)
Typical time5–15 min remote; 30 min–2 h on-site
Typical cost$0 remote / $100–$800 on-site (belt, contactor, overload reset; motor or drive repairs run higher)
Energy impactDirection-dependent: fail-to-start costs delivery, not energy; unexpected-run is pure waste — a 10 kW pump left in HAND over a weekend is ~640 kWh nobody asked for, and the fan-affinity cube makes oversized fans worse

Library-authored playbook for the proof-of-operation family: command versus status, both directions (yFailToStart — commanded on, not proven; yUnexpectedRun — proven on, not commanded). Grounded in ASHRAE Guideline 36-2021’s proof definition (§5.1.6) and its alarm instances (§5.16.13.2 fans, §5.21.10.5 / §5.20.17.6 pumps and tower fans, §5.21.3 boiler prove), paraphrased.

Step 1 — Verify the fault

  1. Confirm mode, schedule, maintenance/test state, and point scope before touching equipment. Compare the final device command with independent proof; inspect interlocks and OEM alarms before assuming a mechanical failure.
  2. Identify the status device — the diagnosis depends on it. A current switch proves the motor draws amps (a broken belt can still read ON); a DP or flow switch proves air/water actually moves; a VFD status word proves only what the drive believes; an aux contact proves the starter closed and nothing downstream.
  3. For fail-to-start: confirm the command is actually reaching the equipment — read the BAS output at the controller, then the terminal. Check the HOA/HOA-equivalent switch position, the overload/reset flag, the drive’s local/remote mode and fault code, and the disconnect.
  4. For unexpected-run: look for HAND at the starter or drive, a local override, or a welded contactor. A qualified person must apply lockout/tagout and verify absence of hazardous energy before opening panels or approaching belts, fans, capacitors, contactors, or compressors.
  5. Boilers (HW-0009): a failed prove usually means the burner-management system locked out — that is the BMS doing its safety job. Read the lockout code at the boiler. Never bypass or repeatedly reset a safety lockout to clear an FDD alarm; repeated lockout-retry cycles also surface as short-cycling (HW-0001).
  6. Active recovery (ERV-0004): identify the technology before interpreting proof. Wheel speed/rotation can prove the media; motor current alone cannot prove a belt. Runaround-loop pump proof has the same current/flow distinction as PMP-0003. Passive plate cores have no proof rule.
  7. FCU/FPB/RTU fans: inspect subtype sequence, condensate, freeze, smoke, purge, post-heat, and local ownership before mechanical work. Heat pumps: inspect defrost, pump-down, pressure/oil/electrical lockouts, inverter state, and matched compressor scope. Never bypass a safety to make command and proof agree.

Step 2 — Remote fix

  1. Release BAS/software overrides on the command point; confirm the output actually changes state at the controller.
  2. Reset a tripped overload or OEM lockout only when a qualified person, the manufacturer procedure, and site policy explicitly allow one reset. Record the code first; a repeated trip requires service, not another reset.
  3. If the status point is derived (VFD word, current threshold), sanity check its configuration — a current-switch threshold set above the motor’s actual draw reports a running fan as off forever.

Step 3 — On-site service

Only qualified personnel following site LOTO and OEM procedures may open electrical/refrigerant compartments or service rotating equipment. Refrigerant work and forced compressor cycles require the appropriate licensed technician.

  1. HOA in HAND: return to AUTO and find out why someone put it there — HAND is usually a workaround for a control problem this library has a rule for.
  2. Broken or slipping belt, failed coupling: replace; check sheave alignment and tension. A current switch that kept reporting ON while the belt was broken should be replaced with a DP switch on rebuild.
  3. Welded contactor or failed starter: replace the contactor; check coil voltage and cycling rate (chronic short-cycling welds contacts — see the equipment’s short-cycling rule).
  4. Drive faults: record the fault history before clearing; recurring drive faults are a power-quality or motor problem, not a reset ritual.
  5. Boiler lockouts: service per the burner-management manual — flame sensor, pilot assembly, fuel train. FDD’s job here ends at reporting availability; the fix belongs to a qualified burner technician.

Step 4 — Confirm resolution

  1. When the OEM procedure and operating conditions permit, coordinate a safe stop→start→stop cycle from the final command and watch both directions prove within their windows. Never force past smoke, freeze, condensate, high/low-pressure, oil, electrical, or burner safeties.
  2. Both sub-condition flags should clear immediately on agreement; the fault should not reassert across at least one scheduled occupied/unoccupied transition.
  3. If the same equipment reappears here monthly, the root cause is upstream: sizing, power quality, or an operator working around a control defect with the HOA switch.

Playbook: RTU Compressor & Refrigerant Faults

Applies toRTU-0001, RTU-0002, RTU-0007, RTU-0008, RTU-0009, RTU-0011
Fix complexityOn-site service required
Typical time1–4 h on-site
Typical cost$100–$500 (cleaning/filter) / $500–$2,000 (capacitor/charge) / $2,000–$8,000 (compressor)
Energy impactEEM-23 (advanced RTU controls): 3–11% electricity savings. Catrini & Piacentino (2023) measured up to 47% fan power increase and 13.3% capacity reduction from evaporator fouling alone. RTUs are the most common HVAC system in commercial buildings — these faults often go unnoticed because the equipment is on the roof.

Adapted from HVAC FDD Reference v1.0, Remediation Playbooks (pp. 168–169).

Step 1 — Verify the fault

  1. Supply-fan prerequisite (RTU-0010): before interpreting capacity, temperature split, or refrigerant signatures, compare the final supply-fan command with independent proof for that fan. A fail-to-start contests the airflow premise of RTU-0002 through RTU-0006. A commanded post-heat fan run must keep the final command true; purge, smoke control, and local hand modes omitted from the command are host NO_EVAL, not timer exceptions. Follow the proof-of-operation playbook for the mismatch itself.
  2. SAT tracking (RTU-0011): confirm sat_sp is the final active mode-specific target, then verify stable supply-fan proof and mechanical heating/cooling status. Exclude startup, setpoint/mode steps, defrost, post-heat, demand response, normal DX off-cycles, and OEM limiting. Use yTooWarm/yTooCold only as direction evidence, not a root-cause verdict.
  3. Compressor short-cycling (RTU-0001): pull the compressor run status trend and count starts per hour — more than 6 starts/hr indicates short-cycling. Check minimum on-time per cycle: less than 5 minutes is abnormal. (Albayati et al. 2023 achieved 95.7% accuracy on RTU fault classification with semi-supervised learning; the trend check remains the ground truth.)
  4. Evaporator fouling (RTU-0002): calculate the temperature split RAT − SAT during steady-state cooling and compare to the baseline split for the current compressor stage. A 25% or greater reduction indicates fouling. Typical baselines: 8 °C (14 °F) at stage 1, 12 °C (22 °F) at stage 2.
  5. Condenser fouling (RTU-0007): measure condenser leaving air temperature minus OAT and compare to baseline for the current stage and OAT. A 30% or greater increase indicates fouling.
  6. Refrigerant charge (RTU-0008/0009): with the compressor settled at a steady stage, measure suction superheat and liquid subcooling at the service ports and compare to the unit’s charging chart for the current conditions. High superheat with low subcooling indicates undercharge; subcooling well above the chart with normal-to-low superheat indicates overcharge. Rule out condenser airflow restriction (RTU-0007) first — it moves the same readings; low-ambient head-pressure control can mimic overcharge on a correctly charged unit.

Step 2 — Remote triage

  1. Confirm command, proof, temperature, and stage timestamps are fresh and aligned, and that each command/status pair has the same equipment scope.
  2. Review local/remote state, smoke and freeze safeties, purge and post-heat states, compressor lockouts, and recent overrides or service activity.
  3. Correct only verified BAS binding or sequence defects. Never bypass smoke, freeze, condensate, high/low-pressure, electrical, or OEM safeties, and do not repeatedly reset compressor or fan lockouts.
  4. For RTU-0011, compare the tracking direction with economizer command, compressor/heating stage, fan proof, and OEM limit history before changing setpoints or tuning. A bad active-target binding is not a capacity fault.

Step 3 — On-site service

Only qualified HVAC/refrigeration personnel may open panels, enter OEM service mode, or work on a refrigerant circuit. Follow the manufacturer’s procedure, lockout/tagout requirements, and applicable refrigerant-recovery rules before approaching belts, capacitors, contactors, fans, or compressors.

  1. Short-cycling — check in order of likelihood:
    1. Thermostat differential: increase from 1 °F to 2–3 °F to prevent rapid cycling.
    2. Refrigerant charge: low charge causes low suction pressure, tripping the low-pressure safety. Check subcooling and superheat against manufacturer specs.
    3. Run capacitor: a weak capacitor makes the compressor struggle to start. Test with a capacitance meter — replace below 90% of rated value ($50–$150).
    4. Oversized equipment: if the unit is significantly oversized for the load, cycling is inherent — staging controls or a compressor VFD may help.
  2. Evaporator fouling:
    1. Replace the air filter — the most common cause and the cheapest fix ($50–$200/bank).
    2. Inspect the evaporator coil; clean with coil cleaner and low-pressure water ($200–$500).
    3. Check the evaporator fan motor — degraded motors reduce airflow across the coil, mimicking fouling.
    4. Check for ice on the coil — icing indicates low refrigerant charge or a failed defrost cycle.
  3. Condenser fouling (RTU-0007):
    1. Inspect the condenser coil from outside the unit — cottonwood seeds, leaves, and debris are the most common culprits.
    2. Clean the coil from the inside out with a garden hose or pressure washer ($0–$200).
    3. Check the condenser fan motor and blade condition.
    4. Check that adjacent RTUs are not discharging hot air into this unit’s condenser intake — rearranging discharge hoods can fix this.

Step 4 — Confirm resolution

  1. Short-cycling: monitor starts per hour over 48 hours. Target: fewer than 6 starts/hr with minimum 5-minute on-time.
  2. Evaporator fouling: recalculate the temperature split — it should return to within 15% of baseline. (Note: this resolution target is tighter than RTU-0002’s 25% alarm threshold — the fault clears well before the coil is fully recovered, so confirm against the 15% target, not against the alarm clearing.)
  3. Condenser fouling: recalculate the condenser split — it should return to within 20% of baseline.
  4. Supply fan: through a normal controller-owned cycle, verify the final command and independent proof agree after commissioned pickup/dropout times. Do not force operation or bypass an interlock.
  5. SAT tracking: after the causal repair, trend final active setpoint, SAT, fan proof, and conditioning proof across normal heating/cooling cycles. The error should remain inside the commissioned band once settled.
  6. Schedule preventive maintenance: quarterly filter changes, annual coil cleaning. For multi-RTU sites, service all units in the same visit.

Playbook: Sensor Drift / Calibration

Applies toSYS-0005, SYS-0006, AHU-0002, AHU-0003, AHU-0008, AHU-0010, AHU-0028, AHU-0032, RTU-0003, CLU-09; SYS-0009 (flatline), SYS-0010 (spike) — library-authored additions, the reference’s row predates them
Fix complexityOn-site service required
Typical time1–2 h per sensor
Typical cost$50–$200 per sensor (recalibrate or replace)
Energy impactEEM-01: 0–5% site energy from sensor recalibration; ~15% prevalence. The direct energy impact of one drifted sensor is small, but the cascade is not — a single biased OAT sensor can disable the economizer (0–7% waste), trigger false alarms across multiple rules, and mask real faults.

Adapted from HVAC FDD Reference v1.0, Remediation Playbooks (pp. 165–166).

Step 1 — Verify the fault

  1. Compare the suspect sensor to a portable reference instrument: temperature → NIST-traceable digital thermometer; pressure → calibrated manometer; CO₂ → fresh-air baseline (outdoor CO₂ ≈ 420 ppm); humidity → sling psychrometer or calibrated RH probe (humidity sensors are the most drift-prone, typical lifespan 1–2 years between recalibrations).
  2. If the FDD system’s virtual sensor model (SYS-0006) shows bias greater than 1.5 °C, drift is confirmed (virtual sensors demonstrate RMSE ≈ 0.30 °C — Koo & Yoon 2022 — so 1.5 °C is well outside normal error).
  3. A drifted sensor may be triggering false alarms on other rules: if multiple faults fire on the same equipment, this sensor may be the root cause (the CLU-09 suppression rationale).

Step 2 — Remote fix (temporary)

  1. Apply a sensor offset / calibration correction in the BAS to compensate; document the offset value and date. This is a stopgap — the physical sensor still needs service.
  2. If the drifted sensor is an OAT sensor disabling the economizer, the offset provides immediate energy savings while waiting for on-site work.

Step 3 — On-site service

  1. Recalibrate per the manufacturer’s procedure.
  2. If the sensor cannot hold calibration, replace it: temperature $30–$80; pressure transducers $80–$200; humidity $50–$150 (most frequent); CO₂ $100–$300 (calibrate with a known gas standard or fresh outdoor air).
  3. Verify the reading against the reference instrument before leaving.
  4. For paired installations (SYS-0005 cross-validation), recalibrate both sensors in the pair.

Step 4 — Confirm resolution

  1. The sensor drift fault should clear within 1–2 evaluation windows.
  2. Downstream false alarms caused by the drifted sensor should also clear.
  3. If the Sensor Integrity cluster (CLU-09) was active, the entire cluster should resolve.

Playbook: Simultaneous Heating & Cooling

Applies toAHU-0004, AHU-0005, AHU-0012, AHU-0016, AHU-0025, AHU-0029, AHU-0032, FCU-0007, CLU-01
Fix complexityRemote fix (70%) · On-site service (30%)
Typical time30 min remote / 2–4 h on-site
Typical cost$0 remote / $200–$1,500 on-site (actuator replacement)
Energy impactEEM-38: 10–30% of AHU thermal energy. Simultaneous heating and cooling is pure waste with no occupant benefit; common across all building types with heating and cooling coils.

Adapted from HVAC FDD Reference v1.0, Remediation Playbooks (pp. 153–154).

Step 1 — Verify the fault

  1. Pull a 24-hour trend of the heating valve command and cooling valve command for the same AHU or FCU on one chart.
  2. Confirm both commands are above the rule’s configured material thresholds for its configured duration. AHU-0016 and FCU-0007 intentionally use different library defaults; verify the deployed parameters before triage.
  3. Check data quality flags — rule out bad sensor readings before acting.
  4. Check for intentional cooling-plus-reheat/dehumidification, freeze protection, valve exercise, commissioning, or mode transition. These states are NO_EVAL for FCU-0007 unless the deployed sequence explicitly says otherwise.
  5. If position feedback sensors exist, compare command vs. actual position to determine whether the problem is the control sequence (software) or a physical issue (stuck valve).
  6. Do not price command overlap alone. Quantify only from verified valve position/flow and delivered heating/cooling energy during the overlap.

Step 2 — Remote fix (BAS changes, no site visit)

  1. First confirm the unit is not designed for intentional cooling-plus-reheat; do not add an interlock that defeats humidity, freeze, or equipment protection.
  2. Check the ordinary control sequence for a heating/cooling interlock:
    • The heating valve should close to 0% before the cooling valve may open, and vice versa. If no interlock exists, add one — the most common fix.
    • G36 §5.16 specifies a minimum deadband of 2.8 °C (5 °F) between heating and cooling loops.
  3. Check for a deadband between the heating and cooling PID loops:
    • At least 1 °C (2 °F) between where heating stops and cooling starts; if the loops overlap, insert a deadband.
    • For single-duct VAV systems, verify the SAT setpoint is above the reheat lockout temperature before investigating individual zone interlocks.
  4. Check for stuck manual overrides on either valve command (BACnet priority array). Release any override holding a valve open — among the most common retro-commissioning findings (PNNL-27338).
  5. Check whether the heating coil has an OAT lockout; if outdoor temps are warm enough that heating shouldn’t run, enable one (typical: disable heating when OAT > 16 °C with 2 °C hysteresis).
  6. If the AHU serves mixed interior/perimeter zones, verify perimeter reheat is not fighting the central cooling coil — SAT reset (see missing-reset) often resolves this.

Step 3 — On-site service (if remote fix doesn’t resolve)

Only qualified personnel may open panels or work on hydronic valves. Follow the site’s lockout/tagout procedure and never bypass freeze, condensate, pressure, or electrical safeties to clear an FDD finding.

  1. Inspect the heating valve actuator: command to 0% from the BAS and physically verify full closure; check the mechanical linkage (disconnected linkages are common on older systems); verify normally-open vs. normally-closed — an NO valve opens whenever the actuator loses its signal.
  2. Inspect the cooling valve actuator with the same checks.
  3. On pneumatic-to-digital conversions: normally-open pneumatic valves need spring-return actuators; check for air leaks in the tubing — small leaks can prevent full closure.
  4. If a valve cannot close mechanically, replace the actuator ($200–$800) or valve body ($500–$1,500).
  5. Check for coil bypass: on older AHUs a face-and-bypass damper can mix hot and cold decks — inspect the bypass damper actuator and linkage.

Step 4 — Confirm resolution

  1. Monitor both valve commands for 48 hours after the fix.
  2. Verify both commands do not overlap above their configured thresholds during applicable modes; separately confirm intentional dehumidification still works.
  3. The fault should clear automatically. FCU-0004/0005 may remain if physical valve leakage persists after the command conflict is fixed.
  4. Recalculate impact from measured thermal input/output, not command percentage.

Playbook: Stuck or Failed Actuator

Applies toAHU-0020, AHU-0014, AHU-0015, VAV-0004
Fix complexityOn-site service required
Typical time1–4 h on-site
Typical cost$200–$1,200 (actuator + labor)
Energy impactStuck actuators are a root cause behind multiple high-energy faults: simultaneous heating and cooling (10–30% AHU thermal), economizer failure (0–7% site), leaking coil valves (0.5–5% site in cold climates, EEM-03). Fixing one stuck actuator often resolves 2–5 related fault alarms.

Adapted from HVAC FDD Reference v1.0, Remediation Playbooks (pp. 159–160).

Step 1 — Verify the fault

  1. Plot the command signal vs. position feedback over 24 hours; confirm a persistent gap of more than 10% for more than 30 minutes.
  2. From the BAS, manually command the actuator through its full range (0% → 100% → back). If feedback tracks the command, it was likely a temporary glitch — monitor for recurrence. If feedback stays flat regardless of command, the actuator or linkage has failed.
  3. Check for downstream faults this actuator causes: a stuck heating valve → simultaneous H&C (AHU-0016); a stuck OA damper → economizer failure (AHU-0017).

Step 2 — Remote check (limited options)

  1. Release any manual overrides on the command point.
  2. Check whether BAS auto-tuning or demand-limiting features are restricting the command range.
  3. Pneumatic actuators: check the pneumatic transducer output against the command signal.
  4. Check the controller’s alarm/fault log — some DDC controllers log communication errors or actuator timeout events that pinpoint the failure.

Step 3 — On-site service

  1. At the actuator, check in order: (1) is the linkage still connected — disconnected linkages are the most common cause; (2) is the actuator receiving its control signal (voltage / air pressure at the actuator); (3) manually stroke the valve or damper — does it move freely?
  2. Disconnected linkage: reconnect and tighten ($0–$50).
  3. No signal at the actuator: trace wiring back to the controller; check fuses and breakers.
  4. Signal present but no motion: replace the actuator — small valve actuators $200–$600, large damper actuators $300–$800, multi-section damper banks $500–$1,200.
  5. Valve body seized from corrosion or debris: replace the valve ($500–$2,000).
  6. Normally-open heating coil valves: verify the actuator has a spring return — without it, any power or signal loss opens the valve and creates simultaneous heating and cooling.

Step 4 — Confirm resolution

  1. After repair, command the actuator through its full range from the BAS; verify position feedback tracks within 5%.
  2. The fault should clear immediately.
  3. If this actuator was causing simultaneous H&C or economizer failure, check all downstream fault codes within 24 hours — they should resolve too.

Playbook: Unnecessary Plant Operation

Applies toSYS-0001 (CHW flow, no cooling demand), SYS-0002 (HW flow, no heating demand), CLU-07
Fix complexityRemote fix (80%) · On-site (20%)
Typical time15–30 min remote
Typical cost$0 remote / $200–$800 on-site (valve repair)
Energy impactPNNL EEM-09 (plant shutdown when no load): < 1% site energy directly, but this is 100% waste — every kWh consumed by the pump and chiller/boiler standby during zero-demand periods provides zero useful conditioning. The waste is pure pump energy plus chiller/boiler parasitic loads (oil heaters, controls, heat loss). For a 100-hp pump system, unnecessary operation costs $5–$10/hr.

Adapted from HVAC FDD Reference v1.0, Remediation Playbooks (pp. 171–172).

Step 1 — Verify the fault

  1. Confirm CHW or HW flow is present: flow sensor reads above the no-demand threshold (typically 10% of design flow).
  2. Confirm that all served AHU valves are closed: every cooling (SYS-0001) or heating (SYS-0002) valve command is below 2%.
  3. Check whether the flow is from a leaking bypass valve, a leaking coil valve, or simply the pump running when it shouldn’t be.
  4. Verify the condition persists for at least 15 minutes — brief transients during mode changes are normal.

Step 2 — Remote fix

  1. Check the pump enable logic in the BAS:
    • The pump should shut down when no AHUs are calling for heating or cooling.
    • If the pump runs on a fixed schedule, convert to demand-based enable: pump runs only when at least one served AHU valve opens above a threshold (typically 5%).
  2. Check for manual overrides on the pump command — release them.
  3. For CHW plants: verify the chiller staging sequence includes a no-load shutdown. Some plants keep the lead chiller running 24/7 as a default.
  4. For HW plants: verify the boiler OAT lockout is active (see the Hot Water Plant Faults playbook). If the boiler is locked out but the pump still runs, the pump enable logic needs correction.
  5. Check for minimum flow bypass valves stuck open — these allow flow to circulate without any end-use demand.

Step 3 — On-site service (if flow persists after control fix)

  1. If the bypass valve is stuck open: inspect and repair or replace the valve actuator.
  2. If one or more AHU coil valves are leaking through: see the Stuck Actuator or Fan Coil Unit playbooks for valve repair procedures.
  3. Check for check valve failures that allow reverse flow through idle equipment.

Step 4 — Confirm resolution

  1. Monitor CHW/HW flow during periods of confirmed zero demand (overnight, weekends).
  2. Flow should drop to zero within minutes of the last AHU valve closing.
  3. The fault should clear within 24 hours.

Playbook: VAV Minimum Flow / Reheat Waste

Applies toVAV-0001, VAV-0003, VAV-0006, VAV-0007, VAV-0008, VAV-0009, SYS-0007, CLU-05
Fix complexityRemote fix
Typical time15 min per box (can be done in batch on most BAS platforms)
Typical cost$0
Energy impactEEM-15: 5–16% site energy, 7.7% national — the single highest-impact individual measure across all building types in the PNNL study. Medium and large offices see the greatest benefit. For a 50,000 ft² office at $2/ft² energy cost, correcting VAV minimums can save $5,000–$16,000/yr.

Adapted from HVAC FDD Reference v1.0, Remediation Playbooks (pp. 160–161).

Step 1 — Verify the fault

  1. For each flagged VAV box, compare the programmed minimum airflow setpoint against the actual ventilation requirement for that zone (zone area and occupancy), and against actual airflow during low-demand periods. ASHRAE 62.1 minimum for a typical office: floor area × 0.06 cfm/ft² + occupants × 5 cfm/person. Many boxes are set 2–5× higher than this requirement.
  2. If the minimum airflow setpoint is significantly higher than the ventilation requirement, it is set too high.
  3. Check whether the zone is in deadband (satisfied) or heating mode — is the reheat valve active?
  4. Count how many boxes are flagged:
    • More than 50% of boxes flagged → likely an air-handler-level issue (supply air temperature set too low, forcing excess reheat). See the missing-reset playbook. PNNL-27338 AIRCx check: if more than 25% of zones have reheat valves open above 50%, the AHU SAT may be too low. (Library note: PNNL-27338 §2.2.2–2.2.3’s own form is a two-quantity test — zones-with-reheat-open fraction above 25% AND fleet-average valve command above 50%; the single-predicate gloss here is the reference’s simplification. See AHU-0019’s Deviations.)
    • Only 1–3 boxes flagged → a zone-level configuration problem.

Step 2 — Remote fix

  1. Reduce the minimum airflow setpoint to match the actual ventilation requirement (calculate the zone’s required outdoor air per ASHRAE 62.1). For dual-maximum VAV boxes, also review the heating maximum flow — it may be higher than necessary. G36 recommends a minimum of 20% of design airflow or the ventilation minimum, whichever is greater.
  2. If the reheat valve is open while the zone is already satisfied (VAV-0003): command the valve to 0% and watch the zone temperature response. If the temperature continues to rise, the valve is physically stuck — see the stuck-actuator playbook.
  3. Enable a summer reheat lockout if not already active: when outdoor air is above 21 °C (70 °F) and the zone is satisfied, disable the reheat valve entirely. SYS-0007 specifically detects zone reheat active during warm weather (~20% of buildings).
  4. For buildings with many flagged boxes, consider a dual-maximum control strategy — separate heating-maximum and cooling-maximum airflow setpoints allow very low airflow during heating while keeping design airflow for cooling.

Step 3 — On-site service

Only if the reheat valve is physically stuck: see the stuck-actuator playbook for valve inspection and replacement procedures.

Step 4 — Confirm resolution

  1. After reducing minimums, verify zone airflow during satisfied/deadband periods sits close to the ventilation requirement, and reheat energy has dropped (valve mostly closed when the zone is comfortable).
  2. Faults should clear within 24 hours.
  3. If more than 50% of boxes were affected, the air-handler-level fault (SAT setpoint too low) should also improve or resolve.
  4. At 5–16% of site energy, this fix alone often pays for the entire FDD deployment within the first year.

Playbook: VFD and Pump Faults

Applies toVFD-0001 through VFD-0005, PMP-0001 through PMP-0006, AHU-0039
Fix complexityOn-site service required
Typical time2–4 h
Typical cost$200–$2,000
Energy impactPump energy follows the cube law: reducing pump speed by 20% reduces pump power by 49%. A pump deadheading (running against closed valves) wastes 100% of its energy as heat and risks mechanical damage. Differential pressure reset (EEM-10/11) saves 0.5–2% of site energy by allowing pumps to run slower.

Adapted from HVAC FDD Reference v1.0, Remediation Playbooks (pp. 167–168).

Pump family index: faults/pmp.

Decision tree

Follow control authority before component replacement:

  1. Confirm remote/auto/bypass state.
  2. Compare speed command and feedback.
  3. Determine whether the loop is pinned at minimum or maximum and read the process-error direction.
  4. Check current, torque, safety, demand, and application limits plus mechanical restrictions.
  5. Review tuning, process-sensor quality, and setpoint-reset behavior.
  6. Inspect current/kW trends only after control-state findings are resolved.

Do not manually energize, transfer, or bypass a drive outside the site’s approved operating and electrical-safety procedure.

Step 1 — Confirm control authority (VFD-0005)

  1. Verify the final drive enable is actually commanded, not merely an upstream system request.
  2. Read the HOA/keypad selector and configured command source. “Auto” must mean the commissioned remote BAS source, not local PID auto or terminal control.
  3. Confirm bypass from authoritative drive mode or contactor/power-path proof. Bypass available, ready, or commanded is not active bypass.
  4. Check whether maintenance, emergency, fire/smoke, commissioning, or a functional test explains the state. If approved, suppress the diagnostic rather than defeating the safety sequence.
  5. Restore authority only through approved procedures, then allow the loop and setpoint to settle before evaluating downstream faults.

Step 2 — Compare speed command and feedback (VFD-0001)

  1. Verify command and feedback are both normalized to percent of rated speed; do not compare percent with Hz.
  2. Check command-source configuration and fieldbus communication.
  3. Check the drive display for current, torque, thermal derating, and fault codes such as overcurrent, overvoltage, or ground fault.
  4. Check the cooling fan and heatsink — overheating can cause quiet derating before the drive trips.
  5. Compare drive-reported speed with a tachometer only after confirming what each point measures.

Step 3 — Inspect loop saturation (VFD-0002, VFD-0003)

  1. Confirm process value and setpoint are from the same loop, share units, and use a commissioned site-specific error threshold.
  2. At minimum speed, use the error direction to distinguish overdelivery/a minimum set too high from underdelivery, obstruction, or bad sensing.
  3. At maximum speed, verify the configured maximum and inspect current, torque, safety, demand, and application limits before calling the equipment undersized.
  4. Inspect filters, coils, strainers, dampers, valves, belts, couplings, impellers, and the distribution path for restrictions or degradation.
  5. Confirm the setpoint was settled long enough for the application’s time constant; a real reset/load step is not a capacity fault.

Step 4 — Inspect hunting (VFD-0004)

  1. Acquire speed, process value, and setpoint together at 60 seconds or faster; resample faster acquisition to the rule’s legal fixed evaluator interval (14.3–150 seconds at the defaults, 60 seconds recommended). Change-of-value logs can hide crossings.
  2. If only speed hunts, inspect command, feedback, drive limits, and mechanical backlash. If only the process hunts, inspect its sensor and external load.
  3. If both hunt, compare phase before retuning: process movement leading speed suggests a real disturbance or sensor problem; speed leading process suggests aggressive tuning or actuator/drive behavior.
  4. Exclude startup, staging, setpoint reset, smoke/purge, and manual tuning tests.
  5. Change one tuning parameter at a time and preserve protective/current/torque limits. Observe for at least two evaluation windows after each change.

Step 5 — Electrical and drive service

  1. Check VFD input and output power and calculate drive efficiency only after mode, scaling, and control-state findings are resolved.
  2. Check harmonic distortion where symptoms or site requirements justify it.
  3. Follow the manufacturer’s diagnostics for the exact drive and motor.
  4. If the VFD is failing, replacement is typically $500–$2,000 depending on motor horsepower; confirm the diagnosis before replacement.

Step 6 — Separate pump delivery signatures (PMP-0001, PMP-0002, PMP-0003, PMP-0005)

  1. Validate command and independent run proof first. An active PMP-0003 makes a stopped/running inference unreliable; resolve that before replacing a hydraulic component.
  2. Confirm flow and DP belong to this individual pump branch. A common-header flow point cannot distinguish a failed lag branch or a passing check valve.
  3. Running with no flow is PMP-0001. Running with high pump DP and low flow is the more specific deadhead signature PMP-0002. A stopped branch with flow is PMP-0005; do not treat these mutually different premises as one alarm.
  4. For PMP-0005, verify meter sign and zero, then compare flow with other-pump status and header pressure. Inspect the discharge check valve, isolation valves, bypass paths, and approved gravity/free-cooling arrangements.
  5. Remote fix: check the differential pressure setpoint — it may be set too high, forcing the active pump to work against closed valves.
  6. Remote fix: implement a differential pressure reset sequence if one doesn’t exist — the same trim-and-respond logic as air-handler duct static pressure reset (see the missing-reset playbook). EEM-10: 0.5–2% site energy savings.
  7. On-site: check for closed isolation valves and a blocked strainer.
  8. On-site: verify the pump impeller and coupling — damage can produce no flow despite the motor running.
  9. For variable-primary CHW systems, verify the minimum-flow bypass valve is functioning. Without it, the lead pump may deadhead when all AHU valves close.

Step 7 — Review starts and staging (PMP-0004)

  1. Use per-pump proof and a fixed evaluator tick that can resolve the shortest cycle. At the defaults, acquire at 60 seconds and set count_scale=evaluation_window/tick; COV loss can hide starts.
  2. Compare each start with plant enable, lead/lag transfer, DP/temperature demand, minimum on/off timers, and any approved exercise sequence.
  3. Check whether VFD speed/process hunting (VFD-0004) is repeatedly crossing a run threshold. Fix the unstable loop before changing motor protection.
  4. Inspect overload, safety, and drive fault histories for trip/auto-reset cycling. A chattering proof device can create the same count.
  5. Apply the pump/motor manufacturer’s starts-per-hour limit; the library’s default is only a commissioning placeholder.

Step 8 — Compare actual and expected power (PMP-0006)

  1. Confirm actual and expected kW cover the same pump motor/drive circuit and the expected model is ready, fresh, in-domain, and fitted on known-good data.
  2. Compare power with speed, individual-branch flow, differential pressure, staging, and fluid condition. A model indexed on a different pump configuration is not a degradation finding.
  3. Resolve same-drive VFD tracking or mode/bypass findings before trusting a baseline that uses those signals. Scope any suppression to this pump/drive.
  4. Check power-sensor scaling and phase coverage before mechanical work.
  5. After control and sensor checks, inspect strainers, impeller, coupling, alignment, bearings, seals, motor, and drive. Refit the baseline only after the equipment is known clean; never train the fault into normal.

Step 9 — Confirm resolution

  1. Verify the drive remains in the commissioned remote-auto state with bypass inactive whenever normal operation is expected.
  2. Verify output tracks command within the commissioned tolerance.
  3. Verify the process returns within its allowance band without sustained minimum/maximum saturation or material hunting.
  4. For pumps, verify expected branch flow/DP, zero stopped-branch flow, and a compliant per-pump start count over at least one full evaluation window.
  5. Where PMP-0006 applies, verify actual power returns inside the commissioned residual band without refitting on the faulty interval.
  6. Confirm suppressions release on the same equipment instance and observe for at least two hunting/baseline windows before closing an instability or degradation finding.

Playbook: Zone Heating in Warm Weather

Applies toSYS-0007, CLU-05
Fix complexityRemote fix (85%) · On-site (15%)
Typical time15–30 min remote
Typical cost$0 remote / $200–$600 on-site (stuck valve)
Energy impactFound in ~20% of buildings per PNNL 151-building study. 100% of reheat energy consumed during warm weather is pure waste — the building is paying to heat air that was just mechanically cooled. In perimeter zones with solar gain, this is especially wasteful. A single reheat coil running at 50% during summer can waste $500–$2,000/yr.

Adapted from HVAC FDD Reference v1.0, Remediation Playbooks (pp. 172–173).

Step 1 — Verify the fault

  1. Confirm the reheat valve command is above 10% when outdoor air temperature is above 21°C (70°F).
  2. Check whether this is a single zone or building-wide:
    • A single zone: likely a stuck valve or a zone-level control issue.
    • Multiple zones: likely a missing seasonal lockout or the AHU SAT is set too low (driving reheat across many zones).
  3. Check the zone temperature: if the zone is already at or above setpoint and the reheat valve is open, the valve is operating wastefully.
  4. In perimeter zones, check for solar gain overcooling the space — the reheat may be fighting solar-driven overcooling. If so, the root cause is lack of solar-responsive control rather than a hardware fault.

Step 2 — Remote fix

  1. Program an outdoor air temperature lockout for zone reheat:
    • When OAT > 21°C (70°F) and the zone is satisfied, disable the reheat valve.
    • Use hysteresis: re-enable at OAT < 18°C (65°F).
  2. Check the zone’s minimum airflow setpoint — if it’s too high, excess cool air is being delivered and then reheated. Reducing the minimum (see the VAV Minimum Flow / Reheat Waste playbook) eliminates the root cause.
  3. For building-wide issues, check the AHU SAT setpoint:
    • If SAT is locked at 55°F (13°C) year-round, raise it during mild weather via SAT reset (see the Missing Reset playbook).
    • A 2°F increase in SAT during part-load conditions can eliminate reheat in 30–50% of zones.
  4. For perimeter zones with solar gain: consider a demand-based reheat enable that allows reheat only when zone temperature drops below the heating setpoint minus 1°F.

Step 3 — On-site service (if the reheat valve is stuck open)

  1. Command the reheat valve to 0% and physically verify it closes.
  2. If the valve does not close, see the Stuck Actuator playbook for repair procedures.
  3. For perimeter heating systems not controlled by the BAS (e.g., standalone baseboard heaters with local thermostats): lower the thermostat setpoint for summer or add a manual seasonal shutoff.

Step 4 — Confirm resolution

  1. Monitor reheat valve commands during warm weather for 7 days.
  2. Reheat valves should remain at 0% when OAT is above the lockout threshold and zones are satisfied.
  3. The fault should clear within 24 hours.

Point Dictionary: AHU

Brick terms were checked against Brick 1.4.4. ASHRAE 223 terms were checked against the public-review artifact 223p-1.0.0-ppr2.1.ttl (owl:versionInfo 1.0.0-ppr.2.1), including its G36 extension and, where noted, the guideline36-2021-A-9 reference model (github.com/open223/models.open223.info). This artifact is a compatibility baseline, not the final published standard. provisional=true marks mappings with genuine ambiguity or unattested patterns; every s223 entry additionally awaits confirmation against the formal ASHRAE 223 standard text, which we do not yet hold.

PointKindUnitBrickDerivedProvisional
satreal°CSupply_Air_Temperature_Sensor
sat_spreal°CSupply_Air_Temperature_Setpoint
matreal°CMixed_Air_Temperature_Sensor
oatreal°COutside_Air_Temperature_Sensor
ratreal°CReturn_Air_Temperature_Sensor
htg_vlv_cmdreal%Valve_Position_Command
clg_vlv_cmdreal%Valve_Position_Command
oa_dmpr_cmdreal%Damper_Position_Command
sf_statusboolboolFan_On_Off_Status
sf_speedreal%Speed_Sensor
dsprealPaSupply_Air_Static_Pressure_Sensor
dsp_sprealPaSupply_Air_Static_Pressure_Setpoint
occ_scheduleboolboolOccupancy_Command
zone_dmpr_pos_maxreal%
override_activeboolboolOverridden_Status
actuator_cmdreal%
actuator_posreal%
zone_reheat_fractionreal1
expected_modeint1
operating_stateint1
zone_dmpr_fraction_openreal1
zone_dmpr_high_avgreal%
zone_dmpr_low_avgreal%
clg_vlv_baselinereal%
sf_cmdboolboolStart_Stop_Command

sat

Supply (discharge) air temperature

  • 223P: Sensor observes QuantifiableObservableProperty attached to the AHU supply-air OutletConnectionPoint
  • QUDT unit: DEG_C

Brick Discharge_Air_Temperature_Sensor is brick:aliasOf this class. 223P pattern verbatim from reference instance MultipleZoneAhu-sa-temp; medium comes from hasMedium on the owning ConnectionPoint.

sat_sp

Supply air temperature setpoint

  • 223P: QuantifiableActuatableProperty linked from the observed property via hasSetpoint
  • QUDT unit: DEG_C

Aspect-Setpoint is inferred by the SHACL rule on QuantifiablePropertyShape when hasSetpoint targets the property; asserting it explicitly is safe but redundant under a reasoner.

mat

Mixed air temperature (after OA and RA mix)

  • 223P: Sensor observes QuantifiableObservableProperty located topologically downstream of the OA/RA mixing junction via hasObservationLocation
  • QUDT unit: DEG_C

223P has no mixed-air role or aspect (EnumerationKind-Role has 21 members, none mixed-air); the reference model disambiguates purely by observation location. A property-only export loses the ‘mixed’ semantics — topology must accompany it.

oat

Outdoor air temperature

  • 223P: Sensor observes QuantifiableObservableProperty at the outdoor-air intake
  • QUDT unit: DEG_C

Brick spelling is Outside_ (Outdoor_Air_Temperature_Sensor does not exist). Role-OutdoorAirIntake is 223P’s only outdoor role; used by reference instance oa-temp.

rat

Return air temperature

  • 223P: Sensor observes QuantifiableObservableProperty at the AHU return-air InletConnectionPoint
  • QUDT unit: DEG_C

Verbatim from reference instance MultipleZoneAhu-ra-temp.

htg_vlv_cmd

Heating coil valve command (0 = closed, 100 = full open)

  • 223P: QuantifiableActuatableProperty on the hot-water Valve via hasProperty; Actuator actuatedByProperty this command and actuates the valve
  • QUDT unit: PERCENT

Brick has no Heating_Valve_Command; attach Valve_Position_Command via isPointOf to a brick:Heating_Valve / Hot_Water_Valve (coil carries s223 hasRole Role-Heating). Provisional only for the class choice: brick:Heating_Command is the alternative if the point is abstract heating demand rather than physical valve position. 223P side verified at g36:HotWaterValveOrShape1; Binary-Position as an aspect on an analog command is counterintuitive but attested.

clg_vlv_cmd

Cooling coil valve command (0 = closed, 100 = full open)

  • 223P: QuantifiableActuatableProperty on the chilled-water Valve via hasProperty
  • QUDT unit: PERCENT

Attach via isPointOf to brick:Cooling_Valve / Chilled_Water_Valve (coil: s223 hasRole Role-Cooling). Same class-choice caveat as htg_vlv_cmd (brick:Cooling_Command for abstract demand). 223P verified at g36:ChilledWaterValveOrShape1 and instance MultipleZoneAhu-clg-coil-valve-command.

oa_dmpr_cmd

Outdoor air damper command (0 = closed, 100 = full open)

  • 223P: QuantifiableActuatableProperty on the Damper via hasProperty; Actuator actuates the damper
  • QUDT unit: PERCENT

Attach via isPointOf to brick:Outside_Damper or Economizer_Damper. In the 223P reference model the damper equipment carries hasRole Role-Economizer (Role-OutdoorAirIntake for a non-economizer minimum-OA damper). Verified at g36:DamperOrShape1 and instance MultipleZoneAhu-economizer-oa-damper-command.

sf_status

Supply fan run status

  • 223P: EnumeratedObservableProperty on the Fan via hasProperty

Attach via isPointOf to brick:Supply_Fan. Deliberately not brick:Run_Status — its inherited definition routes equipment run status to the On/Off branch. 223P: hasEnumerationKind Binary-OnOff (OnOff-On/OnOff-Off), verified at instance MultipleZoneAhu-sa-fan-start-status; fan carries hasRole Role-Supply.

sf_speed

Supply fan speed (VFD feedback)

  • 223P: QuantifiableObservableProperty on the Fan via hasProperty
  • QUDT unit: PERCENT

Brick unit conflict: Speed_Status is deprecated (1.4.0) in favor of Speed_Sensor, but brick:Speed’s applicable units exclude PERCENT, so a %-valued instance fails unit validation; brick:Fan_Speed_Sensor does not exist. Use Fan_Speed_Command if the tag is a commanded-speed readback. 223P side is exact (reference instance MultipleZoneAhu-sa-fan-speed-status: DimensionlessRatio/PERCENT).

dsp

Duct static pressure (measured in supply duct)

  • 223P: Sensor observes QuantifiableObservableProperty on the supply duct Connection/ConnectionPoint
  • QUDT unit: PA

Brick Discharge_Air_Static_Pressure_Sensor is an alias. 223P quantitykind is contested: the reference model asserts plain quantitykind:Pressure, while s223:GaugePressureSensor’s SHACL shape (GaugePressure + isDeltaQuantity true) arguably describes duct static better. Following the reference model; confirm against the formal standard text.

dsp_sp

Duct static pressure setpoint

  • 223P: QuantifiableActuatableProperty on the supply duct ConnectionPoint, linked via hasSetpoint from dsp
  • QUDT unit: PA

Judgment call vs brick:Duct_Air_Static_Pressure_Setpoint (literally closer name but disconnected from the Supply/Discharge alias pair); chose the Supply form for interoperability. Same Aspect-Setpoint inference caveat as sat_sp.

occ_schedule

Occupancy schedule state (true = scheduled occupied)

  • 223P: EnumeratedObservableProperty (EnumeratedActuatableProperty if the schedule writes to the controller) on the Zone or AHU

Neither ontology has a schedule concept. Brick three-way choice by point direction: Occupancy_Command (schedule output driving the unit — our reading), Occupied_Mode_Status (mode read-back), Occupancy_Status (physical room occupancy — wrong here). 223P occupancy enumeration verified from the g36:Zone SHACL shape (Occupied-True/Occupied-False).

zone_dmpr_pos_max

Highest zone damper position across all zones served by this AHU (host-derived aggregate of zone damper feedback)

  • QUDT unit: PERCENT

Aggregate point, not a physical sensor: the host computes max() over the zone damper positions. Semantic tags belong on the underlying per-zone points (VAV damper position feedback) and will be grounded with the VAV dictionary; tagging the aggregate itself is deferred.

override_active

Manual override active flag

  • 223P: EnumeratedObservableProperty with hasEnumerationKind Binary-Logical on the overridden Equipment

Brick exact (Overridden_Status; Override_Command is the write side). 223P has no override vocabulary at all — this pattern is assembled from verified primitives (Binary-Logical + Aspect-OperatingMode) but unattested in the standard or reference models. Highest-priority item for the formal 223 text review.

actuator_cmd

Actuator command for the bound valve or damper (0 = closed, 100 = full open) — template point, one instance bound per monitored actuator

  • QUDT unit: PERCENT

Template point (HVAC FDD Reference AHU-0020): the rule instantiates once per actuated device, binding this to the device’s own command point (oa_dmpr_cmd, htg_vlv_cmd, …). Semantic tags live on the bound instance (e.g. Brick Damper_Position_Command vs Valve_Position_Command), so the template itself carries none by design.

actuator_pos

Actuator position feedback for the bound valve or damper (0 = closed, 100 = full open) — template point, paired with actuator_cmd

  • QUDT unit: PERCENT

Template point paired with actuator_cmd; binds the measured position feedback of the same device. Semantic tags live on the bound instance (e.g. Brick Damper_Position_Sensor vs Valve_Position_Sensor), so the template itself carries none by design. No role suffix: position is a measured quantity per the dictionary’s suffix convention.

zone_reheat_fraction

Fraction of zones served by this AHU with active reheat (host-derived: zones whose reheat valve command exceeds the host’s reheat-active threshold, over total zones reporting)

  • QUDT unit: UNITLESS

Aggregate point like zone_dmpr_pos_max: the host computes the fraction over per-zone reheat valve commands (reference point rht_vlv_cmd_all, AHU-0019). Value range 0-1. Semantic tags belong on the underlying per-zone points (VAV reheat valve command) and will be grounded with the VAV dictionary. The reheat-active counting threshold (typically >5%) is host configuration, not part of this dictionary.

expected_mode

Expected AHU operating mode for current conditions (host-derived: determine_g36_mode over oat, zone demands, and the occupancy schedule, per G36 Section 5.16). Integer encoding defined by this library: 1 = HEATING, 2 = ECONOMIZER, 3 = MECHANICAL_COOLING_MIN_OA

  • QUDT unit: UNITLESS

First integer point in the dictionary (AHU-0029). The mode enumeration is this library’s convention for the derived point, ordered cold-to-hot along the G36 supply-air sequence; neither ontology defines these composite AHU modes, and G36 Section 5.16 states the sequence without numbering it. Hosts map their own mode enum (or compute the mode from scratch) when binding; the three code constants are exposed as rule parameters so a host with a different enum can rebind without editing the graph. Semantic grounding deferred with the other derived aggregates.

operating_state

Current AHU operating state as reported by the control sequence (host-derived integer). Recommended binding: G36 Section 5.16.14’s operating-state index OS#1-OS#5; any stable enumeration works, because the only in-library consumer (AHU-0004) counts transitions and never interprets specific values

  • QUDT unit: UNITLESS

Distinct from expected_mode: that point is what the mode SHOULD be given conditions (AHU-0029 compares actuators against it); this one is what the sequence says the state IS (AHU-0004 counts how often it changes). Hosts typically read it from the BAS mode/state enum or derive it per G36 Section 5.16.14. Value-level semantics are deliberately unspecified here since only transitions are consumed; a host must simply keep the encoding stable, as any re-mapping mid-stream registers as a spurious transition.

zone_dmpr_fraction_open

Fraction of served zones whose damper command exceeds a high-open threshold

  • 223P: QuantifiableObservableProperty, host-computed aggregate
  • QUDT unit: UNITLESS

Aggregate point (zone_reheat_fraction precedent): the host computes the fraction of zones whose damper command exceeds the high-open counting threshold (PNNL-27338 §2.3 uses 90%; host configuration, not dictionary). Value range 0-1. Consumed by AHU-0035 as the cooling-starvation population signal.

zone_dmpr_high_avg

Average damper command over the top half of served zones (dampers sorted descending)

  • 223P: QuantifiableObservableProperty, host-computed aggregate
  • QUDT unit: PERCENT

Aggregate point: sort every served zone’s damper command, average the top half. PNNL-27338 §2.6’s population-percentile signal — TOGETHER with zone_dmpr_low_avg it distinguishes a starved trunk (both halves elevated) from a few busy zones (top only). Consumed by AHU-0036; the host documents the zone population like zone_reheat_fraction.

zone_dmpr_low_avg

Average damper command over the bottom half of served zones (dampers sorted descending)

  • 223P: QuantifiableObservableProperty, host-computed aggregate
  • QUDT unit: PERCENT

Aggregate point, the bottom-half mate of zone_dmpr_high_avg (one sorted pass yields both). Consumed by AHU-0036.

clg_vlv_baseline

Expected cooling-coil valve command for the current operating condition (host-fitted baseline)

  • 223P: QuantifiableObservableProperty, host-computed
  • QUDT unit: PERCENT

HOST-DERIVED virtual point (cond_split_baseline precedent): the commissioning-period valve command fitted against the load condition (OAT band and supply setpoint), published for the CURRENT condition each tick. The fit window must be known-clean — a baseline fitted on a fouled coil learns the fouling as normal (the SYS-0006 training caveat). Consumed by AHU-0038: under temperature control a fouling coil holds its heat duty by opening the valve, so the hydraulic excess — not the temperatures — is the observable.

sf_cmd

Supply fan run command (true = commanded on)

  • 223P: EnumeratedActuatableProperty on the supply Fan via hasProperty

brick:Start_Stop_Command attached via isPointOf to brick:Supply_Fan (pump_cmd precedent — the class is equipment-agnostic and grep-verified in Brick 1.4.4). The command HALF of the sf_status pair; AHU-0039 proof-of-operation compares the two. On VFD-driven fans bind the run enable, not the speed reference (sf_speed carries that).

Point Dictionary: CHW

Chilled-water plant point dictionary. Owning equipment: brick:Chiller / brick:Chilled_Water_Loop (both verified in Brick 1.4.4). On multi-chiller plants, machine rules require a coherent per-chiller identity; loop-common temperature/setpoint points are valid only where the card explicitly permits them. ASHRAE 223 local names were checked against 223p-1.0.0-ppr2.1.ttl (owl:versionInfo 1.0.0-ppr.2.1; SHA-256 1f156f9938c0be430d2216e01e31bb183c438ba318d8d4a23d2f074ebcd6f573), replacing the former unverified v1.0.0-2026 label. Water-ChilledWater identifies the chilled-water circuit, Fluid-Water intentionally leaves condenser-water composition generic, and Constituent-Refrigerant identifies the refrigerant behind host-derived saturation properties. The reference expresses chiller output in tons; 1 ton = 3.517 kW thermal and conversion belongs at binding, not in the graph.

PointKindUnitBrickDerivedProvisional
chiller_kwrealkWActive_Power_Sensor
chiller_tonsrealtonsThermal_Power_Sensor
chiller_cmdboolboolStart_Stop_Command
chiller_statusboolboolRun_Status
cwstrealdegCLeaving_Condenser_Water_Temperature_Sensor
chwstrealdegCLeaving_Chilled_Water_Temperature_Sensor
chwrtrealdegCEntering_Chilled_Water_Temperature_Sensor
chiller_loadreal%
chwst_sprealdegCLeaving_Chilled_Water_Temperature_Setpoint
dp_sprealkPaChilled_Water_Differential_Pressure_Setpoint
chw_valve_maxreal%
cond_sat_tempreal°C
evap_sat_tempreal°C

chiller_kw

Chiller electrical input power

  • 223P: QuantifiableObservableProperty on the Chiller via hasProperty
  • QUDT unit: KiloW

brick:Active_Power_Sensor grep-verified (same mapping as hp elec_power), isPointOf brick:Chiller (verified). Consumed by CHW-0001’s kW/ton numerator.

chiller_tons

Chiller cooling output (thermal)

  • 223P: QuantifiableObservableProperty on the Chiller via hasProperty
  • QUDT unit: TON_FG

brick:Thermal_Power_Sensor grep-verified, but marked provisional for the unit: the reference (and the kW/ton convention) uses refrigeration tons while most BAS trend kW thermal or derive tons from flow x deltaT. Binding must land on tons (1 ton = 3.517 kW) or CHW-0001’s kW/ton and its baseline coefficients are silently wrong together. Often a host-derived point (flow x deltaT x cp).

chiller_cmd

Final individual-chiller run command (true = this machine is requested to operate)

  • 223P: EnumeratedActuatableProperty on the Chiller via hasProperty

brick:Start_Stop_Command is exact and equipment-agnostic in Brick 1.4.4; attach it via isPointOf to this brick:Chiller. Bind the final stage command downstream of plant enable, lead/lag selection, and ordinary anti-recycle/interlock logic. A plant enable, cooling demand, availability, or fleet request is invalid for CHW-0008. ASHRAE 223 uses a Binary-OnOff EnumeratedActuatableProperty on s223:Chiller.

chiller_status

Independent individual-chiller run proof (true = this machine is actually operating)

  • 223P: EnumeratedObservableProperty on the Chiller via hasProperty

brick:Run_Status is exact in Brick 1.4.4; attach it to this brick:Chiller. Bind proof of capacity-producing operation or compressor run, not availability, enable-ready, alarm-free, an echoed command, or a fleet OR. Define the rule-instance boundary explicitly for modular or multi-circuit equipment. ASHRAE 223 uses a Binary-OnOff EnumeratedObservableProperty on s223:Chiller.

cwst

Condenser water supply (chiller-leaving) temperature

  • 223P: QuantifiableObservableProperty on the condenser water leaving connection point
  • QUDT unit: DEG_C

brick:Leaving_Condenser_Water_Temperature_Sensor is exact in Brick 1.4.4. ASHRAE 223 uses Fluid-Water on the connection point; leaving direction is topology in the pattern, not a nonexistent aspect local name. Baseline regressor input for CHW-0001 (condenser lift proxy). LOOP-SIDE WARNING: this is the WARM side (chiller-leaving, chiller-to-tower). A future cooling-tower approach rule needs the COLD side (tower-leaving, tower-to-chiller = entering condenser water) — a different point; do not wire a tower rule here by name alone (internal deep-read finding, 2026-08-18).

chwst

Chilled water supply (chiller-leaving) temperature

  • 223P: QuantifiableObservableProperty on the CHW leaving connection point
  • QUDT unit: DEG_C

brick:Leaving_Chilled_Water_Temperature_Sensor is exact in Brick 1.4.4. ASHRAE 223 uses Water-ChilledWater on the connection point; leaving direction is expressed by topology rather than an aspect. For CHW-0007 prefer the individual chiller evaporator/barrel outlet. A common mixed-header value is acceptable only when the active staged machine(s) genuinely control that same header target; it is not a per-machine proxy by name alone. CHW-0001 may use the fitted model’s documented machine/plant boundary; CHW-0004 intentionally uses loop supply.

chwrt

Chilled water return (chiller-entering) temperature

  • 223P: QuantifiableObservableProperty on the CHW entering connection point
  • QUDT unit: DEG_C

brick:Entering_Chilled_Water_Temperature_Sensor is exact in Brick 1.4.4 — ‘return’ at the plant is ‘entering’ at the chiller. ASHRAE 223 uses Water-ChilledWater on the connection point; entering direction is expressed by topology rather than an aspect. Brick 1.4.4 also carries Chilled_Water_Return_Temperature_Sensor, but the entering-form matches the chiller-owned binding these rules assume. Consumed by CHW-0004.

chiller_load

Chiller load (percent of capacity, from the chiller controller)

  • 223P: QuantifiableObservableProperty on the Chiller via hasProperty
  • QUDT unit: PERCENT

Brick 1.4.4 has no chiller-percent-load class, so the Brick mapping is null and the 223 pattern is provisional. Typical per-machine sources are the chiller controller’s PLR/RLA% or host-derived tons/design_tons. CHW-0001 and CHW-0007 require the same individual machine as their other inputs. CHW-0004 deliberately uses a plant-loaded proxy; CHW-0002 may use an explicitly documented plant range. A fleet maximum is not per-machine load.

chwst_sp

Chilled water supply temperature setpoint

  • 223P: QuantifiableActuatableProperty on the CHW loop via hasProperty
  • QUDT unit: DEG_C

brick:Leaving_Chilled_Water_Temperature_Setpoint is exact in Brick 1.4.4. ASHRAE 223 uses Water-ChilledWater plus the exact Aspect-Setpoint; leaving direction belongs to connection topology. CHW-0002 watches the loop setpoint range. CHW-0007 requires the final active target controlling its bound chwst: prefer the individual chiller’s delivered target, or use a common header setpoint only when the staged machines truly control that same mixed-header point. A supervisory request upstream of local reset, demand, or safety limits is not the active target.

dp_sp

CHW loop differential pressure setpoint

  • 223P: QuantifiableActuatableProperty on the CHW loop via hasProperty
  • QUDT unit: KiloPA

brick:Chilled_Water_Differential_Pressure_Setpoint is exact in Brick 1.4.4. ASHRAE 223 uses Water-ChilledWater with exact Aspect-Delta and Aspect-Setpoint terms. CHW-0003 watches this point’s range (AHU-0024 dsp_sp precedent).

chw_valve_max

Maximum CHW coil valve position across served coils (host-derived)

  • 223P: Host Function hasOutput QuantifiableProperty; no single physical point
  • QUDT unit: PERCENT

HOST-DERIVED (zone_reheat_fraction / satisfied_zone_fraction precedent): the reference’s chw_valve_positions is a vector across AHU/FCU cooling coils; the rule needs only its max, so the host binds max() over the coil valve commands it serves. ASHRAE 223 PPR2.1 provides Function/hasOutput/QuantifiableProperty and Aspect-Maximum; the exact cross-equipment aggregation pattern remains provisional. CHW-0003’s ‘no coil starving’ conjunct. Engine vector blocks could compute this in-graph once the points contract grows vector bindings (tracked in internal engine-block-inventory research notes).

cond_sat_temp

Condensing saturation temperature (HOST-DERIVED: discharge/liquid pressure via refrigerant P-T lookup)

  • 223P: Host Function hasOutput QuantifiableProperty derived from refrigerant pressure and a refrigerant-specific P-T relation
  • QUDT unit: DEG_C

Host-derived P-T saturation lookup from measured discharge/liquid pressure (hp dict precedent, duplicated per the lint’s own-family resolution rule). ASHRAE 223 PPR2.1 provides Function/hasOutput/QuantifiableProperty and Constituent-Refrigerant, but no exact condensing or derived aspect; that semantic detail remains in the derivation record. Consumed by CHW-0005 condenser-approach-high: approach = cond_sat_temp − cwst. Refrigerant-specific lookup is the host’s, documented per binding.

evap_sat_temp

Evaporating saturation temperature (HOST-DERIVED: suction pressure via refrigerant P-T lookup, or coil-surface proxy)

  • 223P: Host Function hasOutput QuantifiableProperty derived from refrigerant pressure and a refrigerant-specific P-T relation
  • QUDT unit: DEG_C

Mirror of the hp dict entry (duplicated per the lint’s own-family resolution rule; same refrigerant P-T host obligation — the lookup is refrigerant-specific and lives at the host). ASHRAE 223 PPR2.1 provides Function/hasOutput/QuantifiableProperty and Constituent-Refrigerant, but no exact evaporating or derived aspect; that semantic detail remains in the derivation record. CHW-0006 evaporator approach = chwst − evap_sat_temp; healthy approach is small and positive, and tube fouling or low charge widens it.

Point Dictionary: ERV

Energy recovery ventilator point dictionary. Exhaust-side, supply/exhaust airflow, and generic command/status classes verified in Brick 1.4.4. ASHRAE 223 terms were checked against ontology artifact 223p-1.0.0-ppr2.1.ttl (owl:versionInfo 1.0.0-ppr.2.1; SHA-256 1f156f9938c0be430d2216e01e31bb183c438ba318d8d4a23d2f074ebcd6f573), not an inferred final 2026 release label. The post-recovery outdoor-air temperature and technology-specific active-recovery attachments are provisional. ERV-0001 uses three ERV-local temperatures; ERV-0002/0003 use the site OAT and actual frost state; ERV-0004 requires a final command plus independent physical proof; ERV-0005 requires comparable post-recovery supply and building-exhaust flow measurements.

PointKindUnitBrickDerivedProvisional
erv_oa_entering_tempreal°COutside_Air_Temperature_Sensor
erv_oa_leaving_tempreal°C
erv_exhaust_tempreal°CExhaust_Air_Temperature_Sensor
oatreal°COutside_Air_Temperature_Sensor
erv_frost_protboolbool
erv_enabledboolboolEnable_Status
erv_recovery_cmdboolboolStart_Stop_Command
erv_recovery_statusboolboolOn_Off_Status
erv_supply_airflowrealL/sSupply_Air_Flow_Sensor
erv_exhaust_airflowrealL/sExhaust_Air_Flow_Sensor

erv_oa_entering_temp

Outdoor air temperature entering the ERV (upstream of recovery)

  • 223P: Sensor observes QuantifiableObservableProperty at the ERV outdoor-air inlet via hasProperty
  • QUDT unit: DEG_C

brick:Outside_Air_Temperature_Sensor (verified via the AHU oat entry); position it upstream of the wheel/exchanger. The effectiveness denominator is (erv_exhaust_temp - this), so its accuracy bounds ERV-0001 directly.

erv_oa_leaving_temp

Outdoor air temperature leaving the ERV (downstream of recovery, before coils)

  • 223P: Sensor observes QuantifiableObservableProperty at the ERV outdoor-air outlet via hasProperty
  • QUDT unit: DEG_C

No Brick 1.4.4 class names the post-recovery OA temperature (heat-exchanger entering/leaving classes exist only for water); tag the generic air-temperature sensor pattern on the ERV supply outlet. This is the effectiveness numerator’s moving term.

erv_exhaust_temp

Exhaust air temperature entering the ERV from the building

  • 223P: Sensor observes QuantifiableObservableProperty at the ERV exhaust inlet via hasProperty
  • QUDT unit: DEG_C

brick:Exhaust_Air_Temperature_Sensor grep-verified in Brick 1.4.4. Bind the building-side (entering) exhaust temperature, not the discharge after recovery.

oat

Outdoor air temperature

  • 223P: Sensor observes QuantifiableObservableProperty at the outdoor-air intake
  • QUDT unit: DEG_C

Brick spelling is Outside_ (Outdoor_Air_Temperature_Sensor does not exist). Role-OutdoorAirIntake is 223P’s only outdoor role; used by reference instance oa-temp. [ERV dictionary] Brick class and 223P pattern carried over (equipment-independent); consumed by ERV-0002’s frost test; on many units the same physical sensor as erv_oa_entering_temp.

erv_frost_prot

Frost protection active (true while the frost sequence is engaged)

  • 223P: EnumeratedObservableProperty (Binary-OnOff) on the frost-protection sequence

No Brick 1.4.4 class; bind the BAS frost-protection status (preheat stage, wheel speed reduction, or bypass modulation — whichever the unit uses).

erv_enabled

ERV enabled/commanded on

  • 223P: EnumeratedObservableProperty (Binary-OnOff) on the ERV via hasProperty

brick:Enable_Status grep-verified in Brick 1.4.4.

erv_recovery_cmd

Final command to operate the ERV’s active recovery device

  • 223P: EnumeratedActuatableProperty on the active recovery component via hasProperty

brick:Start_Stop_Command is exact and equipment-agnostic in Brick 1.4.4, but the owning active component is technology-specific and therefore provisional here: attach to the wheel drive, runaround pump, or other commanded recovery component, not merely to the ERV enable. This is the FINAL command after frost, bypass, smoke, and safety logic; an upstream request can disagree with correct internal sequencing and manufacture ERV-0004. ASHRAE 223 uses an EnumeratedActuatableProperty with Binary-OnOff on that component.

erv_recovery_status

Independent proof that the ERV’s active recovery device is operating

  • 223P: EnumeratedObservableProperty on the active recovery component via hasProperty

Brick 1.4.4 has no recovery-wheel run-status class; generic brick:On_Off_Status is the honest cross-technology mapping and the equipment attachment is provisional. Bind independent physical proof: wheel rotation/speed above its commissioned minimum, runaround-pump current/flow proof, or equivalent. A command echo or auxiliary point sourced from the same relay is not proof and makes ERV-0004 structurally blind.

erv_supply_airflow

Post-recovery supply stream airflow through the energy-recovery device

  • 223P: FlowSensor observes QuantifiableObservableProperty at the ERV connection point with hasRole Role-Supply
  • QUDT unit: L-PER-SEC

brick:Supply_Air_Flow_Sensor is exact in Brick 1.4.4 for the post-recovery supply side. An upstream intake measurement is brick:Outside_Air_Flow_Sensor and is not interchangeable where bypass or leakage exists. In ASHRAE 223, location and Role-Supply live on the ERV connection point/topology (the role is not a property aspect); the FlowSensor observes a VolumeFlowRate property in L/s. Bind the stream actually traversing the recovery device, on the same averaging basis as erv_exhaust_airflow.

erv_exhaust_airflow

Building exhaust stream airflow through the energy-recovery device

  • 223P: FlowSensor observes QuantifiableObservableProperty at the ERV connection point with hasRole Role-Exhaust
  • QUDT unit: L-PER-SEC

brick:Exhaust_Air_Flow_Sensor is exact in Brick 1.4.4. In ASHRAE 223, location and Role-Exhaust live on the ERV connection point/topology (the role is not a property aspect). Bind the building-exhaust stream at a location comparable to erv_supply_airflow and with the same sign convention and averaging interval; ERV-0005 requires finite, nonnegative L/s values and does not repair reversed sensor polarity.

Point Dictionary: FCU

Fan coil unit point dictionary. All points carry from the AHU dictionary (the FCU is a two-coil air handler in miniature); rat doubles as the coil entering temperature and sat as the leaving temperature for the inactive-coil leak pair (FCU-0004/FCU-0005), per the same proxy-binding note G36 makes for RTU coil temperatures. ASHRAE 223 mappings are pinned to the public-review artifact 223p-1.0.0-ppr2.1.ttl (owl:versionInfo 1.0.0-ppr.2.1; SHA-256 1f156f9938c0be430d2216e01e31bb183c438ba318d8d4a23d2f074ebcd6f573), not an inferred final 2026 release. That artifact imports QUDT 3.1.8; the quantity-kind and unit local names here were independently checked against the repository-declared QUDT 3.1.4 artifacts.

PointKindUnitBrickDerivedProvisional
operating_stateint1
fan_cmdboolboolStart_Stop_Command
fan_statusboolboolFan_On_Off_Status
satreal°CSupply_Air_Temperature_Sensor
sat_spreal°CSupply_Air_Temperature_Setpoint
ratreal°CReturn_Air_Temperature_Sensor
htg_vlv_cmdreal%Valve_Position_Command
clg_vlv_cmdreal%Valve_Position_Command

operating_state

Current AHU operating state as reported by the control sequence (host-derived integer). Recommended binding: G36 Section 5.16.14’s operating-state index OS#1-OS#5; any stable enumeration works, because the only in-library consumer (AHU-0004) counts transitions and never interprets specific values

  • QUDT unit: UNITLESS

Distinct from expected_mode: that point is what the mode SHOULD be given conditions (AHU-0029 compares actuators against it); this one is what the sequence says the state IS (AHU-0004 counts how often it changes). Hosts typically read it from the BAS mode/state enum or derive it per G36 Section 5.16.14. Value-level semantics are deliberately unspecified here since only transitions are consumed; a host must simply keep the encoding stable, as any re-mapping mid-stream registers as a spurious transition. [FCU dictionary] Brick class and 223P pattern carried over (equipment-independent); for an FCU the state space is G36 s5.22’s OS#1-#4; only transitions are consumed (FCU-0001), so any stable enum binds.

fan_cmd

Final command requesting operation of this FCU fan

  • 223P: EnumeratedActuatableProperty on the Fan contained by the FanCoilUnit via hasProperty

Attach brick:Start_Stop_Command to the contained brick:Fan, not the FCU/unit enable. PPR2.1 s223:FanCoilUnit requires a contained s223:Fan; g36:Fan requires this Binary-OnOff actuatable property. Bind after normal sequence ownership and interlocks.

fan_status

Independent run proof for this FCU fan

  • 223P: EnumeratedObservableProperty on the Fan contained by the FanCoilUnit via hasProperty

Attach brick:Fan_On_Off_Status to the same contained brick:Fan. The PPR2.1 Binary-OnOff fan-status pattern is attested in the public G36 AHU model. Require independent current, speed/rotation, airflow, or auxiliary proof; command echo is invalid.

sat

Supply (discharge) air temperature

  • 223P: Sensor observes QuantifiableObservableProperty attached to the AHU supply-air OutletConnectionPoint
  • QUDT unit: DEG_C

Brick Discharge_Air_Temperature_Sensor is brick:aliasOf this class. 223P pattern verbatim from reference instance MultipleZoneAhu-sa-temp; medium comes from hasMedium on the owning ConnectionPoint. [FCU dictionary] Brick class and 223P pattern carried over (equipment-independent); doubles as the coil LEAVING temperature for FCU-0004/FCU-0005.

sat_sp

Supply air temperature setpoint

  • 223P: QuantifiableActuatableProperty linked from the observed property via hasSetpoint
  • QUDT unit: DEG_C

Aspect-Setpoint is inferred by the SHACL rule on QuantifiablePropertyShape when hasSetpoint targets the property; asserting it explicitly is safe but redundant under a reasoner. [FCU dictionary] Brick class and 223P pattern carried over (equipment-independent); the FCU discharge setpoint.

rat

Return air temperature

  • 223P: Sensor observes QuantifiableObservableProperty at the AHU return-air InletConnectionPoint
  • QUDT unit: DEG_C

Verbatim from reference instance MultipleZoneAhu-ra-temp. [FCU dictionary] Brick class and 223P pattern carried over (equipment-independent); the unit’s entering (return) air; doubles as coil ENTERING temperature for FCU-0004/FCU-0005.

htg_vlv_cmd

Heating coil valve command (0 = closed, 100 = full open)

  • 223P: QuantifiableActuatableProperty on the hot-water Valve via hasProperty; Actuator actuatedByProperty this command and actuates the valve
  • QUDT unit: PERCENT

Brick has no Heating_Valve_Command; attach Valve_Position_Command via isPointOf to a brick:Heating_Valve / Hot_Water_Valve (coil carries s223 hasRole Role-Heating). Provisional only for the class choice: brick:Heating_Command is the alternative if the point is abstract heating demand rather than physical valve position. 223P side verified at g36:HotWaterValveOrShape1; Binary-Position as an aspect on an analog command is counterintuitive but attested. [FCU dictionary] Brick class and 223P pattern carried over (equipment-independent); the FCU heating coil valve.

clg_vlv_cmd

Cooling coil valve command (0 = closed, 100 = full open)

  • 223P: QuantifiableActuatableProperty on the chilled-water Valve via hasProperty
  • QUDT unit: PERCENT

Attach via isPointOf to brick:Cooling_Valve / Chilled_Water_Valve (coil: s223 hasRole Role-Cooling). Same class-choice caveat as htg_vlv_cmd (brick:Cooling_Command for abstract demand). 223P verified at g36:ChilledWaterValveOrShape1 and instance MultipleZoneAhu-clg-coil-valve-command. [FCU dictionary] Brick class and 223P pattern carried over (equipment-independent); the FCU cooling coil valve.

Point Dictionary: FPB

Fan-powered-terminal point dictionary for series (SFPU) and parallel (PFPU) VAV terminal units. Brick 1.4.4 has generic Terminal_Unit but no exact fan-powered subtype; model a Terminal_Unit containing the exact Fan/Reheat_Valve/coil points and topology. ASHRAE 223 PPR2.1 has exact FanPoweredTerminal and Role-Primary; series/parallel remains deployment topology. ASHRAE 223 local names were checked against public-review artifact 223p-1.0.0-ppr2.1.ttl (owl:versionInfo 1.0.0-ppr.2.1; SHA-256 1f156f9938c0be430d2216e01e31bb183c438ba318d8d4a23d2f074ebcd6f573), not an inferred final 2026 release. That artifact imports QUDT 3.1.8; quantity-kind and unit names were independently checked against repository-declared QUDT 3.1.4. Brick 1.4.4 was checked against Brick.ttl SHA-256 b65720b7b9b64c646745c689777e6138c0d59ce0088df0aeb78fbd444d04d8e7.

PointKindUnitBrickDerivedProvisional
fan_cmdboolboolStart_Stop_Command
fan_statusboolboolFan_On_Off_Status
primary_airflowrealL/sSupply_Air_Flow_Sensor
primary_airflow_sprealL/sSupply_Air_Flow_Setpoint
rht_vlv_cmdreal%Valve_Position_Command
rht_coil_entering_temprealdegCAir_Temperature_Sensor
rht_coil_leaving_temprealdegCAir_Temperature_Sensor
fan_airflowrealL/sAir_Flow_Sensor
fan_airflow_expectedrealL/s
primary_airflow_referencerealL/s
rht_delta_t_expectedrealK

fan_cmd

Final command requesting operation of this fan-powered terminal’s fan

  • 223P: EnumeratedActuatableProperty with Binary-OnOff on the Fan contained by the FanPoweredTerminal via hasProperty

Attach brick:Start_Stop_Command to the contained brick:Fan, not the terminal or AHU enable. PPR2.1 s223:FanPoweredTerminal requires a contained s223:Fan and the Boolean property terms are exact. Bind after subtype sequence ownership, intentional delays, and normal interlocks.

fan_status

Independent run proof for this fan-powered terminal’s fan

  • 223P: EnumeratedObservableProperty with Binary-OnOff on the same Fan contained by the FanPoweredTerminal via hasProperty

Attach brick:Fan_On_Off_Status to the same contained fan as fan_cmd. Require independent current, speed/rotation, airflow/pressure, or auxiliary-contact proof; command echo is invalid. For PFPU rules the proof must establish airflow through the fan/reheat branch.

primary_airflow

Measured AHU-fed primary air entering the fan-powered terminal, excluding induced/plenum branch flow

  • 223P: FlowSensor observes a QuantifiableObservableProperty at the FanPoweredTerminal primary inlet ConnectionPoint whose hasRole is Role-Primary
  • QUDT unit: L-PER-SEC

brick:Supply_Air_Flow_Sensor is exact for the AHU-fed supply/primary stream. PPR2.1 FanPoweredTerminal, FlowSensor, VolumeFlowRate, L-PER-SEC, Fluid-Air and Role-Primary are exact; Role-Primary belongs on the ConnectionPoint via hasRole, not in this property’s aspects array. Do not bind total discharge or the PFPU induced branch.

primary_airflow_sp

Final active primary-airflow setpoint after occupancy, ventilation, and heating/cooling logic

  • 223P: QuantifiableActuatableProperty with Aspect-Setpoint linked by hasSetpoint from the primary inlet airflow property of the FanPoweredTerminal
  • QUDT unit: L-PER-SEC

Brick and PPR2.1 setpoint/property terms are exact. Bind the active delivered target, not design minimum or maximum unless it is currently selected. Primary scope is established by the paired property and inlet ConnectionPoint topology.

rht_vlv_cmd

Hydronic reheat valve command, 0 closed to 100 fully open

  • 223P: QuantifiableActuatableProperty with Binary-Position on the hydronic reheat Valve; actuator and Role-Heating/topology identify the coil branch
  • QUDT unit: PERCENT

brick:Valve_Position_Command and brick:Reheat_Valve are exact in Brick 1.4.4. PPR2.1 QuantifiableActuatableProperty, DimensionlessRatio, PERCENT, Binary-Position, Valve, and Role-Heating are exact; equipment role/topology is not serialized as a property aspect. Electric-reheat-only terminals cannot bind this point.

rht_coil_entering_temp

Air temperature immediately entering the hydronic reheat coil

  • 223P: TemperatureSensor observes a QuantifiableObservableProperty of Fluid-Air at the reheat coil inlet ConnectionPoint: downstream of the SFPU fan or inside the PFPU fan/reheat branch
  • QUDT unit: DEG_C

Brick 1.4.4 has only generic Air_Temperature_Sensor for this coil-local location. PPR2.1 temperature terms are exact, but series/parallel topology and the branch-local sensor attachment require deployment evidence. AHU SAT or plenum temperature is only a documented proxy when bias is characterized.

rht_coil_leaving_temp

Air temperature immediately leaving the hydronic reheat coil before downstream mixing

  • 223P: TemperatureSensor observes a QuantifiableObservableProperty of Fluid-Air at the reheat coil outlet ConnectionPoint before downstream mixing
  • QUDT unit: DEG_C

Brick provides only the generic class and PPR2.1 location is topology. For PFPU bind inside the fan/reheat branch before primary/branch mixing; mixed zone discharge is not equivalent unless a validated host Function derives the coil-leaving estimate. For SFPU bind immediately at coil outlet.

fan_airflow

Measured airflow through the terminal-fan path

  • 223P: FlowSensor observes a QuantifiableObservableProperty of Fluid-Air at the terminal Fan path: PFPU secondary fan/plenum branch or SFPU series-fan flow path
  • QUDT unit: L-PER-SEC

brick:Air_Flow_Sensor and PPR2.1 FlowSensor/VolumeFlowRate/L-PER-SEC/Fluid-Air are exact generic terms. Deployment topology identifies PFPU branch flow (ConnectionPoint Role-Secondary where applicable) or SFPU series-fan path. Do not bind total mixed discharge for PFPU. For SFPU, do not duplicate primary_airflow unless topology proves the same flow and the expected model remains independent.

fan_airflow_expected

Expected terminal-fan-path airflow at the current operating condition (host-fitted baseline)

  • 223P: Host model represented as a Function whose hasOutput is a QuantifiableProperty for Fluid-Air VolumeFlowRate
  • QUDT unit: L-PER-SEC

HOST-DERIVED SITE_FITTED baseline. Train on a known-good period, record model version/fit interval/error/update policy and inputs, and publish only while ready, fresh, and in-domain for fan command/speed, pressure, subtype, damper/topology, and operating state. Actual and expected must cover the same fan path. Brick has no exact expected-airflow point; PPR2.1 Function/hasOutput/QuantifiableProperty are exact but there is no exact predicted/expected aspect.

primary_airflow_reference

Independent or redundant host estimate of the terminal’s primary inlet airflow

  • 223P: Independent host Function hasOutput a QuantifiableProperty for Fluid-Air VolumeFlowRate at the FanPoweredTerminal primary inlet
  • QUDT unit: L-PER-SEC

HOST-DERIVED or redundant reference used only for sensor disagreement. Valid examples include a certified redundant sensor, independent calibrated damper/pressure model, or validated upstream branch balance. It must not consume primary_airflow or any direct transform of it; circular evidence guarantees agreement. Record derivation, inputs, same-stream/location proof, accuracy, readiness, domain, freshness, and time alignment. A physical redundant sensor may use brick:Supply_Air_Flow_Sensor instead of this derived form.

rht_delta_t_expected

Expected air-side temperature rise across the hydronic reheat coil at the current operating condition

  • 223P: Host model represented as a Function whose hasOutput is a QuantifiableProperty for Fluid-Air TemperatureDifference
  • QUDT unit: K

HOST-DERIVED SITE_FITTED baseline. Train on known-good coil operation and publish a positive value only while ready/fresh/in-domain for coil airflow, entering temperature, valve/full-heat state, and hot-water temperature/flow/pressure. Record model version, fit period, error, inputs, and freeze/update policy. Brick has no exact expected delta-T point; PPR2.1 Function/hasOutput/QuantifiableProperty and QUDT TemperatureDifference/K are exact, but no expected/derived aspect exists.

Point Dictionary: HP

Heat pump point dictionary. Air-side and compressor points carry from the AHU/RTU dictionaries. Thermal power is canonically a host-derived Function output; active electrical power uses the meter pattern. Mode command remains Brick-only until a deployment models its custom enumeration, and defrost status remains provisional. Owning equipment: the packaged heat pump unit. EXPANSION 2026-08-17: refrigerant-side points added for the refrigerant-side HP-0004/0005/0006 family (NIST SP 1087 grounding); the two saturation temperatures are HOST-DERIVED virtual points per SCHEMA.md’s virtual-point convention. ASHRAE 223 mappings are pinned to the public-review artifact 223p-1.0.0-ppr2.1.ttl (owl:versionInfo 1.0.0-ppr.2.1; SHA-256 1f156f9938c0be430d2216e01e31bb183c438ba318d8d4a23d2f074ebcd6f573), not an inferred final 2026 release. That artifact imports QUDT 3.1.8; the quantity-kind and unit local names here were independently checked against the repository-declared QUDT 3.1.4 artifacts.

PointKindUnitBrickDerivedProvisional
thermal_powerrealkW
elec_powerrealkWActive_Power_Sensor
oatreal°COutside_Air_Temperature_Sensor
satreal°CSupply_Air_Temperature_Sensor
comp_cmdboolboolStart_Stop_Command
comp_statusboolboolOn_Off_Status
aux_heat_statusboolboolHeating_Start_Stop_Status
defrost_statusboolbool
mode_commandint1Mode_Command
suction_tempreal°C
liquid_tempreal°C
evap_sat_tempreal°C
cond_sat_tempreal°C
comp_discharge_tempreal°C

thermal_power

Delivered thermal output (heating or cooling), kW

  • 223P: Host calculation represented as a Function whose hasOutput is a QuantifiableProperty
  • QUDT unit: KiloW

Canonical host-derived useful heating or cooling output (for example airflow x cp x temperature difference), represented as a Function output. Record derivation, mode sign convention, averaging, and sensor provenance. A physical thermal meter is a separate observable binding.

elec_power

Compressor electrical input power, kW

  • 223P: ElectricityMeter reports a QuantifiableObservableProperty with ActivePower/KiloW at the compressor electrical Connection or ConnectionPoint
  • QUDT unit: KiloW

Brick 1.4.4 has no Electrical_Power_Sensor class; Active_Power_Sensor (verified) is the correct real-power reading. Bind the compressor circuit, not the whole unit, or fan energy pollutes the COP.

oat

Outdoor air temperature

  • 223P: Sensor observes QuantifiableObservableProperty at the outdoor-air intake
  • QUDT unit: DEG_C

Brick spelling is Outside_ (Outdoor_Air_Temperature_Sensor does not exist). Role-OutdoorAirIntake is 223P’s only outdoor role; used by reference instance oa-temp. [HP dictionary] Brick class and 223P pattern carried over (equipment-independent); used by the COP baseline (regression axis) and defrost sanity test.

sat

Supply (discharge) air temperature

  • 223P: Sensor observes QuantifiableObservableProperty attached to the AHU supply-air OutletConnectionPoint
  • QUDT unit: DEG_C

Brick Discharge_Air_Temperature_Sensor is brick:aliasOf this class. 223P pattern verbatim from reference instance MultipleZoneAhu-sa-temp; medium comes from hasMedium on the owning ConnectionPoint. [HP dictionary] Brick class and 223P pattern carried over (equipment-independent); for HP-0003 the discharge temperature is the reversing-valve witness.

comp_cmd

Final run command for the bound heat-pump compressor or compressor scope

  • 223P: EnumeratedActuatableProperty on the bound Compressor via hasProperty

brick:Start_Stop_Command and brick:Compressor are exact Brick 1.4.4 classes. PPR2.1 Compressor, EnumeratedActuatableProperty, and Binary-OnOff are exact, but no public reference-model compressor command instance was found, so the attachment remains provisional. Default binding is one compressor/circuit after normal sequenced permissives; an aggregate OR is a lossy documented exception and must use the identical scope as comp_status.

comp_status

Independent run proof for the bound heat-pump compressor or compressor scope

  • 223P: EnumeratedObservableProperty on the Compressor via hasProperty

Brick 1.4.4 On_Off_Status and Compressor are exact generic classes; PPR2.1 EnumeratedObservableProperty and Binary-OnOff are exact, but no public compressor-property instance was found, so the attachment remains provisional. Default to one compressor/circuit. An aggregate OR is allowed only as an explicit matched command/status scope and hides a lag failure while another compressor remains on.

aux_heat_status

Independent active-production status for the explicitly classified auxiliary heat source

  • 223P: EnumeratedObservableProperty with Binary-OnOff on explicitly classified auxiliary heating Equipment such as an ElectricResistanceElement or Furnace; Role-Heating and containment/topology establish scope

brick:Heating_Start_Stop_Status and the generic PPR2.1 heating/status terms are exact, but neither ontology has an exact auxiliary role. Deployment must identify separate auxiliary equipment and independent current, fuel-flow, thermal, contactor, or local-stage proof that it is actively producing heat. Do not bind availability, demand, command echo, crankcase heat, base-pan heat, or defrost heat.

defrost_status

Defrost cycle active (true while the unit is in defrost)

  • 223P: EnumeratedObservableProperty (Binary-OnOff) on the defrost sequence via hasProperty

Brick 1.4.4 has no Defrost_Status class (grep-verified absent); bind the BAS/unit defrost-active flag and tag the generic Status pattern. Sampling caveat for HP-0002: a defrost cycle shorter than the host tick is invisible to the edge counter — tick well below the ~5-10 min cycle length.

mode_command

Commanded operating mode (host-mapped integer; library encoding 1 = HEATING, 2 = COOLING)

  • QUDT unit: UNITLESS

brick:Mode_Command grep-verified in Brick 1.4.4. The 1/2 encoding is this library’s convention (expected_mode precedent); HP-0003 exposes both codes as rule params so hosts with their own enum rebind without editing the graph. PPR2.1 has no standard EnumerationKind for the library’s site-mapped 1/2 mode encoding, so the s223 mapping is null until a deployment supplies a custom enumeration.

suction_temp

Compressor suction line refrigerant temperature (surface-mount acceptable)

  • 223P: Sensor observes a QuantifiableObservableProperty at the Compressor suction ConnectionPoint
  • QUDT unit: DEG_C

No Brick 1.4.4 suction-temperature class (only Refrigerant_Level_Sensor exists refrigerant-side); null mapping per defrost_status precedent. Superheat = suction_temp - evap_sat_temp.

liquid_temp

Liquid line refrigerant temperature (surface-mount acceptable)

  • 223P: Sensor observes a QuantifiableObservableProperty at the condenser liquid-line outlet ConnectionPoint
  • QUDT unit: DEG_C

No Brick 1.4.4 class; null mapping. Subcooling = cond_sat_temp - liquid_temp.

evap_sat_temp

Evaporating saturation temperature (HOST-DERIVED: suction pressure via refrigerant P-T lookup, or coil-surface proxy)

  • 223P: Function hasOutput a QuantifiableProperty derived from refrigerant pressure and a refrigerant-specific pressure-temperature relation
  • QUDT unit: DEG_C

HOST-DERIVED virtual point (zone_reheat_fraction/chw_valve_max precedent): the P-T relationship is refrigerant-specific and lives at the host, never in a rule graph. Derivation MUST match the site’s refrigerant.

cond_sat_temp

Condensing saturation temperature (HOST-DERIVED: discharge/liquid pressure via refrigerant P-T lookup)

  • 223P: Function hasOutput a QuantifiableProperty derived from refrigerant pressure and a refrigerant-specific pressure-temperature relation
  • QUDT unit: DEG_C

HOST-DERIVED virtual point, same convention and refrigerant caveat as evap_sat_temp.

comp_discharge_temp

Compressor discharge line refrigerant temperature (surface-mount acceptable)

  • 223P: Sensor observes a QuantifiableObservableProperty at the Compressor discharge ConnectionPoint
  • QUDT unit: DEG_C

No Brick 1.4.4 class models refrigerant-line temperatures (suction_temp precedent; discharge-AIR classes are the wrong medium). Refrigerant side, hot-gas line between compressor and reversing valve. Consumed by the reversing-valve internal-leakage rule (HP-0006): NIST SP 1087 (2008) found leakage raises discharge and evaporating temperature while condensing temperature and subcooling fall, with superheat unaffected.

Point Dictionary: HW

Hot-water and boiler-plant point dictionary. Owning equipment is brick:Boiler (brick:Natural_Gas_Boiler for the gas-fired subclass); multi-boiler machine rules bind one coherent boiler and plant rules define their configured fleet and header explicitly. ASHRAE 223 mappings were checked against 223p-1.0.0-ppr2.1.ttl (owl:versionInfo 1.0.0-ppr.2.1; SHA-256 1f156f9938c0be430d2216e01e31bb183c438ba318d8d4a23d2f074ebcd6f573), not an inferred final 2026 release. That artifact imports QUDT 3.1.8; the quantity-kind and unit local names here were independently checked against the repository-declared QUDT 3.1.4 artifacts. The legacy reference covers HW-0001..0003; later loop and regulation rules are library-authored expansions.

PointKindUnitBrickDerivedProvisional
boiler_statusboolboolRun_Status
thermal_powerrealkW
fuel_powerrealkW
boiler_firing_ratereal%
hw_pump_statusboolboolPump_On_Off_Status
oatrealdegCOutside_Air_Temperature_Sensor
hws_temprealdegCLeaving_Hot_Water_Temperature_Sensor
hwr_temprealdegCEntering_Hot_Water_Temperature_Sensor
hws_temp_sprealdegCLeaving_Hot_Water_Temperature_Setpoint
hw_dprealkPaHot_Water_Differential_Pressure_Sensor
hw_dp_sprealkPaHot_Water_Differential_Pressure_Setpoint
hw_pump_vfd_speedreal%Motor_Speed_Sensor
boiler_cmdboolboolStart_Stop_Command
boiler_stage_countint1
hw_plant_load_fractionreal1

boiler_status

Boiler firing status (true = firing)

  • 223P: EnumeratedObservableProperty on the Boiler via hasProperty

brick:Run_Status grep-verified, isPointOf brick:Boiler (verified). Reference Haystack markers: boiler, run. ‘Firing’ vs ‘enabled’ matters for HW-0001’s start counting: bind the FIRING/flame status where available — an enable status hides burner cycling and undercounts starts (comp_status OR-undercount precedent). Consumed by HW-0001 (edge counting) and HW-0003 (lockout).

thermal_power

Boiler useful thermal output (host-derived from aligned flow and water temperatures)

  • 223P: Host Function hasOutput a QuantifiableProperty for useful boiler HeatFlowRate
  • QUDT unit: KiloW

HOST-DERIVED for this canonical contract: aligned HW flow x leaving/entering delta-T x density x specific heat, with sign and unit conventions recorded. PPR2.1 provides Function/hasOutput/QuantifiableProperty but no exact derived-useful-heat aspect. A physical BTU meter is an alternative source, but its observable sensor model is distinct from this calculation. HW-0002 efficiency numerator.

fuel_power

Boiler fuel input power (fuel flow x heating value)

  • 223P: Host Function hasOutput a QuantifiableProperty for fuel input Power derived from fuel flow and heating value
  • QUDT unit: KiloW

HOST-DERIVED from fuel flow x HHV or LHV; pick one declared convention and keep it consistent with the fitted baseline. Brick 1.4.4 has gas usage/flow sensors but no exact fuel-input-power point. Fluid-NaturalGas is the verified PPR2.1 medium. HW-0002 efficiency denominator and divide guard.

boiler_firing_rate

Boiler firing rate (percent of maximum fire)

  • 223P: QuantifiableObservableProperty on the Boiler via hasProperty
  • QUDT unit: PERCENT

No exact Brick 1.4.4 percent-firing-rate class. Bind actual normalized burner modulation feedback when available; a command is a disclosed proxy, not the same observable property. A plant aggregate must be capacity-weighted (total applicable input divided by total applicable rated input), never a simple average across unequal boilers. Consumed by HW-0002 and HW-0011.

hw_pump_status

HW pump run status (true = running)

  • 223P: EnumeratedObservableProperty on the Pump via hasProperty

brick:Pump_On_Off_Status grep-verified (carried from pmp pump_status). Second branch of HW-0003’s lockout OR — pumps circulating above lockout waste energy even with the boiler off.

oat

Outdoor air temperature

  • 223P: TemperatureSensor observes a QuantifiableObservableProperty in an OutdoorPhysicalSpace representative of the plant
  • QUDT unit: DEG_C

Carried from the ahu/erv dictionaries (brick:Outside_Air_Temperature_Sensor, grep-verified there). HW-0003’s lockout comparator; its diagnosis list includes an OAT sensor reading low — pair with sensor-health rules when phase 3 opens.

hws_temp

Hot water supply temperature (loop supply / boiler leaving)

  • 223P: TemperatureSensor observes a QuantifiableObservableProperty at the hot-water plant or boiler outlet ConnectionPoint
  • QUDT unit: DEG_C

brick:Leaving_Hot_Water_Temperature_Sensor is the active Brick 1.4.4 class; the former supply form is deprecated. PPR2.1 expresses leaving/supply direction through outlet topology, not a free-text aspect. Bind the same controlled header or boiler outlet required by the consuming card.

hwr_temp

Hot water return temperature

  • 223P: TemperatureSensor observes a QuantifiableObservableProperty at the hot-water plant or boiler inlet ConnectionPoint
  • QUDT unit: DEG_C

brick:Entering_Hot_Water_Temperature_Sensor is the active Brick 1.4.4 class; the former return form is deprecated. PPR2.1 expresses entering/return direction through inlet topology. HW-0004 delta-T subtrahend.

hws_temp_sp

Hot water supply temperature setpoint

  • 223P: QuantifiableActuatableProperty with Aspect-Setpoint controlling the hot-water plant or boiler outlet ConnectionPoint
  • QUDT unit: DEG_C

brick:Leaving_Hot_Water_Temperature_Setpoint is exact in Brick 1.4.4. PPR2.1 uses Water-HotWater plus Aspect-Setpoint; outlet direction is topology. Bind the final active target controlling the same hws_temp location, not an upstream reset request.

hw_dp

HW loop differential pressure

  • 223P: QuantifiableObservableProperty for hot-water loop pressure with Aspect-Delta
  • QUDT unit: KiloPA

brick:Hot_Water_Differential_Pressure_Sensor grep-verified. Context point for the DP rules; HW-0005’s primary evidence is pump speed, not DP itself.

hw_dp_sp

HW loop differential pressure setpoint

  • 223P: QuantifiableActuatableProperty for hot-water loop pressure with Aspect-Delta and Aspect-Setpoint
  • QUDT unit: KiloPA

brick:Hot_Water_Differential_Pressure_Setpoint grep-verified. HW-0006 watches this point’s RANGE (AHU-0024/CHW-0003 idiom).

hw_pump_vfd_speed

HW pump VFD speed feedback (0-100%)

  • 223P: QuantifiableObservableProperty on the equipment via hasProperty
  • QUDT unit: PERCENT

PPR2.1 supports an observable DimensionlessRatio/PERCENT property on the pump motor or VFD. Brick 1.4.4 has Motor_Speed_Sensor, but its inherited physical Speed unit list does not include PERCENT, so the normalized-percent mapping is provisional. Bind actual feedback where possible; command or EnergyPlus flow fraction is a documented proxy.

boiler_cmd

Boiler enable command (true = commanded to fire)

  • 223P: EnumeratedActuatableProperty on the Boiler via hasProperty

brick:Start_Stop_Command attached via isPointOf to brick:Boiler (pump_cmd precedent). The command HALF of the boiler_status pair; HW-0009 proof-of-operation compares the two. Bind the plant-level enable the BAS actually writes — a lead/lag sequencer’s internal stage command is upstream of what this point means.

boiler_stage_count

Number of staging units in the configured boiler fleet currently proven firing

  • 223P: Host aggregation represented as a Function whose hasOutput is a QuantifiableProperty for Count in NUM
  • QUDT unit: NUM

HOST-DERIVED sum of valid per-unit firing statuses, not enable or availability states. Record fleet membership, status freshness, aggregation timestamp, and whether a staging unit is a whole boiler or modular burner section. Do not mix unlike stage units in one scalar count.

hw_plant_load_fraction

Current useful hot-water plant heating load divided by the commissioned capacity of the configured eligible staging fleet

  • 223P: Host calculation represented as a Function whose hasOutput is a QuantifiableProperty for DimensionlessRatio in UNITLESS
  • QUDT unit: UNITLESS

HOST-DERIVED 0..1 ratio. Record the thermal-load derivation, numerator averaging, capacity source/version, eligible-fleet membership, and denominator-change policy. The capacity basis must remain stable through one evaluation window; if membership or denominator changes, restart evaluation or report NO_EVAL.

Point Dictionary: HX

Indirect liquid-to-liquid hydronic heat-exchanger dictionary. One instance is one physical four-port exchanger with fixed primary and secondary sides and paired inlet/outlet connection points. Brick 1.4.4 has exact generic Heat_Exchanger but no Plate_Heat_Exchanger; primary/secondary identity remains topology-dependent. ASHRAE 223 terms were checked against public-review artifact 223p-1.0.0-ppr2.1.ttl (owl:versionInfo 1.0.0-ppr.2.1; SHA-256 1f156f9938c0be430d2216e01e31bb183c438ba318d8d4a23d2f074ebcd6f573), which has exact HydronicHeatExchanger, four-port pairing, and Role-Primary/Role-Secondary. Brick 1.4.4 was checked against Brick.ttl SHA-256 b65720b7b9b64c646745c689777e6138c0d59ce0088df0aeb78fbd444d04d8e7. The public-review 223 artifact imports QUDT 3.1.8; terms here were independently checked against repository-declared QUDT 3.1.4. Steam, air, refrigerant, direct-contact, potable-water, and incoherently aggregated HX instances are outside this initial contract.

PointKindUnitBrickDerivedProvisional
primary_entering_tempreal°CEntering_Water_Temperature_Sensor
primary_leaving_tempreal°CHeat_Exchanger_Leaving_Water_Temperature_Sensor
secondary_entering_tempreal°CEntering_Water_Temperature_Sensor
secondary_leaving_tempreal°CHeat_Exchanger_Leaving_Water_Temperature_Sensor
primary_flowrealL/sEntering_Water_Flow_Sensor
secondary_flowrealL/sEntering_Water_Flow_Sensor
exchange_cmdboolboolEnable_Command
control_valve_cmdreal%Valve_Position_Command
control_valve_positionreal%Valve_Position_Sensor
effectivenessreal1
effectiveness_expectedreal1
heat_transfer_raterealkW

primary_entering_temp

Primary-side liquid temperature at this heat exchanger’s inlet

  • 223P: TemperatureSensor observes a QuantifiableObservableProperty at the HydronicHeatExchanger primary InletConnectionPoint; the paired connection points carry Role-Primary
  • QUDT unit: DEG_C

brick:Entering_Water_Temperature_Sensor is exact, but Brick’s point class does not distinguish the two HX sides; preserve primary identity in topology and binding evidence. PPR2.1 represents entering location with InletConnectionPoint and Role-Primary on the connection-point pair, not property aspects. Use a more specific water medium at deployment when known. Host derivation input for effectiveness and signed heat transfer.

primary_leaving_temp

Primary-side liquid temperature at this heat exchanger’s outlet

  • 223P: TemperatureSensor observes a QuantifiableObservableProperty at the paired HydronicHeatExchanger primary OutletConnectionPoint
  • QUDT unit: DEG_C

brick:Heat_Exchanger_Leaving_Water_Temperature_Sensor is active and exact in Brick 1.4.4; deprecated supply/discharge HX temperature classes must not be used. The primary-side identity still depends on topology. Pair this outlet with primary_entering_temp on the same branch.

secondary_entering_temp

Secondary-side liquid temperature at this heat exchanger’s inlet

  • 223P: TemperatureSensor observes a QuantifiableObservableProperty at the HydronicHeatExchanger secondary InletConnectionPoint; the paired connection points carry Role-Secondary
  • QUDT unit: DEG_C

Same exact generic Brick class and side-identity caveat as primary_entering_temp. PPR2.1 topology distinguishes the secondary inlet. Host derivation input for effectiveness and signed heat transfer.

secondary_leaving_temp

Secondary-side liquid temperature at this heat exchanger’s outlet

  • 223P: TemperatureSensor observes a QuantifiableObservableProperty at the paired HydronicHeatExchanger secondary OutletConnectionPoint
  • QUDT unit: DEG_C

Exact active Brick class with topology-dependent secondary identity. Pair with secondary_entering_temp on the same physical branch and timestamp basis.

primary_flow

Individual primary-branch liquid volume flow, nonnegative in the declared inlet-to-outlet direction

  • 223P: FlowSensor observes a QuantifiableObservableProperty at the HydronicHeatExchanger primary connection-point pair
  • QUDT unit: L-PER-SEC

The Brick flow class is exact; primary identity is topology-dependent. Bind one individual HX branch, never a common header or fleet total. Required directly by HX-0002 and by the host’s HX-0001/HX-0003 thermal derivations. Mass-flow telemetry must be converted using the configured fluid density before binding.

secondary_flow

Individual secondary-branch liquid volume flow, nonnegative in the declared inlet-to-outlet direction

  • 223P: FlowSensor observes a QuantifiableObservableProperty at the HydronicHeatExchanger secondary connection-point pair
  • QUDT unit: L-PER-SEC

Same individual-branch and conversion contract as primary_flow, with Role-Secondary topology. A primary/common-loop meter duplicated here makes HX-0002 structurally blind and invalidates both thermal derived points.

exchange_cmd

Final downstream command state meaning both heat-exchanger branches are expected to flow

  • 223P: EnumeratedActuatableProperty with Binary-OnOff on the HydronicHeatExchanger or final exchange sequence

brick:Enable_Command and the PPR2.1 Boolean property terms are exact, but no public reference instance establishes a portable final HX-command attachment. Bind after temperature feasibility, anti-cycle, local/HAND, freeze, pressure, valve, and pump sequence logic. Availability, an upstream plant enable, or Heat_Exchanger_System_Enable_Status is invalid unless it truly means both branch flows are currently expected.

control_valve_cmd

Final physical command for the valve intended to stop all exchange through this HX path, 0 closed to 100 open

  • 223P: QuantifiableActuatableProperty with Binary-Position on the adjacent hydronic Valve that isolates the monitored HX path
  • QUDT unit: PERCENT

The property/valve terms are exact, but which valve isolates the whole path is deployment topology. Bind the final actuator output after exercise, minimum-position, local, and protective logic. A secondary valve that leaves a bypass or parallel path open cannot support HX-0003.

control_valve_position

Optional independent position feedback for the valve intended to stop all exchange through this HX path, 0 closed to 100 open

  • 223P: QuantifiableObservableProperty with Binary-Position on the same adjacent hydronic Valve as control_valve_cmd
  • QUDT unit: PERCENT

Optional HX-0003 playbook evidence, not a rule input in the initial batch. The terms are exact, but the deployment must prove this feedback belongs to the same isolating valve and is independent of command echo. It distinguishes an actuator that never closed from transfer through a valve that reports closed; it does not by itself prove seat tightness or eliminate a parallel path.

effectiveness

Actual sign-independent thermal effectiveness of this HX after denominator and side-energy-balance validation

  • 223P: Host calculation represented as a Function whose hasOutput is a QuantifiableProperty for DimensionlessRatio in UNITLESS
  • QUDT unit: UNITLESS

HOST-DERIVED. Compute abs(reconciled heat transfer)/(min(rhocpflow on both sides)*abs(primary entering-secondary entering)). Publish only with positive finite capacity rates and denominator, sufficient inlet separation, aligned valid inputs, configured fluid properties, and side heat-rate balance within uncertainty. The CXF graph deliberately performs no Divide. Record every source point, fluid-property basis, uncertainty test, and readiness/domain state.

effectiveness_expected

Frozen clean/design expected effectiveness for this HX at the current valid operating condition

  • 223P: Frozen host model represented as a Function whose hasOutput is a QuantifiableProperty for DimensionlessRatio in UNITLESS
  • QUDT unit: UNITLESS

HOST-DERIVED SITE_FITTED or commissioned/design curve. Condition on flow-capacity ratio, entering temperatures, mode, and other declared model inputs. Record fit/commissioning interval, version, validation error, domain, readiness, and freeze/update policy. Never fit on the scored fault interval or consume actual effectiveness as a direct target alias.

heat_transfer_rate

Signed reconciled heat-transfer rate for this HX, positive from primary to secondary

  • 223P: Host calculation represented as a Function whose hasOutput is a QuantifiableProperty for HeatFlowRate in KiloW
  • QUDT unit: KiloW

HOST-DERIVED signed value. Primary-side estimate is rhocpflow*(primary entering-primary leaving); secondary-side estimate is rhocpflow*(secondary leaving-secondary entering). Reconcile only after timestamp, fluid-property, flow, and energy-balance checks; otherwise report NO_EVAL. HX-0003 uses the absolute magnitude, so heating and cooling are symmetric.

Point Dictionary: PMP

Hydronic pump point dictionary. Loop-agnostic: the same canonical points serve CHW, HW, condenser-water, and other hydronic pumps; owning equipment is brick:Pump (verified in Brick 1.4.4; use the loop-specific subclass at binding time if the site model has one). ASHRAE 223 mappings were checked against ontology artifact 223p-1.0.0-ppr2.1.ttl (owl:versionInfo 1.0.0-ppr.2.1; SHA-256 1f156f9938c0be430d2216e01e31bb183c438ba318d8d4a23d2f074ebcd6f573), not an inferred final 2026 release label. The reference states no_flow/deadhead thresholds as percent-of-design; the rules carry absolute-unit parameters, so binding a site means converting design flow/head into the loop’s own units first.

PointKindUnitBrickDerivedProvisional
pump_cmdboolboolStart_Stop_Command
pump_statusboolboolPump_On_Off_Status
pump_flowrealL/sWater_Flow_Sensor
pump_dprealkPaWater_Differential_Pressure_Sensor
pump_kwrealkWActive_Power_Sensor
pump_kw_expectedrealkW

pump_cmd

Pump run command (true = commanded on)

  • 223P: EnumeratedActuatableProperty on the Pump via hasProperty

brick:Start_Stop_Command grep-verified in Brick 1.4.4, attached via isPointOf to brick:Pump (verified). Reference Haystack markers: pump, cmd. Consumed by PMP-0001’s command conjunct.

pump_status

Pump run status feedback (true = proven running)

  • 223P: EnumeratedObservableProperty on the Pump via hasProperty

brick:Pump_On_Off_Status grep-verified in Brick 1.4.4 (pump-specific status class exists, unlike the compressor case). Reference Haystack markers: pump, run. Typical source is a current switch or VFD run feedback; PMP-0001 requires BOTH cmd and status true, so a failed status sensor reads as NO_EVAL at the host, not as a fault.

pump_flow

Individual pump-branch water flow (canonical signed direction, when available: positive from suction to discharge)

  • 223P: QuantifiableObservableProperty on the Pump’s discharge segment via hasProperty
  • QUDT unit: L-PER-SEC

brick:Water_Flow_Sensor verified in Brick 1.4.4. The point is on THIS pump branch; common-header flow is invalid. When signed, canonical positive is suction-to-discharge and negative is reverse. A nonnegative magnitude binding remains valid for PMP-0001/PMP-0002 and can support PMP-0005 yFault, but neither PMP-0005 direction label is trustworthy because reverse physical flow may also be reported positive. The older rules’ 5%/10%-of-design thresholds ship as absolute L/s placeholders that MUST be set from design flow.

pump_dp

Pump differential pressure (discharge minus suction, or loop DP where that is what the site trends)

  • 223P: QuantifiableObservableProperty across the Pump via hasProperty
  • QUDT unit: KiloPA

brick:Water_Differential_Pressure_Sensor grep-verified in Brick 1.4.4, but marked provisional because sites trend this two ways — across the pump (deadhead reads HIGH) or across the loop/decoupler (deadhead may read high or low depending on tap location). PMP-0002’s logic assumes the ACROSS-THE-PUMP reading; binding a loop-DP point instead inverts the diagnosis and must be caught at binding review. deadhead_dp_threshold (150% of design head) ships as an absolute kPa placeholder.

pump_kw

Electrical active input power of this pump motor/drive assembly

  • 223P: ElectricityMeter reports a QuantifiableObservableProperty with ActivePower and KiloW at the pump motor/VFD electrical Connection or ConnectionPoint; the deployment associates that circuit with the Pump
  • QUDT unit: KiloW

brick:Active_Power_Sensor is exact in Brick 1.4.4; QUDT ActivePower and KiloW are exact. Bind true active input power for this pump’s motor/drive assembly, not apparent power, a current-derived proxy, VFD output power, or an aggregate panel serving parallel pumps/auxiliaries. The electrical and averaging boundary must match pump_kw_expected.

pump_kw_expected

Expected electrical pump input power at the current operating condition (host-fitted baseline)

  • 223P: Host model represented as a Function whose hasOutput is a QuantifiableProperty for ActivePower
  • QUDT unit: KiloW

HOST-DERIVED virtual point, classified SITE_FITTED. The host fits a model on a known-good period and publishes expected input power for the CURRENT condition only while the model is ready, fresh, and in-domain. Record the fit period, model version, validation error, update/freeze policy, and inputs (for example speed, branch flow, differential pressure, staging, and fluid properties); actual and expected must cover the same electrical/averaging boundary. Brick 1.4.4 has no exact expected-power point. 223 PPR2.1 supports Function/hasOutput/QuantifiableProperty, but has no exact predicted or derived aspect; Aspect-Nominal is rejected because this value changes with operating condition.

Point Dictionary: RTU

RTU (packaged rooftop unit) point dictionary. Shared air-side points carry their Brick classes and 223P patterns from the AHU dictionary (see per-point notes); the owning equipment class is brick:Rooftop_Unit / brick:RTU (both verified in Brick 1.4.4). RTU-specific points (comp_status, comp_stage, htg_status) are grep-verified against Brick 1.4.4 but have no reference-model instance check, hence provisional. ASHRAE 223 mappings are pinned to the public-review artifact 223p-1.0.0-ppr2.1.ttl (owl:versionInfo 1.0.0-ppr.2.1; SHA-256 1f156f9938c0be430d2216e01e31bb183c438ba318d8d4a23d2f074ebcd6f573), not an inferred final 2026 release. That artifact imports QUDT 3.1.8; the quantity-kind and unit local names here were independently checked against the repository-declared QUDT 3.1.4 artifacts.

PointKindUnitBrickDerivedProvisional
satreal°CSupply_Air_Temperature_Sensor
sat_spreal°CSupply_Air_Temperature_Setpoint
matreal°CMixed_Air_Temperature_Sensor
oatreal°COutside_Air_Temperature_Sensor
ratreal°CReturn_Air_Temperature_Sensor
oa_dmpr_cmdreal%Damper_Position_Command
sf_cmdboolboolStart_Stop_Command
sf_statusboolboolFan_On_Off_Status
occ_scheduleboolboolOccupancy_Command
comp_statusboolboolOn_Off_Status
comp_stageint1Stages_Status
htg_statusboolboolHeating_Start_Stop_Status
cond_leaving_tempreal°C
cond_split_baselinerealK
suction_tempreal°C
liquid_tempreal°C
evap_sat_tempreal°C
cond_sat_tempreal°C

sat

Supply (discharge) air temperature

  • 223P: Sensor observes QuantifiableObservableProperty attached to the AHU supply-air OutletConnectionPoint
  • QUDT unit: DEG_C

Brick Discharge_Air_Temperature_Sensor is brick:aliasOf this class. 223P pattern verbatim from reference instance MultipleZoneAhu-sa-temp; medium comes from hasMedium on the owning ConnectionPoint. [RTU dictionary] Brick point class and 223P property pattern are equipment-independent and carried from the AHU dictionary, where instance-level verification was done against the AHU reference model; here the owning equipment is brick:Rooftop_Unit (alias RTU, verified in Brick 1.4.4) and no packaged-unit reference instance has been checked, so treat the 223P attachment as pattern guidance.

sat_sp

Active supply (discharge) air temperature setpoint used by this RTU

  • 223P: QuantifiableActuatableProperty linked from the observed supply-air temperature property via hasSetpoint
  • QUDT unit: DEG_C

Supply_Air_Temperature_Setpoint is exact in Brick 1.4.4. PPR2.1 QuantifiableActuatableProperty, Temperature, DEG_C, Fluid-Air, Aspect-Setpoint, and hasSetpoint are exact and follow the public G36 supply-air-temperature setpoint pattern. Bind the final active mode-specific RTU target; an occupied default or cooling-only constant is invalid during heating.

mat

Mixed air temperature (after OA and RA mix)

  • 223P: Sensor observes QuantifiableObservableProperty located topologically downstream of the OA/RA mixing junction via hasObservationLocation
  • QUDT unit: DEG_C

223P has no mixed-air role or aspect (EnumerationKind-Role has 21 members, none mixed-air); the reference model disambiguates purely by observation location. A property-only export loses the ‘mixed’ semantics — topology must accompany it. [RTU dictionary] Brick point class and 223P property pattern are equipment-independent and carried from the AHU dictionary, where instance-level verification was done against the AHU reference model; here the owning equipment is brick:Rooftop_Unit (alias RTU, verified in Brick 1.4.4) and no packaged-unit reference instance has been checked, so treat the 223P attachment as pattern guidance.

oat

Outdoor air temperature

  • 223P: Sensor observes QuantifiableObservableProperty at the outdoor-air intake
  • QUDT unit: DEG_C

Brick spelling is Outside_ (Outdoor_Air_Temperature_Sensor does not exist). Role-OutdoorAirIntake is 223P’s only outdoor role; used by reference instance oa-temp. [RTU dictionary] Brick point class and 223P property pattern are equipment-independent and carried from the AHU dictionary, where instance-level verification was done against the AHU reference model; here the owning equipment is brick:Rooftop_Unit (alias RTU, verified in Brick 1.4.4) and no packaged-unit reference instance has been checked, so treat the 223P attachment as pattern guidance.

rat

Return air temperature

  • 223P: Sensor observes QuantifiableObservableProperty at the AHU return-air InletConnectionPoint
  • QUDT unit: DEG_C

Verbatim from reference instance MultipleZoneAhu-ra-temp. [RTU dictionary] Brick point class and 223P property pattern are equipment-independent and carried from the AHU dictionary, where instance-level verification was done against the AHU reference model; here the owning equipment is brick:Rooftop_Unit (alias RTU, verified in Brick 1.4.4) and no packaged-unit reference instance has been checked, so treat the 223P attachment as pattern guidance.

oa_dmpr_cmd

Outdoor air damper command (0 = closed, 100 = full open)

  • 223P: QuantifiableActuatableProperty on the Damper via hasProperty; Actuator actuates the damper
  • QUDT unit: PERCENT

Attach via isPointOf to brick:Outside_Damper or Economizer_Damper. In the 223P reference model the damper equipment carries hasRole Role-Economizer (Role-OutdoorAirIntake for a non-economizer minimum-OA damper). Verified at g36:DamperOrShape1 and instance MultipleZoneAhu-economizer-oa-damper-command. [RTU dictionary] Brick point class and 223P property pattern are equipment-independent and carried from the AHU dictionary, where instance-level verification was done against the AHU reference model; here the owning equipment is brick:Rooftop_Unit (alias RTU, verified in Brick 1.4.4) and no packaged-unit reference instance has been checked, so treat the 223P attachment as pattern guidance.

sf_cmd

Final command requesting operation of this RTU supply fan

  • 223P: EnumeratedActuatableProperty on the supply Fan via hasProperty

Attach brick:Start_Stop_Command to brick:Supply_Fan. PPR2.1 g36:Fan and the public MultipleZoneAhu supply-fan instance attest the Binary-OnOff actuatable property; the fan carries Role-Supply. Unit enable is invalid unless demonstrably identical to the final fan output.

sf_status

Independent run proof for this RTU supply fan

  • 223P: EnumeratedObservableProperty on the Fan via hasProperty

Attach via isPointOf to brick:Supply_Fan. Deliberately not brick:Run_Status — its inherited definition routes equipment run status to the On/Off branch. 223P: hasEnumerationKind Binary-OnOff (OnOff-On/OnOff-Off), verified at instance MultipleZoneAhu-sa-fan-start-status; fan carries hasRole Role-Supply. [RTU dictionary] Brick point class and 223P property pattern are equipment-independent and carried from the AHU dictionary, where instance-level verification was done against the AHU reference model; here the owning equipment is brick:Rooftop_Unit (alias RTU, verified in Brick 1.4.4) and no packaged-unit reference instance has been checked, so treat the 223P attachment as pattern guidance. RTU-0010 requires this proof to describe the same physical supply fan as sf_cmd; command echo is invalid.

occ_schedule

Occupancy schedule state (true = scheduled occupied)

  • 223P: EnumeratedObservableProperty (EnumeratedActuatableProperty if the schedule writes to the controller) on the Zone or AHU

Neither ontology has a schedule concept. Brick three-way choice by point direction: Occupancy_Command (schedule output driving the unit — our reading), Occupied_Mode_Status (mode read-back), Occupancy_Status (physical room occupancy — wrong here). 223P occupancy enumeration verified from the g36:Zone SHACL shape (Occupied-True/Occupied-False). [RTU dictionary] Brick point class and 223P property pattern are equipment-independent and carried from the AHU dictionary, where instance-level verification was done against the AHU reference model; here the owning equipment is brick:Rooftop_Unit (alias RTU, verified in Brick 1.4.4) and no packaged-unit reference instance has been checked, so treat the 223P attachment as pattern guidance.

comp_status

Independent run proof for the bound RTU compressor or compressor scope

  • 223P: EnumeratedObservableProperty on the Compressor via hasProperty

Brick 1.4.4 On_Off_Status and Compressor are exact generic classes; PPR2.1 EnumeratedObservableProperty and Binary-OnOff are exact, but no public compressor-property instance was found, so the attachment remains provisional. Default to one compressor/circuit for RTU-0001. An any-running OR is allowed for rules that explicitly need aggregate operation, but it must be documented and hides starts or failures while another compressor remains on.

comp_stage

Active compressor stage (0 = off, 1..N = stage index)

  • QUDT unit: UNITLESS

brick:Stages_Status is verified in Brick 1.4.4 (‘Indicates which stage a control loop or equipment is in’) - an exact fit. Second integer point in the library (after operating_state); stage 0 means no compressor running. RTU-0002 interprets stages 1 and 2 against per-stage baselines and reports stages outside {1,2} as not evaluable via its yStageOk output; hosts with more stages extend by rebinding the baseline constants. No standard PPR2.1 EnumerationKind defines this site-specific integer stage encoding, so the s223 mapping is null until a deployment supplies a custom enumeration.

htg_status

Heating stage run status (true = gas/electric heat or heating coil active)

  • 223P: EnumeratedObservableProperty on the heating element (burner, electric heater, or coil) via hasProperty

brick:Heating_Start_Stop_Status verified in Brick 1.4.4 (subclass of Start_Stop_Status). Alternative for sites tagging the element itself: generic On_Off_Status isPointOf the burner/heater equipment. For staged gas heat bind the OR of stage statuses - RTU-0003 only asks whether ANY heat is active.

cond_leaving_temp

Condenser leaving air temperature (air discharged off the condenser coil)

  • 223P: Sensor observes a QuantifiableObservableProperty at the condenser-air OutletConnectionPoint
  • QUDT unit: DEG_C

No exact Brick 1.4.4 class found for condenser LEAVING air (brick: null, refrigerant-side precedent from the hp dict). Most packaged RTUs lack this sensor — the original deferral reason for RTU-0007; a strap-on or discharge-plenum probe retrofit is the usual binding. Condenser split = cond_leaving_temp − oat.

cond_split_baseline

Expected condenser air-temperature split for the current compressor stage and OAT (host-fitted baseline)

  • 223P: Host baseline Function hasOutput a QuantifiableProperty
  • QUDT unit: K

HOST-DERIVED virtual point (evap_sat_temp / zone_reheat_fraction precedent): the reference’s baseline_condenser_split(comp_stage, oat) — a per-stage curve against OAT fitted from known-clean operation. The host documents the fit window and guarantees the published value is strictly positive (RTU-0007 divides by it). This is the two-variable baseline that originally deferred the fault; publishing the fit as an ordinary point resolves it.

suction_temp

Compressor suction line refrigerant temperature (surface-mount acceptable)

  • 223P: Sensor observes a QuantifiableObservableProperty at the Compressor suction ConnectionPoint
  • QUDT unit: DEG_C

Mirror of the hp dict entry (duplicated per the lint’s own-family resolution rule; same derivation/placement obligations). Consumed by the RTU-0008/0009 charge pair — superheat = suction_temp − evap_sat_temp, subcooling = cond_sat_temp − liquid_temp. Original hp note: No Brick 1.4.4 suction-temperature class (only Refrigerant_Level_Sensor exists refrigerant-side); null mapping per defrost_status precedent. Superheat = suction_temp - evap_sat_temp.

liquid_temp

Liquid line refrigerant temperature (surface-mount acceptable)

  • 223P: Sensor observes a QuantifiableObservableProperty at the condenser liquid-line outlet ConnectionPoint
  • QUDT unit: DEG_C

Mirror of the hp dict entry (duplicated per the lint’s own-family resolution rule; same derivation/placement obligations). Consumed by the RTU-0008/0009 charge pair — superheat = suction_temp − evap_sat_temp, subcooling = cond_sat_temp − liquid_temp. Original hp note: No Brick 1.4.4 class; null mapping. Subcooling = cond_sat_temp - liquid_temp.

evap_sat_temp

Evaporating saturation temperature (HOST-DERIVED: suction pressure via refrigerant P-T lookup, or coil-surface proxy)

  • 223P: Function hasOutput a QuantifiableProperty derived from refrigerant pressure and a refrigerant-specific pressure-temperature relation
  • QUDT unit: DEG_C

Mirror of the hp dict entry (duplicated per the lint’s own-family resolution rule; same derivation/placement obligations). Consumed by the RTU-0008/0009 charge pair — superheat = suction_temp − evap_sat_temp, subcooling = cond_sat_temp − liquid_temp. Original hp note: HOST-DERIVED virtual point (zone_reheat_fraction/chw_valve_max precedent): the P-T relationship is refrigerant-specific and lives at the host, never in a rule graph. Derivation MUST match the site’s refrigerant.

cond_sat_temp

Condensing saturation temperature (HOST-DERIVED: discharge/liquid pressure via refrigerant P-T lookup)

  • 223P: Function hasOutput a QuantifiableProperty derived from refrigerant pressure and a refrigerant-specific pressure-temperature relation
  • QUDT unit: DEG_C

Mirror of the hp dict entry (duplicated per the lint’s own-family resolution rule; same derivation/placement obligations). Consumed by the RTU-0008/0009 charge pair — superheat = suction_temp − evap_sat_temp, subcooling = cond_sat_temp − liquid_temp. Original hp note: HOST-DERIVED virtual point, same convention and refrigerant caveat as evap_sat_temp.

Point Dictionary: SYS

System-level / cross-equipment point dictionary. The sensor-health rules (SYS-0005/SYS-0009/SYS-0010) bind ROLE POINTS, not canonical equipment points — the documented exception to the canonical-name convention (SCHEMA.md points contract; decided 2026-08-17, internal sensor-health design note §3). The reference’s own SYS-0005 card does the same (‘varies by application’). The host’s instance configuration records which real point each role is bound to; that same record drives the adjudicates NO_EVAL fan-out. Role entries carry brick/s223 null — the semantics belong to whatever the role is bound to. The reference ch.16 waste/schedule rules (SYS-0001..SYS-0004, SYS-0007, SYS-0008) bind CANONICAL cross-equipment points (grounded below); SYS-0006 binds the physical_sensor/virtual_value role pair. Points that also exist in equipment dictionaries remain duplicated here when their ownership is contextual (oat, sf_status, rht_vlv_cmd). occ_sensor is a compatibility alias to the canonical zone point.

PointKindUnitBrickDerivedProvisional
sensor_valuerealvaries
sensor_value_arealvaries
sensor_value_brealvaries
equip_activeboolbool
physical_sensorrealvaries
virtual_valuerealvaries
chw_flowrealL/sChilled_Water_Supply_Flow_Sensor
hw_flowrealL/sHot_Water_Supply_Flow_Sensor
ahu_clg_vlv_maxreal%
ahu_htg_vlv_maxreal%
occ_scheduledboolbool
lighting_statusboolboolOn_Off_Status
ef_statusboolboolFan_On_Off_Status
sf_statusboolboolFan_On_Off_Status
demand_override_activeboolbool
rht_vlv_cmdreal%Valve_Position_Command
oatrealdegCOutside_Air_Temperature_Sensor

sensor_value

ROLE: the single sensor under adjudication (flatline/spike rules)

Role point: bound per instance to any real-valued sensor (temperature, pressure, flow…). Thresholds on the rules that consume it are in the BOUND point’s units and MUST be retuned per binding — a 0.1 degC flatline band is nonsense for a Pa sensor. Binding record doubles as the adjudicates target.

sensor_value_a

ROLE: primary sensor of a redundancy pair (SYS-0005)

Role point; the reference’s own required-points table for SYS-0005 lists sensor_a/sensor_b with ‘varies by application’. Pair members must share quantity kind and units at binding.

sensor_value_b

ROLE: reference sensor of a redundancy pair (SYS-0005)

Role point; see sensor_value_a. The pair rule’s verdict is ambiguous — it cannot name which member drifted.

equip_active

ROLE: owning equipment is running/active (gates flatline evaluation)

Role point: bound to the owning equipment’s run/enable status (sf_status, pump_status, boiler_status…). A flat signal on idle equipment is normal; flatline only accuses the sensor while the process should be moving.

physical_sensor

ROLE: the physical sensor cross-checked against a virtual-sensor prediction (SYS-0006)

Role point; the reference’s required-points table lists physical_sensor plus 3+ correlated features. Thresholds (bias/noise) are in the BOUND point’s units — the reference’s defaults are for temperature bindings and MUST be retuned for other quantity kinds. Binding record doubles as the adjudicates target.

virtual_value

ROLE: host-computed virtual-sensor prediction of the bound physical_sensor (SYS-0006)

Host-derived role point: the host evaluates a regression model (reference: Ridge trained over a learning period on 3+ correlated features) each tick and exposes the prediction as this point. The graph computes only the residual statistics — model training/inference is host-side, per the host-fitted baseline convention (SCHEMA.md; HP-0001/VAV-0001 precedent).

chw_flow

Chilled water plant supply flow

  • 223P: Sensor observes QuantifiableObservableProperty on the CHW supply segment
  • QUDT unit: L-PER-SEC

hw_flow

Hot water plant supply flow

  • 223P: Sensor observes QuantifiableObservableProperty on the HW supply segment
  • QUDT unit: L-PER-SEC

ahu_clg_vlv_max

Maximum cooling-coil valve command across all AHUs served by the CHW plant

  • 223P: host-derived aggregate; no single physical point
  • QUDT unit: PERCENT

Host computes max(clg_vlv_cmd) over the served-AHU set each tick (chw_valve_max precedent, points/chw.points.json). The reference’s all-valves-closed condition is equivalently max(valves) below the closed threshold — one aggregate point instead of a variable-width vector, which the graph cannot express.

ahu_htg_vlv_max

Maximum heating-coil valve command across all AHUs/zones served by the HW plant

  • 223P: host-derived aggregate; no single physical point
  • QUDT unit: PERCENT

HW mirror of ahu_clg_vlv_max; include reheat valves in the served set where the HW plant feeds zone reheat.

occ_scheduled

TRUE while current time falls inside the occupied schedule (host-evaluated)

The reference’s in_occupied_schedule(current_time, occ_schedule) is a calendar function; the graph has no clock, so the host evaluates the schedule and exposes the boolean. Same treatment as AHU-0018’s occupancy gate.

lighting_status

Lighting circuit on/off status

  • 223P: EnumeratedObservableProperty on the lighting Equipment via hasProperty

Brick 1.4.4 has no lighting-specific run status class; On_Off_Status on the lighting equipment/zone is the closest grounding.

ef_status

Exhaust fan run status

  • 223P: EnumeratedObservableProperty on the Exhaust Fan via hasProperty

Brick 1.4.4 has no exhaust-specific fan status point class (only Exhaust_Fan equipment + enable/disable commands); Fan_On_Off_Status on the Exhaust_Fan equipment is the grounding, matching sf_status convention.

sf_status

Supply fan run status of the paired AHU (SYS-0008)

  • 223P: EnumeratedObservableProperty on the Fan via hasProperty

Duplicate of points/ahu.points.json sf_status — SYS-0008 is a cross-equipment rule instantiated per exhaust-fan/AHU pair.

demand_override_active

TRUE while a demand-based exhaust override (e.g. kitchen hood, restroom call) legitimately holds the fan on

BAS override flag; no Brick 1.4.4 point class models an override status. Host exposes the controller’s override/demand flag directly.

rht_vlv_cmd

Zone reheat valve command (SYS-0007, instantiated per zone)

  • 223P: QuantifiableActuatableProperty on the reheat Valve via hasProperty; Actuator actuates the valve
  • QUDT unit: PERCENT

Duplicate of points/vav.points.json rht_vlv_cmd — the reference files SYS-0007 under SYS but scopes it to VAV/FCU zones; bind per zone.

oat

Outdoor air temperature

  • 223P: Sensor observes QuantifiableObservableProperty at the outdoor-air intake
  • QUDT unit: DEG_C

Compatibility aliases

  • occ_sensorpoints/zone.points.json#occ_sensor

Point Dictionary: TOWER

Cooling-tower point dictionary. LOOP-SIDE SEMANTICS: water leaving the tower is the COLD stream that enters the chiller condenser, so tower_leaving_temp and its active target ground as Entering_Condenser_Water points; tower_entering_temp is the WARM chiller-leaving return. Bind per tower/cell when outlets and fans are individually sensed; common-header points are valid only where a card explicitly permits them. ASHRAE 223 local names were checked against public-review artifact 223p-1.0.0-ppr2.1.ttl (owl:versionInfo 1.0.0-ppr.2.1; SHA-256 1f156f9938c0be430d2216e01e31bb183c438ba318d8d4a23d2f074ebcd6f573), not an inferred final 2026 release, replacing the former unverified v1.0.0-2026 label. That artifact imports QUDT 3.1.8; quantity-kind and unit local names here were independently checked against the repository-declared QUDT 3.1.4 artifacts. Connection direction and tower/basin location belong in topology/patterns, not invented aspects. Brick 1.4.4 was checked against Brick.ttl SHA-256 b65720b7b9b64c646745c689777e6138c0d59ce0088df0aeb78fbd444d04d8e7.

PointKindUnitBrickDerivedProvisional
tower_leaving_temprealdegCEntering_Condenser_Water_Temperature_Sensor
tower_entering_temprealdegCLeaving_Condenser_Water_Temperature_Sensor
oa_wetbulbrealdegC
tower_fan_speedreal%Motor_Speed_Sensor
tower_fan_statusboolboolFan_On_Off_Status
tower_fan_cmdboolboolStart_Stop_Command
tower_leaving_temp_sprealdegCEntering_Condenser_Water_Temperature_Setpoint
oatrealdegCOutside_Air_Temperature_Sensor
tower_basin_temprealdegCCollection_Basin_Water_Temperature_Sensor
tower_basin_heater_cmdboolboolStart_Stop_Command
tower_basin_heater_statusboolboolOn_Off_Status

tower_leaving_temp

Tower leaving (cold, tower-to-chiller) condenser water temperature

  • 223P: TemperatureSensor observes a QuantifiableObservableProperty at the CoolingTower Fluid-Water outlet ConnectionPoint that supplies the chiller condenser inlet
  • QUDT unit: DEG_C

brick:Entering_Condenser_Water_Temperature_Sensor is exact in Brick 1.4.4: tower-leaving/cold water next enters the condenser. ASHRAE 223 uses s223:CoolingTower, TemperatureSensor, ConnectionPoint and Fluid-Water; leaving direction is topology, not an aspect. TOWER-0001 approach and TOWER-0002 range use this point; TOWER-0005 requires the individual-cell outlet when evaluating an individual fan.

tower_entering_temp

Tower entering (warm, chiller-to-tower) condenser water temperature

  • 223P: TemperatureSensor observes a QuantifiableObservableProperty at the CoolingTower Fluid-Water inlet ConnectionPoint returning from the chiller condenser outlet
  • QUDT unit: DEG_C

brick:Leaving_Condenser_Water_Temperature_Sensor is exact in Brick 1.4.4 because this warm stream has just left the condenser. Same physical stream as points/chw.points.json cwst. ASHRAE 223 expresses entering direction through the tower inlet ConnectionPoint, not a nonexistent aspect. TOWER-0002 range = tower_entering_temp - tower_leaving_temp.

oa_wetbulb

Outdoor air wet-bulb temperature (normally host-derived psychrometric)

  • 223P: Host Function hasOutput a QuantifiableProperty representing outdoor Fluid-Air wet-bulb temperature; a physical Air_Wet_Bulb_Temperature_Sensor is an alternative binding
  • QUDT unit: DEG_C

Usually a multivariable psychrometric result from dry-bulb, RH, and station pressure. PPR2.1 provides Function/hasOutput/QuantifiableProperty but no exact derived-wet-bulb aspect; the derivation record carries that meaning. Brick is null because this canonical point is explicitly host-derived; a separate physical binding may use exact brick:Air_Wet_Bulb_Temperature_Sensor and a TemperatureSensor/QOP pattern. The host must document routine, inputs, freshness, and accuracy near saturation. EnergyPlus supplies a native outdoor wet-bulb output.

tower_fan_speed

Individual tower-fan speed feedback (verified command or effective-airflow proxy only when explicitly documented)

  • 223P: QuantifiableObservableProperty on the CoolingTower Fan or contained motor via hasProperty
  • QUDT unit: PERCENT

ASHRAE 223 PPR2.1 supports an observable DimensionlessRatio/PERCENT property on s223:Fan. Brick 1.4.4 has Motor_Speed_Sensor, but its inherited Speed applicable-unit set does not include PERCENT; this same percent-speed conflict is documented for AHU/VFD points, so the mapping is provisional. Feedback is required for TOWER-0001 and preferred for TOWER-0005. A verified command proxy is weaker; EnergyPlus Cooling Tower Air Flow Rate Ratio x 100 is an explicitly simulation-only effective-airflow proxy rather than mechanical VFD feedback.

tower_fan_status

Independent individual cooling-tower fan run proof

  • 223P: EnumeratedObservableProperty on the CoolingTower Fan via hasProperty

brick:Fan_On_Off_Status and PPR2.1 s223:Fan with Binary-OnOff EnumeratedObservableProperty are exact. Bind independent electrical, rotation, airflow, or auxiliary-contact proof for one fan/cell, not command echo or fleet OR. TOWER-0003 counts this point’s rising edges; TOWER-0004 compares it with the final same-fan command.

tower_fan_cmd

Final individual cooling-tower fan run command

  • 223P: EnumeratedActuatableProperty on the CoolingTower Fan via hasProperty

brick:Start_Stop_Command is exact and equipment-agnostic; attach it to brick:Cooling_Tower_Fan. PPR2.1 s223:Fan plus Binary-OnOff EnumeratedActuatableProperty is exact. TOWER-0004 requires the final per-fan request after staging, free-convection, anti-cycle, and normal interlock logic; plant enable is invalid.

tower_leaving_temp_sp

Active tower-leaving cold condenser-water temperature setpoint

  • 223P: QuantifiableActuatableProperty with Aspect-Setpoint controlling the CoolingTower Fluid-Water outlet ConnectionPoint that supplies the chiller condenser inlet
  • QUDT unit: DEG_C

brick:Entering_Condenser_Water_Temperature_Setpoint is exact: tower-leaving cold water is entering-condenser water. PPR2.1 uses exact Aspect-Setpoint; outlet direction remains topology. Bind the active delivered target after reset/limit logic, not an upstream request. A common condenser-loop supply setpoint is valid for individual parallel cells only when the sequence actually gives each cell that target.

oat

Outdoor dry-bulb temperature representative of cooling-tower exposure

  • 223P: TemperatureSensor observes a QuantifiableObservableProperty located in an OutdoorPhysicalSpace representative of the CoolingTower exposure
  • QUDT unit: DEG_C

brick:Outside_Air_Temperature_Sensor and s223:OutdoorPhysicalSpace are exact. Unlike an AHU intake point, tower ambient exposure is a physical-space observation; Role-OutdoorAirIntake is therefore not used or mis-serialized as an aspect. TOWER-0006 requires representative tower exposure rather than a remote weather point with materially different wind/sun conditions.

tower_basin_temp

Representative cooling-tower collection-basin/sump water temperature

  • 223P: TemperatureSensor observes a QuantifiableObservableProperty of Fluid-Water in a site-defined basin/storage component contained in the CoolingTower
  • QUDT unit: DEG_C

brick:Collection_Basin_Water_Temperature_Sensor is exact in Brick 1.4.4. PPR2.1 has CoolingTower, TemperatureSensor and Fluid-Water but no collection-basin equipment class, so basin topology is site-defined and the cross-ontology mapping is provisional. Bind a submerged bulk-water sensor away from the heater plume and stagnant pockets; the host separately proves water level and applicability.

tower_basin_heater_cmd

Final request for cooling-tower basin heater operation

  • 223P: Binary-OnOff EnumeratedActuatableProperty on an ElectricResistanceElement contained in the CoolingTower and identified by site topology as the basin heater

Brick 1.4.4 has exact Collection_Basin_Water_Heater and Start_Stop_Command classes; attach the command to that equipment. PPR2.1 has ElectricResistanceElement and the enumerated property pattern but no basin-heater class, so basin identity/topology remains provisional. Bind the final local post-thermostat/post-safety request, not a remote enable upstream of low-water or OEM interlocks. A non-electric equivalent needs a site-defined s223 equipment class rather than the ElectricResistanceElement pattern.

tower_basin_heater_status

Independent proof of cooling-tower basin heater operation

  • 223P: Binary-OnOff EnumeratedObservableProperty on an ElectricResistanceElement contained in the CoolingTower and identified by site topology as the basin heater

Brick 1.4.4 has exact Collection_Basin_Water_Heater and On_Off_Status classes. PPR2.1 has the electrical element/property terms but no basin-heater class, so the mapping remains provisional. Prefer current, power, contactor, or defensible thermal proof; a command echo is invalid. A proven heater does not prove safe basin level or adequate bulk-water temperature.

Point Dictionary: VAV

VAV terminal unit point dictionary. Zone-level points grep-verified against Brick 1.4.4 (equipment anchor: brick:Variable_Air_Volume_Box_With_Reheat); 223P patterns follow the AHU dictionary’s property-class conventions but no VAV reference-model instance has been checked, so 223P attachments are pattern guidance. satisfied_zone_fraction is host-derived (see zone_reheat_fraction precedent). Airflow unit is L/s throughout; hosts on CFM convert at binding.

PointKindUnitBrickDerivedProvisional
zone_airflowrealL/sSupply_Air_Flow_Sensor
zone_airflow_sprealL/sSupply_Air_Flow_Setpoint
zone_airflow_sp_minrealL/sMin_Air_Flow_Setpoint_Limit
zone_dmpr_posreal%Damper_Position_Sensor
rht_vlv_cmdreal%Valve_Position_Command
zone_clg_requestreal%Cooling_Demand_Sensor
satisfied_zone_fractionreal1
oatreal°COutside_Air_Temperature_Sensor
vav_datreal°CDischarge_Air_Temperature_Sensor
satreal°CSupply_Air_Temperature_Sensor
occ_scheduledboolbool
zone_temp_neighbor_medianrealdegC

zone_airflow

VAV box measured supply airflow

  • 223P: Sensor observes QuantifiableObservableProperty at the VAV box discharge via hasProperty
  • QUDT unit: L-PER-SEC

brick:Supply_Air_Flow_Sensor grep-verified in Brick 1.4.4; attach isPointOf the VAV box (brick:Variable_Air_Volume_Box_With_Reheat, also verified). Reference unit is L/s; hosts on CFM convert before binding (rules do no unit conversion, SCHEMA.md).

zone_airflow_sp

VAV box active airflow setpoint

  • 223P: QuantifiableActuatableProperty (setpoint) on the VAV box via hasProperty
  • QUDT unit: L-PER-SEC

brick:Supply_Air_Flow_Setpoint grep-verified in Brick 1.4.4. The ACTIVE setpoint the damper loop tracks (after G36 min/max clamping), not the design value — VAV-0004 compares measured flow against what the loop is currently trying to do.

zone_airflow_sp_min

VAV box minimum airflow setpoint (configuration value as a live point)

  • 223P: QuantifiableActuatableProperty (configuration limit) on the VAV box via hasProperty
  • QUDT unit: L-PER-SEC

brick:Min_Air_Flow_Setpoint_Limit grep-verified in Brick 1.4.4 — the configured floor, distinct from the active setpoint. VAV-0001 reads it as a point so the rule can watch for bad commissioning values without a BAS config export.

zone_dmpr_pos

VAV damper position feedback

  • 223P: Sensor observes QuantifiableActuatableProperty… position feedback on the box Damper via hasProperty
  • QUDT unit: PERCENT

brick:Damper_Position_Sensor grep-verified in Brick 1.4.4. Position FEEDBACK where available; sites with command-only boxes bind the command and accept that a stuck blade is invisible (the same caveat the AHU dictionary carries on actuator_pos).

rht_vlv_cmd

Reheat valve command (0 = closed, 100 = full open)

  • 223P: QuantifiableActuatableProperty on the reheat Valve via hasProperty; Actuator actuates the valve
  • QUDT unit: PERCENT

brick:Valve_Position_Command grep-verified in Brick 1.4.4; attach isPointOf brick:Reheat_Valve (verified). This is the per-box point whose building-wide aggregate became the AHU dictionary’s zone_reheat_fraction — the grounding promised with that entry lands here.

zone_clg_request

Zone cooling request signal (0-100; 100 = maximum cooling demand)

  • 223P: QuantifiableObservableProperty on the Zone via hasProperty
  • QUDT unit: PERCENT

Brick 1.4.4 has no Cooling_Request class; Cooling_Demand_Sensor (verified) is the nearest reading for a continuous 0-100 demand signal. Sites on G36 discrete request COUNTS (integer requests-to-reset) should map 100 to ‘importance-weighted requests at max’ and note the binding; VAV-0002 only tests for saturation at maximum.

satisfied_zone_fraction

Fraction of zones served by the same AHU currently satisfied (host-derived: zones inside their temperature deadband over total zones reporting)

  • QUDT unit: UNITLESS

Host-derived aggregate like the AHU dictionary’s zone_reheat_fraction and zone_dmpr_pos_max: computed over sibling zones’ temperatures and setpoints, range 0-1. Semantic tags belong on the underlying zone points; the aggregation (which zones count, staleness handling) is host configuration. Consumed by VAV-0002’s most-zones-are-happy test.

oat

Outdoor air temperature

  • 223P: Sensor observes QuantifiableObservableProperty at the outdoor-air intake
  • QUDT unit: DEG_C

Brick spelling is Outside_ (Outdoor_Air_Temperature_Sensor does not exist). Role-OutdoorAirIntake is 223P’s only outdoor role; used by reference instance oa-temp. [VAV dictionary] Carried from the AHU dictionary (class and pattern are equipment-independent); for a VAV rule the sensor is the site OAT feeding the zone-level season test, not a box-mounted device.

vav_dat

VAV box discharge air temperature (downstream of the reheat coil)

  • 223P: Sensor observes QuantifiableObservableProperty at the terminal discharge
  • QUDT unit: DEG_C

Available on many but not all boxes (NIST/PIER 2001 §5.1.4); the reheat-leak CUSUM channel (VAV-0009) is the only consumer — boxes without it run the reduced two-channel VPACC per the source’s own fallback. EAT is approximated by the AHU’s broadcast SAT per the same source.

sat

AHU supply air temperature broadcast to the terminal (EAT approximation)

  • 223P: Sensor observes QuantifiableObservableProperty attached to the AHU supply-air OutletConnectionPoint
  • QUDT unit: DEG_C

Duplicate of ahu dict sat per the own-family lint rule. Consumed by VAV-0009’s dT channel: the terminal’s entering air temperature is approximated by the AHU SAT broadcast per NIST/PIER 2001 §5.1.4’s own workaround; simulation validates the approximation’s bias at < 0.005 degC (harness vavcal).

occ_scheduled

TRUE while current time falls inside the occupied schedule (host-evaluated)

Duplicate of sys dict occ_scheduled (same host-evaluated schedule boolean). CUSUM rules reset their accumulators in-graph while unoccupied (Switch to zero), per NIST/PIER 2001 §5.1.3: errors are computed only during occupied periods and CUSUMs reset to zero outside them.

zone_temp_neighbor_median

Median zone temperature across the sibling zones served by the same AHU, excluding this zone

  • 223P: QuantifiableObservableProperty, host-computed aggregate
  • QUDT unit: DEG_C

HOST-DERIVED aggregate (zone_dmpr_pos_max / satisfied_zone_fraction precedent), computed PER INSTANCE: the median EXCLUDES the adjudicated zone, so one drifting sensor cannot pull its own reference (self-exclusion matters most in small fleets). Host obligations: document the sibling population and its size — a median over fewer than ~5 zones is fragile and the instance should not be created below that; zones with legitimately divergent setpoints (server rooms, vestibules) are excluded from the population, not compensated. Consumed by VAV-0010.

Compatibility aliases

Point Dictionary: VFD

Variable frequency drive point dictionary. The VFD family’s rules are application-agnostic (fan, pump); the process-variable pair is deliberately untyped beyond ‘the controlled variable in its own units’ and marked provisional. Owning equipment: brick:Variable_Frequency_Drive (verified in Brick 1.4.4). ASHRAE 223 mappings were checked against local artifact 223p-1.0.0-ppr2.1.ttl, SHA-256 1f156f9938c0be430d2216e01e31bb183c438ba318d8d4a23d2f074ebcd6f573; this is a public-review artifact pin, not a claim of a later published release.

PointKindUnitBrickDerivedProvisional
vfd_speed_cmdreal%Speed_Setpoint
vfd_speedreal%Motor_Speed_Sensor
vfd_process_valuereal1
vfd_process_spreal1
vfd_enable_cmdboolboolVFD_Enable_Command
vfd_auto_statusboolboolManual_Auto_Status
vfd_bypass_statusboolbool

vfd_speed_cmd

VFD speed command (0-100%)

  • 223P: QuantifiableActuatableProperty on the VFD via hasProperty
  • QUDT unit: PERCENT

brick:Speed_Setpoint is an exact Brick 1.4.4 class, but Brick’s inherited Speed quantity does not list PERCENT as an applicable unit; the normalized percent binding is therefore provisional. ASHRAE 223 PPR2.1’s g36:FanWithVFD shape explicitly uses a QuantifiableActuatableProperty with quantity kind DimensionlessRatio for the drive speed command; this dictionary narrows that ratio to PERCENT.

vfd_speed

VFD speed feedback (0-100%)

  • 223P: Sensor observes QuantifiableObservableProperty on the driven motor via hasProperty
  • QUDT unit: PERCENT

brick:Motor_Speed_Sensor is an exact Brick 1.4.4 class and brick:Speed_Status is the drive-reported alternative, but Brick’s inherited Speed quantity does not list PERCENT as an applicable unit; the normalized percent binding is therefore provisional. Bind whichever the site trends, but note VFD-0001 diagnosis 5 (tachometer failure) argues for the drive-reported value where both exist. ASHRAE 223 represents the normalized feedback as a DimensionlessRatio QuantifiableObservableProperty in PERCENT.

vfd_process_value

Controlled process variable (pressure, flow, or temperature — the loop’s PV, in its own units)

Deliberately generic: the VFD family’s rules compare PV against setpoint in whatever unit the loop uses, and VFD-0002’s pv_error_threshold is set in those same units. Semantic tags belong on the application-specific point (duct static pressure, water differential pressure, …) that the host binds here.

vfd_process_sp

Setpoint for the controlled process variable (same units as vfd_process_value)

See vfd_process_value; the pair must be bound from the same loop in the same units.

vfd_enable_cmd

Final BAS command requesting VFD operation/control

  • 223P: EnumeratedActuatableProperty on the VFD via hasProperty, with hasEnumerationKind Binary-OnOff

brick:VFD_Enable_Command is an exact Brick 1.4.4 class. Bind the final drive-level command downstream of schedule and normal equipment interlocks; a system request several sequence layers upstream is not equivalent. ASHRAE 223 represents the command as an EnumeratedActuatableProperty with s223:Binary-OnOff.

vfd_auto_status

True when the drive is accepting remote automatic control from the BAS/control loop

  • 223P: EnumeratedObservableProperty on the VFD via hasProperty, with hasEnumerationKind Binary-Logical and hasAspect Aspect-OperatingMode; true denotes the deployment’s verified remote-auto state

brick:Manual_Auto_Status is exact in Brick 1.4.4 for manual-versus-automatic operation, but it does not by itself prove that ‘auto’ is remote BAS control rather than a drive-local automatic source; that deployment-specific narrowing keeps this canonical point provisional. Enabled and automatic are not equivalent. ASHRAE 223 uses a Binary-Logical EnumeratedObservableProperty with Aspect-OperatingMode whose true value must be mapped to the verified remote-auto state.

vfd_bypass_status

True while the motor is operating through a bypass path rather than regulated VFD output

  • 223P: EnumeratedObservableProperty on the drive/bypass assembly via hasProperty, with hasEnumerationKind Binary-Logical and hasAspect Aspect-OperatingMode

Brick 1.4.4 has brick:Bypass_Command but no exact bypass-status class; using the command would confuse request with proof, so the Brick mapping is null. In ASHRAE 223 this is a Binary-Logical observable operating-mode property on the drive/bypass assembly; native bypass-contactor proof may instead be represented as Binary-OnOff, but the canonical binding still means confirmed active bypass, not a request. Approved emergency or maintenance bypass is an operating-state exclusion, not a different point meaning.

Point Dictionary: ZONE

Zone point dictionary. These records describe the occupied space independently of the equipment serving it.

PointKindUnitBrickDerivedProvisional
zone_tempreal°CZone_Air_Temperature_Sensor
zone_temp_sp_htgreal°CZone_Air_Heating_Temperature_Setpoint
zone_temp_sp_clgreal°CZone_Air_Cooling_Temperature_Setpoint
occ_sensorboolboolOccupancy_Sensor

zone_temp

Zone air temperature

  • 223P: Sensor observes QuantifiableObservableProperty on the Zone (DomainSpace) via hasProperty
  • QUDT unit: DEG_C

brick:Zone_Air_Temperature_Sensor grep-verified in Brick 1.4.4. 223P attachment is to the zone DomainSpace rather than the VAV box; no reference-model instance checked for the zone-side pattern, but the property class and quantitykind (Temperature, DEG_C) follow the AHU temperature points.

zone_temp_sp_htg

Zone heating temperature setpoint

  • 223P: QuantifiableActuatableProperty (setpoint) on the Zone via hasProperty
  • QUDT unit: DEG_C

brick:Zone_Air_Heating_Temperature_Setpoint grep-verified in Brick 1.4.4. Bind the ACTIVE (occupied-mode) heating setpoint the loop is controlling to, not a schedule default — same active-setpoint convention as the AHU dictionary’s sat_sp.

zone_temp_sp_clg

Zone cooling temperature setpoint

  • 223P: QuantifiableActuatableProperty (setpoint) on the Zone via hasProperty
  • QUDT unit: DEG_C

brick:Zone_Air_Cooling_Temperature_Setpoint grep-verified in Brick 1.4.4. Active-setpoint convention as zone_temp_sp_htg.

occ_sensor

Occupancy sensor state (TRUE = occupied detected)

  • 223P: EnumeratedObservableProperty on the occupancy Sensor via hasProperty