VFD-0002 — At minimum speed with load unsatisfied
| Status | verified — engine e2ff2f8, cxf:fnv1a128:eb1ff89459aa410286eaaad01914e3fa, 2026-08-17 |
| Severity | 3 |
| Method | rule |
| Phase | 2 |
| Category | COMFORT_ENERGY |
| Confidence | LOW |
| Estimation | QUALITATIVE_ONLY |
| G36 | — |
| Clusters | — |
| Suppresses | — |
| Suppressed by | VFD-0001, VFD-0005 |
| Related | VFD-0001, VFD-0003, VFD-0004, VFD-0005 |
| Playbooks | vfd-pump-faults |
| Source | HVAC FDD Reference v1.0 §15, VFD-0002; Engineering best practice |
| Operating states | drive 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:
| Name | Default | Unit | CXF path | Description |
|---|---|---|---|---|
min_speed | 20.0 | % | minSpd.k | The drive’s configured minimum speed, in points of rated speed |
speed_tolerance | 3.0 | % | tol.k | How far above the minimum the feedback may sit and still count as parked at minimum |
pv_error_threshold | 10.0 | 1 | pvOff.t | Absolute 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_duration | 900.0 | s | persist.delayTime | Continuous 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):
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
- 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
- Mechanical obstruction reducing output: a closed isolation valve, a blocked strainer, a clogged filter bank, or a damper someone shut
- System undersized for the actual load — a design finding rather than an operating one, and usually seasonal
- 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
- 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_thresholdships 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. SopvOff.t = 10.0means 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_durationhas 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 identicaltracking_duration— a loop failing to reach setpoint, sustained. VFD-0001’s 5-minutedeviation_durationwas 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 writesvfd_speed <= min_speed + speed_tolerance; CDL Reals has noLessEqual, 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
LessThresholdwitht = 23.0would 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_variableandsetpointwithvfd_process_valueandvfd_process_spperpoints/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
yCmdOkand ERV-0001’syTempDeltaOk, 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 fromvfd_speedthat 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_valueandvfd_process_spdirectly. persist.delayOnInit = true(CDL default isfalse): 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.mdcarries VFD-0002 as an explicitly marked library addition. - Frontmatter
clustersis empty andg36is 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 invectors.jsonis 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.
| Scenario | Description |
|---|---|
at_minimum_with_load_satisfied | The 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_unsatisfied | 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. |
modulating_above_minimum | 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 — the minimum-speed term is what makes it actionable. |
speed_exactly_at_floor | 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. |
speed_just_below_floor | One 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_floor | 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. |
pv_error_exactly_at_threshold | 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. |
pv_error_just_above_threshold | Boundary from the other side: 10.1 units below setpoint at minimum speed, and the alarm lands after sustained_duration. |
pv_above_setpoint_at_minimum | 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 — the minimum is set too high for the load, so the loop cannot stop overshooting. |
transient_load_excursion | A 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_alarm | 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. |
load_satisfied_after_alarm | 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. |
stopped_drive_reads_as_at_minimum | 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 — 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
}
]
}
]
}