Changelog: 0.60 to 0.89
Archived CAMBER releases 0.60.0 to 0.89.0, in the same format as the current changelog, which holds the recent releases. The changelog index lists every archive.
[0.89.0] — 2026-09-27
Catalog release 2. The research-only tier, manual-download entries, Excel workbooks and
Brick-grouped ingest (the framework), one reconciled design for the source layouts real buildings
publish, and 14 new catalog entries -- 21 in all. CAMBER's first result on real, labelled
multi-zone VAV data is published in docs/VALIDATION.md. The camber.datasets API stays
provisional.
Added
The catalog framework
- Research-only tier (
access: "research_only"):camber datasets fetchrefuses such an entry without--accept-noncommercial(exit 3) on every fetch, with no environment-variable bypass; each acceptance goes to theacknowledgements.jsonledger and the manifest before anything downloads.fetch --allis the open tier only; research-only entries need--licence alland--accept-noncommercial.ingestneeds an acknowledgement of the entry's current licence (or its own--accept-noncommercial) and recordsredistribution: prohibited.datasets listshows the licence tier. - Banners everywhere: store reads stamp the facility's dataset provenance on the frame, so
build_dashboardandbuild_site_reportshow the licence block and the non-commercial / do-not-redistribute banner even when nodata_sourcesare passed (audit, RCx and drift already did through the config). - Manual-download entries (
manual: true+manual_instructions) andcamber datasets ingest <id> --from-dir DIR(APIingest(..., from_dir=)): files downloaded by hand are verified against their pins (size + SHA-256; a mismatch exits 2 and leaves the file untouched) and adopted into the cache. Works for any entry whose files you already have. xlsxextra (openpyxl>=3.1, imported lazily): catalog runs may read.xlsxworkbooks ("sheet"names the worksheet); a missing extra is an actionable error. The validator requires"requires_extras": ["xlsx"]on such entries. Legacy.xlsis not in the extra (no entry needs one).- Brick owner grouping:
BrickPointMapping.owner/owner_class(fromhasPointor the inverseisPointOf, now read by both parsers) andinterop.brick.part_parents_from_brick. A catalog run with"group": "brick"splits a per-quantity table into equipment by the entry's Brick model (ingest.brick.equip_classes), walking uphasPart/isPartOf; the mapping file'saliases,equipmentandequipment_classesoverride the model. - Link check:
scripts/datasets_linkcheck.pyand the weeklydatasets-linkcheckworkflow (advisory: job summary + warnings, never fails the build) check every file's served size / ETag and, withlicence_check(expect,json_path), the licence page.pytest -m networkfetches one small open file end to end.
Source layouts for real buildings (one design)
Four intake branches built these in parallel; 0.89 ships them as one design, each concept with one
key and one code path (camber/datasets/_readers.py), documented in
DATASETS.md:
- members -- a run's per-quantity tables joined on the timestamp, or {raw column: member} for
one file per point (a headerless timestamp,value export or a table whose last non-clock column
is the value); only the needed members are extracted, and a table several runs share is parsed
once.
- where: {column: value or [values]} -- a run's rows of a stacked table.
- A run's own mapping with vars filling {placeholders}, and its own timestamp_format,
units, encoding and sheet; equip_id stores its equipment verbatim.
- group: "mapping" splits one table into equipment by the mapping file (no Brick model), and a
grouped run's target names the equipment under test -- the rest is unscored context.
derive gains copy. The naming topology understands scenario equipment
(RTU_VAV_104__d2_stuck_040 sits under RTU__d2_stuck_040).
- The per_point adapter: one series file per sensor, a sensor index, one facility per group,
sample-and-hold resampling (hold).
- clock: an elapsed simulation clock, or a synthetic day / rows clock (recorded as such
in the notes and the provenance); timestamp_format: "ISO8601"; source_timezone ("UTC", an
IANA zone or "offset") + local_timezone move a dataset onto the site's wall clock (offset
labels are otherwise dropped, the wall clock kept).
- Units psia (to psig), psig, m3/min; licences CDLA-Permissive-1.0 and NIST-PD (open);
tar archives' extracted sizes are checked against free disk; contiguous: false marks an entry
whose days are not consecutive.
- Duplicate-run detection compares the members' bytes and what a run selects from them (rows,
mapping, placeholders, worksheet), so a stacked table's rooms are not "duplicates".
- The validator rejects the intake branches' earlier spellings (points, where: {column, in},
tz_convert, synthetic_index, day_clock, timestamp_unit / timestamp_origin, the
per_point adapter's points) and names the key to use.
14 catalog entries
Every licence re-verified on the host's page, every file pinned (size + SHA-256), every problem in
the published data described with its evidence and handling (docs/DATASETS.md).
- DCV and CO2: lbnl-b59 (LBNL Building 59: four RTUs with measured OA flow, 11 CO2 zones, 51
underfloor terminals, Brick model; manual download, CC-BY-4.0), finnish-dcv (a laboratory
room with occupancy-based DCV and ground-truth counts, CC-BY-4.0), b4b-windesheim (three office
rooms, two CO2 sensors each, CC-BY-4.0; cited by pinned commit, it has no DOI).
- AHU and refrigerant side: nuig-ahu101 (a 100%-OA lecture-theatre AHU, CDLA-Permissive-1.0),
irish-ahu (an industrial mixing-box AHU, 5.5 years, CC-BY-4.0), nist-heatpump-fdd (labelled
residential heat-pump lab test points, NIST-PD; xlsx extra), nist-ibal (a lab chiller with
refrigerant pressures; manual download, NIST-PD).
- Real buildings and refrigeration: robod (five Singapore rooms, CC-BY-4.0), sdu-ou44 (three
Danish rooms, anonymised days on a synthetic day clock, CC0-1.0), ornl-frp-ops (one RTU and ten
VAV boxes under seven operating scenarios, CC-BY-4.0), ornl-supermarket-fdd (a CO2 booster
rack with six labelled faults, reference only, CC-BY-4.0).
- Multi-zone VAV and the research-only tier: ornl-frp-vav (one RTU and ten VAV boxes, 31
labelled one-day tests, CC-BY-4.0), rbc-g36-ahu (simulated G36 and rule-based AHUs with five
VAV zones, 414 runs; research-only, below), at-30bldg-sensors (1,832 raw sensors from a
30-building campus, CC-BY-NC-SA-4.0, research-only).
Changed
- The site-neutrality guard's rules may carry per-rule
exempt_paths(a third output field). Only the third-party dataset-host rule and its DOI-prefix rule use it, and only forcamber/datasets/catalog.json, which must link data as published; commit messages, the CHANGELOG and release artefacts still apply every rule, and the licence-encumbered dataset rules are never exempt. The CI guard, the pre-commit hook andscripts/gates.shapply it. - CI installs the
xlsxextra so the workbook path is tested. scripts/catalog_sweep.pyverifies manual entries from their seeded copies, sweeps research-only entries only with--accept-noncommercial, requires the non-commercial banner exactly on research-only data, builds an unlabelled entry's reports for its first equipment, and with--skip-rcx ID,...builds only the audit report for the named entries (the RCx report'sselect_weektakes about 8 minutes on one 23-monthat-30bldg-sensorssensor, #35).
Maintainer decisions
rbc-g36-ahuis research-only for a stated reason. Its record is CC BY 4.0, but the archive bundles a folder of ASHRAE 1312-RP data (01_RBC-ASHRAE1312) whose open licence CAMBER cannot vouch for. The folder is never ingested (data issuebundled-1312-rp-folder), and the whole entry sits behind--accept-noncommercial. The newaccess_reasonstates why an open-licence entry is research-only; the licence gate,datasets info, the facility provenance and every report's banner and source block show it. An NC / ND licence can still never be labelled open, andaccess_reasonis rejected anywhere but on an open-licence research-only entry.datasets.catalog(licence="commercial")is the open tier (it excludes such an entry).- Data-issue citations. A data issue cites the documentation it contradicts by DOI whenever the dataset has one; a dataset without a DOI may cite a pinned https URL -- fixed to a commit or a version, never a moving branch or landing page. The validator enforces exactly this.
- The ORNL multi-zone VAV result is published in
docs/VALIDATION.md: onornl-frp-vav's full subset,unmet_setpoint_hourscatches 10 of 18 stuck-damper days (56%, Wilson 95% 34-75%) with 1 false alarm in 13 negatives (8%, 1-33%) -- the documented solar-overheating day. It is a symptom rule on small n in one building, not a gated benchmark, and the caveats are listed there. - Default subsets are bounded by size, not run count: at most 100 MB once ingested. With that,
ornl-frp-ops's default keeps all ten boxes of its two heating scenarios (24 runs, 8.6 MB) instead of two.
Fixed
- #34: three flaky tests are deterministic -- the manual-entry fixture zip has fixed member dates (it is built twice and compared against its pin, so a second boundary between the builds changed its hash; this also broke the research-only manual-entry test), and the portfolio idempotence test injects the lock's clock.
- Store timestamps are always nanoseconds. A facility written from a millisecond source
(the
at-30bldg-sensorsparquet series) made pyarrow read every facility of a shared store at millisecond resolution, and rules that derive sample spacing from nanosecond integers misread it by 1,000x (control_huntingfaulted everyrbc-g36-ahuscenario at 77,381 reversals/hour). The store now writes and reads nanoseconds. - #36: the sweep's "no G36 verdict without a sequence" check skips citation columns (an action
plan's
Cite, an ECMStandard/Reference) and judges verdict text only.
Known follow-ups (0.89 intake)
-
35:
2,460-equipmentselect_week(RCx) is slow on long or large runs, and a template run over therbc-g36-ahufull subset takes a long time. -
37 DCV verification by hour of day with a damper fallback; #38
air; #39 an R-410A saturation transform; #40 heat-pump / DX refrigerant rules on the NIST data; #41 dehumidification-with-reheat vs simultaneous heating and cooling; #42 a fan-heat allowance forco2_ventilationand economizerleaking_valve; #43 setback held by a cycling fan; #44 a zone below setpoint with reheat saturated; #45 a time-series point-type suggester.
[0.88.0] — 2026-09-26
A retro-commissioning report. camber report --layout rcx turns one run into a printable RCx
document: what the data covers and which sensors to trust, one representative week, the
economizer and supply-air reset, and one page per issue, with related findings linked into ranked
issues and priced once. Re-reading its output against the LBNL single-duct AHU runs also found
several rules judging fan-off samples; those are fixed. The report and its issue layer are
provisional until 1.0.
Added
- The RCx report layout (provisional,
camber.report.rcx):camber report CONFIG --layout rcx, orcamber.report.build_rcx_report. One printable HTML document: a cover with the data source and provenance (the non-commercial banner on every printed page for research-only data), a one-page executive summary, data coverage and gated sensor trust, a representative week, the economizer, a three-tier SAT reset census that declines a G36 verdict when the site has no declared sequence, air distribution, M&V and drift, one page per issue, and appendices of declined checks and assumptions. The week is chosen deterministically (select_week:evidence,oat-range,typicalor a fixed date) and the report says why. Engineer notes (--notes,--notes-template), the fault store's notes on each issue page (--lifecycle),--paper letter|a4and--week;report.layoutand areport.rcxconfig section; other layouts plug in through thecamber.reportsentry point. See the newdocs/RCX-REPORT.md. - Linked issues (provisional):
camber.rules.triage.link_findingsgroups findings into rankedIssues. A sensor fault that could explain a finding is attached as a conditional cause, never used to delete it; violation hours are the union across the linked findings, and a cause chain is costed at its largest member, not the sum.finding_confidencegrades each issue H / M / L with its reasons;issue_totalsrolls them up. RunResult.registry,frame_for,refs,data_sources,configandbase_dir;data_sources=onbuild_site_report/build_dashboard(so the site report and dashboard carry the non-commercial banner too);sensor_trust(..., gate=), which ignores flat stretches while the fan is off (opt-in, the default is unchanged);charts.template_violations;charts.box_by_hour, which labels each hour with its sample count (red under 10); and an"equip": [names]filter on config equipment entries.outdoor_air_fractiontakesdenom_min_f(default 5 °F, the |RAT − OAT| guard); its fan gate has been on by default since 0.86. Both OA rules report how many samples each guard left out (n_masked_fan_off,n_masked_small_delta_t,n_masked_out_of_range;OAFractionResult.masked) andoutdoor_air_fractionrecords itsfailure_mode.
Changed
- Rules judge fan-on samples.
supply_air_resetfits and draws occupied, cooling, fan-on samples only (on the LBNL stuck-damper run its slope goes from +0.015 to 0.000 and the verdict holds);supply_air_reset_compliancejudges fan-on, occupied samples by default and recordsfan_gate,occupancy_gate,n_gatedandreset_source;supply_air_controlcounts the fan as running only when it is on for most of the interval, so start-up hours are no longer scored as off setpoint;economizer_high_limitis fan-gated by default. All record which fan signal gated them. Benchmark metrics are unchanged. - Evidence matches the verdict.
economizer_high_limitandoutdoor_air_fractioncharts are built from the rule's configured envelope and exactly the samples the verdict counts (with a seasonalmin_oa_pct_by_month, the OA-fraction chart plots each sample against its own month's minimum);supply_air_resetevidence is the SAT-vs-OAT cloud the rule fits, not a packaged G36-like band. - Advice follows the failure mode.
asorecommendations for an OA-fraction finding now depend on what failed: under-ventilation gets "Restore minimum outside air", excess outside air or a missed high limit gets lockout advice at the rule's configured limit, and missed free cooling gets "enable economizing". The audit report's action plan benefits too. - When nothing is costed, reports show "no costed issues" (and the fleet rollup "no costed
findings") instead of a $0 total;
FleetReport.cost_estimatedrecords whether a price was given. - A citation's DOI is printed once, in every report that renders citations, and a lead-in line stays on the page with its figure or short table.
Fixed
CAUSE_CHAINSnamedreheat_minimizationinstead ofreheat_minimization_g36, so that link never grouped. The chains are nowsat,econandstatic, and a test checks every member is an emitted rule.- The high-limit summary printed "OAF > 50%" while counting > 55%; it now states the threshold it uses.
- Chart legends read
Role.SUPPLY_AIR_TEMP; they now readsupply_air_temp. supply_air_reset_compliancelisted unused fan-signal alternatives as missing inputs; it now names a fan signal as missing only when none is present.
Known issues
- A rule without a rule-derived violation mask (e.g.
simultaneous_heat_cool) shows its hours as "not measured" in the RCx report. Economizer and OA-fraction issues are uncosted (there is no excess-outside-air cost model yet). - The LBNL chiller- and boiler-plant RCx reports decline the representative week although the plant data is there (the week panels consider air-side roles only), and a unit with no declared G36 sequence can still get G36 advice on an issue page (#32).
Notes
- New provisional names:
camber.report.rcx(RcxOptions,RcxReport,build_rcx_report,select_week,WeekChoice,load_notes,notes_template,P3_FAMILIES,WEEK_MODES) and incamber.rules.triageIssue,SensorCause,Confidence,link_findings,finding_confidence,sensor_causes,issue_totals,SHARED_ROLES. The additive parameters and fields above are stable in shape. Snapshot regenerated. No new dependency. - Next for the RCx report: grounded AI prose with a cited fact index, an excess-OA cost model, cross-equipment (AHU → VAV) issues, and PDF output.
[0.87.0] — 2026-09-26
M&V baselines no longer extrapolate silently (#20), and the groundwork for rebaselining is in (#21, phase 21a). No savings path checked whether the reporting period's drivers lay inside the range the baseline was fitted on: a spring baseline projected onto a summer reported a saving, and a savings band, as if the model held there. Models now serialise losslessly, report coefficient p-values and the DOE SEP validity verdict, and gain backcast savings, an exact uncertainty kernel and multi-step non-routine event detection.
Added
M&V coverage
camber.mandv.coverage:ExtrapolationPolicy,Coverage,TIERS,support_of,assess_coverageandtowt_coveragegrade how well a fitted model's driver support covers a set of conditions —in_range,moderate,severeornot_evaluated— with the share of points and of baseline-projected energy outside the support, and the furthest distance beyond the fitted range. Multi-driver models add a leverage test (hidden extrapolation); TOWT is judged per occupancy mode x temperature cell; a categorical model's unseen category is unsupported.coverage()onChangePointModel,DegreeDayModel,DriverModel,TOWTModel/TOWTAtIndexandCategoricalModel(plusCategoricalModel.at(cat)to bind a period);ChangePointModel.fit_range.SavingsResultandIsolationSavingsgaincoverage,declined,declined_reason,caveatsandfsu_extrapolation_factor;NormalizedSavingsgains the same pluscoverage_baseline/coverage_reporting.NMECResult/HourlyNMECResultcarry them in the nested savings.- A keyword-only
extrapolation=ExtrapolationPolicy(...)onavoided_energy_savings,caltrack_savings,caltrack_savings_hourly,isolation_savings,isolation_normalized_savings,normalized_savingsandsavings_chart. - A moderate extrapolation widens the FSU of linear-in-parameters baselines by the parameter
variance factor
k(see docs/MANDV.md); TOWT is flagged, never widened, since it holds its response flat beyond the fitted range.caltrack_savingscaveats a baseline shorter than 365 days. The savings chart rug-marks out-of-support points, suffixes a moderate title and draws a declined saving as "extrapolated — not a saving" with no band. - Config
mventries take an optional"reporting_period": [start, end], emitting onemv_savingsfinding per meter (avoided_energy,savings_pct,fsu,fsu_extrapolation_factorand the coverage metrics; a severe extrapolation is aninfofinding withmetrics["declined"], the reason and a caveat), and an optional"extrapolation": {...}policy (ExtrapolationPolicy.from_dict; unknown keys are an error).mv_baselinefindings gainoat_fit_min,oat_fit_max,oat_support_lo,oat_support_hiandrho.
M&V foundations for rebaselining (#21, phase 21a)
- Model serialisation.
as_dict()/from_dict()onChangePointModel,DriverModel,DegreeDayModelandTOWTModelround-trip through JSON losslessly: prediction is rebuilt from the kind and coefficients through the model's design and is bit-identical to the fitted model (zero-intercept kinds and a 5P that fell back to a line included), and the private fit record travels with it, so coverage and uncertainty are unchanged.DriverModel.as_dict()now reports full-precision coefficients (it rounded to 6 decimals) plustypeandfit_record. - Coefficient p-values and the SEP validity verdict (provisional).
stats.regression_testsreturnsRegressionTests(coefficients, standard errors, t, two-sided p, the overall F p-value, adjusted R²,rhoand rho-adjusted p-values); change points count as parameters and flag the slope p-values as conditional on them.stats.sep_validityapplies DOE SEP 50001 M&V Protocol 2019 Ed. 2 §6.4.1 (F p < 0.10, every relevant variable p < 0.20, one p < 0.10, R² >= 0.50, logical signs) and reports the verdict as written and rho-adjusted, caveating a disagreement — a separate verdict from the G14acceptgate, which is unchanged.model_regression_testsandlogical_signsapply them to a fitted model. The t and F tails are computed in the standard library (a continued-fraction incomplete beta), checked against closed forms and the NIST StRD Norris certified values.FitStatsgains trailingf_pvalueandadj_r2. - Backcast savings (provisional),
camber.mandv.methods.backcast_savings: the DOE SEP backcast (SEP 2019 Ed. 2 §6.2.2, Eq 9),S = O_b - P_r|b— a model fitted on the reporting period projected back onto the baseline period's conditions. Coverage is the reporting model's support graded against the baseline drivers; the uncertainty is the reporting model's (G14 kernel by default, exact optional). Returns aMethodResultlabelledmethod="backcast",basis="baseline-period conditions". - Multi-step non-routine event detection (provisional),
nonroutine.detect_step_changes(also exported fromcamber.mandv): PELT (Killick, Fearnhead & Eckley 2012) with a Gaussian mean-change cost on the weather-model residuals, the variance inflated for serial correlation (sigma^2 * kappa), an mBIC-style penalty of3 ln nper step,min_segment_days=28andmax_steps, after Touzani et al. (Energy & Buildings 185, 2019). The weather model is refitted with one level indicator per segment so a step is not absorbed into the slope, and detection repeats until the step set is stable (at most three refinement rounds). Each step reports its size and a rho-inflated standard error. On synthetic AR(1) sites it places two planted steps to the day and flagged none of 50 step-free runs at rho = 0, 0.4 or 0.8. detect_step_changetakes a keyword-onlyautocorrelation=Truethat divides its statistic bysqrt(kappa)(recommended: at rho = 0.8 the uncorrected statistic fired on most step-free runs). The default is unchanged, so existing results do not move.- An exact uncertainty kernel,
kernel="exact"onavoided_energy_savings,normalized_savings,isolation_savingsandisolation_normalized_savings(default"g14", unchanged): the OLS projection variancekappa*s2*(g'Ag + m)of the fitted model at the application conditions (textbook OLS; BPA/SBW 2017 §3.3), withton the fit'sn - pdegrees of freedom. It carries the leverage of extrapolating itself, sofsu_extrapolation_factoris 1.0 under it. In a seeded Monte Carlo on change-point data with AR(1) residuals (rho estimated), its nominal 90% band covered 90.2%, 90.0% and 86.6% at rho = 0, 0.4 and 0.8.SavingsResult,NormalizedSavingsandIsolationSavingsrecord thekernelused. -
The single private fit record (#20) also carries the fit's residual variance
s2,n,pandrho;fit_model,best_model,fit_driver_modelandfit_degree_daytake a keyword-onlytime_indexsorhois recorded, andfit_towtrecords it from its own index. -
Docs:
docs/MANDV.mddocuments the p-values and the SEP verdict, the backcast, the exact kernel (with its Monte Carlo coverage) and multi-step detection; its stale "step-change detection is on the roadmap" is gone.docs/API-STABILITY.mdlists the new surfaces as provisional.
Changed
- Severe extrapolation declines by default:
avoided_energy,baseline_projected,savings_pct,fractional_uncertaintyandabs_uncertainty(andIsolationSavings.savings/adjusted_baseline,NormalizedSavings.normalized_savingsand the extrapolated side's NAC) becomeNone, withdeclined_reason. These fields are nowfloat | None. To keep the old behaviour — computed, with a widened band and a "SEVERE extrapolation — not a defensible saving" caveat — passextrapolation=ExtrapolationPolicy(decline=False). In-range results are byte-identical to 0.86.0, and the BDG2 benchmark (in-sample) is unchanged.
Fixed
normalized_savingsused the large-sample t (1.645 at 90%) whatever the fits' sizes; it now uses Student's t onmin(n - p)of the two models — a 12-point monthly fit gets 1.812. It also applied onerhoto both models:rhois now the baseline's and a new keyword-onlyrho_reportingthe reporting model's, each falling back to the rho its fit recorded, with the baseline's substituted (and caveated) only when the reporting one is unknown. The module docstring claimed a G141.26form the code does not use; corrected. Bands change only wheren - p <= 120or the two rhos differ (#21).- Option B dropped autocorrelation and parameter counts.
isolation_savingsnever estimated rho (no time index reached the fit statistics) — pass the newbaseline_index;isolation_normalized_savingsnever passedp_baseline/p_reporting, so a multi-driver model was banded asp = 2— it now passes each model'sp, and takesbaseline_index/reporting_indexfor a rho per model. The in-range golden forisolation_normalized_savingsmoves from ±23.79 to ±24.17 (the df-aware t on 58 degrees of freedom); savings are unchanged (#21). avoided_energy_savingsdropped reporting rows without a finite baseline projection silently — e.g. aCategoricalModelcategory the baseline never fitted. The rows are now counted (coverage["n_used"]vs["n_report"]), caveated and, for an unseen category, graded as unsupported (#20).- The config
mvpath passed no time index to the fit statistics, so the residual autocorrelationrhowas never estimated and any band from it would have been unadjusted.
[0.86.0] — 2026-09-26
An open dataset catalog, analysis that runs from the store, and the first two steps of the
portfolio lifecycle. A learner — or anyone validating CAMBER — can list open building datasets,
download them with verified checksums, ingest them into a ParquetStore, and run the ordinary
camber run / report / drift against them. A portfolio workspace now gives facilities a
lifecycle state, an audit trail and a stable facility_id, so renaming a facility no longer
orphans its fault history, drift baselines or tickets. This is the first of four catalog
releases; both APIs are provisional until 1.0.
Added
camber.datasetsandcamber datasets list | info | fetch | ingest | status | remove | config | score. Seven entries: the LBNL FDD single-duct AHU, fan-coil, dual-duct AHU, fan-powered units (parallel and series), chiller plant and boiler plant (CC-BY-4.0), and Building Data Genome 2 (CC-BY-SA-4.0, all 19 files). Every file is pinned by size and SHA-256.- Downloads are HTTPS-only (redirects included), resumable, verified before use, written
atomically; a changed upstream file is refused, never silently accepted. A disk-space check
runs first. Research-only (NC/ND) entries need
--accept-noncommercial, recorded in a ledger. - Archive extraction refuses path traversal, absolute paths, links and decompression bombs.
- Ingest is idempotent (content-hashed;
--forcereplaces, never appends): one facility per dataset (ds-<id>, BDG2 one per site), each labelled run as<equip>__<scenario>, a spliced fault-onset run for drift exercises, metric → IP unit conversion, and declared data fixes vs annotations. Provenance and labels are recorded on the facility. camber datasets scorescores findings against the dataset's labels with Wilson intervals.- CAMBER redistributes no datasets; it downloads them from their publishers (
NOTICE,docs/SECURITY.md§7, newdocs/DATASETS.md). - Published-data issues, described and handled (#24). The catalog links each dataset exactly
as its publisher provides it and never corrects it silently: every problem found in the
published data is a
data_issuesrecord with the affected columns, the evidence (numbers), the publisher documentation it contradicts (cited by DOI) and CAMBER's handling --fix,annotate,excludeornone.validate_catalog()checks the records and that each handling is wired (every quirk links to its issue; afixhas a fix quirk; anexcludenames what it excludes). 29 issues across the seven entries, all re-measured on the data, from the audit of 2026-09-26. camber datasets ingest --no-corrections(ingest(corrections=False)) skips the fix quirks and ingests the data as published; the mode is part of the content hash and recorded in the provenance with the data issues and the runs excluded from scoring.camber datasets info <id>lists the issues and their handling, and each subset's estimated store size (now per subset;ingestwarns when the disk is smaller than the estimate).docs/DATASETS.mdgains a "Data issues and how CAMBER handles them" section generated fromcatalog.json(scripts/datasets_issues_doc.py; a test keeps the two identical).- The ingest spec can pin a
timestamp_formatand declare CAMBER's own column semantics (recode, e.g. a 0/1/2 mode point read as occupied only in mode 1;derive, a sum of columns or a 0/1 "above a threshold" flag).camber.eval.benchmarkaccepts several target fault types per detector. - Store-backed runs:
"source": {"kind": "store", "path": …, "facility_id": …}in a config;resolve.StoreEquipRef/discover_store/clear_store_cache;ParquetStore.drop_facility(an irreversible, policy-free primitive) and.equipment. Rules, drift and SOO run unchanged. - Data source & licence in reports:
AuditReport.data_sourcesrenders the citation and licence in audit and drift reports, with a non-commercial banner for research-only data and a share-alike note. - A config
mvsection: a daily change-point M&V baseline per meter.
Portfolio lifecycle (provisional, camber.portfolio)
- A portfolio workspace:
camber portfolio init | adopt | status | audit. A workspace holds the store, the retention policy, an fsynced append-only audit log and a single-writer lock.adoptwraps an existing store in place (it moves nothing, and re-adopting is a no-op). The retention defaults are stored now and enforced by a later release: raw trends 25 months, hourly rollups 7 years, daily rollups indefinitely, findings 7 years, drift baselines for the life of the equipment plus the last 10 versions, the last 12 reports, and the audit log forever; a legal hold beats a facility override, which beats the default. See the newdocs/PORTFOLIO.md. - Facility lifecycle states with
camber facility add | list | show | rename | activate | suspend | resume. Every change needs--reasonand is audited with the OS user and host. A second change command while the lock is held is refused at once; automated writes inside a workspace (store writes, dataset ingest) wait up to 30 s.offboard,archive,restoreandpurgeare defined in the state machine and exit 2 until a later release adds export bundles and the deletion cascade. - Registry v2: each entry gains
state, lifecycle dates, an editabledisplay_name, owner, tags and notes; entries written before 0.86 read as active and are not rewritten until changed. Removed ids are tombstoned (instore/_tombstones.json) and never reused, and an id that differs from a known one only by case is refused, on registration and on write. - Suspended facilities are skipped:
ParquetStore.active_facilities()andfacility_state();discover_storeand store-backed configs skip a suspended facility with oneUserWarning(include_inactive=True, or"include_inactive": truein a config, overrides). The read API's/facilitiesaddsdisplay_nameandstate, and the/uiselector marks facilities that are not active. - Per-facility state keyed by
facility_id.FaultLifecycle,BaselineStore, the ticket and findings-export helpers andFaultRegistertakefacility_id=; with it, fingerprints are keyed by the facility andsiteis only a label, so a rename keeps the history. Records gainfacility_idandaliases, and a lookup by an old fingerprint still resolves. Withoutfacility_id=nothing changes. camber portfolio migrate [FILE...] [--config CFG] [--map SITE=ID] [--apply --reason R]re-keys site-keyed fault and baseline files intostate/<facility_id>/. It is a dry run by default; it refuses the whole apply (exit 1, nothing written) on an ambiguous, unknown or tombstoned label; it keeps the originals undermigrated/, leaves redirect stubs so existing configs keep working, merges a history split across an old and a new name, and writes a sha256 manifest. A second apply writes nothing;--applytakes the lock and is audited.- Config runs inside a workspace open facility-bound stores (by default under
state/<facility_id>/) and list every output in the facility's manifest, whichcamber facility showprints. Folder configs acceptfacility_idandworkspace; a tombstoned facility's config is refused. A new optionalfaultsconfig section keeps fault history across runs, andRunResultgainsfacility_id,workspaceandfaults. Inside a workspacedrift freezeneeds--reason, anddrift freeze/drift accepttake the lock and are audited.
Fixed
The catalog's own assumptions (#23, #25-#29, #31)
The 2026-09-26 audit compared every catalog config with its publisher's documentation and with the
data. CAMBER's own mistakes are simply fixed:
- Single-duct AHU (#25, #23). SF_CS is the supply-fan speed (it was mapped as the status;
SF_SPD, a constant 0.9, was mapped as the speed); a fan status is derived as SF_CS > 0 (it
agrees with fan power in 99.8% of rows). SYS_CTL maps to occupancy: the simulated schedule is
not Mon-Fri 07-18. The unit's minimum OA is a 10% damper position that measures as a 1.6% OA
fraction, not the assumed 20%; the template adds economizer_high_limit at the sequence's 60 F.
SA_CFM / RA_CFM are divided by 60 (published as cfm x 60). The one leak run is
coi_leakage_010: the four published "severities" are one file with a 10% leak.
- Dual-duct AHU (#26). The minimum OA is seasonal (28% damper Jun-Aug, 45% otherwise:
11.9% and 31.8% OA); the flat 20% read the fault-free unit as excess OA -- the family's FPR of
1.0. Fan status, occupancy (setback read as unoccupied), measured OA flow and supply flow are
mapped; simultaneous_heat_cool leaves the template (the hot deck heats by design); the
month-first timestamp format is pinned.
- Fan-coil and fan-powered units (#27). The FCU's FCU_SPD is in rev/s (no longer read as a
percent; the fan status comes from the discharge airflow) and FCU_CTRL maps to occupancy. The
FPU's room setpoints map to cool_sp / heat_sp (so unmet_setpoint_hours evaluates),
reheat_minimization_g36 leaves its template, and scoring targets are declared.
- Plants (#28). The undocumented chiller-fouling runs are scored as chiller_efficiency
positives (065 reads 2.36 kW/ton and was scored as a false positive); the default subset uses the
most severe tower fouling (065, not the mildest 095). The boiler's BOI_GAS_CSUM_* is an
instantaneous gas input in kW, not a counter; with no gas-input role or gas-per-heat rule yet,
that check -- and a supply-vs-setpoint check -- is recorded as a follow-up. The boiler's loop DP
points are declared in inH2O.
- BDG2 benchmark (#29). It scored the raw meters (~24,700 all-zero outage days per meter
type in 2016) while the catalog ingests the cleaned ones; it now uses the cleaned meters, fits
whole days only (at least 23 hourly readings) and computes EUI once per building from
electricity only (it was per meter record, mixing thermal and electric kWh).
- mandv.rate_to_energy credited the reading before a gap with the whole gap (#31) -- two
missing days after an hourly reading of 1 put 72 into one day. Each interval is now capped at the
series' nominal step, so gaps stay unfilled and a coverage rule can drop the day.
- Duplicate runs in published data are ingested once. Publishers ship one simulation under
several labels — the LBNL single-duct AHU "leak severities" 010/025/040/050 are one file four
times, as are its four OA-sensor-bias runs and the fan-coil set's cooling/heating airside
minor-fouling runs. Ingest now keeps the first of any byte-identical archive members (same CRC-32
and size) and records the rest as duplicates, so one case is scored once. (The same flaw in the
LBNL benchmark was fixed on main before this release: it listed three copies of the leak run.)
- The dataset-ingest test fixture's "leak" run was byte-identical to its fault-free run (its leak
never fired); the duplicate guard caught it.
- camber drift accept on a store config without "site" saved the new baseline under an empty
key that runs never read.
- A suspended facility whose config had a drift section raised an error instead of being
skipped.
Changed
outdoor_air_fractionjudges fan-on samples by default (fan_gate=True; fan status, else fan speed, else airflow --camber.schedules.fan_on_mask) and uses a trendedoccupancypoint when the unit has one; a unit with no fan signal is judged ungated and the finding says so. With the fan stopped the mixing-box temperatures read still air: on the LBNL single-duct unit they alone made 1.6% OA read "ok" against a 20% assumption (#23).min_oa_pct_by_monthsets a seasonal minimum. The synthetic and fleet benchmarks are unchanged;fan_gate=Falserestores the old behaviour.- The LBNL point mappings moved to
camber/datasets/mappings/, one source of truth for the benchmark and the catalog; the chiller plant's wet-/dry-bulb and secondary-loop supply/return swaps are catalogfixquirks. The LBNL benchmark now also reads each dataset's ingest spec (quirks, timestamp format, column transforms) and its run template's rule parameters, so it scores exactly whatcamber datasets ingestproduces. - An unknown
source.kindin a config now warns and falls back to folders. FacilityRegistry.remove(),drop_facility(forget=True)anddatasets remove --purge-storenow tombstone the id. Re-ingesting the same dataset lifts its own tombstone (FacilityRegistry.reclaim, audited); no other id can.FacilityRegistry.name()andfacility_name()return the display name, so reports show a facility's new name after a rename;namekeeps the name it was registered under.
Deprecated
- Reading site-keyed fault and baseline records (fingerprint
sha1(site, equip, rule/kind)) through the compatibility path of a store opened withfacility_id=— it warns with aDeprecationWarning— and the ticket fieldlegacy_fingerprint. Both are deprecated since 0.86 and removed in 2.0; runcamber portfolio migrate. Outside a workspace nothing warns and nothing changes. Seedocs/API-STABILITY.md.
Notes
- The LBNL and BDG2 benchmark baselines are refreshed (#30), with the maintainer's sign-off. Every gated metric that moved beyond the 0.05 tolerance (or changed count), old -> new:
- LBNL single-duct AHU: TPR 0.8 -> 0.4, correct diagnosis 0.8 -> 0.4, accuracy 0.8333 -> 0.5
(FPR stays 0).
outdoor_air_fractionnow judges fan-on, occupied samples against the unit's own 1.6% minimum, not an assumed 20%: the dampers stuck at 10% (the minimum itself) and 25% (4.4% OA) look like normal ventilation outside economizer weather and are no longer "detected" as under-ventilation against 20%. Their real symptom, the missed economizer, iseconomizer_damper_drift's, which still catches 4/4 (#23, #25). - LBNL dual-duct AHU: FPR 1.0 -> 0.0, TPR 0.5 -> 1.0, correct diagnosis 0.5 -> 1.0, accuracy 0.3333 -> 1.0. The seasonal design minimum (31.8%, 11.9% Jun-Aug) on fan-on, operate-mode samples replaces a flat 20% on all hours: fault-free no longer reads as excess OA, and the damper stuck shut now reads as under-ventilation (#26).
- LBNL pooled: TPR 0.8 -> 0.7 and correct diagnosis 0.8 -> 0.7 (7/10: two single-duct detections lost, one dual-duct gained), FPR 0.3333 -> 0.0 (the dual-duct false alarm is gone); accuracy stays 0.7692 (10/13). The fan-coil unit and the drift metrics are unchanged.
- BDG2 chilled water: buildings 518 -> 514, with a fitted rho 507 -> 501, median rho 0.6163 -> 0.6664, p10 rho 0.3024 -> 0.3953, share with rho > 0.3 0.9014 -> 0.9521. Electricity: buildings 1,526 -> 1,497, with a fitted rho 1,515 -> 1,497. Pooled: buildings 2,044 -> 2,011, with a fitted rho 2,022 -> 1,998. The benchmark now scores the publisher's cleaned meters (the raw ones carry ~24,700 all-zero outage days per meter type) and fits whole days only (>= 23 hourly readings), so zero and partial days no longer enter the fits or the residual autocorrelation (#29).
- BDG2 EUI: median 12.23 -> 11.07 kWh/ft2/yr, n 2,044 -> 1,497. It is now one value per building, electricity only, from complete days annualized; it was one per meter record, mixing chilled-water and electric kWh and including one site's ~1,000x chilled water (#29).
- Within tolerance, for the record: acceptance chilled water 0.3552 -> 0.3638, electricity
0.0839 -> 0.0982, pooled 0.1526 -> 0.1661; median CV(RMSE) pooled 0.2357 -> 0.2037. #31 moves
no BDG2 benchmark metric (the benchmark sums energy per interval rather than reading rates).
docs/VALIDATION.mdand the validation dossier's cited figures (OA-fraction pooled TPR 78% [45-94%], FPR 0%; BDG2 acceptance 17% [15-18%] over 2,011 meters) are updated to match. The FCU result rests on a 0.4-point margin (its leak runs give 12.7 / 15.4 / 17.4% OA against a 15% line). - New public names:
camber.datasets.*,resolve.StoreEquipRef/discover_store/clear_store_cache,config.data_sources, the reportdata_sourceshelpers,ParquetStoremethods,schedules.fan_on_mask/FAN_GATE_NONE. Snapshot regenerated. No new core dependency. - New provisional names for the lifecycle:
camber.portfolio(Portfolio,PortfolioLocked,LifecycleError,STATES,TRANSITIONS,DELETING,DEFAULT_POLICY,allowed_actions,transition,find_workspace,is_workspace),FacilityRegistry.state/tombstones/reclaimand itslock_timeout=, and thefacility_id=keywords above. - Until authentication lands, "admin" means write access to the portfolio root; the lock and the
audit log record who acted, they do not authorise. See
docs/SECURITY.md§8. - Offboarding, archive bundles, restore, purge and retention enforcement are the next lifecycle steps.
[0.85.0] — 2026-09-25
Sensor health and semantic models, checked against real buildings. The trust layer scored a healthy plant as broken and missed a documented sensor fault; the Brick importer could not read back what the exporter wrote, and point-name matching got 3 of 16 real names right.
Fixed
Sensor health
- Trust collapsed on a healthy plant. Points held at a setpoint have almost no spread, so
rounding noise scored as outliers; a pump idling at minimum then ramping with load is one skewed
population, not two regimes, so all load operation counted as outliers. On a fault-free boiler
plant flow, pump speed and loop DP scored 0.18–0.28 trust — the trust gate would have switched
off the one detector that works there (loop ΔT, 8/8).
assess(shape_aware=, scale_floor=)judges each side of the median against its own spread with a precision floor per role (0.5 °F, 1 %-pt, 30 ppm CO₂), and only excuses points that persist in runs;sensor_trustopts in. The same plant now reads 0.92–0.99; spikes, scattered rails and sentinels stay untrusted. compare_to_referenceextrapolated drift from days of data. Three days of room-vs-duct CO₂ read "fault, drifting +275 ppm/month" with a 7 ppm bias. Drift now needs ≥ 28 days and 14 daily baselines (elsedrift_per_monthisNonewith a caveat — a field that was always a number can now beNone), is estimated by a robust (Theil–Sen) slope through daily offsets, optionally restricted to quiet hours (baseline_hours=), and the verdict leads with the worst issue — a 148 ppm bias fault no longer reads as "drifting +28.7/month".
Semantic models and mapping
- Brick export and import were not inverses. The exporter wrote
CO2_Sensor,Outside_Air_Flow_Sensor, outdoor CO₂ / RH andSupply_Air_Flow_Setpoint; the importer knew none of them, so DCV inputs could never come from a Brick model. A test now enumerates every exportable role and round-trips it. Import coverage 15 → 45 of 64 roles. - Hydronic plants were invisible to Brick import — a published boiler-plant model mapped 1 of
22 points; now 17. A boiler on/off status or any
Enable_*class is reported as ambiguous and never mapped toboiler_status(an enable held at 1 all year made summer lockout fault on every run). - Point-name matching used letter fragments of unsplit camelCase names (
OaTemp→oa_airflow,ReHeatVlvPos→evap_approach_temp). Names are now split into words and matched whole: a published 16-point air-handler list goes 3 → 16 correct by name alone. - Range checks were always °F, so declaring °C sent every temperature to
wetbulb_temp. - Haystack return / exhaust / relief / mixed-air dampers imported as the VAV-box damper.
- A percent point typed as a flow rated "high" confidence.
mapping_confidenceflags a flow role whose data stays within 0–100 or 0–1 (percent_scale) or whose declared unit is%(unit_mismatch);score_token(unit=),score_mapping(units=),review(units=).
Added
Sensor health
copied_signal_consistency: two roles on one unit carrying identical changing data (a return air temperature that was a copy of supply air for 2,627 consecutive hours).gapfill_signature,cross_unit_identity: screening checks for imputed data — a change in value granularity, repeated days, units implausibly identical (r ≈ 0.998). Warn at most; a known false positive (fans on one common speed) is named in the caveat.percent_scale_suspectand physical bounds forOA_AIRFLOW: a 0–100 (or 0–1) signal mapped to an airflow role is flagged in sensor trust (50 of 51 fan-speed points typed as airflow in a published building model).mixing_flow_consistency: mixed-air temperature against the OA/SA flow balance (f·OAT + (1−f)·RAT), widened for flow-station uncertainty, warn at most. Found two units reading +3.1 / +4.7 °F warm where the dataset documents inaccurate mixed-air sensors. Not yet part of the trust score.co2_outdoor_consistencyand abelow_ambienttrust flag: indoor CO₂ below outdoor (or under ~380 ppm background) means one sensor is wrong.- New
docs/SENSOR-HEALTH.md.
Semantic models
brick_mapping_report(ttl): per point,mapped/alias(a non-standard class accepted with a caveat —Outdoor_Air_Flow_Rate,Outdoor_*spellings) /ambiguous(and why) /unmapped. A flow-typed point named like a speed or percent is never mapped to a cfm role. On a published 267-point building model: 193 mapped (17 via aliases), 57 ambiguous, 17 unmapped — where 0.84 mapped 134, 51 of them wrongly.
Notes
- New public names:
sensorhealth.{mixing_flow_consistency, copied_signal_consistency, gapfill_signature, cross_unit_identity, co2_outdoor_consistency, percent_scale_suspect},interop.brick.{brick_mapping_report, report_from_triples, BrickMappingReport, BrickPointMapping, ALIAS_CLASS_TO_ROLE, AMBIGUOUS_CLASSES}, plus additive fields and parameters. Snapshot regenerated. No new dependency. Benchmark baselines unchanged. docs/ONTOLOGY.mdcoverage corrected: Brick import 45/64, export 39/64, Haystack 64/64, 223P 51/64 (its previous count was stale).- The "insufficient data" results of
chiller_efficiencyandovercooling_min_flownow carrymetrics["declined"], so a scorer excludes them instead of counting a correct negative (the conventiondriftvalidationand the LBNL benchmark rely on). Found re-checking 0.82–0.85 on the complete open datasets, which otherwise reproduced every recorded result. - The LBNL chiller-plant mapping notes a second label swap in that dataset: the secondary loop's supply / return temperatures (return reads colder than supply in 98 % of flowing hours).
- BDG2's licence was misstated as CC-BY. The data repository is CC-BY-SA 4.0
(Attribution-ShareAlike); the paper describing it is CC-BY, which is where "CC-BY" came from.
Commercial use and analysis are unaffected; a redistributed adapted version of the data must stay
CC-BY-SA. Corrected in the README,
docs/VALIDATION.md,docs/DEPLOY.md, thecamber validatedossier andexamples/bdg2/.
[0.84.0] — 2026-09-25
Plant and refrigerant drift, checked against real machines. Run on open data from a lab heat pump with labelled refrigerant faults, two real 5-ton water-cooled chillers, a CO₂ transcritical refrigeration rack, a pilot wet cooling tower and a simulated boiler plant, the refrigerant-side drift detectors declined almost everything they were given — and where they did score, they discarded the fault.
Fixed
- Superheat and subcooling drift threw away the fault. A 0–50 °F plausibility band dropped
liquid floodback (superheat below 0 °F — the direction the rule itself calls most urgent), flash
gas, and superheat above 50 °F. A half-flooded period read "ok, −0.1 °F". Ranges are now
−20…150 °F (superheat) and −20…80 °F (subcooling), shared with
sensorhealth; sentinels are still rejected. - Small machines always declined. The baseline fit demanded a 10-ton load spread and samples
at ≥ 5 tons, so a 5-ton chiller and 2–4 ton heat pumps declined 100 %. Gates now scale to the
machine — 10 % / 20 % of observed capacity, capped at the old 5 t / 10 t, so ≥ 50-ton chillers
are unchanged (a test pins that) — via
size_relative_load_gates, withmin_tons/min_tons_spanon all eight tons-based rules. A machine whose load never moves gets a flat-level baseline, scored only near the load it saw, with a caveat. Heat pump: 151 of 151 labelled cases declined → none; real chillers: 22 normal days scored with no false alarms. - Pressure ceilings excluded CO₂. Head 700 psig / suction 400 psig (with comments calling them refrigerant-neutral) dropped every sample of a transcritical rack. Now 2000 / 1000 psig.
- Head and suction pressure were normalized on load alone, although pressure follows the heat
sink. Head pressure now regresses on entering condenser water (
CW_SUPPLY_TEMP, elseOAT), suction on leaving chilled water (CHW_SUPPLY_TEMP), falling back to load only with a caveat. Heat pump head-pressure scatter 61 → 5 psi (recall 0.01 → 0.32); a real chiller's suction false alarms on chilled-water reset days 33 % → 0 %. - The sustained-shift CUSUM assumed independent samples. At 1–5 minute cadence residuals are autocorrelated (lag-1 0.8–0.9), so normal days alarmed: approach CUSUM 59 % → 5 % (5-min) and 75 % → 15 % (1-min) on a real chiller; subcooling 90 % → 5 % at 1-min. Limits now scale by the long-run sigma; hourly data with independent residuals is unchanged. Injected steps are still caught.
- Loop DP drift declined any non-psi loop — the plausible band was (0, 100), so a plant in
inH₂O declined every case and blamed the wrong cause. The band now scales to the loop's own
median (unit-free);
dp_range=sets it. - Declines named the wrong cause. "No loaded samples" when the metric column was all-NaN; new
unscoreable_reasonnames the check that failed, across chiller, pump and loop rules. hw_pump_dp_resetnever evaluated the DP-setpoint reset it claimed, and failed on a 0–1 speed when called directly. It now readsHW_DIFF_PRESS_SPand says "not evaluated" when absent.- Cooling tower: the decline says what happened ("only 5 samples at ≥ 90 % fan… fewer than
the 10 needed" rather than "never reached 90 %"); derived wet-bulb takes
elevation_ft/pressure_psiaand is solved psychrometrically (within 0.06 °F of a reference). Sea-level Stull reads ~1.4 °F high at 500 m and ~2.6 °F at 1600 m hot/dry; the default is unchanged and now caveated.
Notes
- What still misses, honestly: refrigerant-side recall on the heat pump is 0.22–0.52 per detector (subcooling catches every overcharge, 15/15, and half the undercharges); suction pressure catches nothing there; the rack's condenser-air blockage is not detected (the fault day was hotter than every baseline day, and the only load proxy rises with the fault). One real chiller still alarms on 33–62 % of summer days because it ran outside its spring baseline — a tuning question, documented, not tuned.
- The condenser simulator's tower-fouling case no longer corroborates through head pressure: at matched entering-water temperature the chiller's high side really is healthy.
- New public names in
chillerbaseline(size_relative_load_gates,unscoreable_reason,residual_lag1,FULL_SIZE_MIN_LOAD,FULL_SIZE_MIN_LOAD_SPAN),coolingtower(psychrometric_wetbulb_f,pressure_psia_at_elevation,SEA_LEVEL_PSIA),loop_dp_ruleand the superheat / subcooling rules, plus defaulted fields — baselines frozen by earlier versions still load. Snapshot regenerated. No new dependency. Benchmark baselines unchanged.
[0.83.0] — 2026-09-25
Air-side rules, checked against real buildings. Every fix below came from running the rules on open real-building data (an office-building fleet, a messy industrial air handler, a test building's VAV fleet), and every regression test fails on 0.82.0.
Changed
Read these if you pass the old values
ControlHunting(deadband=)is now in percent (default 5). It was 0.05 applied to 0–100 % data, i.e. effectively no deadband. A 0–1 signal is rescaled first.FreeCoolingMissed(active=)is now in percent (default 5), for the same reason: a valve sitting at 1 % read as "mechanical cooling ran 100 %".n_free_cooling_hoursis now real hours — it was a sample count labelled hours (140,274 "hours" of 15-minute samples ≈ 35,000 h).EconomizerHighLimit(min_oa_pct=)now defaults toNone(unknown). The 20 % assumption faulted all four units of an office building whose measured minimum is ~33 %. With measuredOA_AIRFLOW/AIRFLOWthe rule judges real flow; differential economizing (OA cooler than return) is left alone; with no configured minimum it faults only on excess above a generous bound, and declines when the verdict hinges on the unknown design minimum.realio.load_statusresamples to time-weighted duty (how="duty");how="any"keeps the old per-bin max. A cycling fan's max-per-bin inflated its duty, so the setback verdict depended on the resample interval. (A test pins 1-min / 15-min / hourly to the same verdict. The one real-data case reported by the investigation did not reproduce, so this rests on synthetic evidence.)fdd_g36.classify_osreturns 0 (OS_UNCLASSIFIED) for missing valve data instead of "free cooling". On the industrial air handler FC8 was diluted from 25.7 % to 15.0 %.
Fixed
control_huntingreported "stable" when it could not see. At 15-minute sampling the 6 /h warn rate is unresolvable; the rule now declines (info + caveat,Nonerate) and caps at warn when only the fault rate is out of reach. Data gaps no longer count as calm time. A damper limit-cycling 20 → 54 → 20 → 51 every sample had read "stable (2.3×/hr)".overcooling_min_flowsaid "ok" without testing. With noAIRFLOW_SP, "at minimum" was all-False, so every box read ok at 0 % — with a caveat claiming the minimum had been inferred. It now declines.overcooling_severitycalled other things overcooling. Morning recovery from setback (viaWARMUP, else the first 2 h), fan-off free-floating, and a saturated reheat valve (≥ 90 %, now reported as a heating shortfall) are no longer overcooling. On the office building: 38 zone faults → 22, with 32 zones now correctly reported as short of heat.- Five rules ignored a trended occupancy point — setback, overcooling, overcooling severity,
zones census and reheat used a hard-coded weekday 07–18 window. New
schedules.effective_occupied_mask: a trendedOCCUPANCYpoint replaces the schedule, otherwisestart_hour/end_hour/occupied_days(defaults unchanged). - The reset-request census fired with the HVAC off. The SAT rogue-zone census and cohort
starvation now gate request cycles on supply-fan status / occupancy (
gate_cols=), and caveat when ungated (UNGATED_CAVEAT). A free-floating week went from "starved cohort" to info once fan status reaches the zone frames; with occupancy alone it still fires. reheat_penaltyread the wrong air at a terminal. At a VAV boxMIXED_AIR_TEMPis the entering primary air andSUPPLY_AIR_TEMPthe discharge (the convention the reheat-valve drift rule already used); a discharge-only judgement is caveated as a lower bound, never a confident ok. On a 10-box test fleet: 9 ok / 1 warn → 10 fault with the primary air mapped.run_g36_afddcrashed on unsorted or duplicate timestamps (rolling("60min")); input is now sorted and deduplicated, and a non-time index raises a clearTypeError.supply_air_resetsaid "pinned low" for a flat reset held at 68 °F; it now says "held at".- A served-by topology that covers no zones reports no provenance, and says 0 of N.
Notes
- New public names:
schedules.effective_occupied_mask,rules.hunting_rule.max_resolvable_per_hour,fdd_g36.OS_UNCLASSIFIED,g36_reset.UNGATED_CAVEAT, plus additive result fields and constructor arguments. Snapshot regenerated. No new dependency. Benchmark baselines unchanged.
[0.82.0] — 2026-09-25
DCV verification judged the economizer, not the DCV. On a normally modelled building
dcv_verification evaluated nothing: CO₂ lives on the VAV zones, OA on the air handler, and no
equipment frame had both. Where it did run, it scored whatever moved OA — usually the economizer.
Measured on faultlab.dcv_sim (a zone CO₂ mass balance, 21 days hourly), old check vs new rule:
| Case | 0.81.0 | 0.82.0 |
|---|---|---|
| Working proportional DCV + economizer | uncorrelated (corr −0.81) | functioning (ok) |
| Working PI DCV + economizer | uncorrelated (corr −0.82) | functioning (ok) |
| Static DCV + economizer | uncorrelated (corr −0.78) | static (warn) |
| Static DCV + morning warm-up closure, no WARMUP flag | functioning (corr 0.71) | static (warn) |
| Working DCV, lightly occupied building | static | insufficient (info) |
Fixed
- Economizer periods were judged as DCV. OA follows
max(economizer, DCV minimum); while economizing it tracks outdoor temperature. Those samples are now excluded, fromECON_CMD(any nonzero hourly mean) or, failing that, inferred fromOAT+HEAT_VALVE. With no economizer evidence at all, an "uncorrelated" verdict drops toinfowith a caveat. - A closed damper made a stuck DCV look responsive. A warm-up closure inside the occupied
window put OA at 0 while CO₂ was low, which read as a response. OA at or below 2% of its p95 is
now excluded and reported as
closed_pct. Fan-off / partial-fan hours (SUPPLY_FAN_STATUS) andWARMUP/COOLDOWNare excluded too. - The
OCCUPANCYpoint was loaded and never used. It now gates the occupied mask. The rule's documented fallback to occupancy as demand could not run (CO₂ is required) and is no longer claimed; binary occupancy demand still works throughassess_dcv. - Flat or low demand produced a verdict. A building whose CO₂ never reached the level where DCV
should respond read "static"; flat CO₂ read "uncorrelated". Both are now
insufficient, with areason. - One spike could flip the verdict.
modulationused raw max/min; it now uses p5–p95, and the "OA at minimum" band for the CO₂-breach check is built from the robust minimum. - Duplicate timestamps crashed the rule (
cannot reindex on an axis with duplicate labels).
DCV, from a real-data pass
The new DCV code was run against five open real-building datasets (licence-clean, cited by DOI in
docs/VENTILATION.md, not vendored) before release. It held up where it should — a lab room with
a known DCV law reads functioning, an office building with no DCV reads insufficient on every
unit, never a false functioning — and turned up nine defects:
- An occupant count was discarded. Anything not strictly 0/1 was read as CO₂ and filtered as
implausible, so a people count — or a presence point averaged to 10-minute means — returned
too_few_samples, n=0 on every dataset. assess_dcv now detects co2 / presence / count
(demand_kind), with min_lift_people.
- A time clock was credited as DCV. Occupancy follows the clock, so a valve opened on a
schedule "responded" to presence (lift 0.245). Occupancy demand is now judged within each hour
of day, weekdays and weekends apart; the scheduled valve drops to 0.087, the real responders
keep 2–20 people of lift. OA never both raised and at its floor within an hour is
insufficient, reason="schedule_confounded".
- The wildfire damper closure was invisible. The below-floor check ran after the economizer
and closed-damper exclusions, then vanished into too_few_samples. It now runs on every
occupied sample first: the 2020 closure is a fault on the two units that fell below the 62.1
requirement, and not on the two that stayed above it.
- The worst outage read "not judged". Fan off, CO₂ at the sensor's 2000 ppm full scale, 110
occupied hours — excluded as closed-OA samples. New unventilated_high_co2_hours: a fault at
≥ 4 h (unventilated_fault_hours), judged by duration because an outage's share of a long
dataset says nothing about its severity.
- Brick chains attributed nothing. The fleet rule took a zone's direct parent — in a Brick
model the VAV, not the unit bringing in outdoor air — and joined 0 of 11 zones. It now takes the
nearest ancestor with an OA signal: 11 of 11.
- Duplicate zone timestamps crashed the fleet rule (cannot reindex on an axis with duplicate
labels, on raw 1-minute BMS data).
- A trended occupancy point could only narrow the weekday 07–18 window, so a 24/7 space lost
two-thirds of its samples and the fleet rule dropped its working zones as "offset". The point now
replaces the schedule; start_hour / end_hour / occupied_days set it otherwise
(schedules.occupied_mask gains days).
- A Celsius OAT excluded every sample as possibly economizing, with no hint why. CAMBER
temperatures are °F; the finding now says so when nearly everything is excluded.
- Microsecond timestamps silently dropped rows. Two regular indexes at us resolution (as
parquet loads them) aligned to 10 rows instead of 3,013 under pandas ≥ 2; inputs are normalized
to nanoseconds.
Changed
assess_dcv's verdict compares CO₂ when OA was raised with CO₂ when OA sat at its floor (demand_lift, ppm) instead of a Pearson correlation. Correlation is still reported, as a diagnostic.min_corris deprecated and ignored (warns; removal in 1.0).dcv_verificationreturns nothing for a frame with CO₂ but no OA signal (a VAV zone) instead of aninfo"declined" finding on every zone;dcv_system_verificationreports zone coverage.modulationis now the robust p5–p95 range, so its values differ from 0.81.0.
Added
dcv_system_verification(DcvSystemVerification), an auto-registered fleet rule: zone CO₂ joined to the serving air handler's OA through the served-by topology (a naming-heuristic grouping caps severity atwarn), per-timestamp maximum across zones, with zone sensors that are implausible, stuck or offset when unoccupied excluded. One finding with aper_ahubreakdown.economizer_active_mask,DEFAULT_DCV_ENGAGE_PPM,DEFAULT_ECON_HIGH_LIMIT_F.- OA-floor sub-checks —
below_floor_pct(62.1 dynamic-reset floorRa·Az) andexcess_at_low_demand_pct(DCV not saving energy) — viaoa_floor/ the rule'soa_floor_cfm(a number or{equip: cfm});breach_fault_pct,below_floor_fault_pct,excess_warn_pct. - New
DcvResultfields (all defaulted, so positional construction still works):demand_lift,reason,demand_span,oa_low_demand,oa_high_demand,n_econ_excluded,econ_excluded,below_floor_pct,excess_at_low_demand_pct,closed_pct. faultlab.dcv_sim— the simulator above (proportional / PI / static DCV, economizer, warm-up closure, fan schedule). Thedcv_verificationbenchmark scenario now uses it: TPR 1.0 / FPR 0.0.OA_AIRFLOWandAIRFLOWare optional roles on the AHU template (OA_AIRFLOWon RTU), so an air handler's OA flow is discovered at all. Completeness scores for AHUs and RTUs without those points go down.CO2/OUTDOOR_CO2physical bounds in the sensor-health gate, and stuck-flat detection for zone CO₂.
Hardening
Benchmarks and validation claims
- The benchmark gates never saw a newly scored detector.
check_against_baselineonly walked keys already in the baseline, so a metric the baseline had never seen was silently ungated — six synthetic-suite metrics (the three reset rules added in 0.55–0.56), fifteen BDG2 ρ metrics (0.80.0), and every LBNL metric, because no LBNL baseline was ever committed: CI's LBNL step took its "seed a baseline" branch on every run and gated nothing. The check now listsunbaselinedmetrics and, withstrict_new=True(all four gate scripts), fails on them.examples/lbnl_fdd/benchmark-baseline.jsonis committed for the CI-fetched families; the synthetic and BDG2 baselines are refreshed (no existing value changed). Neweval.baseline_reportreplaces four copies of the gate printout, one of which dropped missing metrics without naming them. - Declined drift cases were scored as correct negatives.
driftvalidation.evaluatetreated a detector that could not test its claim (no fittable baseline, nothing scoreable) as "did not fire": a healthy decline became a true negative and a faulted one a miss.duct_static_driftdeclines all six LBNL cases and was reported as FPR 0.0 / specificity it never measured. Declines are now excluded and counted inDetectorScore.n_declined; an unmeasured FPR is omitted, not 0.0. Two plumbing tests had only passed because of this and now test a detector that actually scores. - The real-data drift validation was scoring the wrong things — four bugs in the benchmark, one in
the library.
docs/VALIDATION.mdhad said "real TPR" since 0.64–0.66 without ever writing a number down; measured, every AHU and VAV drift detector scored 0. Each zero traced to a defect: - The fan-powered-box mapping pointed at a healthy box. Its comment said the faults are imposed
on the West zone (
_W); diffing each faulted run against the fault-free one shows they are on the South box (_S). Remapped:vav_airflow_drift0/4 → 3/3,vav_reheat_valve_drift0/5 → 1/2, no false positives. - Valves and dampers were read from their measured positions where the datasets also carry the
controller demand (
*_DM). CAMBER's rules read these roles as commands, and a stuck device's position is exactly what hides its fault: against its position a stuck damper's OA fraction matches, soeconomizer_damper_driftscored 0/4; against the command it scores 4/4 (11–17σ). All four LBNL mappings now use the demand columns; the main per-family scores are unchanged. - Positives contradicted the detectors' documented design. The coil-valve, reheat-valve and
VAV-airflow drift detectors are one-sided up — the controller opening further for the same duty.
A leaking or stuck-open valve does the opposite (the demand falls), yet it was listed as their
target. Those faults are now cross-negatives, chosen from each rule's docstring rather than its
results;
coil_valve_driftbecomes specificity-only (its targets aren't in the set) with 1 false alarm in 6 (a stuck-open OA damper's latent load, a confound the rule documents). - The chiller dataset's wet-bulb and dry-bulb labels are swapped.
OA_TEMP_WBexceedsOA_TEMPin 97% of rows, and read as labelled the tower would cool water below wet-bulb half the time; read the other way its approach is 5.9–12.5 °F and never below. Remapped. - Library bug:
TOWER_FAN_SPEED(andHW_PUMP_SPEEDand the three humidity roles) were never rescaled from 0–1.camber.units.PERCENT_ROLESmissed them though the sensor-health bounds treat them as percent, so a BAS trending tower fan speed as a fraction never clearedcooling_tower_approach's "fan > 5%" gate and the rule never ran — on any site, not just here. A test now holdsPERCENT_ROLESin step with the bounds. Running at last, the tower rule fired on every condenser-bypass-valve run — see the next entry. What is left is genuine: severe reheat-coil fouling leaves no trace in the box's trended points, a high-reading airflow sensor is out of a one-sided detector's scope, and duct-static drift still declines every case. cooling_tower_approachcalled a healthy tower fouled whenever it held a minimum condenser-water temperature. Plants keep condenser water above a floor (~60 °F) in cold weather, so the tower deliberately leaves water far above wet-bulb + design with its fan idling — a high approach that is correct control. The rule judged every fan-running hour, so it fired on all five LBNL condenser-bypass-valve runs (a leaking bypass keeps the tower running all winter at its floor, holding its setpoint to 0.0 °F with the fan at minimum). Approach is now judged only at high fan effort (min_effort_pct=90), the CTI basis for a tower's capability; a tower that never reaches it declines. Without a trended fan speed the old gate applies, with a caveat. The LBNL result: TPR 0/3, FPR 0/2, 6 declined (was FPR 4/7). Fouling is visible at high fan (20% of hours above design + 3 °F at 65% capacity vs 0% healthy) but the median-based severity staysok— not tuned here, because tuning a threshold on the validation set would only fit it.score_chilleralso stopped scoring declined runs as negatives, the bug fixed indriftvalidationabove.RELEASING.mdgains the two lessons of the 0.80/0.81 release: push stacked tags oldest-first and wait for eachimagejob (:latestotherwise goes to whichever finishes last), and review the conda-forge autotick PR's dependencies.deploy/conda/recipe.yamlis now documented as a mirror of the feedstock's recipe and synced to 0.81.0.
Notes
- Validated on simulation and on open real-building data — see the real-data pass below and
docs/VENTILATION.md#validation-and-limits. No labeled DCV-fault dataset exists, so detection is established on one lab room with a known DCV law, not a sample. - Pearson correlation was not the main defect. In simulation an integral loop still correlates at 0.48–0.66 once the economizer and closures are excluded. The economizer, closed-damper samples and outliers were what broke the old check.
- Without
ECON_CMD, expectinsufficientmore often. The OAT-only fallback discards all mild weather. That is the honest answer. - A fixed OA damper on a variable-speed fan moves OA flow with fan speed, which tends to follow occupancy; that can mimic DCV and is not detected.
- The system-level ASHRAE 62.1 VRP (the shipped check compares an air handler's OA to one zone's requirement) is next.
- Six new public names →
tests/public_api_snapshot.jsonregenerated. No new dependency.
[0.81.0] — 2026-09-13
Hourly NMEC, and three documented claims that were false. 0.80.0 fixed the savings-uncertainty kernel; that was the real blocker for an hourly path. This wires one up — and fixes the claims that said it already existed.
Fixed
docs/VALIDATION.mdsaid "Change-point / TOWT models report … fractional savings uncertainty with every saving". False for the TOWT half: no code path anywhere could produce an FSU from a TOWT model.TOWTModel.predict(index, temp)takes two arguments while all five savings consumers callpredict(T)and coerce withnp.asarray(..., dtype=float)first, so the timestamps could not be passed at all.camber/mandv/normalized.pyclaimed it "operates on any model with apredict(temps)method (e.g. a change-point or TOWT model)". Passing a TOWT model raisedTypeError. No test covered it, so nothing caught it.docs/MANDV.mdequatedmandv.towtwith CalTRACK Hourly. It is a different estimator; the page now carries the differences table.TOWTModel.predictsilently produced a wrong baseline on unseen hours. The one-hot design has a column per hour-of-week bin observed at fit, so a bin the baseline never saw yielded an all-zero row and the prediction collapsed to the temperature term. Measured: a weekday-only fit projected onto a weekend predicts −1.2 where the truth is 40 — a negative baseline, which reads downstream as a large negative saving, with no error and no NaN. It now raises, naming the bins and the row count. Returning NaN was rejected: those hours would vanish from a savings sum without saying so.- The daily
caltrack_savingsjudged its fit by the 0.20 default rather thancv_rmse_max_for("daily")= 0.30, then discarded the verdict entirely. Now judged against the right gate and surfaced asbaseline_accepted/cv_rmse_max.
Added
caltrack_savings_hourly→HourlyNMECResult: hourly NMEC / IPMVP Option-C on a TOWT baseline, reusingintervalfit.hourly_energy_vs_temp(which had zero production callers until now) and 0.80.0's corrected kernel, ρ estimator and df-aware t-table.towt.TOWTAtIndex— binds a reporting index so a TOWT model satisfies the one-argumentpredict(temp)contract, unblocking all five consumers with no signature change to any of them.TOWTModel.covers(index)— ask before projecting.
Notes
- Sufficiency is coverage, not row count. A TOWT design has a column per hour-of-week bin, so a
bin seen once is a fitted level carrying no information.
fit_towt's own 50-observation guard cannot be the constraint — 50 hours cannot populate 168 bins.min_hoursandmin_obs_per_binmust both pass. - Expect a band comparable to the daily method, not tighter. Hourly residuals are strongly serially correlated. Measured on a 20-week synthetic: n=3360 hours → n_eff=282 at ρ=0.85, and the band goes 1.6% (ρ=0) → 9.7% (ρ=0.85). More data at a finer interval does not buy proportionally more certainty; a band that ignored this would be the overconfident one.
- Non-routine screening is day-level. Hourly residuals are heavier-tailed, and trimming individual hours on residual magnitude would bias CV(RMSE) down and so narrow the band — the dishonest direction. Whole days are excluded or none.
- Not CalTRACK Hourly, and does not claim to be. That specification prescribes per-calendar-month segmented models, six fixed temperature bin edges and residual-derived occupancy; CAMBER's TOWT is pooled, quantile-spaced and load-median. Same posture the daily method already takes.
- Three new public names →
tests/public_api_snapshot.jsonregenerated. No new dependency.
[0.80.0] — 2026-09-13
Every fractional savings uncertainty CAMBER has ever reported was wrong. The ASHRAE G14 Annex-B
bracket was transcribed with n′ where the published form has n/n′. One substitution, two
defects, three copies.
The band was √n too wide. At ρ=0 the bracket became (n/m)(1+2/n) instead of (1+2/n)/m — a
factor of n under the root. On a near-perfect fit (CV(RMSE) 2.87%, 365 baseline days, 90 reporting
days, clean 20% saving) CAMBER reported 19.9% ± 60.4%; the published form gives ± 3.1%. The
ratio is 19.105 against √365 = 19.105. A textbook-quality M&V result was being reported as
statistically unusable.
And the autocorrelation correction ran backwards. (n′/m)(1+2/n′) collapses algebraically to
(n′+2)/m, strictly increasing in n′ — so passing rho, documented as
"autocorrelation-adjusted", made the band narrower. Nothing ever passed it, which is the only
reason this did not compound: caltrack_savings and isolation_savings had no rho parameter at
all, and the one site that could pass it forwarded its own 0.0 default.
Fixed
mandv.stats.avoided_energy_savingsnow computest·1.26·CV·√((n/n′)·(1+2/n)·(1/m))/F, with(n/n′)written as a literal factor so the expression reads term-for-term against the published one. More autocorrelation now correctly widens the band.mandv.normalizedhad a second, independent copy of the same bracket with norhohook at all;mandv.rc_model(Option D) imported it and called it withn == m, which collapsed the bracket to ≈1 and left Option D's band with no sample-size content whatsoever. Both now use a shared kernel.- The t-table silently substituted.
stats.pyheld three confidence levels and fell back to 1.645 for anything else, soconfidence=0.99quietly returned a 90% band;normalized.pyhad a different five-entry table. Now one df-aware table that raisesValueErroron an unsupported level. (A monthly model at df=7 needs t=1.895, not 1.645 — a 15% understatement in the dishonest direction.)
Added
mandv.stats.lag1_autocorrelation— the ρ the correction needs, estimated from residuals. ReturnsNone, never0.0, when it cannot be estimated:0.0asserts independence nobody tested. Non-finite residuals invalidate the pairs on both sides; pass atime_indexand only pairs one modal interval apart are admitted, so a gap never glues its neighbours together. Negative estimates are clamped to 0 — a noisy negative would narrow the band.FitStats.rho_lag1and a keyword-onlyfit_stats(..., time_index=). Required rather than cosmetic:fit_statsdrops non-finite rows, which compresses the array and would otherwise make non-adjacent residuals look neighbouring.caltrack_savingsestimates ρ from its baseline residuals and applies it automatically;SavingsResultgainsrho,n_effectiveandfsu_autocorrelation_adjustedso a reader can tell an adjusted band from an unadjusted one.NMECResultgainsbaseline_rho.examples/bdg2now reports the measured lag-1 residual autocorrelation across ~2,044 real meters (rho_metrics: median, p10/p90, fraction above 0.3). Reported and archived but deliberately ungated — a descriptive distribution is not a pass/fail quantity.
Notes
- Two kernels, and the boundary is physical, not modular.
measured − projectedcarries the reporting period's residual noise, which averages down overm.projected − projected(NAC, Option D) contains no measured energy — only parameter error, shared by every projected period — so its band does not depend on how many periods you project onto. Measured directly, the spread of a projected total is flat across a 730× range inm. An earlier draft of this fix used one shared kernel; that would have been ~4× too narrow at 8760 hourly periods, replacing a conservative error with an overconfident one in a documented case. - Different provenance, stated. The measured kernel is the published G14 expression,
1.26and all. The projected kernel isCV·√(p/n)— plain OLS average leverage, citable as regression theory. G14's empirical constant is deliberately not carried across to a case it was never derived for. - Verified two ways, neither of them the paywalled standard: reconstruction from Reddy & Claridge (2000) as reproduced in the public BPA/LBNL/NYSERDA M&V guides, and an independent Monte Carlo of AR(1)-residual fits. Consistent with the project's clean-room rule; say so rather than implying the standard was read.
- Why the tests did not catch it: every existing uncertainty assertion was
> 0,isfinite, or monotonic in the savings fraction. All of them pass both before and after a 19× correction. They are replaced with pinned magnitudes, a pinned ρ direction, an m-scaling test, and a test that the projected kernel is invariant to the projection length. - Out of scope and named: the Sun & Baltazar polynomial refinement of
1.26; autocorrelation in the CUSUM/online control limits (a different literature); an exact design-matrix projection uncertainty to replace the√(p/n)approximation. - One new public name →
tests/public_api_snapshot.jsonregenerated. No new dependency.
[0.79.0] — 2026-09-09
A healthy duty-cycled point could silently switch a diagnostic off. ingest.quality.assess
assumes a series is one population, so an intermittent signal — an HHW BTU meter near zero except
during heating events, a lead pump, a 0/1 status — has every legitimate burst counted as an outlier.
That score is the trust score (sensorhealth.py: trust = q.score * (1 - rng_pen)), and a role
below trust_gate.min_trust makes a rule decline to fire. So the layer built to prevent false
negatives was manufacturing them.
Measured on a healthy synthetic meter (720 hourly samples, no faults injected), the score was not
merely low but chaotic — 0.166 to 0.998 with no monotonic relation to duty cycle, because
_mad_z switches between its MAD branch and its meanAD fallback as the median crosses into the "on"
band. Four of seven duty cycles landed under 0.5. A building's lead pump and its standby pump could
score 0.166 and 0.998 on identical health. Five rules take a status role as required
(boiler_summer_lockout, boiler_short_cycle, compressor_short_cycle, compressor_staging,
hw_plant_deltat), so the reach went well past BTU meters.
Fixed
assessnow reads two-regime structure and reports the outlier count judged within each regime. A duty-cycled point scores ~0.998 across the whole 0.10–0.80 duty range instead of swinging 0.166–0.998, and nothing in that sweep is gated atmin_trust=0.5.sensorhealth.sensor_trustopts in per role via a new_INTERMITTENT_ROLESallow-list (BTU/flow/ airflow/power/stage roles, unioned withSTATUS_ROLES), mirroring the existing_SENSOR_ROLESprecedent. An HHW meter goes0.741 / suspect / ["outliers"]→0.998 / trusted / ["intermittent"].
Added
QualityReport.n_regimes/regime_threshold/n_regime_outliers/regime_outlier_frac, tri-state per the honesty convention:Nonemeans the split could not be tested, never "no split found".assess(..., *, regime_aware=False)— keyword-only and defaulted, so the stable import path is unchanged.- Two
sensor_trustflags:intermittent(an expected duty cycle; explains why outliers were read within-regime) andbimodal(a point that should have one population has two — new information the tool did not previously surface, carrying no penalty). - An opt-in
regime_outlier_fracmetric in the pattern-I quality dashboard, kept out of the default set so existing dashboards are unchanged; a metric that cannot be computed now renders blank rather than raising.
Notes
- A split is claimed only on mass and separation and temporal coherence. Mass is what keeps
spike detection intact — a lone spike is ~5% of the samples and never qualifies, so
[10.0]*20 + [10000.0]still flags exactly one point. Coherence is what stops the fix becoming a masking bug: a flow meter randomly railed to zero for 30% of the record has median run length 1, is refused a split, and staysuntrustedat 0.397. A real duty cycle persists (4–48 samples). - The pooled
n_outliers/outlier_fracnever change meaning and are never masked — that is what keeps a two-regime read visible next to the plain one. regime_awareis off by default and enabled per role, because scoring a duty cycle as normal is the direction that could hide a fault; a role-blind caller therefore cannot mask anything. Flipping the default is a follow-up once there is field evidence.- Deferred, and pinned by tests so they stay visible:
anomaly.detect_anomaliesstill returnsfaulton a healthy intermittent meter — it is role-blind and its point test independently counts every burst, so fixing the quality door alone does not fix it. An in-range sensor railing between two plausible values in long blocks still scores ~0.99 (longest_flatlinemeasures the longest single run); this change neither creates nor closes that gap, butn_regimesnow makes the structure visible. A contiguous rail on an allow-listed role remains undecidable from values alone — the house answer is to corroborate with a mapped status point. - No new dependency; no public-API surface change (the new names are private, the new fields are
dataclass attributes, the new parameter is keyword-only) —
tests/public_api_snapshot.jsonis unchanged andtests/test_public_api.pypasses unmodified. - Packaging, landed after the tag was cut:
deploy/conda/recipe.yamland the conda-forge submission (conda-forge/staged-recipes#34742) were retargeted from 0.74.1 to 0.79.0, with the sdistsha256verified by hashing the published artifact rather than reading the PyPI API. Runtime dependencies are unchanged across 0.75–0.79, so only the version, hash and documentation URL moved;linux_64/osx_64/win_64all build green. The recipe'sdocumentationnow points at the MkDocs site instead ofgithub.com/.../blob/main/docs— the old link worked (it 301s to/tree/) but it is a file browser that also surfacesdocs/dev/anddocs/proposals/, internal notes the docs site deliberately excludes. - Release-process fix:
camber/__init__.pyhad been left at0.74.1whilepyproject.tomladvanced through 0.75–0.79, so every commit in the stack was internally inconsistent. Caught during the pre-release checks and corrected before any tag was pushed;tests/test_public_api.pynow fails if the two ever drift again.
[0.78.0] — 2026-09-08
Every drift finding now renders its own evidence. 0.77.0 built fitted_band — a baseline's own
fitted line as a plottable band — but nothing was connected to it: of the ~20 drift rules, none
had an evidence() hook, so pattern J fell back to a default multitrend of the raw roles. For a
drift detector that default is actively misleading: it shows the levels and hides the movement,
which is the only thing the rule claims.
Added
camber.charts.evidence.drift_evidence(rule, equip, frame)— builds the chart a drift rule actually reasons about: the current period scattered on its frozen baseline's band. Duck-typed over two new methods every drift rule now declares —drift_signature() -> (kind, load_col, metric_col)anddrift_frame(frame)— so one implementation serves the whole family and a new detector opts in by declaring them.Evidence.frame— a prepared frame to render instead of the caller's role-frame. Most drift baselines are fitted on derived columns (a chiller'stons, a coil's air-ΔT) that no raw role-frame carries, so the rule hands over the frame it actually fitted.camber drift report --charts— embeds those charts in the drift page, andrun_drift(..., evidence=True)/run_drift_config(..., evidence=True)build them. Off by default: it re-resolves each equipment's current window, which a scoring run doesn't need, and the plain page stays pure text and tables with no matplotlib import.finding_evidencetries the drift band before the default trend, so the dashboard picks it up too.
Fixed
fitted_bandwas never exported fromcamber.charts(0.77.0 added it tocamber.charts.diagnosticonly, unlike its four sibling constructors). Now exported.- Three rules unwrapped their prepared frame wrongly —
economizer_damper_drift,vav_airflow_driftandvav_reheat_valve_driftcall a_prepared()that returns(frame, excluded_fraction), and took the tuple. Found by an AST check across all 20 rules after the first one showed up as a silently missing chart, then pinned by a per-family coverage test.
Notes
- A rule with nothing frozen produces no chart, deliberately. A scatter with no band invites the reader to judge the cloud by eye — precisely the comparison the frozen baseline exists to make.
- The charts separate the cases rather than decorating the verdict: on the
examples/driftsite an injected loading filter reads 100% of the period outside the band (and fan power 93%, the corroboration the roll-up reports), while the healthy unit sits at 3–6% — about what you expect outside ±2σ. - Two new public names →
tests/public_api_snapshot.jsonregenerated. No new dependency; matplotlib stays lazy-imported and is not touched unless--chartsis passed.
[0.77.0] — 2026-09-08
A drift baseline becomes something you can plot. The drift detectors score residuals against a
frozen, load-normalized line — but that line had no renderer, so the comparison stayed inside the
rule and every drift finding's evidence had to be described rather than shown. The chiller
drift-detection plan called for a fitted_band constructor and it was the one line item never built.
Added
camber.charts.diagnostic.fitted_band(baseline, x, y, *, k=2.0, within_envelope=True, …)— a pattern-GDiagnosticTemplatewhose expected band is a frozenLoadBaseline's own fitted line ±kresidual sigmas. Rebuild a model from the baseline store, hand it todiagnostic_scatter, and the current period is plotted against exactly what the detector compares it to.
Notes
- Every other template constructor here encodes a band someone designed (a reset schedule, a high limit); this is the first that encodes one the equipment earned.
- Outside the fitted load envelope the band is
NaN. Two things follow, both deliberate: the shaded region shows a visible gap where there is no claim, and a point out there is not counted as a violation — judging a reading against an extrapolated fit is the same asserted negative the drift rules refuse to make.within_envelope=Falseopts into extrapolating. kis a band width, not a severity threshold: the detectors' own sigma floors decide what warns or faults, and those remain screening-grade (camber.driftthresholds).- One new public name →
tests/public_api_snapshot.jsonregenerated. No new dependency; matplotlib stays lazy-imported.
[0.76.0] — 2026-09-08
Moving a drift baseline becomes a signed operator decision you can run. 0.75.0 made the drift
family reachable and gave it a create path (camber drift freeze); the reference could still only
be moved from Python. This adds the second write verb and the runnable walkthrough that shows why
there are two.
Added
camber drift accept <config> --equip EQ --by NAME --reason TEXT— supersede a frozen baseline with one re-fit over an acceptance window.--byand--reasonarerequired=Trueat the argparse level, so the command exits 2 before any code runs without them (BaselineStore.accept_new_normalre-rejects an empty one as the backstop).--equipis repeatable and required — there is no blanket "accept everything".--kindnarrows further,--period START ENDpicks the re-fit window (default: the config'sdrift.current, because accepting a new normal means what it is doing now is the reference), and--dry-runshows the moves without writing. Printsfrozen_at <old> -> <new> (supersedes …, history now N)per record.camber.driftrun.refit_baselinesandaccept_new_normal_from_periods, plusconfig.drift_refitwhich re-fits every configured family over one window.examples/drift/— a data-free, network-free walkthrough (make_data.py+mapping.json+config.json+ README) over three air handlers: one with an injected loading filter, one healthy, and one whose points never resolve. It runs the whole loopfreeze → run → accept → runand ends with the drift cleared, which is the point of the third verb.
Notes
- The re-fit is the detector's own. Rather than reimplementing each rule's fit,
refit_baselinesruns the family against a scratch in-memoryBaselineStorewithfreeze_if_missing=Trueand harvests what it froze. Every model therefore comes from the rule it will later be compared against — same metric and load columns, same minimum-load filter, plausibility bounds and running-status gate — and cannot drift out of sync with it. A detector that cannot fit over the window produces no entry, andacceptsays so (could not refit … — leaving it frozen) instead of substituting something. - No new dependency; four new public names →
tests/public_api_snapshot.jsonregenerated.
[0.75.0] — 2026-09-08
The drift family becomes reachable without writing Python. Releases 0.40–0.67 built ~20 drift
detectors across six families (AHU air-side, chiller, condenser, evaporator, pump, VAV) with
per-family roll-ups and HTML tables — but they are PeriodRules, so they never appeared in the
single-frame builtin_registry() that config-driven runs and the CLI use. There was no drift
config section and no camber drift subcommand: the largest capability in the toolkit could only be
driven from a script. This adds the missing layer — equipment discovery → period slicing → suite →
roll-up → output — plus the baseline lifecycle the comparison depends on.
Added
camber.driftrun— the family runner and the single source of truth for suite membership.DRIFT_FAMILIES/DriftFamily/family_names(),build_drift_suite(family, store, …), andrun_drift(refs_by_class, mapping, *, store, families, baseline, current, …)→DriftResult/DriftFamilyResult. The sixbuild_*_suitehelpers in the*simmodules now delegate here, so the production path and the physics-validation path cannot diverge (a parity test pins the exact classes and ordering each one returns).- A
driftsection in the JSON config —store/baseline/current/run_idplus afamilieslist (class,family, and the optionalcoils/plant/sustained_alarmand per-family window overrides).camber runscores drift alongside the ordinary rules and folds the verdicts, caveats and tables into the audit report. Newconfig.run_drift_configandconfig.drift_store_pathrun or locate just the drift half. camber drift run | report | freeze | list— score a current window against the frozen baselines, write the standalone HTML page, establish missing references (--dry-runshows what it would do), and inspect what is frozen and on whose say-so (--equip/--kind/--json).camber.report.drift_report_html— one page composing every family's existing verdict table, the plant roll-up, the threshold-confidence banner, and an Equipment not evaluated table. Plusthreshold_confidence_html().
Fixed
- Scorecard category parity. Twenty-eight shipped rules had no
RULE_CATEGORYentry and fell through to"other"— including four drift rules (chiller_superheat_drift,chiller_suction_pressure_drift,chiller_head_pressure_drift,cooling_tower_approach_drift) whose four siblings were mapped to"maintenance", so half the chiller drift family scored outside the maintenance grade. All are now categorized (the drift family asmaintenance, the G36 Trim-&-Respond reset family asenergy), and a new parity test fails if any shipped rule is added without one.
Notes
- The write policy is a verb, not a setting. Every drift rule defaults
freeze_if_missing=Trueand writes the reference inline, so a scheduled run would quietly mint baselines from whatever window the config called "baseline" — circular by construction.run/reportand the config section always passFalseand never save; onlyfreezecreates, and it still refuses to overwrite. Moving a reference remainsBaselineStore.accept_new_normal's attributed decision. - Untested is not steady. Two paths leave an equipment unscored — no required role resolved, or
every detector declined (nothing frozen, an untrusted input, an empty window) — and both would
roll up to
severity="ok",locus="steady", asserting a negative nobody tested. Neither is diagnosed now: the equipment is listed underunevaluatedwith the reason, in the terminal, indrift.jsonand in the HTML, and the no-role case also emits aninfoFinding so it reaches the audit report's caveats. - Screening-grade, un-suppressibly. Every drift command prints and every drift page renders the
two-class threshold note from
camber.driftthresholds;drift.jsoncarries the block. There is no flag to hide it. - Two same-named
coil_valve_driftinstances (cooling + heating) cannot share oneRegistry, which keys onrule.name— each rule runs through its own one-entry scratch registry, reusing all ofrun_periodsunchanged. No change tocamber/rules/base.py. - Six new public names across two new modules →
tests/public_api_snapshot.jsonregenerated. No new dependency (stdlibjson/argparse/html+ the existing numeric stack).
[0.74.1] — 2026-09-08
Fix: camber CLI crashed on a legacy Windows (cp1252) console. The help text and finding
summaries contain non-ASCII characters (—, °, →); on a non-UTF-8 console, argparse writing
--help (which lists the edge subcommand, whose help contains →) raised UnicodeEncodeError.
main() now reconfigures stdout/stderr to UTF-8 (with a backslashreplace fallback) before
parsing, so every CLI code path is encodable on any platform.
Fixed
- Windows CLI portability —
camber --helpand all CLI output no longer crash on acp1252console. Caught by the conda-forgewin_64build; regression-tested against a simulated cp1252 stdout intests/test_cli.py.
Notes
- Patch release: no public-API change (
_ensure_utf8_streamsis private) → snapshot unchanged, no new dependency. Thedeploy/conda/recipe.yamland the conda-forge submission bump to 0.74.1.
[0.74.0] — 2026-08-30
A second weather provider: real NOAA weather stations. NASA POWER (0.70+) is a global ~50 km
reanalysis grid; this adds NOAA's Integrated Surface Database (ISD-Lite) — also keyless — so you
can pull from a real weather station near the site. oat_reference_isd finds the nearest station
covering your window and returns the identical °F oat_f Series the weather-normalization and
OAT-sensor-validation consumers already accept, so it drops into sensordrift.compare_to_reference
and mandv.weather unchanged.
Added
oat_reference_isd(lat, lon, start, end, *, transport, catalog_transport, tz, timeout)— the station-precise counterpart tooat_reference: resolves the nearest covering station, fetches its hourly °F, and attaches the resolved station toseries.attrs["isd_station"].isd_nearest_station(lat, lon, start, end, *, transport, stations, timeout)— haversine-nearest station filtered to those whose record spans the window (skips decommissioned / not-yet-begun); raises a clearValueErrorwhen no station covers it.isd_stations(*, transport, timeout)→list[IsdStation](the parsed catalog; null-island0.000/0.000rows skipped) andfetch_isd(usaf, wban, start, end, *, transport, tz, timeout, dew_point)→ aDataFrameof one station's hourly °F (per-year gz files concatenated to a unique, sorted index; tenths-of-°C → °F;-9999→NaN; optionaldewpt_f).IsdStation(frozen value,.as_dict()),isd_transport(*, timeout)(the default stdlib bytes transport), andcached_bytes_transport(inner, cache_dir, *, ttl, clock)— the bytes sibling ofcached_transport(ISD payloads are gzipped/CSV, not JSON), memoizing to<cache_dir>/<sha256(url)>.binwith the same atomic-write / self-healing / injectable-clock TTL behavior.
Changed
docs/WEATHER.mdbecomes a two-provider guide (NASA POWER + NOAA/ISD), with a "NOAA/ISD station data" section on the station-precise-but-gappy trade-off, endpoints, tz caveat, and bytes caching.docs/CAPABILITIES.mdand the mkdocs nav name both providers.
Notes
- Seven new public names under
camber.weather_source→tests/public_api_snapshot.jsonregenerated. No new runtime dependency (stdliburllib/gzip/csv/math+ pandas). ISD is a second transport seam (bytes, vs. the NASA path's JSON) — both offline-injectable, so every parse / nearest-station / tz / missing-value / cache path is tested with no network. Honest trade-off: ISD is station-precise but gappy and sparse (no station near remote sites); NASA POWER stays the choice for global coverage and a gap-free series. They complement.
[0.73.0] — 2026-08-25
Fetch weather by address, not just coordinates. Adds a keyless geocoder to camber.weather_source
so you can specify a location by place name; NASA POWER is a lat/lon point query, so an address is
geocoded to coordinates first.
Added
geocode(address, *, transport, limit, user_agent, timeout)→ a frozenGeoResult(latitude, longitude, display_name)(the display name lets you confirm the match) via OpenStreetMap Nominatim — also free and keyless. Plusnominatim_url(...)(pure builder) andnominatim_transport(...)(default stdlib transport that sends the policy-requiredUser-Agent).oat_reference_for(address, start, end, *, tz, geocode_transport, transport, ...)— a convenience that geocodes then fetches, returning the same °F OAT Series asoat_reference(with the resolved place onseries.attrs["geocode"]). Two transport seams, both offline-injectable.
Changed
docs/WEATHER.mdgains a "Geocoding — fetch by address" section (the keyless/User-Agent/rate-limit policy, the ~50 km-grid precision honesty, and the still-explicittzcaveat).
Notes
- New public names (
GeoResult,geocode,nominatim_url,nominatim_transport,oat_reference_for) →tests/public_api_snapshot.jsonregenerated. No new runtime dependency (stdliburllib/json).geocodecomposes withcached_transport(satisfies Nominatim's cache-your-lookups policy).tzis not derived from the address — still an explicit param.
[0.72.0] — 2026-08-25
A live web dashboard served by the read-only API. The self-contained HTML dashboard is a one-shot
snapshot; this adds its live counterpart — a framework-free page that fetches the running store and
polls, so views refresh as new data lands. Bridges the two halves that already existed (the stdlib
read-only API and the window.CAMBER cross-panel selection bus) with no new dependency.
Added
camber.api.ui.live_dashboard_html()+ aGET /uiroute onReadAPIHandler— a single vanilla-JS page (inline JS/SVG, no framework, no CDN) that fetches/facilities//points//historysame-origin, renders facility/equip/role selectors + a synchronized multitrend, brush-links via the shippedwindow.CAMBERbus, and polls (default 15 s, adjustable, with a Live toggle). The JSON endpoints are unchanged; the HTML route carries a strict same-originContent-Security-Policy.camber serve <store> [--host --port]CLI verb — starts the read-only API + live/ui.
Changed
dispatchnow returns HTML (astr) for/uiand JSON (adict) for every other route; the handler branches content-type accordingly.docs/VISUALIZATION.md,CAPABILITIES.md,CLI.md, andSECURITY.mddocument the live UI + its read-only/localhost/CSP posture.
Notes
- Read-only (GET-only) and
127.0.0.1-bound by default; no auth (documented). New public modulecamber.api.ui→tests/public_api_snapshot.jsonregenerated. No new runtime dependency (stdlibhttp.server+ vanilla JS). A live carpet panel + agent narration remain nice-to-haves.
[0.71.0] — 2026-08-25
Complete the weather-fetch adapter: multi-year requests + an on-disk cache. Rounds out
camber.weather_source (0.70) with the two pieces its docs flagged as deferred, staying entirely
within the stdlib-only, injectable-transport, offline-testable design.
Added
- Transparent multi-year chunking in
fetch_nasa_power— NASA POWER caps a single hourly request at ~1 year, so the request is split into calendar-year chunks (one call per year) and concatenated into a single unique, sorted hourly index. A multi-year request just works; the public signature is unchanged. Calendar-year seams share no day, so no hour is duplicated or dropped. camber.weather_source.cached_transport(inner, cache_dir, *, ttl, clock)— a dependency-light on-disk cache decorator that composes with any transport (default or test double). Memoizes each URL's parsed JSON to<cache_dir>/<sha256(url)>.jsonwith an atomic write; corrupt files self-heal; cache-forever by default (historical reanalysis is stable) with an optionalttlfor revised recent months;clockis injected so TTL expiry is deterministic in tests.
Changed
docs/WEATHER.mddocuments multi-year requests + the on-disk cache (drops the two "not yet here" notes; NOAA/ISD station ingest remains a deferred future arc).
Notes
- New public name
cached_transport→tests/public_api_snapshot.jsonregenerated. No new runtime dependency (stdliburllib/json/hashlib/os+ pandas). Existing 0.70 API + tests unchanged.
[0.70.0] — 2026-08-25
A live weather-fetch adapter (NASA POWER). CAMBER could weather-normalize M&V and validate an OAT sensor against an external reference, but only from a local EPW/TMY file or a series you brought yourself. This fetches one — hourly historical temperature (and optional RH) from NASA POWER, a free, keyless, global reanalysis service — in the exact °F Series shape those consumers already accept.
Added
camber.weather_source—oat_reference(lat, lon, start, end, ...)→ a °F OAT Series (name="oat_f", matchingmandv.weather.load_epw) that drops straight intosensordrift.compare_to_referenceand M&V normalization;fetch_nasa_power(...)→ a DataFrame (oat_f+ optionalrh_pct);nasa_power_url(...)(pure URL builder) andnasa_power_transport(...)(the default stdlib-urllibtransport). Dependency-light with an injectable transport (mirrorsingest.haystack.http_json_transport), so every parse/unit/timezone/fill path is tested offline.- Timezone handling is explicit (
tz="UTC"tz-aware, or a site IANA zone → DST-correct naive-local that inner-joins to a BAS trend) — the one thing a naive fetch gets silently wrong, so it's tested for the exact UTC→local hour mapping. NASA's-999fill becomesNaN, not a bogus-999 °C.
Changed
- New
docs/WEATHER.md(+ nav);docs/MANDV.md,docs/CAPABILITIES.md,ROADMAP.mdreference it.
Notes
- New public module
camber.weather_source→tests/public_api_snapshot.jsonregenerated. No new runtime dependency (stdliburllib/json+ pandas). No API key needed. On-disk caching, NOAA-station ingest, and multi-year chunking are noted as future work.
[0.69.0] — 2026-08-24
Option-D depth — a 2R2C thermal-mass grey-box, multi-zone calibration, and an EnergyPlus
cross-validator. Extends IPMVP Option-D (camber.mandv.rc_model) beyond the 1R1C core, each addition
preserving its honesty invariant: grid the nonlinear time-constant(s), OLS the linear
conductances/gains, gate on ASHRAE G14, no scipy. The 1R1C path is unchanged.
Added
- 2R2C —
RC2Model(ua_env, uc_mass, gain_eff, tau_air, tau_mass, w)+calibrate2(...): a slow thermal-mass node coupled to the air node, capturing the post-re-entry recovery tail a singletaucan't. Grids(tau_air, tau_mass, w)and OLS-fits the conductances, withp=6in the G14 gate. On mass-dominated data it beats 1R1C on CV(RMSE) (the test that earns the complexity). - Multi-zone —
calibrate_zones(oat, schedules, metered_energy, *, order=1|2)+MultiZoneModel/ZoneModel: several zones whose hourly predictions sum to the whole-building meter, calibrated by stacking their basis columns into one OLS. Reusesoption_d_savingsunchanged (pass per-zone schedule dicts). Candid about the whole-building split being under-determined without differing schedules or sub-metering. camber.interop.energyplus.compare_option_d([energyplus]extra,eppy) — runs a user-supplied IDF under as-found/as-corrected control and diffs its avoided energy against the grey-box saving, returning anagreementblock. The runner is injectable, so the compare logic is fully tested without the E+ engine (own-it-then-cross-check, like the pvlib/BETTER bridges).
Changed
docs/OPTION-D.mdgains 2R2C / multi-zone / EnergyPlus sections (dropping the "out of scope" note);docs/MANDV.md,docs/CAPABILITIES.md,ROADMAP.mdreference the depth.
Notes
- New public names (
RC2Model,calibrate2,ZoneModel,MultiZoneModel,calibrate_zones, and thecamber.interop.energyplusmodule) →tests/public_api_snapshot.jsonregenerated. The 2R2C + multi-zone core adds no runtime dependency (numpy only);[energyplus]is an optional extra.
[0.68.0] — 2026-08-24
A unified validation & credibility dossier + a camber validate command. CAMBER validates itself
four ways (synthetic whole-suite FDD, generated multi-zone fleet FDD, real-data FDD on LBNL, real-data
M&V on BDG2), each with its own benchmark and CI gate but no single artifact. This adds one — a
legible, sellable capstone across all four tracks, in text / self-contained HTML / JSON.
Added
camber/dossier.py—build_dossier()→ValidationDossier/TrackResult. It live-recomputes the two pure tracks (faultlab,fleetlab) on every run (deterministic, no download) and cites the two real-data tracks (LBNL FDD, BDG2 M&V) from committed reference figures with provenance + a reproduce command. Rates carry 95% Wilson intervals (camber.validation); each track shows a LIVE/CITED tag, coverage, and its honest boundary. The HTML is a single self-contained file (no external assets, pure-CSS CI bars,camber.reporttheme-safe style). No wall-clock timestamp — anchored on the package version, so builds are byte-identical.camber validateCLI verb —--html/--json/--full.tests/test_dossier.py— plumbing + two anti-rot cross-checks: the cited BDG2 figures must equal the committedexamples/bdg2/benchmark-baseline.jsonexactly, and the cited LBNL figures must match the pooled OA-fraction row indocs/VALIDATION.md— a drift fails CI.
Changed
docs/VALIDATION.mdgains a "The unified dossier —camber validate" section;docs/CLI.md,docs/CAPABILITIES.md, andROADMAP.mdreference the new verb.
Notes
- New public module
camber.dossier→tests/public_api_snapshot.jsonregenerated. No new runtime dependency (numpy/pandas + stdlib). Only CC-BY datasets already named in the docs are named.
[0.67.0] — 2026-08-24
Validation of the G36 reset fleet family on a generated labeled multi-zone fleet. Closes the one
unfilled validation gap docs/VALIDATION.md carried: the multi-zone rogue-zone census,
cohort-starvation, and reset-effectiveness detectors could never be accuracy-scored on public data (no
vendorable labeled multi-zone-fleet fault dataset exists), so they are now scored on a fleet
generated from the public ASHRAE Guideline 36 Trim-&-Respond logic — never copying any encumbered
simulation.
Added
camber/fleetlab.py— a clean-room generator + validation harness.generate_fleet(...)emits a physically-coherent labeled fleet (per-zone role-frames whose requests aggregate into the T&R reset viag36_reset.tr_simulate, a served-byTopology, and a ground-truthFleetLabel) with one injected fault: a rogue zone, a starved cohort, or an inert reset in one of four G36 failure modes.labeled_records/targets/attribution/coveragescore the six detectors (3 × SAT + static) withcamber.eval.benchmark, adding an attribution rate (did it name the right zone / air handler / failure mode?) on top of TPR/FPR, with genuine cross-archetype negatives.examples/fleet_fdd/benchmark.py+ committedbenchmark-baseline.json— the CI-gated runner (--json/--gate/--tol/--update-baseline), deterministic, no download.tests/test_fleetlab.py+tests/test_fleet_benchmark.py— always-run plumbing: generator determinism, label correctness, per-archetype firing + attribution, fault-free/cross-archetype quiet, and the runner reproducing its committed baseline attol 0.0.
Changed
docs/VALIDATION.md— the three*_reset_effectiveness/*_rogue_zone_census/*_cohort_starvation"synthetic-only (a real gap)" verdicts flip to scored, with a new fleet-validation section (mermaid, the attribution guard, the honest internal-validity framing, and the deferred Modelica Buildings cross-check — revised BSD-3, but needs a Modelica toolchain outside the dependency-light envelope).ROADMAP.mdticks the labeled-multi-zone-fleet item.
Notes
- New public module
camber.fleetlab→tests/public_api_snapshot.jsonregenerated. No new runtime dependency (numpy/pandas + stdlib). The encumbered ASHRAE chiller dataset is never named.
[0.66.0] — 2026-08-24
Real-data validation of the plant-level chiller detectors (LBNL chiller-plant subset). Wires the
CC-BY LBNL chiller-plant subset into the benchmark, turning the chiller chiller_efficiency /
cooling_tower_approach detectors from synthetic-only into real-data-scored — the open, simulated
chiller FDD source that sidesteps a licence-encumbered ASHRAE chiller-FDD dataset.
Added
examples/lbnl_fdd/fetch.py --chiller— fetches the LBNL chiller-plant subset (basename-matched extraction) +mapping_chiller.json(chiller-1 power + CHW loop and tower-1 supply-temp + wet-bulb → roles; the plant-level points the runnable detectors need).examples/lbnl_fdd/benchmark.pyscore_chiller— scoreschiller_efficiency(kW/ton; target = tower fouling / PID + three-way-bypass leak/stuck) andcooling_tower_approach(CW supply vs wet-bulb; target = tower fouling / PID) on the labeled physical faults. Because the simulated chiller/tower design curves aren't published, each detector's absolute design ceiling is calibrated from the plant's own fault-free run (commissioning practice) rather than guessed, so the informative number is the TPR on the faults; sensor-bias runs act as genuine negatives.tests/test_lbnl_benchmark.py— chiller calibrate-and-fire + metric-key + empty-baseline + registration plumbing tests on synthetic plant-shaped frames (always run).
Notes
- The subset is water-side only — it exports no refrigerant-side points (evaporator/condenser
approach, subcooling, superheat), so the refrigerant-side chiller-drift family stays synthetic-only.
Documented in
docs/VALIDATION.md; the encumbered ASHRAE chiller dataset is never named. - No
camber/change → public-API snapshot unchanged; new code isexamples/+ tests.
[0.65.0] — 2026-08-24
Real-data validation of the VAV zone-terminal drift family (LBNL Fan-Power-Unit subset). Wires the CC-BY LBNL FPU subset into the benchmark, turning two "synthetic-only" verdicts into real-data ones.
Added
examples/lbnl_fdd/fetch.py --fpu— fetches the LBNL Fan-Power-Unit subset (basename-matched extraction, robust to the archive layout) +mapping_fpu.json(the West-zone box + AHU points → roles; the West zone is the one faulted, so the other zones stay healthy).examples/lbnl_fdd/benchmark.pynow scoresvav_airflow_drift(target = damper-stuck + airflow-sensor-bias faults) andvav_reheat_valve_drift(target = reheat-valve leak/stuck + coil-fouling) on the FPU data viacamber.driftvalidation.evaluate(baseline = fault-free run, current = each labeled fault run).build_drift_cases/score_driftgeneralized to take a subset-specific detector set + baseline file + multiple positive-fault prefixes.tests/test_lbnl_benchmark.py— FPU case-builder + scorer plumbing tests on synthetic FPU-shaped frames (always run); real-data scoring runs in the benchmark CI job with the data.
Notes
- The LBNL chiller-plant subset is not yet wired here (its download path was still unresolved at this
release; resolved and wired in 0.66.0). Documented in
docs/VALIDATION.md. The encumbered ASHRAE chiller dataset is never named. - No
camber/change → public-API snapshot unchanged; new code isexamples/+ tests.
[0.64.0] — 2026-08-24
Real-data validation of the AHU air-side drift family (LBNL FDD). Extends the LBNL benchmark to score the newer drift detectors on labeled real faults, and documents — honestly — which new families the public datasets can and cannot validate.
Added
examples/lbnl_fdd/benchmark.pydrift-scoring path — scorescoil_valve_drift(target = coil leak, real TPR),economizer_damper_drift(target = stuck damper, real TPR with a command-vs-position caveat), andduct_static_drift(specificity only) on the LBNL SDAHU data viacamber.driftvalidation.evaluate(baseline = fault-free run, current = each labeled fault run). Thedrift.*metrics flow through the existing--json/--gate/--update-baselinepath; NaN metrics (a specificity-only detector has no recall) are omitted to keep the JSON valid.tests/test_lbnl_benchmark.py— locks the case-builder + scorer plumbing on synthetic SDAHU-shaped frames (always runs, no download); the real-data scoring test skips when the ~580 MB LBNL CSVs are absent (they run in the benchmark CI job).
Notes
- Honest feasibility matrix in
docs/VALIDATION.md: fan-efficiency / filter drift need points the SDAHU sim doesn't export (synthetic-only); chiller/pump/VAV drift are air-side-absent here; the reset-effectiveness detectors need the per-cycle reset-request point; and the multi-zone rogue-zone / cohort-starvation census have no vendorable public multi-zone-fleet fault dataset (a real gap) — all validated on the synthetic whole-suite harness (camber.faultlab) instead. - BDG2 is meter-level → M&V/anomaly only, not component FDD. Documents the open-licensed datasets that would close the gaps (CC-BY LBNL sibling subsets incl. the simulated chiller plant that sidesteps the encumbered ASHRAE chiller-FDD dataset; the CC-BY Korean office AHU set) and the ones to avoid (re-uploads of that encumbered chiller dataset; the CC-BY-NC-ND multi-zone VAV set — research-only, not vendorable).
- No
camber/change → public-API snapshot unchanged; all new code is inexamples/+ tests.
[0.63.0] — 2026-08-24
Fault economics for the fleet reset rules (rogue-zone census & cohort starvation). Completes the Trim-and-Respond reset family's dollar-impact coverage, with per-air-handler load attribution for the fleet-level findings.
Added
sat/static_rogue_zone_censuscost model → the whole air handler over-services because one zone drags the reset: avoidable reheat (SAT) / fan (static) scaled by the rogue's request share (worst_zone_share). Uncosted when the census is ungrouped (building-wide screening only) or the AHU sizing is missing.static_cohort_starvationcost model → sustained fan over-pressure from the upstream fault, scaled by the cohort's sustained fraction (worst_group_frac);sat_cohort_starvationis unmet-load/comfort → uncosted by design.<fleet>-finding load attribution — a new_resolve_load/_fleet_loadpath resolves sizing forequip="<fleet>"findings to the offending air handler named in the metrics (worst_group, or the group holdingworst_zone), so a{equip: EquipmentLoad}map keyed by AHU costs each handler on its own fan/coil. Applied consistently incost_findingsandannotate_costs.
Notes
- Registered in
DEFAULT_MODELS; flows throughrank_by_cost/ scorecard / action plan with no call-site changes; public API surface unchanged (estimators + helpers are private). With this the entire Trim-and-Respond reset family is money-rankable (drift remains uncosted-by-design).
[0.62.0] — 2026-08-24
Fault economics for the Trim-and-Respond reset family. Extends camber.fault_economics so the
reset family's findings can be ranked by money, not just severity — triage-grade, honest, and
uncosted-by-design where the physics doesn't support a dollar figure.
Added
supply_air_reset_compliancecost model → avoidable terminal reheat (reheat-coil capacity × below-target hours × diversity, scaled by themean_gap_fbelow-target gap); uncosted withoutEquipmentLoad.heating_capacity_kbtuh.sat/static_reset_effectivenesscost model (dispatched on the failurereason):not_trimming→ wasted fan energy (static, needsfan_kw) or avoidable reheat (SAT, needs coil capacity).not_responding(comfort/capacity risk),stuckanddiverges(indeterminate energy sign) are uncosted by design — an honest basis, never a fabricated$.- Two documented assumptions (
g36_gap_ref_f,reset_fan_excess_frac) inDEFAULTS;_reheat_gasgains anintensity_scaleso a marginal gap costs proportionally less.
Notes
- Registered in
DEFAULT_MODELS, so the new models flow throughrank_by_cost/ the scorecard / action plan with no call-site changes; the public API surface is unchanged (estimators are private). - Drift families remain uncosted by design (documented + a lock test): a drift is a leading recommission indicator whose magnitude is a condition-space residual, not a priceable energy quantity. Fleet-rule (rogue/cohort) costing lands next, after the per-air-handler load attribution.
[0.61.0] — 2026-08-24
AHU cohort-starvation diagnosis (topology-aware fleet analytics — arc item 4). The common-mode twin of the rogue-zone census: where a rogue is one zone monopolizing an air handler's reset, a starved cohort is most/all of an AHU's zones requesting at once — one upstream fault (duct-static SP capped, supply fan maxed, restricted upstream damper), not N zone faults. A cross-layer call only the served-by topology makes possible.
Added
camber.g36_reset.cohort_starvation+CohortStarvationResult— per AHU group, measures the fraction of active cycles on which at leastcohort_fracof the group's zones request the reset at once; flags a group as starved when that sustained fraction clearssustained_frac(with group-size and active-cycle floors). Provably distinct from the rogue statistic: a lone dominant zone never reaches the cohort fraction, and a starved cohort shares requests too evenly to be a rogue.camber.rules.cohort_starvation_rule.CohortStarvation— aFleetRuleshipped as two instances,static_cohort_starvation(airflow vs setpoint and damper — the primary case) andsat_cohort_starvation(zone temp vs cooling setpoint, caveated as a possible design-day). Scopes per air handler via the served-by topology (wants_topology=True), names the AHU, and says "look upstream, not at individual zones". Warn-level.
Changed
- Internal refactor (no behaviour change): the shared per-zone request-series builder
(
_build_request_series) and the topology grouping-resolution + caveat matrix (camber.rules._topology_grouping) are factored out so the rogue-zone census and cohort-starvation twins stay byte-identical.
[0.60.0] — 2026-08-24
Topology-aware fleet grouping (topology-aware fleet analytics — arc item 3, the payoff). The
fleet runner now hands a served-by Topology to grouping-aware rules, so the rogue-zone census
auto-scopes per air handler instead of pooling every zone building-wide.
Added
Registry.run_fleet(..., topology=None)— passes a served-byTopologyto the rule. When it isNoneand the rule opts in (wants_topology), a naming-heuristic grouping is auto-built from the fleet's own equipment ids, so the census scopes per-AHU even with no semantic model.FleetRule.analyze_fleet(self, frames, *, topology=None)— the protocol gains an optional topology channel; the four non-grouping fleet rules ignore it (behaviour unchanged).
Changed
sat/static_rogue_zone_censusnow scope per air handler whenever a topology is available, with a provenance/coverage-aware caveat: a semantic grouping (Brickfeeds/ HaystackahuRef) drops the confound caveat; a heuristic (naming-inferred) grouping keeps a softened screening caveat; partial coverage pools the uncovered remainder building-wide and caveats it; no topology retains the original building-wide pool + full caveat. New metricsgrouping_provenanceandn_zones_ungroupedrecord the grouping used.
Notes
- Backward compatible: every existing
run_fleetcaller and a census with no topology reproduce the pre-0.60.0 output exactly. Heuristic auto-scoping is screening-grade and always labelled as such — never a false-confident per-AHU verdict.