Skip to content

Commit 5352510

Browse files
jscott3201claude
andauthored
simharness plant mode: CHW/HW FPR via DOE prototypes + DCV preconditions (#32)
Plant-loop discovery/mapping/gating for --mode plant; DOE prototype IDFs enter via the E+ 22.1->25.1 transition chain + ConvertInputFormat. First plant sweep (OfficeLarge STD2019 Atlanta, Jul+Jan): CHW-FC-053 and HW-FC-052/053/054 clean in gated windows — validation: blocks recorded, first empirical validation outside the AHU family. HW-FC-056 verified firing correctly on the prototype's constant-HWST-at-low-load operation (excluded by-construction with reason). DCV-aware NO_EVAL precondition added to AHU-FC-006/055 per the fleet-sweep finding. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent a597632 commit 5352510

8 files changed

Lines changed: 249 additions & 6 deletions

File tree

faults/ahu/AHU-FC-006/card.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ suppressed_by: [AHU-FC-062]
2222
related: [AHU-FC-055, AHU-FC-064, AHU-FC-051, AHU-FC-062]
2323
playbooks: [economizer-failure]
2424
operating_states: "OS#1 and OS#4 (minimum outdoor air states) — host-gated"
25-
preconditions: "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-FC-062 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."
25+
preconditions: "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-FC-062 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)."
2626
points:
2727
- mat
2828
- rat

faults/ahu/AHU-FC-055/card.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ suppressed_by: [AHU-FC-062]
2121
related: [AHU-FC-006, AHU-FC-051, AHU-FC-064]
2222
playbooks: [economizer-failure]
2323
operating_states: "occupied, non-economizer operation (host-gated); reference OS 1, OS 4"
24-
preconditions: "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-FC-062, 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."
24+
preconditions: "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-FC-062, 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)."
2525
points:
2626
- mat
2727
- rat

faults/chw/CHW-FC-053/card.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,14 @@ energy_impact:
6060
emissions:
6161
scope: "2"
6262
method: PROXY_EMISSIONS
63+
validation:
64+
- kind: simulation_fpr
65+
harness: simharness/v1
66+
date: 2026-08-18
67+
fleet: "DOE prototype OfficeLarge STD2019 Atlanta (IDF 22.1 auto-transitioned to E+ 25.1), one July + one January week, plant mode"
68+
scenarios: 1
69+
failures: 0
70+
notes: "gated windows only in the July week (chiller load > 40% floor); Atlanta January never crosses the floor"
6371
verified:
6472
engine_rev: e2ff2f8
6573
content_id: "cxf:fnv1a128:f12c5169f0aa1755a37402fe2dbfe04a"

faults/hw/HW-FC-052/card.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,13 @@ energy_impact:
4747
emissions:
4848
scope: "1+2"
4949
method: DIRECT_EMISSIONS
50+
validation:
51+
- kind: simulation_fpr
52+
harness: simharness/v1
53+
date: 2026-08-18
54+
fleet: "DOE prototype OfficeLarge STD2019 Atlanta (IDF 22.1 auto-transitioned to E+ 25.1), one July + one January week, plant mode"
55+
scenarios: 2
56+
failures: 0
5057
verified:
5158
engine_rev: e2ff2f8
5259
content_id: "cxf:fnv1a128:d6dd066552fff48e4a1b0ebab1cff9e6"

faults/hw/HW-FC-053/card.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,14 @@ energy_impact:
6262
emissions:
6363
scope: "1+2"
6464
method: PROXY_EMISSIONS
65+
validation:
66+
- kind: simulation_fpr
67+
harness: simharness/v1
68+
date: 2026-08-18
69+
fleet: "DOE prototype OfficeLarge STD2019 Atlanta (IDF 22.1 auto-transitioned to E+ 25.1), one July + one January week, plant mode"
70+
scenarios: 1
71+
failures: 0
72+
notes: "gated windows only in the January week (boiler active); hw_pump_vfd_speed is a flow-fraction proxy"
6573
verified:
6674
engine_rev: e2ff2f8
6775
content_id: "cxf:fnv1a128:022b2acd6415b5ce7aaba663fa8c3c49"

faults/hw/HW-FC-054/card.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,14 @@ energy_impact:
5555
emissions:
5656
scope: "2"
5757
method: PROXY_EMISSIONS
58+
validation:
59+
- kind: simulation_fpr
60+
harness: simharness/v1
61+
date: 2026-08-18
62+
fleet: "DOE prototype OfficeLarge STD2019 Atlanta (IDF 22.1 auto-transitioned to E+ 25.1), one July + one January week, plant mode"
63+
scenarios: 2
64+
failures: 0
65+
notes: "hw_pump_vfd_speed is a flow-fraction proxy (affinity-law approximation)"
5866
verified:
5967
engine_rev: e2ff2f8
6068
content_id: "cxf:fnv1a128:6d35dff9c968ba4897bf8966d5555c16"

tools/simharness/README.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,27 @@ fault by construction; excluded from FPR claims, useful as a
8484
known-positive. Reset-family rules are likewise excluded (constant-setpoint
8585
baselines read "reset absent" by construction).
8686

87+
## Plant mode (DOE prototypes)
88+
89+
`--mode plant` maps CHW/HW plant loops instead of air loops: loop
90+
supply/return node temperatures and setpoints from `PlantLoop`, chiller/
91+
boiler part-load ratios for load and status, pump electricity for
92+
`hw_pump_status`, and `hw_pump_vfd_speed` as a pump mass-flow fraction
93+
(affinity-law proxy). DOE prototype IDFs enter via the E+ transition chain
94+
(`PreProcess/IDFVersionUpdater`, e.g. 22.1 → 25.1 in six steps) +
95+
`ConvertInputFormat`. Rules replay per family with rule-specific gates
96+
(`PLANT_GATE`: CHW-FC-053 needs chiller load > 40%; boiler rules need the
97+
boiler active; unnecessary-operation rules replay ungated — gating on the
98+
equipment they accuse would mask them). `PLANT_EXCLUDE` names the
99+
baseline-fitted, reset-class, and by-construction rules with reasons.
100+
101+
First plant results (OfficeLarge STD2019 Atlanta, Jul + Jan weeks):
102+
CHW-FC-053, HW-FC-052/053/054 all clean in their gated windows (recorded
103+
as `validation:` blocks); HW-FC-056 verified firing correctly on the
104+
prototype's constant-HWST-at-low-load operation (excluded by-construction);
105+
Atlanta January never crosses CHW-FC-053's 40% chiller-load floor — season
106+
selection matters per family.
107+
87108
## First results (single building, superseded by the fleet sweep above) (B2B OfficeMedium-4004, Albuquerque, July week, 3 loops)
88109

89110
**16 of 18 auto-eligible AHU rules replayed clean across all three loops

tools/simharness/harness.py

Lines changed: 195 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -229,14 +229,15 @@ def card_points(card: Path) -> list[str]:
229229
return re.findall(r"-\s+(\S+)", m.group(1)) if m else []
230230

231231

232-
def eligible_rules(families=("ahu",)) -> dict:
232+
def eligible_rules(families=("ahu",), mapped=None) -> dict:
233+
mapped = mapped or MAPPED_POINTS
233234
rules = {}
234235
for fam in families:
235236
for d in sorted((REPO / "faults" / fam).iterdir()):
236237
card = d / "card.md"
237238
if card.is_file():
238239
pts = card_points(card)
239-
if pts and set(pts) <= MAPPED_POINTS:
240+
if pts and set(pts) <= mapped:
240241
rules[d.name] = {"dir": d, "points": pts}
241242
return rules
242243

@@ -305,6 +306,135 @@ def emit_and_replay(building: str, loops_pts: dict, rules: dict, out: Path):
305306
capture_output=True, text=True, cwd=REPO)
306307
return r.stdout + r.stderr
307308

309+
310+
311+
# ================================================================ plants
312+
313+
PLANT_EXCLUDE = {
314+
"CHW-FC-050": "host-fitted kW/ton baseline placeholders — needs a per-plant fit first",
315+
"CHW-FC-051": "reset-class: prototype uses a constant scheduled CHW setpoint, fires by construction",
316+
"HW-FC-050": "host-fitted baseline placeholders — needs a per-plant fit first",
317+
"HW-FC-051": "host-fitted baseline placeholders — needs a per-plant fit first",
318+
"HW-FC-057": "reset-class: constant scheduled HW setpoint, fires by construction",
319+
"HW-FC-056": "by-construction: constant HWST at low evening load IS the retuning condition it detects (no HWST reset in the prototype); verified firing correctly, Jan week",
320+
}
321+
PLANT_MAPPED = {"chwst", "chwrt", "chwst_sp", "chiller_load", "boiler_status",
322+
"hw_pump_status", "hws_temp", "hwr_temp", "hws_temp_sp",
323+
"hw_pump_vfd_speed", "oat"}
324+
# rule -> gate key; rules absent here replay ungated (lead margin only):
325+
# unnecessary-operation rules must NOT be gated on the equipment they accuse.
326+
PLANT_GATE = {"CHW-FC-053": "chw_load40", "HW-FC-053": "hw_on", "HW-FC-056": "hw_on"}
327+
PUMP_ON_W = 100.0
328+
329+
330+
def plant_nodes(b: dict) -> dict:
331+
"""CHW + HW loop node/equipment map (DOE prototype shapes)."""
332+
out = {}
333+
for name, pl in b.get("PlantLoop", {}).items():
334+
fluid = pl.get("fluid_type", "Water")
335+
entry = {"out_node": pl["plant_side_outlet_node_name"],
336+
"in_node": pl["plant_side_inlet_node_name"],
337+
"sp_node": pl.get("loop_temperature_setpoint_node_name")}
338+
if any(name.startswith(p) for p in ("CoolSys", "CHW")) and "Demand" not in name:
339+
entry["chillers"] = [c for c in b.get("Chiller:Electric:ReformulatedEIR", {})
340+
] + [c for c in b.get("Chiller:Electric:EIR", {})]
341+
out["chw"] = entry
342+
elif any(name.startswith(p) for p in ("HeatSys", "HW")) and "SWH" not in name:
343+
entry["boilers"] = [x for x in b.get("Boiler:HotWater", {}) if name.split("_")[0].lower() in x.lower() or "Central" not in x]
344+
entry["pumps"] = [p for p in list(b.get("Pump:VariableSpeed", {})) if name.split("_")[0].lower() in p.lower()]
345+
out["hw"] = entry
346+
return out
347+
348+
349+
def patch_plant(b: dict, pn: dict, begin, end) -> dict:
350+
b = json.loads(json.dumps(b))
351+
for rp in b.get("RunPeriod", {}).values():
352+
rp["begin_month"], rp["begin_day_of_month"] = begin
353+
rp["end_month"], rp["end_day_of_month"] = end
354+
b.setdefault("Output:Variable", {})
355+
def req(key, var):
356+
n = f"simharness {len(b['Output:Variable'])}"
357+
b["Output:Variable"][n] = {"key_value": key, "variable_name": var,
358+
"reporting_frequency": "Timestep"}
359+
req("Environment", "Site Outdoor Air Drybulb Temperature")
360+
if "chw" in pn:
361+
c = pn["chw"]
362+
req(c["out_node"], "System Node Temperature")
363+
req(c["in_node"], "System Node Temperature")
364+
req(c["sp_node"], "System Node Setpoint Temperature")
365+
for ch in c["chillers"]:
366+
req(ch, "Chiller Part Load Ratio")
367+
if "hw" in pn:
368+
h = pn["hw"]
369+
req(h["out_node"], "System Node Temperature")
370+
req(h["in_node"], "System Node Temperature")
371+
req(h["sp_node"], "System Node Setpoint Temperature")
372+
for bo in h["boilers"]:
373+
req(bo, "Boiler Part Load Ratio")
374+
for p in h["pumps"]:
375+
req(p, "Pump Electricity Rate")
376+
req(p, "Pump Mass Flow Rate")
377+
return b
378+
379+
380+
def extract_plant(csv_path: Path, pn: dict, b: dict) -> dict:
381+
with open(csv_path) as f:
382+
rows = list(csv.reader(f))
383+
header, data = rows[0], rows[1:]
384+
oat = series(data, col(header, "Environment", "Site Outdoor Air Drybulb Temperature"))
385+
fams = {}
386+
if "chw" in pn:
387+
c = pn["chw"]
388+
plrs = [series(data, col(header, ch, "Chiller Part Load Ratio")) for ch in c["chillers"]]
389+
fams["chw"] = {
390+
"oat": oat,
391+
"chwst": series(data, col(header, c["out_node"], "System Node Temperature")),
392+
"chwrt": series(data, col(header, c["in_node"], "System Node Temperature")),
393+
"chwst_sp": series(data, col(header, c["sp_node"], "System Node Setpoint Temperature")),
394+
# plant chiller_load proxy: max PLR across chillers x100 — the
395+
# loaded chiller's PLR, the quantity the delta-T floor gates on
396+
"chiller_load": [round(max(v) * 100.0, ROUND) for v in zip(*plrs)],
397+
}
398+
if "hw" in pn:
399+
h = pn["hw"]
400+
plrs = [series(data, col(header, bo, "Boiler Part Load Ratio")) for bo in h["boilers"]]
401+
pump_w = [series(data, col(header, p, "Pump Electricity Rate")) for p in h["pumps"]]
402+
flows = [series(data, col(header, p, "Pump Mass Flow Rate")) for p in h["pumps"]]
403+
fmax = max(v for row in flows for v in row) or 1.0
404+
fams["hw"] = {
405+
"oat": oat,
406+
"hws_temp": series(data, col(header, h["out_node"], "System Node Temperature")),
407+
"hwr_temp": series(data, col(header, h["in_node"], "System Node Temperature")),
408+
"hws_temp_sp": series(data, col(header, h["sp_node"], "System Node Setpoint Temperature")),
409+
"boiler_status": [max(v) > 0.02 for v in zip(*plrs)],
410+
"hw_pump_status": [max(v) > PUMP_ON_W for v in zip(*pump_w)],
411+
# VFD speed proxy: pump mass-flow fraction of observed max x100
412+
# (affinity-law approximation; documented proxy)
413+
"hw_pump_vfd_speed": [round(max(v) / fmax * 100.0, ROUND) for v in zip(*flows)],
414+
}
415+
return fams
416+
417+
418+
def plant_gate_windows(key: str | None, pts: dict, n: int) -> list:
419+
if key is None:
420+
return [(GATE_LEAD_S, (n - 1) * STEP_S)]
421+
if key == "chw_load40":
422+
ok = [v > 40.0 for v in pts["chiller_load"]]
423+
elif key == "hw_on":
424+
ok = pts["boiler_status"]
425+
else:
426+
raise KeyError(key)
427+
wins, start = [], None
428+
for i, v in enumerate(list(ok) + [False]):
429+
if v and start is None:
430+
start = i
431+
elif not v and start is not None:
432+
a, b_ = start * STEP_S + OS_LEAD_S, (i - 1) * STEP_S
433+
if b_ - a >= 1800:
434+
wins.append((a, b_))
435+
start = None
436+
return wins
437+
308438
# ---------------------------------------------------------------- main
309439

310440
def main():
@@ -315,16 +445,77 @@ def main():
315445
runp.add_argument("--out", default=None)
316446
runp.add_argument("--begin", default="7-6", help="run period start M-D")
317447
runp.add_argument("--end", default="7-12", help="run period end M-D")
448+
runp.add_argument("--mode", default="airloop", choices=["airloop", "plant"])
318449
args = ap.parse_args()
319450

320451
bdir = Path(args.building)
321452
out = Path(args.out) if args.out else bdir / "simharness_out"
322453
out.mkdir(parents=True, exist_ok=True)
323-
b = json.load(open(bdir / "building.epjson"))
454+
src = bdir / "building.epjson"
455+
if not src.is_file():
456+
src = next(bdir.glob("*.epJSON"))
457+
b = json.load(open(src))
324458
epw = next(bdir.glob("*.epw"))
325-
loops = loop_nodes(b)
326459
begin = tuple(int(x) for x in args.begin.split("-"))
327460
end = tuple(int(x) for x in args.end.split("-"))
461+
462+
if args.mode == "plant":
463+
pn = plant_nodes(b)
464+
patched = patch_plant(b, pn, begin, end)
465+
pj = out / "patched.epjson"
466+
pj.write_text(json.dumps(patched))
467+
print(f"plant loops: {list(pn)}; running EnergyPlus…")
468+
csv_path = run_energyplus(pj, epw, out / "ep")
469+
fams = extract_plant(csv_path, pn, b)
470+
rules = {}
471+
for fam in fams:
472+
for rid, r in eligible_rules(families=(fam, "sys"), mapped=PLANT_MAPPED).items():
473+
if rid not in rules:
474+
if rid in PLANT_EXCLUDE:
475+
print(f" excluded {rid}: {PLANT_EXCLUDE[rid]}")
476+
else:
477+
rules[rid] = r
478+
print(f"eligible plant rules: {sorted(rules)}")
479+
replay = out / "replay"
480+
if replay.exists():
481+
shutil.rmtree(replay)
482+
dirs = []
483+
for rid, r in rules.items():
484+
fam = rid.split("-")[0].lower()
485+
pts = fams.get(fam) or fams.get("chw") or fams.get("hw")
486+
if not all(p in pts for p in r["points"]):
487+
continue
488+
n = len(pts["oat"])
489+
wins = plant_gate_windows(PLANT_GATE.get(rid), pts, n)
490+
if not wins:
491+
print(f" {rid}: no gated windows this period"); continue
492+
d = replay / rid
493+
d.mkdir(parents=True)
494+
shutil.copy(r["dir"] / "rule.cxf.jsonld", d / "rule.cxf.jsonld")
495+
scen = {
496+
"name": f"{bdir.name}_{fam}".replace("-", "_").replace(".", "_").lower(),
497+
"description": f"Healthy-baseline EnergyPlus week ({bdir.name}); "
498+
"plant-mode replay, gated; any FAIL is a false positive.",
499+
"inputs": {p: to_steps(pts[p]) for p in r["points"]},
500+
"expect": [{"output": "yFault", "from_s": a, "to_s": b_,
501+
"equals": False} for a, b_ in wins],
502+
}
503+
(d / "vectors.json").write_text(json.dumps({
504+
"schema": "cxf-library/vectors/v1",
505+
"clock": {"step_s": STEP_S, "horizon_s": (n - 1) * STEP_S},
506+
"scenarios": [scen]}, indent=1))
507+
dirs.append(d)
508+
rr = subprocess.run(["cargo", "run", "--quiet", "--manifest-path",
509+
str(REPO / "tools/verify/Cargo.toml"), "--", *map(str, dirs)],
510+
capture_output=True, text=True, cwd=REPO)
511+
log = rr.stdout + rr.stderr
512+
(out / "verify.log").write_text(log)
513+
for line in log.splitlines():
514+
if re.search(r"replay/|PASS|FAIL", line):
515+
print(line if "replay/" not in line else " " + line.split("replay/")[-1])
516+
return
517+
518+
loops = loop_nodes(b)
328519
patched = patch(b, loops, begin, end)
329520
pj = out / "patched.epjson"
330521
pj.write_text(json.dumps(patched))

0 commit comments

Comments
 (0)