Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion faults/ahu/AHU-FC-006/card.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion faults/ahu/AHU-FC-055/card.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions faults/chw/CHW-FC-053/card.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
7 changes: 7 additions & 0 deletions faults/hw/HW-FC-052/card.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
8 changes: 8 additions & 0 deletions faults/hw/HW-FC-053/card.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
8 changes: 8 additions & 0 deletions faults/hw/HW-FC-054/card.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
21 changes: 21 additions & 0 deletions tools/simharness/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
199 changes: 195 additions & 4 deletions tools/simharness/harness.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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():
Expand All @@ -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))
Expand Down
Loading