From 648b1486760dab3ca801ffcadc56c97d5c244cfe Mon Sep 17 00:00:00 2001 From: Justin Scott Date: Tue, 18 Aug 2026 10:58:20 -0400 Subject: [PATCH] simharness plant mode: CHW/HW FPR via DOE prototypes + DCV preconditions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- faults/ahu/AHU-FC-006/card.md | 2 +- faults/ahu/AHU-FC-055/card.md | 2 +- faults/chw/CHW-FC-053/card.md | 8 ++ faults/hw/HW-FC-052/card.md | 7 ++ faults/hw/HW-FC-053/card.md | 8 ++ faults/hw/HW-FC-054/card.md | 8 ++ tools/simharness/README.md | 21 ++++ tools/simharness/harness.py | 199 +++++++++++++++++++++++++++++++++- 8 files changed, 249 insertions(+), 6 deletions(-) diff --git a/faults/ahu/AHU-FC-006/card.md b/faults/ahu/AHU-FC-006/card.md index 2478870..73c464f 100644 --- a/faults/ahu/AHU-FC-006/card.md +++ b/faults/ahu/AHU-FC-006/card.md @@ -22,7 +22,7 @@ suppressed_by: [AHU-FC-062] related: [AHU-FC-055, AHU-FC-064, AHU-FC-051, AHU-FC-062] playbooks: [economizer-failure] operating_states: "OS#1 and OS#4 (minimum outdoor air states) — host-gated" -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." +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)." points: - mat - rat diff --git a/faults/ahu/AHU-FC-055/card.md b/faults/ahu/AHU-FC-055/card.md index 62d3c96..e125d0e 100644 --- a/faults/ahu/AHU-FC-055/card.md +++ b/faults/ahu/AHU-FC-055/card.md @@ -21,7 +21,7 @@ suppressed_by: [AHU-FC-062] related: [AHU-FC-006, AHU-FC-051, AHU-FC-064] playbooks: [economizer-failure] operating_states: "occupied, non-economizer operation (host-gated); reference OS 1, OS 4" -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." +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)." points: - mat - rat diff --git a/faults/chw/CHW-FC-053/card.md b/faults/chw/CHW-FC-053/card.md index 903c53f..9735216 100644 --- a/faults/chw/CHW-FC-053/card.md +++ b/faults/chw/CHW-FC-053/card.md @@ -60,6 +60,14 @@ energy_impact: emissions: scope: "2" method: PROXY_EMISSIONS +validation: + - kind: simulation_fpr + harness: simharness/v1 + date: 2026-08-18 + fleet: "DOE prototype OfficeLarge STD2019 Atlanta (IDF 22.1 auto-transitioned to E+ 25.1), one July + one January week, plant mode" + scenarios: 1 + failures: 0 + notes: "gated windows only in the July week (chiller load > 40% floor); Atlanta January never crosses the floor" verified: engine_rev: e2ff2f8 content_id: "cxf:fnv1a128:f12c5169f0aa1755a37402fe2dbfe04a" diff --git a/faults/hw/HW-FC-052/card.md b/faults/hw/HW-FC-052/card.md index 4db0f85..2623760 100644 --- a/faults/hw/HW-FC-052/card.md +++ b/faults/hw/HW-FC-052/card.md @@ -47,6 +47,13 @@ energy_impact: emissions: scope: "1+2" method: DIRECT_EMISSIONS +validation: + - kind: simulation_fpr + harness: simharness/v1 + date: 2026-08-18 + fleet: "DOE prototype OfficeLarge STD2019 Atlanta (IDF 22.1 auto-transitioned to E+ 25.1), one July + one January week, plant mode" + scenarios: 2 + failures: 0 verified: engine_rev: e2ff2f8 content_id: "cxf:fnv1a128:d6dd066552fff48e4a1b0ebab1cff9e6" diff --git a/faults/hw/HW-FC-053/card.md b/faults/hw/HW-FC-053/card.md index 3e7a152..a8cbae0 100644 --- a/faults/hw/HW-FC-053/card.md +++ b/faults/hw/HW-FC-053/card.md @@ -62,6 +62,14 @@ energy_impact: emissions: scope: "1+2" method: PROXY_EMISSIONS +validation: + - kind: simulation_fpr + harness: simharness/v1 + date: 2026-08-18 + fleet: "DOE prototype OfficeLarge STD2019 Atlanta (IDF 22.1 auto-transitioned to E+ 25.1), one July + one January week, plant mode" + scenarios: 1 + failures: 0 + notes: "gated windows only in the January week (boiler active); hw_pump_vfd_speed is a flow-fraction proxy" verified: engine_rev: e2ff2f8 content_id: "cxf:fnv1a128:022b2acd6415b5ce7aaba663fa8c3c49" diff --git a/faults/hw/HW-FC-054/card.md b/faults/hw/HW-FC-054/card.md index a082505..934bcba 100644 --- a/faults/hw/HW-FC-054/card.md +++ b/faults/hw/HW-FC-054/card.md @@ -55,6 +55,14 @@ energy_impact: emissions: scope: "2" method: PROXY_EMISSIONS +validation: + - kind: simulation_fpr + harness: simharness/v1 + date: 2026-08-18 + fleet: "DOE prototype OfficeLarge STD2019 Atlanta (IDF 22.1 auto-transitioned to E+ 25.1), one July + one January week, plant mode" + scenarios: 2 + failures: 0 + notes: "hw_pump_vfd_speed is a flow-fraction proxy (affinity-law approximation)" verified: engine_rev: e2ff2f8 content_id: "cxf:fnv1a128:6d35dff9c968ba4897bf8966d5555c16" diff --git a/tools/simharness/README.md b/tools/simharness/README.md index 5ed8130..912b6bc 100644 --- a/tools/simharness/README.md +++ b/tools/simharness/README.md @@ -84,6 +84,27 @@ fault by construction; excluded from FPR claims, useful as a known-positive. Reset-family rules are likewise excluded (constant-setpoint baselines read "reset absent" by construction). +## Plant mode (DOE prototypes) + +`--mode plant` maps CHW/HW plant loops instead of air loops: loop +supply/return node temperatures and setpoints from `PlantLoop`, chiller/ +boiler part-load ratios for load and status, pump electricity for +`hw_pump_status`, and `hw_pump_vfd_speed` as a pump mass-flow fraction +(affinity-law proxy). DOE prototype IDFs enter via the E+ transition chain +(`PreProcess/IDFVersionUpdater`, e.g. 22.1 → 25.1 in six steps) + +`ConvertInputFormat`. Rules replay per family with rule-specific gates +(`PLANT_GATE`: CHW-FC-053 needs chiller load > 40%; boiler rules need the +boiler active; unnecessary-operation rules replay ungated — gating on the +equipment they accuse would mask them). `PLANT_EXCLUDE` names the +baseline-fitted, reset-class, and by-construction rules with reasons. + +First plant results (OfficeLarge STD2019 Atlanta, Jul + Jan weeks): +CHW-FC-053, HW-FC-052/053/054 all clean in their gated windows (recorded +as `validation:` blocks); HW-FC-056 verified firing correctly on the +prototype's constant-HWST-at-low-load operation (excluded by-construction); +Atlanta January never crosses CHW-FC-053's 40% chiller-load floor — season +selection matters per family. + ## First results (single building, superseded by the fleet sweep above) (B2B OfficeMedium-4004, Albuquerque, July week, 3 loops) **16 of 18 auto-eligible AHU rules replayed clean across all three loops diff --git a/tools/simharness/harness.py b/tools/simharness/harness.py index f0bb316..7174787 100644 --- a/tools/simharness/harness.py +++ b/tools/simharness/harness.py @@ -229,14 +229,15 @@ def card_points(card: Path) -> list[str]: return re.findall(r"-\s+(\S+)", m.group(1)) if m else [] -def eligible_rules(families=("ahu",)) -> dict: +def eligible_rules(families=("ahu",), mapped=None) -> dict: + mapped = mapped or MAPPED_POINTS rules = {} for fam in families: for d in sorted((REPO / "faults" / fam).iterdir()): card = d / "card.md" if card.is_file(): pts = card_points(card) - if pts and set(pts) <= MAPPED_POINTS: + if pts and set(pts) <= mapped: rules[d.name] = {"dir": d, "points": pts} return rules @@ -305,6 +306,135 @@ def emit_and_replay(building: str, loops_pts: dict, rules: dict, out: Path): capture_output=True, text=True, cwd=REPO) return r.stdout + r.stderr + + +# ================================================================ plants + +PLANT_EXCLUDE = { + "CHW-FC-050": "host-fitted kW/ton baseline placeholders — needs a per-plant fit first", + "CHW-FC-051": "reset-class: prototype uses a constant scheduled CHW setpoint, fires by construction", + "HW-FC-050": "host-fitted baseline placeholders — needs a per-plant fit first", + "HW-FC-051": "host-fitted baseline placeholders — needs a per-plant fit first", + "HW-FC-057": "reset-class: constant scheduled HW setpoint, fires by construction", + "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", +} +PLANT_MAPPED = {"chwst", "chwrt", "chwst_sp", "chiller_load", "boiler_status", + "hw_pump_status", "hws_temp", "hwr_temp", "hws_temp_sp", + "hw_pump_vfd_speed", "oat"} +# rule -> gate key; rules absent here replay ungated (lead margin only): +# unnecessary-operation rules must NOT be gated on the equipment they accuse. +PLANT_GATE = {"CHW-FC-053": "chw_load40", "HW-FC-053": "hw_on", "HW-FC-056": "hw_on"} +PUMP_ON_W = 100.0 + + +def plant_nodes(b: dict) -> dict: + """CHW + HW loop node/equipment map (DOE prototype shapes).""" + out = {} + for name, pl in b.get("PlantLoop", {}).items(): + fluid = pl.get("fluid_type", "Water") + entry = {"out_node": pl["plant_side_outlet_node_name"], + "in_node": pl["plant_side_inlet_node_name"], + "sp_node": pl.get("loop_temperature_setpoint_node_name")} + if any(name.startswith(p) for p in ("CoolSys", "CHW")) and "Demand" not in name: + entry["chillers"] = [c for c in b.get("Chiller:Electric:ReformulatedEIR", {}) + ] + [c for c in b.get("Chiller:Electric:EIR", {})] + out["chw"] = entry + elif any(name.startswith(p) for p in ("HeatSys", "HW")) and "SWH" not in name: + entry["boilers"] = [x for x in b.get("Boiler:HotWater", {}) if name.split("_")[0].lower() in x.lower() or "Central" not in x] + entry["pumps"] = [p for p in list(b.get("Pump:VariableSpeed", {})) if name.split("_")[0].lower() in p.lower()] + out["hw"] = entry + return out + + +def patch_plant(b: dict, pn: dict, begin, end) -> dict: + b = json.loads(json.dumps(b)) + for rp in b.get("RunPeriod", {}).values(): + rp["begin_month"], rp["begin_day_of_month"] = begin + rp["end_month"], rp["end_day_of_month"] = end + b.setdefault("Output:Variable", {}) + def req(key, var): + n = f"simharness {len(b['Output:Variable'])}" + b["Output:Variable"][n] = {"key_value": key, "variable_name": var, + "reporting_frequency": "Timestep"} + req("Environment", "Site Outdoor Air Drybulb Temperature") + if "chw" in pn: + c = pn["chw"] + req(c["out_node"], "System Node Temperature") + req(c["in_node"], "System Node Temperature") + req(c["sp_node"], "System Node Setpoint Temperature") + for ch in c["chillers"]: + req(ch, "Chiller Part Load Ratio") + if "hw" in pn: + h = pn["hw"] + req(h["out_node"], "System Node Temperature") + req(h["in_node"], "System Node Temperature") + req(h["sp_node"], "System Node Setpoint Temperature") + for bo in h["boilers"]: + req(bo, "Boiler Part Load Ratio") + for p in h["pumps"]: + req(p, "Pump Electricity Rate") + req(p, "Pump Mass Flow Rate") + return b + + +def extract_plant(csv_path: Path, pn: dict, b: dict) -> dict: + with open(csv_path) as f: + rows = list(csv.reader(f)) + header, data = rows[0], rows[1:] + oat = series(data, col(header, "Environment", "Site Outdoor Air Drybulb Temperature")) + fams = {} + if "chw" in pn: + c = pn["chw"] + plrs = [series(data, col(header, ch, "Chiller Part Load Ratio")) for ch in c["chillers"]] + fams["chw"] = { + "oat": oat, + "chwst": series(data, col(header, c["out_node"], "System Node Temperature")), + "chwrt": series(data, col(header, c["in_node"], "System Node Temperature")), + "chwst_sp": series(data, col(header, c["sp_node"], "System Node Setpoint Temperature")), + # plant chiller_load proxy: max PLR across chillers x100 — the + # loaded chiller's PLR, the quantity the delta-T floor gates on + "chiller_load": [round(max(v) * 100.0, ROUND) for v in zip(*plrs)], + } + if "hw" in pn: + h = pn["hw"] + plrs = [series(data, col(header, bo, "Boiler Part Load Ratio")) for bo in h["boilers"]] + pump_w = [series(data, col(header, p, "Pump Electricity Rate")) for p in h["pumps"]] + flows = [series(data, col(header, p, "Pump Mass Flow Rate")) for p in h["pumps"]] + fmax = max(v for row in flows for v in row) or 1.0 + fams["hw"] = { + "oat": oat, + "hws_temp": series(data, col(header, h["out_node"], "System Node Temperature")), + "hwr_temp": series(data, col(header, h["in_node"], "System Node Temperature")), + "hws_temp_sp": series(data, col(header, h["sp_node"], "System Node Setpoint Temperature")), + "boiler_status": [max(v) > 0.02 for v in zip(*plrs)], + "hw_pump_status": [max(v) > PUMP_ON_W for v in zip(*pump_w)], + # VFD speed proxy: pump mass-flow fraction of observed max x100 + # (affinity-law approximation; documented proxy) + "hw_pump_vfd_speed": [round(max(v) / fmax * 100.0, ROUND) for v in zip(*flows)], + } + return fams + + +def plant_gate_windows(key: str | None, pts: dict, n: int) -> list: + if key is None: + return [(GATE_LEAD_S, (n - 1) * STEP_S)] + if key == "chw_load40": + ok = [v > 40.0 for v in pts["chiller_load"]] + elif key == "hw_on": + ok = pts["boiler_status"] + else: + raise KeyError(key) + wins, start = [], None + for i, v in enumerate(list(ok) + [False]): + if v and start is None: + start = i + elif not v and start is not None: + a, b_ = start * STEP_S + OS_LEAD_S, (i - 1) * STEP_S + if b_ - a >= 1800: + wins.append((a, b_)) + start = None + return wins + # ---------------------------------------------------------------- main def main(): @@ -315,16 +445,77 @@ def main(): runp.add_argument("--out", default=None) runp.add_argument("--begin", default="7-6", help="run period start M-D") runp.add_argument("--end", default="7-12", help="run period end M-D") + runp.add_argument("--mode", default="airloop", choices=["airloop", "plant"]) args = ap.parse_args() bdir = Path(args.building) out = Path(args.out) if args.out else bdir / "simharness_out" out.mkdir(parents=True, exist_ok=True) - b = json.load(open(bdir / "building.epjson")) + src = bdir / "building.epjson" + if not src.is_file(): + src = next(bdir.glob("*.epJSON")) + b = json.load(open(src)) epw = next(bdir.glob("*.epw")) - loops = loop_nodes(b) begin = tuple(int(x) for x in args.begin.split("-")) end = tuple(int(x) for x in args.end.split("-")) + + if args.mode == "plant": + pn = plant_nodes(b) + patched = patch_plant(b, pn, begin, end) + pj = out / "patched.epjson" + pj.write_text(json.dumps(patched)) + print(f"plant loops: {list(pn)}; running EnergyPlus…") + csv_path = run_energyplus(pj, epw, out / "ep") + fams = extract_plant(csv_path, pn, b) + rules = {} + for fam in fams: + for rid, r in eligible_rules(families=(fam, "sys"), mapped=PLANT_MAPPED).items(): + if rid not in rules: + if rid in PLANT_EXCLUDE: + print(f" excluded {rid}: {PLANT_EXCLUDE[rid]}") + else: + rules[rid] = r + print(f"eligible plant rules: {sorted(rules)}") + replay = out / "replay" + if replay.exists(): + shutil.rmtree(replay) + dirs = [] + for rid, r in rules.items(): + fam = rid.split("-")[0].lower() + pts = fams.get(fam) or fams.get("chw") or fams.get("hw") + if not all(p in pts for p in r["points"]): + continue + n = len(pts["oat"]) + wins = plant_gate_windows(PLANT_GATE.get(rid), pts, n) + if not wins: + print(f" {rid}: no gated windows this period"); continue + d = replay / rid + d.mkdir(parents=True) + shutil.copy(r["dir"] / "rule.cxf.jsonld", d / "rule.cxf.jsonld") + scen = { + "name": f"{bdir.name}_{fam}".replace("-", "_").replace(".", "_").lower(), + "description": f"Healthy-baseline EnergyPlus week ({bdir.name}); " + "plant-mode replay, gated; any FAIL is a false positive.", + "inputs": {p: to_steps(pts[p]) for p in r["points"]}, + "expect": [{"output": "yFault", "from_s": a, "to_s": b_, + "equals": False} for a, b_ in wins], + } + (d / "vectors.json").write_text(json.dumps({ + "schema": "cxf-library/vectors/v1", + "clock": {"step_s": STEP_S, "horizon_s": (n - 1) * STEP_S}, + "scenarios": [scen]}, indent=1)) + dirs.append(d) + rr = subprocess.run(["cargo", "run", "--quiet", "--manifest-path", + str(REPO / "tools/verify/Cargo.toml"), "--", *map(str, dirs)], + capture_output=True, text=True, cwd=REPO) + log = rr.stdout + rr.stderr + (out / "verify.log").write_text(log) + for line in log.splitlines(): + if re.search(r"replay/|PASS|FAIL", line): + print(line if "replay/" not in line else " " + line.split("replay/")[-1]) + return + + loops = loop_nodes(b) patched = patch(b, loops, begin, end) pj = out / "patched.epjson" pj.write_text(json.dumps(patched))