Skip to content

Commit fe35bc5

Browse files
jscott3201claude
andauthored
TPR physics-level FaultModel campaign + frontmatter-parsed OS gating (#34)
--faultmodel patches FaultModel:TemperatureSensorOffset:OutdoorAir into the epJSON: the controller acts on the biased sensor, FDD replays truth. +4C (early lockout) caught by AHU-FC-051 3/3; -4C (economizing past changeover) by AHU-FC-068 3/3 (one loop ~5 min); mirrors and the entire envelope family correctly silent — the exact complement of the input-bias campaign (PR #33). simulation_tpr blocks recorded on both cards. OS gating now parses each card's operating_states frontmatter (ranges, comma lists, 'all'); unparseable prose is a hard error with OS_OVERRIDE, never a silent all-states default — closing the defect class behind the fleet sweep's spurious clusters. Regression: healthy replay matches sweep v3 exactly. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent a020f31 commit fe35bc5

4 files changed

Lines changed: 88 additions & 12 deletions

File tree

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,13 @@ emissions:
7171
scope: "2"
7272
method: DIRECT_EMISSIONS
7373
validation:
74+
- kind: simulation_tpr
75+
harness: simharness/v1
76+
date: 2026-08-18
77+
fleet: "TPR physics-level: FaultModel:TemperatureSensorOffset:OutdoorAir +/-4 degC patched into the epJSON (controller acts on the biased sensor; FDD replays true node values), B2B OfficeMedium-4004 July week, 3 loops; failures = missed detections"
78+
scenarios: 3
79+
failures: 0
80+
notes: "+4 degC controller offset (locks out economizing early): detected 3/3 loops, ~3-4 h on two; correctly silent at -4. Envelope rules stayed silent — physically consistent sensors — the exact complement of the input-bias campaign"
7481
- kind: simulation_tpr
7582
harness: simharness/v1
7683
date: 2026-08-18

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,13 @@ emissions:
7373
scope: "2"
7474
method: DIRECT_EMISSIONS
7575
validation:
76+
- kind: simulation_tpr
77+
harness: simharness/v1
78+
date: 2026-08-18
79+
fleet: "TPR physics-level: FaultModel:TemperatureSensorOffset:OutdoorAir +/-4 degC patched into the epJSON (controller acts on the biased sensor; FDD replays true node values), B2B OfficeMedium-4004 July week, 3 loops; failures = missed detections"
80+
scenarios: 3
81+
failures: 0
82+
notes: "-4 degC controller offset (economizes past true changeover): detected 3/3 loops, one within ~5 min; correctly silent at +4. Complement of the input-bias campaign"
7683
- kind: simulation_tpr
7784
harness: simharness/v1
7885
date: 2026-08-18

tools/simharness/README.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,25 @@ non-OAT rules stay correctly silent. Empirical demonstration of the CLU-09
9898
biased-OAT cascade; recorded as `simulation_tpr` validation blocks
9999
(failures = missed detections).
100100

101+
## TPR: physics-level FaultModel campaigns (`--faultmodel kind=delta`)
102+
103+
Patches EnergyPlus `FaultModel:*` objects into the epJSON before
104+
simulation, so the CONTROLLER acts on the faulted sensor while FDD replays
105+
true node values — the complement of the `--bias` campaign (where FDD's
106+
own input lies). First campaign, OAT sensor offset +/-4 degC on all OA
107+
controllers (OfficeMedium-4004, July): +4 (economizer locks out early) is
108+
caught by AHU-FC-051 (3/3 loops); -4 (economizes past true changeover) by
109+
AHU-FC-068 (3/3, one loop in ~5 min); each direction's mirror rule and the
110+
entire envelope family stay correctly silent, since every replayed sensor
111+
is physically consistent. Where FDD and the controller share a sensor,
112+
real deployments see BOTH signatures — the input-bias envelope detections
113+
and the behavioral detections — which is what the cluster grouping is for.
114+
115+
The OS gating map is now parsed from each card's `operating_states`
116+
frontmatter (`parse_operating_states`; ranges, comma lists, "all");
117+
unparseable prose is a hard error requiring an explicit `OS_OVERRIDE`
118+
entry — never a silent all-states default.
119+
101120
## Plant mode (DOE prototypes)
102121

103122
`--mode plant` maps CHW/HW plant loops instead of air loops: loop

tools/simharness/harness.py

Lines changed: 55 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -175,17 +175,43 @@ def extract(csv_path: Path, loops: dict) -> dict:
175175
# ------------------------------------------------- operating states
176176

177177
ALL_OS = {1, 2, 3, 4, 5}
178-
# G36-style OS sets transcribed from each card's operating_states frontmatter.
179-
OS_MAP = {
180-
"AHU-FC-002": ALL_OS, "AHU-FC-003": ALL_OS,
181-
"AHU-FC-005": {1}, "AHU-FC-006": {1, 4}, "AHU-FC-008": {2},
182-
"AHU-FC-009": {2}, "AHU-FC-010": {3}, "AHU-FC-011": {3},
183-
"AHU-FC-012": {2, 3, 4}, "AHU-FC-013": {3, 4}, "AHU-FC-015": {2, 3, 4},
184-
"AHU-FC-056": ALL_OS, "AHU-FC-057": {2, 3, 4},
185-
"AHU-FC-014": {1, 2}, "AHU-FC-051": {4}, "AHU-FC-053": {2, 3, 4},
186-
"AHU-FC-054": ALL_OS, "AHU-FC-055": {1, 4}, "AHU-FC-062": ALL_OS,
187-
"AHU-FC-066": {2, 3, 4}, "AHU-FC-067": {1, 2, 3, 4}, "AHU-FC-068": {3},
188-
}
178+
OS_OVERRIDE: dict = {} # rule-id -> set, only for cards whose prose defeats the parser
179+
180+
181+
def parse_operating_states(s: str):
182+
"""G36-style OS set from a card's operating_states frontmatter.
183+
184+
Handles ranges (OS#2-#4), comma lists (OS 2, 3, 4), singletons, and
185+
a leading "all". Returns None when nothing parses — callers must treat
186+
that as an ERROR, never default to ALL_OS: the fleet sweep's two
187+
spurious FP clusters came from exactly that silent default.
188+
"""
189+
s = s.strip().strip('"')
190+
out = set()
191+
for m in re.finditer(r'OS\s?#?\s?(\d)((?:\s*,\s*\d)*)(?:\s*[-–]\s*(?:OS\s?)?#?(\d))?', s):
192+
a, lst, b = m.groups()
193+
if b:
194+
out |= set(range(int(a), int(b) + 1))
195+
else:
196+
out.add(int(a))
197+
out |= {int(x) for x in re.findall(r'\d', lst or '')}
198+
if not out and re.match(r'(?i)^\s*"?all\b', s):
199+
return set(ALL_OS)
200+
return out or None
201+
202+
203+
def rule_os_set(rid: str, card: Path):
204+
if rid in OS_OVERRIDE:
205+
return OS_OVERRIDE[rid]
206+
fm = card.read_text().split("---", 2)[1]
207+
m = re.search(r'^operating_states:\s*(.+)$', fm, re.M)
208+
got = parse_operating_states(m.group(1)) if m else None
209+
if got is None:
210+
sys.exit(f"{rid}: operating_states unparseable ({m.group(1)[:60] if m else 'missing'}) — "
211+
"add an OS_OVERRIDE entry rather than defaulting")
212+
return got
213+
214+
189215
SMOOTH_TICKS = 6 # 30 min any-window smoothing over DX/burner cycling
190216

191217

@@ -279,7 +305,7 @@ def emit_and_replay(building: str, loops_pts: dict, rules: dict, out: Path):
279305
n = len(next(iter(pts.values())))
280306
horizon = (n - 1) * STEP_S
281307
wins = gated_windows(pts["sf_status"], derive_os(pts),
282-
OS_MAP.get(rid, ALL_OS))
308+
rule_os_set(rid, r["dir"] / "card.md"))
283309
if not wins:
284310
continue
285311
d = replay / f"{rid}__{lp.replace(' ', '_')}"
@@ -448,6 +474,11 @@ def main():
448474
runp.add_argument("--mode", default="airloop", choices=["airloop", "plant"])
449475
runp.add_argument("--reuse", action="store_true",
450476
help="reuse an existing eplusout.csv instead of re-running EnergyPlus")
477+
runp.add_argument("--faultmodel", default=None, metavar="KIND=DELTA",
478+
help="physics-level fault: patch FaultModel objects into the "
479+
"epJSON before simulation (e.g. oa_temp_offset=4.0 — the "
480+
"CONTROLLER acts on the biased sensor; FDD replays truth). "
481+
"FAILs are DETECTIONS")
451482
runp.add_argument("--bias", default=None, metavar="POINT=DELTA",
452483
help="TPR mode: add DELTA to POINT in the replayed inputs "
453484
"(faulted-sensor-as-seen-by-FDD); FAILs are DETECTIONS")
@@ -524,6 +555,18 @@ def main():
524555

525556
loops = loop_nodes(b)
526557
patched = patch(b, loops, begin, end)
558+
if args.faultmodel:
559+
kind, delta = args.faultmodel.split("=")
560+
assert kind == "oa_temp_offset", kind
561+
patched.setdefault("FaultModel:TemperatureSensorOffset:OutdoorAir", {})
562+
for i, oa in enumerate(patched.get("Controller:OutdoorAir", {})):
563+
patched["FaultModel:TemperatureSensorOffset:OutdoorAir"][f"simharness OAT fault {i}"] = {
564+
"controller_object_type": "Controller:OutdoorAir",
565+
"controller_object_name": oa,
566+
"temperature_sensor_offset": float(delta),
567+
}
568+
print(f"FaultModel injected: OAT sensor offset {delta} degC on "
569+
f"{len(patched.get('Controller:OutdoorAir', {}))} OA controllers — FAILs are DETECTIONS")
527570
pj = out / "patched.epjson"
528571
pj.write_text(json.dumps(patched))
529572
csv_path = out / "ep" / "eplusout.csv"

0 commit comments

Comments
 (0)