Changelog: 0.90 to 0.97
Archived CAMBER releases 0.90.0 to 0.97.0, in the same format as the current changelog, which holds the recent releases. The changelog index lists every archive.
[0.97.0] — 2026-10-03
0.97: the PNNL re-tuning workbook (#79, #80, #81, #82, #83). A hands-on course that follows
PNNL's free Building Re-tuning training, run on the open dataset catalog in camber lab. Default
outputs are unchanged, and no gated benchmark moves.
Added
- The workbook framework (#79, provisional).
docs/workbook/holds a curriculum map from the PNNL chapters and guides to exercises and datasets (index.md), an exercise page template (_template.md: goal, Learn more, datasets and licence, setup in the lab and on the CLI, steps, questions, what CAMBER shows, caveats, going further) and an instructor page with a section per exercise (answer key, discussion points, common mistakes). The docs site has a new Re-tuning workbook section, and the README a "Learn re-tuning with CAMBER" section. - Answer keys pinned by tests. Each exercise declares its datasets, configs, CLI commands and
expected answers (a finding present or absent on given equipment, a metric within a
tolerance, a label score, or a free-form check) in
tests/workbook/exercises/<id>.py, with a synthetic stand-in shaped like each dataset. The offline tests run the exercise's configs on the stand-in every time;pytest -m networkruns them on the real catalog data. A stale answer fails and names the exercise, and the instructor page must quote every pinned figure. - Reference links from the registry. Workbook pages link PNNL only through reference-style
Markdown links labelled with
camber.referencesids;scripts/workbook_refs.pyprints the definitions and--check(also a test) rejects an unknown id, a URL that differs from the registry's, or a PNNL link made any other way. No mkdocs plugin is needed. - Catalog
exercisefield.suggested_analyses.exerciseis validated: a docs-relative workbook page (workbook/<id>.md, optionally#<anchor>) or an https URL.camber datasets infoprints its published URL.camber labserves a relative link offline from the local docs tree (/lab/docs/workbook/<id>.md, a script-free reading copy;--docs DIR, by default the checkout'sdocs/), and links the published docs site otherwise. - Exercise config templates.
camber/datasets/configs/exercises/<id>.json(package data) holds a tuned config per exercise where the dataset's default doesn't fit;camber datasets config <dataset> --exercise <id>(anddatasets.config_template(..., exercise=)) writes it. - Worked example:
air-economizeronlbnl-sdahu: a damper stuck open is found byoutdoor_air_fractionandeconomizer_high_limit, one stuck near minimum only as missed free cooling (free_cooling_missed), and the label score is read against the published labels. - Workbook: the air side (#80). Five exercises following PNNL re-tuning chapters 5 and 6 and the AHU guides, each with an instructor key pinned by the answer-key tests:
air-sat-reset(lbnl-sdahu,lbnl-ddahu,irish-ahu): is the supply-air temperature reset, which way should it move with the outdoor air, how far below the Guideline 36 target does it sit, and does the unit hold its setpoint.air-static-pressure(lbnl-sdahu,ornl-frp-vav): a flat duct static setpoint, and a damper census over one real test day's ten boxes.air-heat-cool(lbnl-sdahu,lbnl-ddahu,irish-ahu): the published 10 % cooling-valve leak (whichleaking_valve's 3 °F margin misses; the exercise teaches the comparison with the fault-free run), both valves open on a dual-duct unit by design, and a real unit's coils.air-economizergains anirish-ahupart: the economizer rules on a real, unlabelled unit with a documented 100 % outdoor-air period, split by period.air-scheduling(ornl-frp-ops): a 24/7 baseline against a night setback held by fan cycling, compressor short-cycling, and the supply fan's night energy.- Exercise configs
air-sat-reset,air-sat-reset--lbnl-ddahu,air-sat-reset--irish-ahu,air-static-pressure,air-static-pressure--ornl-frp-vavandair-heat-cool--lbnl-ddahu; the catalog linkslbnl-ddahu,irish-ahuandornl-frp-opsto their exercises. - Workbook: terminal units and ventilation (#81). Five exercises for PNNL chapter 7 and the
zone heating and cooling and minimum outdoor-air guides, each with an instructor key pinned by
offline stand-in tests and
-m networktests on the real data: zone-reheat-overcooling(lbnl-fpu): a high minimum airflow that overcools and reheats, against a stuck damper that floods the box (airflow_tracking,reheat_penalty,overcooling_min_flow); the south-zone box is the one under test.zone-bad-box(ornl-frp-vav): the one box with a flat damper among ten, the SAT rogue-zone census, the rooftop unit's airflow and static following the stuck box, and why a comfort rule scores poorly as a stuck-damper detector (TPR 33 %, FPR 100 % on the published labels).zone-reheat-saturated(lbnl-fpu,ornl-frp-vav): a zone below its heating setpoint with the reheat demand saturated (reheat_capacity_shortfall), a heating shortfall rather than overcooling; and a real building whose electric reheat is not trended, where no reheat verdict is possible.zone-dcv(finnish-dcv,b4b-windesheim,sdu-ou44): a documented DCV law, a verdict that depends on which CO₂ sensor you trust, and the honest "not judged".zone-min-oa(lbnl-b59): the system-level 62.1 Ventilation Rate Procedure with stated assumptions (public sources only), measured OA several times the requirement, and CO₂ and DCV as second opinions.- Four exercise config templates (
zone-reheat-overcooling,zone-reheat-saturated,zone-reheat-saturated--ornl-frp-vav,zone-min-oa); the catalog entrieslbnl-fpu,ornl-frp-vav,finnish-dcv,b4b-windesheim,sdu-ou44andlbnl-b59link their exercise. - Workbook: the central plant (#82). Five exercises on the open LBNL plant datasets, each with a page, an instructor key, a tuned config and a synthetic stand-in:
plant-chiller-efficiency(lbnl-chiller): a generic kW/ton ceiling flags the healthy plant; the ceiling calibrated from the fault-free run finds the severe chiller fouling and the tower bypass, and the label score shows its false alarms.plant-cooling-tower(lbnl-chiller): a controlled tower hides fouling in fan effort rather than approach; condenser-water reset;condenser_bypass_leakon the stuck bypass.plant-chw-reset-pumping(lbnl-chiller): chilled-water reset, a low delta-T that is a property of the constant-flow primary loop, a flat DP setpoint, and a VFD floor above the pump rule's fixed near-minimum band.plant-boiler(lbnl-boiler): why the lockout, short-cycle and delta-T rules stay silent on a plant whose boiler status is an enable, a pump that runs all year, and boiler fouling that no point-in-time rule sees.plant-sensor-vs-equipment(lbnl-chiller): where each sensor bias shows up (kW/ton, the secondary pump, a sensor-offset attribution) and why the score counts it as a negative.- The
lbnl-chillerandlbnl-boilercatalog entries link their exercise. lbnl-chillermapping (#82). The secondary loop's lead pump speed and its DP and DP setpoint now map tochw_pump_speed,chw_diff_pressandchw_diff_press_sp, sochw_pump_dp_resetcan run on the plant. Re-ingest (--force) to pick them up. The dataset template's rules do not read them, so its findings are unchanged.- Workbook: data quality, energy charting, M&V and the capstone (#83, provisional). Four
exercises, each with its page, instructor key and answer-key test (stand-in offline, real data
under
-m network): data-trend-quality(nuig-ahu101,lbnl-b59,irish-ahu): the core points each unit trends, then the sensor-health layer on real faults -- a copied return-air point, mixed-air sensors that fail the flow balance, room CO2 clipped at full scale, a meter held over two weekends, a gap-filled CO2 stretch -- and an "untrusted" supply air that is a well-controlled sensor.data-energy-charting(bdg2,valladolid-uva): weekday and weekend profiles, out-of-hours base load, load factor and the daily change-point baselines as the weather-dependence chart.mv-baselines(cofactor-drammen,valladolid-uva,bdg2): G14 acceptance and SEP 50001 §6.4.1 validity, forecast against backcast, SEP chaining, a chain across the spring 2020 school closure with a non-routine adjustment, and bill-only M&V on synthetic bills cut from an open meter (days-weighted fit, bases chosen from the bills, calendarized). Four exercise configs:mv-baselines,mv-baselines--forecast,--backcastand--covid.capstone(lbnl-sdahu,ornl-frp-ops): the RCx report on the fault-onset splice, a walk-down checklist built from its recommended actions, conditional issues and default assumptions, a re-tuning plan, verification by drift (a first-half baseline scored on the second half), and a before-and-after scheduling test where hourly M&V is refused for lack of baseline data. Exercise configcapstone.- Catalog
exerciselinks:nuig-ahu101todata-trend-quality,bdg2todata-energy-charting,valladolid-uvaandcofactor-drammentomv-baselines.
Fixed
- Workbook network tests (test harness only).
pytest -m networknow ingests each run'ssubset: the plant exercises that needfullget their own sibling store, so the default and full ingests oflbnl-chillerno longer replace each other. A dataset already in the store is ingested again when its subset or mapping has changed (for example the newlbnl-chillerpump roles). A manual download (lbnl-b59) whose files are neither inCAMBER_WORKBOOK_FROM_DIRnor in the cache is skipped, with a message listing the files to download, instead of failing on a fetch. The harness docstring lists the filesCAMBER_WORKBOOK_FROM_DIRneeds for each dataset, and a test keeps that list in step with the catalog. - Workbook index. The status note says that all 19 exercises are written.
[0.96.0] — 2026-10-03
0.96: a local catalog UI, a real-building dataset for point mapping, report fixes, and a
fault-lifecycle scoping fix (#75, #45, #76, #77, #78). camber lab serves the dataset catalog
as a loopback-only page with fetch and ingest jobs, licence gates and workspace registration
(#77). The catalog gains BTS, three real buildings with about 20,000 Brick-labelled BMS points,
and a brick_streams adapter to ingest it (#75); the point-role suggester can now read a point's
data as well as its name, and is evaluated on BTS and on real published point names (#45).
Recommended actions follow each finding's cause, trend-only reports are no longer titled as
Std-211 audits, the trend viewer draws one panel per unit, and reports link to the PNNL Building
Re-tuning guides (#78). A site-keyed fault run no longer resolves other sites' faults (#76).
Default outputs are unchanged apart from the report text and titles noted below, and no gated
benchmark moves.
Added
camber lab: a loopback-only local UI for the dataset catalog (#77, provisional).camber lab [--workspace W | --store S] [--dir D] [--port 8765]serves a catalog page athttp://127.0.0.1:8765/lab.- The page. It is vanilla JS: no framework, no CDN, everything inline. Datasets carry
licence badges; the table has filters, and shows download and store sizes against the free
disk. Fetch & ingest runs as a job, one at a time on a single worker thread, with
progress and cancel. Rows link to the trend viewer (
/ui?facility_id=ds-<id>, a new deep link), an on-demand audit report, and the publisher. - Research-only datasets. A dialog asks the user to accept the terms and type the dataset
id. The acceptance is recorded in the existing
acknowledgements.jsonledger (via: "lab fetch"), and research-only reports carry the non-commercial banner. - Workspaces. In a portfolio workspace, a dataset's
ds-<id>facility is registeredprovisioning, ingested under the single-writer lock, and then activated. Every fetch, acknowledgement and ingest is audited (lab.*). Suspended, offboarding and archived dataset facilities are not re-ingested. Without a workspace the lab uses a plain store, as thecamber datasetscommands do. - API.
camber.lab:LabApp,dispatch_lab(pure routing; the read routes/ui,/facilities,/pointsand/historyare delegated unchanged tocamber.api.server.dispatch),make_lab_server,serve_lab,JobQueue. - Lab security (docs/SECURITY.md §11):
- It binds 127.0.0.1 only; any other bind address is refused.
- A Host and Origin allowlist (against DNS rebinding) and a
Sec-Fetch-Sitecheck. - A per-run CSRF token compared with
hmac.compare_digest. - JSON only (415), a 16 KiB body cap (413), unknown fields refused, catalog ids only.
- A hash-pinned CSP on the page, and sandboxed reports.
-
A static test proves
camber.labandcamber.datasetsreach no BACnet, Modbus, OPC-UA, MQTT, OpenADR or edge module. -
Linked PNNL Building Re-tuning references (#78, provisional).
camber.referencesis a registry of the nine guides to re-tuning measures, the ten training chapters, Trending Requirements for Re-tuning, the ECAM interval-data guide, the large-office savings report and the project pages: id, title, publisher, document number, URL, kind andverified_on(2026-09-29).RULE_REFERENCESmaps 35 rules to them, checked against each guide's section headings; a rule no guide clearly covers maps to a training chapter or stays unmapped. - Reports: the audit findings table and the Recommended actions table get Learn more
links; each RCx issue page ends its action with them, and the RCx report adds a short
Further reading section (id
reading) listing only the guides relevant to its issues. - Text and JSON carry the ids:
learn more: <ids>in the text audit,Recommendation.references, the action-plan rows'references, each RCx issue'sreferences. - Link only: no PNNL text, figure or PDF is copied into the repository or a report.
scripts/datasets_linkcheck.pynow also checks every reference URL weekly (a 404 / 410 is drift;--no-referencesskips them). - Docs: a references page, links from the rule pages (ventilation, economizer, reset), and ECAM as a related tool in the ecosystem page.
- The
btscatalog entry (#75). BTS, the Building TimeSeries dataset (Prabowo et al., NeurIPS 2024 Datasets and Benchmarks; CC BY 4.0, open tier): three real Australian buildings, about 20,000 Brick-labelled BMS points over 2021-2023, the data behind the Brick by Brick 2024 challenge. It is the evaluation set of the time-series role suggester (#45). - Fetched from the data archive, never the MIT-licensed repository snippet; the MIT (code and
snippet) versus CC BY 4.0 (data) split is recorded in the entry's known issues. All nine
files are pinned (size and SHA-256) from real downloads with
scripts/datasets_refresh.py. defaultis the three sites' metadata and Brick models plus site B's streams (1.5 GB, 38 MB once ingested);fullis all three sites (19 GB, 1.1 GB once ingested).- Nine data issues, each with evidence: UTC timestamps, undocumented units, 125 listed streams
without a file, site C running outside the documented period, week-long whole-site outages,
site C's zero dropouts (masked by a
fixquirk;--no-correctionskeeps them), placeholder and 32-bit overflow values, site C's negative airflows and mixed pressure scales, and 136 site C points that keep a non-anonymised second stream id. - The
brick_streamsingest adapter (#75). Per-site Brick models whose points name their series through a literal (senaps:stream_id), a stream index and a zip of series files: one facility per site, roles from the Brick class, equipment from theisPointOfowner or the first containing entity whose classequip_classesmaps (named<class>_<id prefix>), UTC instants moved to each site's wall clock, sample-and-hold resampling. A second point with the same role on one owner becomes equipment<equip>-2, never averaged; unmapped points are counted per class in the provenance. Series pickles are read with a restricted unpickler that resolves only numpy's array globals, straight from the verified zip. Quirks run per stream (a quirk'srunsnames sites). - Time-series evidence for point-role suggestion (#45).
FeatureSuggester(use_timeseries=True)also reads what a point's data says, for exports whose names are anonymised (camber.mapping_timeseries, provisional, numpy and pandas only): profile_series/SeriesProfile: value quantiles, cadence and the change-of-value pattern, binary and two-level values, plateaus at the series' extremes, step-like movement, daily and weekly periodicity, and the correlation with a site outdoor-air series (oat=);ROLE_TEMPLATES/template_scores: 45 hand-written role templates scored in every plausible unit when none is declared;ProfileModel: an optional numpy Gaussian class model fitted on other buildings' labelled points (model=);blend: the data's weight falls from 0.8 to 0.1 as the name becomes informative, so a clear name still dominates.suggest()also takesoat=and a precomputedprofile=; the basistimeseriesis new.- Evaluated in
examples/suggester_eval(none of it a gated benchmark):- Real BMS point names (
real_names.py): every mapped point of seven open real-building catalog datasets, scored by its published name against the catalog mapping (hand-curated by CAMBER). Pooled over 422 points, the name alone reaches 82.5 % top-1 and the name plus the data 83.9 % top-1 / 89.1 % top-3. Excludingirish-ahuandlbnl-b59, whose names the tokenizer was written against (129 points), the figures are 52.7 % and 58.1 % top-1, and 53.5 % and 72.9 % top-3. The data helped 11 points and hurt 5, all of the losses on weather-station points and one valve. The LBNL simulated FDD sets are reported apart: 48.6 → 56.9 % top-1 over 72 points. - BTS with the names hidden (
bts.py, leave one building out): the name-only suggester places 0 % of 903 points; the data alone places 48.0 % top-1 / 65.2 % top-3 with the templates, and 38.4 / 58.8 with the fitted model. With Brick-class labels used as names (an upper bound, not real-world naming), adding the data moves top-1 from 93.0 to 95.2 %. - Synthetic vendor-style names (
messy_names.py, five seeded styles, labelled synthetic): 25-88 % top-1 from the name alone, 52-91 % with the data. - Results and caveats in docs/MAPPING-ASSIST.md and docs/VALIDATION.md.
- Real BMS point names (
- Opt-in: without
use_timeseries=Truethe suggestions are byte-identical to 0.95.
Fixed
- A site's fault run no longer resolves other sites' faults (#76). In the legacy, site-keyed
path (no
facility_id),FaultLifecycle.update(..., auto_resolve_absent=True)resolved every open fault in the store file, including other sites' faults and facility-keyed faults. Theabsentlist had the same error withoutauto_resolve_absent. - A run now covers only the records whose fingerprint is keyed by its
site. The fingerprint decides, not the stored label.site=""is its own scope, andaliasesnever widen it. Facility-id runs are unchanged. - A site-keyed record whose site cannot be told is never auto-resolved. This covers records
whose fingerprint matches neither the run's
sitenor their own stored label, such as hand-edited records or records with no storedsite, which now load with""instead of failing. Such records are listed under a newunscopedkey in the result, present only when non-empty, andcamber runprints a line for them. - The same fix reaches config runs that share a
faults.store. - The in-memory
rules.triage.FaultRegistershared the flaw across sites and facilities, and now resolves only faults keyed like the run. - Outputs for single-site stores and facility-id runs are byte-identical. See docs/FAULT-LIFECYCLE.md, "Which faults a run can close".
- Recommended actions follow the cause, not the rule (#78). A
dcv_verificationfinding whose DCV works but whose outdoor air stays above its floor at low demand was told to "Enable / repair demand-controlled ventilation"; it is now told to lower the minimum outdoor air at low demand (to the area-based Ra·Az floor). Under-ventilation causes get "restore the OA floor", "make OA respond to high CO₂" or "restore ventilation while occupied", never "cuts over-ventilation".dcv_system_verificationreads the cause from the air handler that set its severity. An audit of every recommender fixed the same pattern inchw_plant_reset(low loop ΔT vs a flat CHWST),chw_pump_dp_reset/hw_pump_dp_reset(pinned at the VFD minimum, or a reset already present),supply_air_reset(a setpoint that already resets; SAT rising with load),cooling_tower_approach(wide at full fan),reheat_minimization_g36(the dual-maximum sequence) and the overcooling rules (no "raise the cooling setpoint" for a box at minimum). Finding severities and metrics are unchanged; only the advisory text and target change. - Audit report title and scope (#78). A report built from trend data alone (a dataset, one
room) was titled "ASHRAE Std-211 Level 2 Audit". It is now a "Building analytics report"
unless it carries the Std-211 inputs (an EUI benchmark, plus an ECM table at Level 2 and
above);
report.titlesets a title andreport.ecmssupplies the ECM table from a config. Empty sections (the ECM table) are left out, in HTML and text. The report carries minimal scoped styling (no external asset; light and dark), wide tables scroll inside the page at phone width, andcamber report, the config'sout_htmland the lab write a full HTML document (charset, viewport, title). - Trend viewer (
/ui) (#78). Series of different scales shared one unlabelled axis, so CO₂ in ppm flattened temperature and airflow. The viewer now draws one panel per unit with a labelled y axis, a shared time axis (UTC), a legend with each series' unit and range, a hover readout, line breaks at data gaps and a Normalised (0–1) toggle; one request per ticked role. Units come fromcamber.api.ui.role_units()(IP, as stored). Still vanilla JS, same CSP, no CDN. - Lab page (#78). The header shows the workspace, store and cache home-relative (
~/…) and shortened; full paths stay in the startup log and the JSON (newdisplayblock). Each dataset's what it teaches list is collapsed by default. - Trend viewer: site time and count units. The time axis and the hover readout show the
site's wall clock labelled with its zone (e.g.
time (Australia/Sydney)) when the facility's zone is recorded, with a UTC box to convert; without a zone they read UTC as before. The zone reaches the page as a newtimezonekey on/facilitiesrows, present only when known (a registrytimezone, or the zone a catalog dataset was ingested into).occupancynow reads persons instead of "no unit", the G36 request roles requests, and stage roles stage; the humidity, filter pressure drop, pump head and source-loop roles get their documented units. The mapping suggester's unit table (ROLE_UNIT) is unchanged.
Unchanged
camber servestays GET-only (regression test), and default outputs are unchanged.
[0.95.0] — 2026-10-03
0.95: bill-based M&V follow-ups (#74) and the portfolio lifecycle from offboarding to the edge
(#18 steps 3–5). A billing meter's rebaseline window is now searched, as a daily meter's is, so
camber mv rebaseline no longer needs --period for bills; an opt-in step test built for bills
gives trigger T1 a calibrated false-alarm rate; and the degree-day model at bases selected from the
bills is a candidate in the SEP method proposal. A facility now leaves a portfolio in audited,
reversible steps (offboard, archive, restore, purge), and nothing is deleted without a verified
export bundle. The agreed retention defaults are enforced by camber retention apply, which rolls
raw data up, verifies the rollup, and only then prunes; the store writes month partitions so
retention works month by month. At the edge, what lands in the cloud follows the facility
registry: uploads from a facility that has left are quarantined instead of stored, a device can
be retired without losing data, and the bucket's own lifecycle rules are generated from the
retention policy.
Added
- Rebaseline windows of whole bills (#74).
camber mv proposeanswers a rebaseline-class trigger on a billing meter with a window, fromcamber.mandv.billwindow.new_bill_window(provisional). - Candidates are the shortest runs of whole bills covering a full service year
(
min_baseline_days), ending at each bill from the latest backwards. - A window starts
settle_daysafter the trigger, avoids ECM installation windows and declared events, holdsmin_bills, and leaves at mostmax_missing_fracof its days unserved (the freeze rule). Its model must be valid underrequire_validityand must not be asevereextrapolation of the bills seen.autobases are selected on each candidate. - Ranking is the daily path's: the latest qualifying window wins. At most 12 fits, stepping
back one bill after a failure. The proposal states the ranking. Its
windowaddsn_bills,bases,rankingandtried. camber mv rebaselinewithout--perioduses the search.--from-proposalfreezes the proposed model exactly (statistics weighted by days, at the monthly G14 thresholds).--periodstill works.- A step test on bills, opt-in (#74).
"rebaseline": {"bill_steps": "scan"}on a billing entry makes trigger T1 a scan of the two-sample t of the bills' deviation from the frozen projection (camber.mandv.billsteps, provisional). Each bill is weighted by its projected energy, at least 6 bills are needed each side, and the variance is inflated by the bias-corrected lag-1 ρ. The series is cut at ECM installation windows and declared events. - The threshold 3.75 is calibrated by simulation to a 5% false-alarm rate per meter over 36 bills, looked at after every bill (4.8% pooled; 1 to 13% by cell). The simulation used an office and a heating-only gas meter, bills of 28 to 35 days, baselines of 12 or 24 bills, and a monthly CV(RMSE) of 3 to 13%, with 300 seeds a cell.
- Detection: 20% steps 61 to 100%, 10% steps 21 to 99%, 5% steps 3 to 62%, with a median delay of 5 to 10 bills.
- The daily PELT on bills, for comparison, raised false alarms on 13 to 22% of meters with 6 bills a segment, and on 49 to 62% with 3. The other candidates were two CUSUM forms.
- With strongly persistent residuals the false-alarm rate rose to 22%. Declared events remain the recommended path, and the docs say so.
- The degree-day model in
method: "auto"(#74). Withbase_f: "auto",select_methodoffers the degree-day model at the selected bases in every period (the same bases throughout, counted inp). - The criterion is unchanged: SEP validity (§6.4.1), then adjusted R² with every fitted parameter counted.
- The
mv_method_proposalfinding states it (model_criterion, a caveat) and names the candidate (degree_day_candidate). - Standard conditions pair the baseline model with a reporting model of the same form.
- The adjusted sensitivity rows follow the same models.
select_method(degree_day=False)leaves it out.- Validation (#74).
tests/test_mv_billing_followups.pycovers: - the window search's rules (settle days, ECM windows, events,
min_bills, gaps, stepping back) and the propose / rebaseline paths; - the scan's calibration on independent noise, its detection and dating of a 20% step, the opt-in, and ECMs and declared events not detected again;
- the degree-day candidate in the proposal.
camber facility offboard | archive | restore | purge(andPortfolio.offboard,archive,restore,purge; provisional).offboardwrites a verified export bundle, then starts a 30-day reversible grace period.archive, after the grace period (or with--skip-grace, audited), deletes the hot data: store and rollup partitions,state/<fid>/, and unchanged external report files. It keeps the bundle, re-exporting first if anything changed during the grace period.restorebrings an offboarding or archived facility back; from a bundle it re-verifies every checksum after the copy.purgedeletes everything but the tombstone and the audit record; the id is never reused.- Each is a dry run unless
--apply, needs--reasonand a confirmation (--yesor the typed id;purgeonly the typed id), takes the portfolio lock and is audited with the OS user. A legal hold blocksarchiveandpurge. - Export bundles under
archive/<fid>/<bundle_id>/: raw partitions, rollups, the wholestate/<fid>/(fault history, drift baselines, M&V and bill-based M&V baselines, the #73 weather audit log, reports, the manifest), external artifacts, the registry entry, catalog keys and retention override, with a sha256 manifest and a checksum of the manifest itself.camber facility exportmakes one on demand;camber facility bundles --verifyre-hashes them. -
Crash safety. Trees are replaced through a fsynced
_swap-*stage and deleted through one atomic rename to_trash-*. Every lifecycle command first finishes or rolls back whatever a crash left (Portfolio.recover, audited asportfolio.recover). Tests cover a crash at each step, including a killed child process. -
camber retention show | set | override | hold | release | apply(andPortfolio.set_retention,set_retention_override,hold,release_hold,apply_retention,retention_policy; provisional). Defaults: raw trends 25 months, hourly rollups 7 years, daily rollups indefinite, findings 7 years, drift baselines for the life of the equipment with the last 10 versions, M&V (and bill-based M&V) baselines every version, reports the last 12 per facility, the weather audit log while the facility exists, the audit log never. Precedence: legal hold > facility override > portfolio default. applyrolls expired raw month partitions up intorollups/hourly/androllups/daily/(mean and count per bucket), verifies that the counts add up to the raw rows, and only then prunes. It also trims closed faults, drift baseline history and old reports, and archives offboarding facilities whose grace period has ended.- It is a dry run unless
--apply --reason R --yes, takes the lock (--wait S; exit 75 when held), recovers interrupted work first, audits each facility before acting, and is idempotent, so it is safe from cron. - The policy as a documented JSON document (
camber retention show --json,Portfolio.retention_policy()), described by the JSON Schemacamber.portfolio.RETENTION_SCHEMA: each class's storage location, the effective rule per facility with its source, a conservativemin_age_daysfor object-store lifecycle rules, and the legal holds. camber store migrate-partitions(ParquetStore.migrate_partitions): converts year-only partitions toyear=/month=, a dry run unless--apply --yes, crash-safe and idempotent.ParquetStore.partitions()anddrop_partition()list and delete single partitions.- Central reconciliation (#18).
camber edge reconcile(provisional,camber.edge.landing) classifies landed objects against the registry asok,orphaned,unknown_facility,unregistered,inactiveorquarantined. It reads the workspace store, a local landing directory, or a key listing exported from S3, GCS or Azure. It is read-only by default and never calls a cloud API. In the store, an inactive facility's objects that landed before its state change are history and are only reported. - Quarantine (#18). Uploads for a facility that is
suspended,offboarding,archived,purgedor unknown, and objects whose content fails the hash in their name, go to<workspace>/quarantine/with a record of why, not into the store. The routes arecamber edge land <inbox>,camber edge reconcile --apply, orroute_key()for a presigned-URL broker, which routes to the bucket's_quarantine/prefix.camber edge quarantine list | release | discardare dry runs by default. They take the lock and are audited with a reason.discardneeds--yesor the typed facility id, and a legal hold refuses it. - Edge decommissioning (#18).
camber edge decommissionflushes the spool, waits for the landing to acknowledge every batch, then retires the device. The spool refuses new batches from then on. The retirement is recorded as an audit line and anedge_devices.<device_id>note on the facility's registry entry, directly or later withcamber edge record-retirement. It refuses while data is unacknowledged unless--forceis given with a reason. A forced retirement keeps the payloads on disk, and a legal hold refuses it. - Spool journal compaction (#18).
camber edge compact/Spool.compact()rewrite the append-only journal to the pending batches. The rewrite is verified before an atomic swap, so a crash never drops an unacknowledged batch, and sequence numbers are never reused. - Bucket lifecycle rules (#18).
camber edge bucket-rules --provider s3|gcs|azure(camber.edge.bucket_rules) emits lifecycle JSON from a retention-policy dict or the workspace's policy document (Portfolio.retention_policy()), whoselocationpatterns give the prefixes (rollups/hourly/,rollups/daily/;--layout store|workspace). Ages are conservative, and facility overrides and legal holds produce per-facility rules; a held facility gets no expiry rule. It is text only: the admin applies the rules. EdgeConfig.device_id(configdevice_id, envCAMBER_EDGE_DEVICE_ID).Portfolio.auditandPortfolio.note_edge_device(provisional): the public, audited way for code outside the lifecycle (the edge landing) to write an audit record or anedge_devices.<device_id>registry note.auditrefuses the lifecycle's own namespaces (facility.,portfolio.,retention.); the note is audited before the registry changes.
Changed
- Bill-based M&V (#74): nothing changes for existing configs. A byte-identity harness
compared the findings of billing entries (numeric and
autobases, every method, adjustments, versioned runs,mv report) and of a daily workspace (propose, dry-runrebaseline,report) before and after: they were identical. The exceptions are the intended ones: - a billing meter's
propose/rebaselinewith a rebaseline-class trigger (a window instead of a decline); method: "auto"on a billing entry with selected bases: the proposal gains the degree-day candidate, and the 0.94 caveat "ranks the change-point models only" is gone.
RebaselinePolicy.as_dict(), stored in rebaseline provenance, carries bill_steps only when
it is set.
- ParquetStore writes year=/month= partitions (was year=). Year-only and mixed stores
are read unchanged, and range reads also skip month directories. A full read_long now
returns a month column.
- An archived facility refuses store writes (its data lives in its bundle).
- ParquetStore.read_long on a store with no partitions left returns an empty frame instead of
raising.
- ParquetStore.prune and drop_facility delete through one atomic rename, then removal, so a
crash never leaves a half-deleted partition visible.
- Spool journal writes now take the spool's single-writer lock (<spool>/_lock), and an append
after a torn last line starts a fresh line. Before, the next record could be glued onto the
torn line and lost with it. Spool contents and forwarding are otherwise unchanged.
- camber edge status adds a RETIRED line for a decommissioned device. edge run and
send-once refuse a retired spool, and the forwarder daemon stops on one.
- The edge forwarder writes year=/month= keys
(facility_id=<id>/year=<yyyy>/month=<m>/part-<sha16>.parquet, one part per month), matching
the store's layout, so retention prunes month by month without splitting an edge part. The
batch manifest gains month. Year-only keys from older forwarders are still accepted and read,
and camber store migrate-partitions converts them. A year-only part re-sent after its year
was migrated is recognised by name and sha256 and quarantined as a duplicate (a new
reconciliation category) instead of being stored twice; release refuses it.
Fixed
- A write after
ParquetStore.prunecould overwrite live data. The part-file counter was the number of files left, so after a prune a new write could reuse the name of an existing file in the same partition and replace it. The counter is now one past the highest part number on disk. - The store read caches (the per-facility fragment index and the resolve frame cache) now notice writes into month directories.
- An archived facility accepted store writes again once an edge object recreated its partition. The archived check ran after the "partition exists" fast path, so an upload PUT straight into a store-as-bucket reopened the facility. It now runs first.
- A late upload into an already rolled-up month replaced that month's rollup. Retention
replaced the whole rollup partition with the rollup of whatever raw rows were left, so raw
rows landing in a month after it was rolled up and pruned (an edge backlog, a backfill; likely
with a short
raw_trendsoverride) wiped the month's earlier rollup. Each rollup part now records the raw files it covers: a run replaces only the parts whose raw files are all still there and keeps the rest, so re-runs never double-count and late rows add to the month. - Purge left a facility's quarantined edge uploads behind. Purge now deletes
quarantine/facility_id=<id>/with the rest (crash-safe, finished byPortfolio.recover, which also sweepsquarantine/). Archive keeps them: they are not in the bundle. - Migrating a year a second time could destroy rows migrated the first time. Year-only files
that land after a migration (an older edge forwarder) are migrated again. The second run reused
the first run's
part-legacy0-0name and wrote through the stage's hard link to that file, truncating it, then refused with "nothing was changed". Migrated parts are now named by the source file's content and written to a temporary name first. Each migrated year also records its source files and their sha256 inyear=Y/_migrated.json(ParquetStore.migrated_files), so the edge landing recognises a re-sent legacy upload.
[0.94.0] — 2026-10-03
0.94: weather for non-public sites, with privacy guardrails (#73). M&V and FDD can use real
weather for client sites without disclosing where they are. A privacy mode per config or per
fetch decides what leaves the machine: coarse sends only ISD station ids, NASA POWER grid-cell
centres and Open-Meteo points rounded to 0.1°; offline sends nothing. A facility marked
private defaults to offline. Every request to a weather or price service is audited, and
camber weather audit shows exactly what was sent.
Bill-based M&V (#72). Pre/post M&V from utility bills alone: versioned billing baselines
through camber mv freeze | rebaseline | adjust | report, Portfolio Manager calendarization,
billed cost and avoided cost, and degree-day bases chosen from the bills (base_f: "auto", with
separate heating and cooling bases, degree days built from each day, a selection profile with
ranges and a flat-profile warning, and R² / adjusted R² beside CV(RMSE) and NMBE).
Added
- Weather privacy modes (#73).
camber.weather_privacy(provisional) adds the modes"public"(the behaviour before 0.94, still the default),"coarse"and"offline". Set them as a config's"weather": {"privacy": ..., "precision_deg": 0.1}, or as aweatherblock in anmventry'soator inreport.rcx.oat_reference.coarsen()is the one function that turns a location into request coordinates, and the URL builders call it: - ISD requests never carry coordinates, because the station is chosen locally from the downloaded catalogue;
- NASA POWER gets its grid-cell centre (0.5° × 0.625°, MERRA-2's native grid, per the POWER data-sources page);
- Open-Meteo gets the point rounded to
precision_deg(default 0.1°, about 11 km; no finer than 0.05°).
Independently, check_url() checks every request at send time. It refuses a coordinate finer
than the policy, any coordinate in an ISD request, a field the service does not need, and, under
offline, any request at all (PrivacyViolation). Geocoding, which sends an address, is
refused under both guarded modes.
- Private facilities. private: true in a facility's registry entry (camber facility add
--private, or camber facility private <id> --reason R, audited) or in its config makes its
weather and price requests default to offline. It may opt in to coarse, never to public.
Offline reads only caches and user-supplied weather files. A billing entry with nothing cached
declines and says how to supply a file.
- Places without coordinates. A fetch may name "place": "KORD" (an airport ICAO code, from
the ISD catalogue's new IsdStation.icao), an ISD station id, or one of about 80 bundled cities
("Chicago, IL"). All are resolved locally (weather_source.resolve_place).
- Audit log. Each outbound request to NOAA ISD, NASA POWER, Open-Meteo, EIA or OpenEI URDB is
appended to state/<facility_id>/weather_audit.ndjson in a portfolio workspace, or to
weather_audit.ndjson next to the cache. Cache hits are logged too. Each record holds the
timestamp, the service, the URL as sent (API keys redacted), the purpose, the cache hit or miss,
whether it was sent and the privacy mode. The facility id appears only in the local record. camber
weather audit [--facility] [--since] [--json] prints the log.
- Provenance. A guarded fetch records the mode, the precision, the policy's origin, the
coarsening applied to each source, and the requests sent and served from the cache. They go in
attrs["weather_privacy"] (and weather_provenance["privacy"]), next to the existing station,
cell and bias-correction records.
- EIA and URDB requests (fetch_state_price, fetch_urdb_rate, and the billing unit-scale
check) take the same privacy= / audit=. URDB under offline is refused with a pointer to
urdb_file.
- Docs: a SECURITY.md section on what CAMBER sends to weather and price services and what it
never sends; privacy modes in WEATHER.md; camber weather audit in CLI.md.
- Degree-day bases chosen from the bills (#72). "base_f": "auto" on a billing mv entry
searches a heating base and a cooling base separately. The search lives in
camber.mandv.basetemp (provisional: select_bases, BaseSearch, BillingDegreeDayModel,
bill_degree_days, fit_bill_degree_day, compare_models).
- Candidate degree days come from each day's temperatures (hourly when sub-daily) summed over
each bill's service days, not from the bill's mean temperature.
- DD-H, DD-C and DD-HC are fitted by days-weighted least squares, with the bases counted
as parameters (p = 3 or 5).
- The degree-day model at the selected bases competes with the change-point models by BIC.
- A slope of the wrong sign is refused.
- base_search sets the ranges and step (or grid: "data"), the likelihood-ratio
tolerance of a base's range (3.84), flat_share, fixed_f (65), kinds and r2_min.
The mv_baseline finding carries:
- base_selection: a profile row per candidate base with SSE, R², adjusted R², CV(RMSE),
NMBE and BIC, plus each base's range and flat / at_edge caveats;
- heating_base_f / cooling_base_f, adj_r2 and n_params;
- model_comparison: every change-point kind, the fitted-base model and the fixed-65 °F model,
each with R², adjusted R², CV(RMSE), NMBE, BIC, and mismatch where R² and CV(RMSE) / NMBE
disagree;
- fixed_base.
One set of bases feeds hdd_total / cdd_total, the reporting rows, the standard-conditions
projection and every refit. An R² under 0.50 is a caveat, not a refusal, under validity:
"g14". 0.50 is the SEP 50001 M&V Protocol 2019 Ed. 2 §6.4.1 threshold, verified against the
public DOE document.
- Calendarization (#72). BillingSeries.calendarize() / mandv.billing.calendarize()
(provisional) prorate each bill's energy and cost per day into calendar months, the ENERGY STAR
Portfolio Manager method (Technical Reference, Thermal Energy Conversions, Figure 1 step 3).
- Months are flagged complete or estimated, and carry HDD / CDD from the same daily series.
- Gaps and overlaps are listed. Beyond max_gap_days (0) the totals are withheld, as Portfolio
Manager withholds metrics.
- annual() and total() give calendar totals.
- A config entry's "calendarize": true adds calendarized to its mv_baseline finding.
- Models are still fitted on the billing periods.
- Billed and avoided cost (#72). bills.cost names the cost column (BillingSeries carries
it as frame["cost"], and merged estimated reads sum it).
- The baseline finding reports billed_cost and unit_cost (the implied $/unit per bill, the
quantity #71's scale check screens).
- "avoided_cost": "bills" prices a forecast saving at each reporting bill's own rate;
{"rate": r} uses a stated rate.
- Versioned billing baselines (#72). camber mv freeze | rebaseline | adjust | propose | report
| list | run handle billing entries, keyed (facility, name, "mv_bills"), with the #48
reason, lock and audit rules.
- The frozen record stores the model (including BillingDegreeDayModel), the bills (start, end,
days, energy, estimated, cost), the unit, the weather basis and the bases with their selection
profile.
- The run path and mv report measure against the in-force version at its own bases.
- A rebaseline names its window (--period) and, under auto, selects the bases afresh. A
change of bases is recorded as bases_changed.
- mv report shows each version's bases, the avoided cost per link and the calendarized months.
- Validation (#72). tests/test_mv_billing_bases.py covers:
- synthetic buildings with heating / cooling bases of 58 / 68 °F, recovered within one 1 °F
step. A scratch run over 40 seeds recovered them in 40 at low noise and 37 at moderate noise,
with the truth inside both ranges in 38;
- a flat profile, which is flagged;
- degree days built from each day against those from the bill mean on shoulder-month bills
(per-day RMSE about 1.7 against 5.0, better on 40 of 40 seeds);
- bills of 28 to 35 days from a mid-month start with an estimated read, and a known 12% saving.
examples/bdg2/billing_agreement.py (not gated) re-expresses the BDG2 daily meters as 28 to 35
day bills with a 10% injected saving. On electricity meters whose daily model has CV(RMSE) ≤ 30%
(886), the billing saving (auto) was within 1 / 2 / 5 percentage points of the daily path's
on 67 / 85 / 98% of meters. The median difference was 0.0 points, and the saving was inside the
daily 90% band on 97%. On chilled water (172 meters) the figures were 58 / 82 / 98%, a median of
0.1 points and 98%. The mean differences are dominated by a few meters whose projection is near
zero, so medians and shares are the figures to read.
Changed
- Nothing by default. With no
weatherblock and no private flag, every request is the same URL as before, and every report and finding is byte-identical. The only new file is the audit log, which a configured weather fetch writes into its workspace state or next to its cache. - The RCx report's
oat_referencewith"fetch": "nasa_power"goes throughoat_reference_auto(POWER alone, as before) when a privacy policy or an audit log applies, so it honourscache_dir/offlinethere. - Billing entries (#72): with a numeric
base_f(or none), and no new keys, every finding is byte-identical. Additions: - a stored
mv_billsversion is now used by the run path. Before, a same-named stored version was ignored with a caveat; fit_frame_sha256also hashesdays,hddandcddfor a bills frame, while daily frames hash as before;BillingSeries.energy_vs_temptakesheating_base_f/cooling_base_fand records its bases inattrs;mvrun.MeterSeriesgainsbills/oat/oat_source;MeterChaingainsbilling.
[0.93.0] — 2026-10-03
0.93: hardening from real data, and the refrigerant cluster (#6, #37-#44, #68, #70). Real-data fixes to setback, leaking valves, simultaneous heating and cooling (dehumidification with reheat), DCV verification and CO2 over-ventilation (economizer hours); a VAV rule for a zone the box cannot heat; daylight-saving day lengths, holiday calendars and a break-day driver for M&V, and the energy-units follow-ups; an R-410A (and R-134a, R-22, R-32, CO2) saturation curve that turns refrigerant pressures and line temperatures into subcooling, superheat and approach; DX / heat-pump charge and indoor-airflow rules scored on the NIST heat-pump FDD data; the discharge-superheat drift detector deferred in #6; and operating-mode, capacity, same-room and source-loop rules for water-source heat pumps trended with three points.
Added
- A VAV rule for a zone the box cannot heat (#44).
rules.reheat_capacity_rule. ReheatCapacityShortfall(reheat_capacity_shortfall, built-in, terminal boxes only) flags a zone more than 1.5 F below its heating setpoint while its reheat valve is at or above 90 %: the box has run out of heating, so it is a capacity or airflow problem, not a tuning one. It judges occupied samples, leaving out morning recovery,warmupand fan-off samples. It warns at 5 % and faults at 20 % of them, once 10 hours have accumulated (screening-grade). The setpoint comes fromheat_sp, else from the config'sheat_sp_f(one value, or{equip: degF}); without either the rule declines. Withairflowandairflow_spthe finding says whether the box was short of air or of heat; the summary also reports the under-heated time with reheat to spare. Onlbnl-b59, zone 051 faults (35.8 % of occupied hours, median 2.9 F below its setpoint) and nine more terminals warn. The rule is in that dataset's run template, and itsfaultlabscenario is a gated synthetic key (see Benchmarks). - Coil leaving-air roles (#41, #42).
Role.HEAT_COIL_LEAVING_TEMP/Role.COOL_COIL_LEAVING_TEMP(heat_coil_leaving_temp,cool_coil_leaving_temp): the air straight after an air handler's own heating / cooling coil (G36's HCLT / CCLT), upstream of a draw-through supply fan and, for the cooling coil, of any post-heat coil. One definition each, shared byleaking_valve(#42) andsimultaneous_heat_cool(#41), with sensor-health bounds, flatline and fan-gated trust, the AHU equipment template and 223P hints. Theirish-ahumapping mapsHCALTemp/CCALTempto them (their 0.00 C outage readings are blanked like the other temperatures); thenuig-ahu101mapping maps TE_101_3 to the cooling-coil one (the dataset needs a re-ingest to pick it up). co2_ventilation_system(rules.iaq_rule.CO2VentilationSystem), a fleet rule, built in: each CO2 zone is joined to its serving air handler (served-by topology, else the naming heuristic; one economizing unit with no grouping takes every zone) and judged byco2_ventilationwith that unit's economizer-mode hours left out (#38). One finding, aper_zonebreakdown; categoryventilation; roles-only applicability.iaq.economizer_mode_mask(provisional): where an economizer brings in outdoor air beyond the ventilation minimum -- a trended economizer command, else the OAT below the high limit (75 F) with the OA damper more than 5 points above its minimum or the unit on ~100 % outside air (freecooling.integrated_economizer_mask). An OAT alone excludes nothing (#38).-
DemandControlledVentilation/DcvSystemVerificationparametersfull_outdoor_airandstratify_hour;assess_dcv(stratify_hour=True);DcvResult.lift_basisanddemand_lift_pooled;analyze_ahu(simul_classes=...)andAHUResult.simul_class_pct;SimultaneousHeatCoolparametersdehumidification,reheat_lift_f,dewpoint_margin_f,humid_rh_pct,fault_pct,warn_pct;CO2Ventilationparametersexclude_economizer,oa_damper_min_pct,econ_high_limit_f;CO2VentilationResult.econ_hours_pct,over_vent_econ_pctandover_vent_all_pct. -
Daylight-saving day lengths in daily M&V (#68). With the site's zone known (
source.timezone, or a catalog store'slocal_timezone), a dailymvday is as long as its clock: the autumn fall-back day sums 25 hours of energy (the repeated hour, which a naive index holds once, counts for both passes) and weights that hour twice in its mean temperature; the spring-forward day has 23.mandv.intervalfit.daily_energy_vs_tempandrate_to_energytaketimezone=;repeated_hour_weightsis new. Only fall-back days change, and nothing changes without a zone. Oncofactor-drammenthe fall-back days now match the publisher's fixed-offset data exactly (they were 3-5 % short). - Holiday calendars for the occupied-day driver (#68).
mv[].holiday_calendartakes a country code for bundled public holidays,"US"(federal, observed, 2011-2030),"NO"(Norway, 2000-2040) or"ES-<community>"(Spain per autonomous community, 2016-2026). It can also take{"country", "subdivision", "files", "dates"}to add calendar CSV files (date, orstart/endranges) and single dates. Each bundled file cites its sources (5 U.S.C. 6103 / E.O. 11582 checked against OPM; the Norwegian statutes; the annual BOE resolutions), andscripts/calendars_refresh.pyrebuilds them. The new modulecamber.calendars(HolidayCalendar,public_holidays,load_calendar_csv,register_calendarfor any other source, e.g. theholidayspackage, which CAMBER does not depend on) serves them. A day outside a calendar's coverage is left out, never treated as holiday-free. The new driver"break_day"withmv[].break_calendargives school breaks their own coefficient. As holidays they made the COFACTOR school models worse; as break days 15 of 16 schools met daily G14 acceptance (13 without). Thecofactor-drammentemplate now uses"holiday_calendar": "NO", with identical results. -
Energy-units follow-ups (#70). The chain report
camber mv reportfollowsunits.system: its page, JSON (unitsper meter) and CUSUM give energy in kBtu or kWh (MeterChain.units). Trended gas meters measured as a volume flow ("units": "cfh",CCF/h,Mcf/h,m3/h) convert withmv[].heat_contentor aunits.factor_set(mv[].meter_type), with no default (energy_units.VOLUME_FLOW_UNITS,quantity_of_rate). The fleet report takeseui_unit=/units=and labels its EUIs (FleetReport.eui_unit),camber fleetpasses the configs' shared system, and the agent context's fleet facts name the unit. Carbon factors may be given per any unit ({"rate", "per"}), converted throughconvert_rate(carbon.factor_per). In a bills file, spellings of one unit (kWh/kwh) are one unit, not mixed units. A greenhouse-gas factor set (eGRID / EIA) is documented as a follow-up. Every default output is unchanged. -
Refrigerant properties (#39).
camber.refrigerant:saturation_temp/saturation_pressure(bubble and dew) for R-410A (Lemmon 2003), R-744 (Span & Wagner 1996), R-134a, R-22 and R-32 (CoolProp's published ancillary fits to the reference equations of state; credited in NOTICE), gauge or absolute, psi / kPa / bar / MPa, degF / degC / K. No new dependency: the Wagner-form correlations are evaluated directly. R-410A matches the NIST REFPROP saturation temperatures published in the NIST heat-pump data within 0.011 degF (bubble) and 0.007 degF (dew); CoolProp is an optional cross-check in the tests only. Transformssubcooling,superheat,discharge_superheat,condenser_approach,evaporator_approach; NaN (a decline) above the critical pressure -- a transcritical CO2 gas cooler -- below the valid range or below vacuum;is_supercritical. See docs/REFRIGERANT.md. - Derived refrigerant roles. New roles
liquid_line_temp,suction_line_temp,discharge_line_temp,liquid_line_pressure,discharge_superheat_temp. A config equipment entry's"refrigerant": "R-410A"(orEquipRef/StoreEquipRef.refrigerant) makesresolvederive subcooling, superheat, discharge superheat and both approaches wherever a rule asks for them (refrigerant.derive_refrigerant_roles; a controller-reported value is kept). - DX / heat-pump refrigerant charge and indoor airflow (#40).
dx_refrigerant_charge(subcooling; superheat as corroboration, ormetric="superheat"for a fixed orifice) anddx_indoor_airflow(evaporator temperature split matched on return air and the newreturn_air_dewpoint_temp, or return RH), built-in: manufacturer targets (targets, per equipment by glob), universal limits without one, or a frozen fault-free baseline at matched outdoor / return-air conditions (analyze_periods; the newdxdrift family,dxdrift.diagnose_dx_drift). A narrowed split with lost subcooling is reported as a capacity (charge) symptom, not high airflow. Onnist-heatpump-fdd, leave-one-file-out: charge TPR 91% [83-95] at 8% [4-15] of fault-free files; airflow 22% [14-31] at 2% [1-8] (18/44 at 15%+ airflow reduction). - Discharge-superheat drift (#6).
rules.dx_discharge_superheat_rule.DischargeSuperheatDrift(discharge_superheat_drift, in thedxdrift family): two-sided frozen-baseline drift with the family's CUSUM, normalized on OAT and return air (DX) or tons (a water-cooled chiller); discharge superheat is mapped or derived from a discharge pressure and discharge-line temperature. On the NIST data it catches 32% [23-41] of charge-fault files at 6% [2-12] of fault-free ones: the weaker signal on TXV units, as #6 anticipated. - Water-source heat pumps with three points (#40).
rules.heatpump_ops_rule.infer_hp_mode(mode from discharge air against the zone) and the built-inhp_mode_vs_need,hp_capacity_shortfall(capacity vs control verdict) andhp_room_imbalance(fleet: units in one room that fight or split the work unevenly).source_loop_deltat: a heat-pump loop pumped with next to no temperature difference. New rolessource_loop_supply_temp,source_loop_return_temp,source_loop_diff_press,source_loop_pump_speed; new equipment familiesdxandsource_loop.
Changed
night_weekend_setbacktells a fan holding the setback from a missing one (#43). When the runtime test fails, a fan that cycles (mean duty below 90 % in the unoccupied hours it runs) while the zone sits at setback now reads "effective (fan cycling to hold)" (ok,setback_basis="held_setback"). The zone signal isspace_temp, else the return air while the fan runs. The setback is judged against the trendedheat_sp/cool_spin unoccupied hours, else the newunoccupied_heat_sp_f/unoccupied_cool_sp_f, else (heating side only) a zone at leastmin_setback_depth_f(3 F) below its occupied temperature. A trended setpoint that never sets back vetoes the test. A fan that runs through every unoccupied hour is still "MISSING", and the 5 % absolute runtime floor (#57) still decides first. With no zone signal, the runtime verdict now carries a caveat that it cannot tell the two apart. Onornl-frp-ops, the heating setback test (the fan cycles 46-85 % of each night hour to hold 15.6 C) moves from MISSING to effective. The baseline and pre-heat tests, which run the fan through the night by design, stay MISSING. The template now carries the descriptor's unoccupied setpoints.leaking_valveallows for fan heat and prefers the coils' own sensors (#42). New constructor parameters:fan_heat_f(the supply fan's temperature rise, default 2 F, G36's ΔT_SF,fdd_g36.G36Thresholds.dT_sf),delta_thr_f,valve_closed_thrandcoil_sensor_fan_heat. Fan heat is now an allowance, not an offset: a heating leak must rise more thanfan_heat_f+ 3 F above the mixed air, while a cooling leak gets no credit for it (supply more than 3 F below the mixed air). Until 0.93 a fixed 1 F was subtracted on both sides. A mapped fan status or speed now limits the check to fan-on samples. A coil with its own leaving-air sensor is judged on it (against the mixed air) instead of the supply air downstream of the fan; setcoil_sensor_fan_heat=Trueon a blow-through unit. New metrics:fan_heat_f,fan_gated,hw_basis/chw_basis,hw_median_delta_f/chw_median_delta_f. Onirish-ahuthe 34 % heating-leak signature before the 2022-05-01 valve replacement (0.2 % after) falls to 11 % on the heating coil's own leaving air (0 % after). The supply air sat 2.6 F above the cooling coil's leaving air before the date, so most of the old signature was downstream of the coils. The whole record reads ok. The LBNL SDAHU benchmark runs keep their verdicts; the 10 % leak run is still missed.simultaneous_heat_cooltells dehumidification with reheat from coil fighting (#41). A both-open interval with the fan running, the cooling coil leaving at least 2 F below the supply air (the heat is added after the coil) and at or within 2 F of the entering dew point (outdoor and return dew points from temperature + RH, the lower of the two; else a return humidity of 55 % or more) is dehumidification with reheat: reported (dehum_reheat_pct), not counted. A coil leaving above the entering dew point is dry, and the reheat after it still counts. Partial evidence (reheat after the coil but no humidity, or high humidity but no coil-leaving temperature) isdehum_possible_pct: a caveat that caps the finding atwarn, never a fault.dehumidification=truedeclares the sequence (reheat after the coil suffices unless a dew point shows the coil dry);falsecounts every both-open interval as before. The severity is judged onunexplained_hc_pct. Units with none of these signals are judged exactly as before, with a caveat when they trip. Onnuig-ahu101(13.3 % of occupied hours both open) 5.2 % now reads as dehumidification with reheat (June-July, the coil held at ~12 C below the outdoor dew point) and 8.1 % stays unexplained (winter hours with the fan stopped, and summer hours with the coil held at ~12 C while the entering air is drier): still a fault, now for the part that is one.- DCV verification compares CO2 within the hour of day (#37).
assess_dcvtakes the CO2 lift (CO2 when OA is raised minus CO2 at its floor) within each hour of day, weekdays and weekends apart, as it already did for occupancy, whenever enough same-hour pairs exist; else the pooled lift decides, with a caveat that a time clock cannot then be told from CO2 response. A valve that follows a clock and CO2 no longer reads "uncorrelated" because its clock-driven morning opening drags the pooled lift down: onb4b-windesheimroom 917810 now reads "functioning" on both of its sensors (pooled: "uncorrelated" on one); room 999169 stays sensor-dependent.finnish-dcv's DCV-law test still reads "functioning" (lift 332 ppm); its two training sets of undocumented mixed strategies both read "uncorrelated" (training 1 was "functioning" pooled). - DCV falls back to the OA damper where OA flow is missing, and to fan speed on a 100 %
outdoor-air unit (#37). The best OA signal judges the samples it covers and a lesser one the
rest (
metrics["oa_segments"], each with its verdict and date span; worst severity wins). Onlbnl-b59the months before the OA-flow record (Aug 2019 - Mar 2020) are now judged on the damper: "not judged -- demand never varied", like the flow period (no DCV; zone CO2 within ~150 ppm of outdoor).full_outdoor_air=trueadds the supply airflow, then the supply fan speed, as proxies with no economizer exclusion; thenuig-ahu101template now runsdcv_verificationon the fan speed ("not judged" on fan-on hours; the occupied fan-off hours at full-scale CO2 are the fault they were designed to catch). co2_ventilationleaves economizer-mode hours out of over-ventilation (#38). Where the equipment carries an economizer command, or an OAT with an OA damper or mixed/return temperatures,over_vent_pctis judged on the other occupied hours (not judged below 10) and the economizer hours are reported apart; a zone with no such evidence that reads over-ventilated says the air handler's economizer may be the cause and points toco2_ventilation_system. Thelbnl-b59template now runsco2_ventilation_system: 57-71 % of occupied hours are economizer mode and set apart, and the zones still sit within 150 ppm of outdoor in 99-100 % of the remaining minimum-damper hours -- over-ventilated at the minimum, not only while economizing.nist-ibalmaps its liquid- and suction-line RTDs toliquid_line_temp/suction_line_tempand its run template names R-410A, so the whole refrigerant-side chiller drift family runs. Specificity: 0 of 36 monthly detector-windows (April-September 2025 against a January-March baseline) raised a magnitude alarm; 2 provisional CUSUM prompts at severity ok. New data issuerefrigerant-pressure-below-vacuum(a dead transducer reading -70 psig).nist-heatpump-fddrunsdx_refrigerant_charge(target mode) and declares it as the scored detector for the charge-fault runs. Its mapping now carries discharge superheat, the raw line temperatures and pressures and the indoor inlet dew point.
Fixed
- A catalog entry whose
timezoneis a prose description of its clock (BDG2, Valladolid) is no longer taken as the site's zone by a store source (it failed ashared_oatfile read). nist-heatpump-fddsuction pressure. The 16 SEER unit's1710_ODSuctPort_psiais a copy of the discharge pressure;suction_pressurenow maps the vapour service valve pressure (1701_ODVapSV_psia), the one NIST itself used for that unit. New data issuessuction-port-pressure-copies-dischargeandfault-free-points-not-steady-cooling.
Benchmarks
- Synthetic baseline refreshed with the maintainer's sign-off. The faultlab scenarios of the
six new single-equipment rules moved from
faultlab.PENDING_SCENARIOS(now empty) into the gatedSCENARIOS:reheat_capacity_shortfall(#44),dx_refrigerant_charge,dx_indoor_airflow,hp_mode_vs_need,hp_capacity_shortfallandsource_loop_deltat(#40). The synthetic baseline gains their.tpr(1.0) and.fpr(0.0) keys, andcoverage.n_scored/coverage.n_singlemove from 39 / 39 to 45 / 45 (the other new rules are fleet or drift rules:co2_ventilation_system,hp_room_imbalance,discharge_superheat_drift). Every other gated key is byte-identical, as are the fleet, LBNL, BDG2 and BDG2 savings baselines. See docs/VALIDATION.md.
[0.92.0] — 2026-10-03
0.92: detection gaps on the complete catalog data (#11-#17, #50, #64-#67, #69, #71). New plant detectors (boiler combustion efficiency, tower fouling from fan effort, the condenser-water bypass leak), a plant run gate and cross-sensor physics in sensor trust, the system-level ASHRAE 62.1 VRP, the G36 supply-air reset direction and the FC13-only plant link, days-weighted billing M&V with a config path and weather fallbacks, energy reported in kBtu or kWh by a config unit system, revised benchmark target lists, and two open meter datasets, one of them the published real-data SEP chaining case.
Added
- A plant run gate for sensor trust (#66).
schedules.plant_run_mask(frame, loop)reads when a chiller ("chw": run status, else power above a tenth of its own 95th percentile) or a boiler ("hw": status, else gas input) ran, andframe_sensor_health(frame, plant_gate="auto")judges the plant roles (sensorhealth.PLANT_GATED_ROLES) on those running samples: range, outliers, flatline and stuck runs over running samples, coverage over the whole span, the first 30 minutes after a start left out. A chiller that sat off no longer reads as a stuck or out-of-range chilled-water sensor. New flagnot_running;SensorTrust.run_gatenames the gate. The runner's trust gate and the RCx report use it;frame_sensor_healthkeepsplant_gate=Noneby default. Role.GAS_INPUT_RATE(gas_input_rate): a boiler's fuel input rate, kW (#13, #66).- Boiler combustion-efficiency drift (#13).
rules.boiler_efficiency_rule.BoilerEfficiencyDrift(boiler_efficiency_drift, the newboilerdrift family) compares a boiler's gas input per unit of heat delivered (500 x gpm x delta-T, or pump speed x delta-T without a flow meter) with a frozen baseline at matched load (and return-water temperature where it moves). One-sided up; warn at +5 % and 1.5 sigma, fault at +15 % and 3 sigma (screening-grade). A second frozen model, gas against OAT, corroborates: a ratio rise that the gas burned at matched weather does not share is reported as a heat-metering problem (info,attribution="heat_metering"), not a fouled boiler; without OAT the severity is capped at warn.plantdrift.diagnose_boiler_driftrolls it up. Thelbnl-boilercatalog mapping now mapsBOI_GAS_CSUM_1(boiler 1's gas input, kW) togas_input_rate; BrickNatural_Gas_Flow_Sensormaps to it, andGas_Meterused as a point type is accepted as an alias with a caveat. - Condenser-water tower-bypass valve leak (#15).
rules.condenser_bypass_rule. CondenserBypassLeak(condenser_bypass_leak, built-in) compares the water entering the chiller condensers with the towers' leaving water while the bypass is commanded shut and a chiller runs: warn at a 2 F median difference, fault at 5 F (screening-grade), with the bypassed fraction estimated from the condenser range. A difference that does not grow with the range (a miscalibrated sensor), or entering water colder than the tower's, is reported as a sensor offset (info), not a leak. Two new roles:Role.COND_ENTERING_WATER_TEMP(condenser water after the bypass mixing) andRole.CW_BYPASS_VALVE(the bypass command/position, %). Brick:Condenser_Water_Bypass_Valvevalve points andBypass_Commandmap to the valve role; a cooling tower'sLeaving_/Entering_Water_Temperature_Sensormap tocw_supply_temp/cw_return_temp, and a chiller'sEntering_Condenser_Water_Temperature_Sensorbecomescond_entering_water_tempwhen the model has that separate tower point. Thelbnl-chillercatalog mapping mapsCDWL_SW_TEMPandTWV_CTRLto them. Its synthetic scenariocondenser_bypass_leakis a gated synthetic benchmark key (see Benchmarks). - Cooling-tower fouling from fan effort (#14).
rules.tower_fan_effort_rule. CoolingTowerFanEffortDrift(cooling_tower_fan_effort_drift, in the newtowerdrift family with the approach drift) compares the tower's fan speed with a frozen baseline at matched load (the tower range, else chilled-water tons) and wet-bulb (measured, or OAT + RH). A controlled tower that fouls keeps its approach and works its fans harder, which the approach rules cannot see. One-sided; warn at +5 %-points, fault at +10 (screening-grade). A biased leaving-water sensor drives the fans the same way, so when the condenser-entering water is trended the rule checks the two sensors' offset against the baseline; a shift of 1 F or more is reported as a sensor problem (info,attribution="sensor_offset").plantdrift.diagnose_tower_driftrolls the family up. examples/lbnl_fdd/plant_detectors.pyscores the three plant detectors on the labelled LBNL chiller and boiler plants with Wilson intervals (measured, not gated): boiler fouling 3/3 with 0/14 false alarms, tower fouling from fan effort 2/3 with 0/21 (the approach drift: 0/3), the condenser bypass 5/5 with 0/19. New pagedocs/PLANT-DETECTORS.md; results indocs/VALIDATION.md.- OAT cross-check without a reference (#66). With no
oat_reference, the RCx report compares the site's OAT sources with each other: three or more against their median (the outlier gets a scopedsensor_drift:oatfinding), two shown side by side with no finding. - System-level ASHRAE 62.1 Ventilation Rate Procedure (#17). An air handler serving several
zones is judged against the system intake
Vot = Vou / Ev(Vou = D·ΣRp·Pz + ΣRa·Az,D = Ps / ΣPz), not one zone's Voz.camber.ventilation.system_outdoor_aircomputes it with the simplified Ev of the free 62.1-2016 Addendum f (0.88·D + 0.22below D = 0.60, else 0.75; the default, with the addendum'sVpz-min ≥ 1.5·Vozcheck when minimum primary airflows are given) or the multiple-zone appendix calculation (Evz = 1 + Xs − Zpz), for multiple-zone, single-zone and 100 % OA systems, with mode-aware Ez (1.0 cooling, 0.8 heating).assess_system_62_1judges each sample against its mode's Vot; without a flow stationestimate_oa_cfmestimates OA from the mixing temperatures × supply airflow with a propagated band, and a verdict must hold across it. The fleet ruleventilation_system_62_1(VentilationSystemVRP) runs from a new configventilationsection (a zones CSV or list, per-systemps/d/vps_cfm/system_type/method); zones join their air handler by a declaredsystem, else the configtopology. It declines when an input is missing, caps atwarnfor assumed areas or populations, a temperature estimate, or membership from a Brick model, the naming heuristic or a single-source fallback, and says so. Section and table numbers of the 2019/2022 editions, default densities and Ez rows are marked unverified (docs/VENTILATION.md). On the open LBNL Building 59 data, with a stated area assumption, all four RTUs are over-ventilated 3.4–5.6×. - Days-weighted billing fits (#64). A bill's per-day energy is the mean of its days, so its variance
falls as 1/days. The change-point fitters,
fit_stats, the regression tests, the NRE indicator fit, the exact kernel and the G14 FSU now take the bills' days:weights=fits by weighted least squares, anddays=sums per-day predictions back to energy with the matching noise term. Equal weights are neutral (the unweighted fit, byte for byte), and daily and hourly paths are unchanged. On synthetic bills of uneven length (8-62 days), the exact 90% band covers the planted saving 89.9% of the time over 1,000 runs, and a linear fit's slope error falls 11%. The non-routine detectors fit billing baselines with the same weights. - Config
mventries on bills (#64). An entry with"bills": "gas.csv"(start, end, energy, and optional units and estimated-read columns) runs the full M&V flow oncamber.mandv.billing.BillingSeries: per-bill mean temperature and degree-days, days-weighted baseline at the G14 monthly thresholds, coverage, validity, every SEP method andauto, and the adjustments ledger (bills expanded to their days, so an adjustment is dated to the day). Estimated reads are merged into the next actual read (BillingSeries.merge_estimated). The temperature comes from the entry'soatfile, an opt-in fetch, orshared_oat; a bills-only config needs nosource. Versioned baselines andcp_driverare not supported for bills yet (docs/MANDV.md, "Billing data"). - Weather fallbacks (#64). An hour-of-day correction after the monthly offset, per season and UTC
hour: out of sample at three public airports it cut the NASA POWER fallback's hourly RMSE from
3.8-5.9 °F to 2.9-3.4 °F. Neighbouring ISD stations that fill gaps are now offset-corrected
against the reference station by the same method. Open-Meteo is a third, keyless source
(
fetch_open_meteo), bias-corrected in the same way.oat_reference_blended(fallbacks=...)sets the fallback order, andoat_reference_autopicks a source by name. The RCxoat_referenceaccepts"fetch": "auto": ISD, then POWER, then Open-Meteo, with the source of each hour in the report. Weather requests carry only coordinates and dates. - Quirk ops
remapandfill(#50).remapmoves columns within a time window, all at once, for a header that names the wrong columns from a date onward.fillfills a short run of missing values with the median of the same clock time on nearby days; gaps longer thanmax_runstay missing. lbnl-b59ingests its electricity meters and the heat pump's water temperature (#50). SixELECTRICITY_METERequipment andHPare added. The 2020 column shift is undone by aremapfix. The HVAC meters' zero dropouts are masked and filled. The heat-pump swap that leaves the metering boundary is annotated, not corrected: it is a non-routine event. The fixes reproduce the earlier hand correction to within 0.4 % of HVAC energy.- Catalog:
valladolid-uva(#50). Two University of Valladolid buildings (Mendeley Data doi:10.17632/mzkyh37mtr.2, CC BY 4.0, re-verified on the host): hourly whole-building electricity 2016-2020 with the publisher's daily NASA POWER weather. Pinned originals, mappings, a daily M&V template, and eight data issues with evidence: hour-ending local stamps with DST handling that changes between years, daily weather repeated on every hour, mislabelled weather units, aHOLIDAYflag that is an academic calendar and changes in 2020, gaps left empty although the paper says they were interpolated, files named A/B against the paper's Building 1/2, Building B's meter netting out on-site generation, and a 2020 COVID-19 year the description does not mention (kept out of the chaining analysis). examples/valladolid/chaining.py(#50): the real-data SEP chaining case. Working days, monthly rows weighted by days, a station OAT series from the weather fallback chain, baseline 2016, reporting 2019, 2017 and 2018 intermediates andselect_method's own proposal; 2020 kept out. Published in docs/VALIDATION.md with the maintainer's sign-off: building 2 SEnPI 0.838 ± 0.037 (16.2%, including on-site generation behind the meter), building 1 (the control) 1.022 ± 0.046, agreeing through both intermediate years.- Catalog:
cofactor-drammen(#50). 45 Norwegian public buildings (Lien, Walnum & Sørensen 2025, doi:10.1038/s41597-025-04708-3; Zenodo v3, CC BY 4.0, re-verified on the host): four years of hourly electricity import, sub-meters, district heat and per-building outdoor temperature. The fixed UTC+1 stamps are stored on the Europe/Oslo clock. Twelve data issues are documented, among them the spring-2020 COVID-19 closures, a heat-pump heat meter 100x too large at one building until May 2019 (masked), meters that stop in March 2020, and sub-meters that exceed the import. An M&V template fits 2018 daily baselines, electricity with an occupied-day driver. The wide-CSV reader gainssepandheader_markerfor text exports that open with a metadata block. - Energy units: kBtu (IP) or kWh (SI) (#69, provisional). A config
"units": {"system": "ip" | "si"}reports energy in kBtu or kWh, demand in kBtu/h or kW, and EUI in kBtu/ft2/yr or kWh/m2/yr. Temperatures, pressures and flows are unchanged. Without the block every output is byte-identical to before. The newcamber.energy_unitsconverts kWh, MWh, Wh, kBtu, MMBtu, Btu, therms, GJ, MJ and ton-hours through kWh, and kW, kBtu/h, MBH and tons. The factors are exact: the IT Btu is 1055.05585262 J, so 1 kWh = 3.412142 kBtu and 1 kBtu = 1.055056 MJ. Gas by volume (Mcf, CCF, m3) needs an explicit heat content and steam by mass an explicit enthalpy. Unknown and ambiguous units (MBtu,Mlb, a baretonfor energy) are refused. Where the system applies: - M&V. Savings, bands, adjusted figures, chain links, the waterfall and the
autosensitivity table are converted and named (energy_unit,meter_unit, the unit in each summary). The fits stay in the meter's unit. A trended entry names its rate unit ("units": "kW"). - Billing. The bills' unit must parse, and gas in Mcf needs
bills.heat_content.BillingSeries.convertedconverts a series in code. - SEP.
primary_energyandaggregate_energy_typestakeunits=and convert each energy type's delivered units to energy before the Annex B multipliers. - EUI.
bps.site_eui_unitstakes a stated area unit, andreport.benchmark.unitlabels the audit report's EUI. - Prices. A
priceblock takes a rate in any unit ({"rate": 8.5, "per": "Mcf", "heat_content": ...}).
bps.EUI_FACTORS_KBTU keeps its historical 3.412 kBtu/kWh, 0.004% below the exact factor, so
site_eui does not move (docs/UNITS.md).
- Energy conversion factor sets: ENERGY STAR "Thermal Energy Conversions" (#69,
provisional). The new camber.energy_factors loads published factor sets from JSON files in
the package and validates each one. The first set is energy_star_thermal_2015: every factor
of Figures 2 and 3 of the ENERGY STAR Portfolio Manager technical reference (U.S. EPA, August
2015), with US and Canadian columns. It covers 17 meter types, from electricity, natural gas,
fuel oils, propane and district energy to coal, coke and wood, in 210 entries. The set is
pinned by URL, edition, retrieval date and sha256, and keeps the source's footnotes (40 CFR 98
Tables C-1/C-2, Statistics Canada, IDEA). The loader checks every multiplier against its heat
content and unit size. Five rows that the source prints inconsistently are kept as printed and
raise a caveat when used. to_kbtu(value, unit, meter_type, factor_set=, region=) and
factor_for convert with a set.
- Config (opt-in). "units": {"system": ..., "factor_set": "energy_star_thermal_2015",
"region": "US" | "CA"} converts billing entries in volume or mass with the set's heat
contents: gas in cf/kcf/MMcf/m3 (1,026 Btu/cf US, 1,031.43 CA), oil, diesel, kerosene and
propane in gallons or litres, steam in lb/klb, and coal and wood in tons. bills.meter_type
names the fuel. An explicit heat_content or enthalpy always wins. Energy units keep the
exact factors. The findings record the factor used (energy_factor) and a provenance
caveat. Without factor_set nothing changes.
- "M" means a thousand or a million. ENERGY STAR writes Mcf for a million cubic feet, and
many US utilities write it for a thousand. A bare Mcf stays a thousand cubic feet, as in
camber.energy_units, and MBtu and Mlb stay refused. Each conversion of a bare Mcf
now carries a caveat naming the 1,000x conflict. With a set, kcf, MMcf and
million cf, MMBtu and MMlb are accepted.
- bps.EUI_FACTORS_KBTU is unchanged. Its 3.412 per kWh, 100 per therm and 12 per ton-hour
equal ENERGY STAR's, and a test keeps them so. docs/UNITS.md has the details, and
docs/ENERGY-FACTORS.md is the generated table. scripts/energy_factors_refresh.py
validates, re-pins and documents a set.
- Unit-scale plausibility: 1000x prefix errors in billed quantities (#71, provisional). The
new camber.unit_scale asks of each meter whether its quantities are right as given, x0.001 or
x1000. A steam bill printing MLb for thousands of pounds, MMcf entered as Mcf, and MWh
labelled kWh all pass every schema check and move every result 1,000x. check_bills,
check_series and check_eui return a UnitScaleCheck: the most likely scale, a confidence,
each evidence item's verdict and an explanation. The evidence items are:
- a tariff recompute of each electricity bill under a Tariff or URDB rate (weight 4);
- the implied $/MMBtu against the state's EIA commercial price, or the bundled bands (3);
- site EUI against the ENERGY STAR property-type median and hard bounds (2 or 3);
- heating fuel per HDD per ft2 and the peak bill's load, as lb/h for steam (2);
- the load factor (2) and the meter-read arithmetic (2);
- ~1000x steps bill to bill, confirmed year over year.
The rule is fixed and conservative. A scale is ruled out only by an item of weight 3 or more
(or 4 in total) that outweighs the strongest item for it. Price and tariff evidence outweigh
EUI, and decisive price evidence is enough on its own. Nothing is corrected: the explicit
bills.scale_override: {"factor": 0.001, "reason": ...} is the only correction, and it is
recorded on every finding. Where it runs:
- Billing M&V. Bills that are implausible as given get a unit_scale warning, and their
M&V is declined unless bills.scale_check.on_implausible is "warn". bills.scale_check
names the fuel, area, property type, cost and demand columns, a tariff and the price source.
Plausible bills produce exactly the output they did before.
- Report benchmark. An implausible benchmark.site_eui adds a unit_scale finding.
- BPS. bps.site_eui_plausibility judges each fuel behind an EUI.
- BDG2 ingest. Each implausible meter is an ingest warning. The data are not rescaled.
Two screening references join camber.energy_factors as new kinds (factor_sets(kind),
get_reference_set). camber_price_bands_2024 (price_band) holds $/MMBtu bands per fuel,
from EIA 2024 state prices and CBECS 2018 Tables C1/C2. energy_star_us_median_eui_2024
(eui_reference) is the ENERGY STAR U.S. Energy Use Intensity by Property Type (August
2024), sha256-pinned, with CAMBER's policy factors. camber.interop.eia.fetch_state_price is
opt-in: it needs EIA_API_KEY, sends only the state and the months, caches by the key-free URL
and works offline. Without a key the bundled bands are used.
On the BDG2 cleaned meters the check independently catches the documented Eagle chilled-water
error: 82 of 87 meters are implausible and 4 more are uncertain, all pointing to x0.001. It
also finds the same signature in 58 of Eagle's 60 hot-water meters (now a catalog data issue,
below). It flags 4 of 1,572 electricity meters (0.25%), all meters that stopped reading.
docs/UNITS.md, "Unit-scale plausibility".
- The ENERGY STAR median EUI reference is complete, and two BDG2 hot-water data issues (#71).
energy_star_us_median_eui_2024 now carries all 46 printed Portfolio Manager property types,
the Public Services row that opens page 4 included. The site-neutrality guard exempts that one
rule in that one file only (exempt_paths), and the local denylist accepts the same per-line
exemption (pattern<TAB>paths, see .githooks/denylist.local.example). The bdg2 entry
records eagle-hot-water-1000x (58 of 60 meters at a median 867x the building's electricity,
x0.001 most likely) and fox-hot-water-scale (15 meters at 2,244-43,785 kBtu/ft2/yr on sound
floor areas; the hot-water energy scale is at fault, with no single factor). Both are
annotated, flagged by the unit-scale check at ingest and never rescaled. docs/DATASETS.md.
Changed
- Sensor trust reads the mixed-air flow balance and copied points (#16).
frame_checks(and soframe_sensor_health, the runner's trust gate and the RCx report) now appliescopied_signal_consistencyandmixing_flow_consistency. A measured point that carries another point's data is flaggedcopied_signal; the copy is told from the original at the edges of the identical stretch (it jumps across the gap between them) and loses trust by the share of its samples that are copied, capped at "suspect"; when the copy cannot be told apart both are flagged and capped at "suspect". A MAT that fails the flow-weighted OA/RA balance is flaggedmixing_balanceand capped at "suspect"; OAT and RAT are flagged, not lowered. Both flags make the unit's findings on that point conditional in triage (sensor_causes), on that unit only. On the open LBNL Building 59 data (catalog example) RTU01/RTU02's MAT drop to "suspect" and RTU04's copied return air to 0.56, while its supply air keeps its score. - The chilled-water plant rules fall back to chiller power (#66).
chw_plant_resetandchw_supply_trackinggate on the chiller's power (run_source="power", with a caveat) when no run status is mapped, before falling back to the supply temperature. - The all-points-frozen trust check ignores intervals a plant was off throughout (#66).
oat_reference_blendedcorrects the daily cycle by default (#64) (diurnal=Falsegives the 0.90.1 monthly-only offset) and corrects gap-filling stations (station_offsets=Falseturns this off). The bias record'srmse_after_fis the RMSE after the full correction, andrmse_after_monthly_fis the RMSE after the monthly step alone.lbnl-b59is a documented data-issues teaching case for M&V, with no published savings (#50). Its known issues now point tovalladolid-uvafor chaining, and say that the system-level 62.1 VRP (#17) runs on it with stated assumptions rather than not at all.
Fixed
supply_air_resetread supply air rising with OAT as a reset (#65). A G36 cooling SAT reset lowers supply air as OAT rises and for each cooling request (§5.16.2.2; the trim-and-respond response is negative), so the reset is now a negative SAT-vs-OAT slope, and a trended setpoint must move against its driver (OAT or SAT reset requests). Supply air that rises with OAT over cooling hours reads "SAT RISING WITH LOAD (possible capacity shortfall, not a reset)" (info,warnwhen cold supply air dominates) with a caveat to check the chilled-water supply and the cooling valve; a setpoint that rises with its driver is "in the wrong direction" (sp_wrong_direction) and neverok. New:SATResetResult.directionand the finding metricreset_direction(reset/rising_with_load/flat/ None). Thefaultlabclean scenario forsupply_air_resetnow resets in the G36 direction; the synthetic benchmark did not move.- The G36 -> plant link overlapped the plant with every G36 fault hour (#67). The same-hours
test for a
g36_afddFC13 finding used the rule's violation mask, the union of all fault conditions, so duct-static (FC1) or economizer hours counted as plant symptoms.g36_afddevidence now carries one mask per evaluated fault condition (Evidence.masks, provisional), andlink_findings(part_mask_for=...)reads FC13's own hours (the RCx report passes it).UpstreamCause.unit_hourssays which hours were used ("FC13"or"finding"), and the "why" line names them. - One unit's stuck OAT made the whole site conditional (#66). A stuck or untrusted OAT on
one air handler that trends its own sensor was a site-wide cause, so the chiller and boiler
findings (which read the weather station) were marked conditional on it. The RCx report now
passes
link_findings(..., shared_scope=...)(andsensor_causes) the units that read each OAT source, and the cause taints only them.
Benchmarks
- Chiller-plant benchmark lists are explicit (#11). The chiller-fouling runs have been
chiller_efficiencytargets since 0.86. The chiller, tower and secondary-DP sensor-bias runs are now listed as negatives for both plant rules, and the chiller-fouling and bypass runs as cross-negatives forcooling_tower_approach. A run on no list is excluded and printed instead of counting as a negative by default. No count moved:chiller_efficiencyTPR 6/11, FPR 2/13;cooling_tower_approachTPR 0/3, FPR 0/14 with 7 declined. - The
lbnl-chillercatalog entry declares its scored targets, socamber datasets score lbnl-chillerreproduces the benchmark'schiller_efficiency6/11 and 2/13 from the store. - Sensor bias vs physical fault on the chiller plant (#11, new, opt-in, ungated).
compare_to_referencecompares chiller 1's leaving water with the primary supply (chiller 1 running alone) and tower 1's leaving water with the condenser supply (tower 1 alone, bypass commanded shut). At the default 2.0 °F threshold both pairs score TPR 2/4. The chiller pair has FPR 0/20. The tower pair has FPR 5/20: on the five bypass-valve runs the valve ignores its command. New keyschiller.sensor.*. - VAV drift target lists revised, and series fan-powered boxes scored (#12). The lists now
follow each rule's one-sided physics. Stuck dampers at 0 / 20 % are excluded from airflow drift.
Reheat targets the valve stuck at 0 / 20 % and coil fouling. Over-delivering valves, the stuck
dampers and the sensor biases are cross-negatives. Parallel boxes: airflow 4/6 → 5/5 with 0 false
positives in 24, reheat 1/7 → 2/8 with 0 in 17. Series boxes are scored for the first time, with
the reheat duty on the fan discharge flow: airflow 5/5 with 4 false positives in 24, reheat 2/8
with 4 in 14. New keys
drift.sfpu.*. None of these metrics is gated (optin-measured.json). - Synthetic baseline refreshed for
condenser_bypass_leak(#15; maintainer sign-off for 0.92). Itsfaultlabscenario moved fromfaultlab.PENDING_SCENARIOS(now empty) intoSCENARIOS. Added:condenser_bypass_leak.tpr1.0 /condenser_bypass_leak.fpr0.0;coverage.n_scoredandcoverage.n_single38 -> 39. Every other synthetic key is byte-identical to 0.91.0, and the fleet, LBNL, BDG2 and BDG2 savings baselines did not move. The plant run gate (#66) left the opt-in chiller metrics unchanged (optin-measured.jsonmatches).
[0.91.0] — 2026-09-27
0.91: real-data correctness (#60-#63, #32, #33, #35). Fixes for wrong or misleading results
found in private checks on real buildings (two offices with air handlers, chillers, boilers and VAV
boxes; a ground-source heat-pump school), plus RCx report speed and plant weeks. Every fix has a
synthetic reproduction in the test suite. New names are provisional (docs/API-STABILITY.md).
The fleet, LBNL, BDG2 and BDG2 savings benchmark gates did not move; the synthetic baseline gained
the two new rules and nothing else (see Changed).
Fixed
- The G36 engine scored fan-off intervals (#60).
run_g36_afddread a stopped AHU with both valves shut as OS#2 free cooling and tripped FC8/FC9 on stagnant duct air. G36 §5.16.14 suspends AFDD while the AHU is not operating, so the engine now gates on the supply fan in theschedules.fan_on_maskorder:FAN_STATUS, elseFS(speed), elseAIRFLOW.G36Result.fan_gaterecords which signal was used. With no fan signal the run is declined (G36Result.declinedplus a caveat).fan_gate="none"evaluates every row and says so.os_distributionandn_unclassifiednow count fan-on intervals only. - Cooling-only AHUs returned None (#60). A frame without
HCis now an AHU without a heating coil: HC is taken as 0 %, FC7 and FC15 are omitted (G36Result.omitted), and a caveat says so. A frame withoutCCis handled the same way (FC13 and FC14 omitted). With neither valve the run is declined. - No G36 time delays (#60). FC1 fired on the normal morning duct-static ramp, and a single 5-minute excursion at a state change counted as a fault. The engine now applies the §5.16.14 filters, with the defaults verified in the public Addendum p to Guideline 36-2021:
- ModeDelay, 30 min: no evaluation after a fan start, or after a change of the optional
MODE(zone-group mode) column. - AlarmDelay, 30 min: an FC counts only in episodes that stayed true that long. A confirmed episode counts in full, and a data gap breaks an episode.
- Rolling averages, 5 min, of the measured temperatures and duct static.
All three are keyword arguments (mode_delay_min, alarm_delay_min, avg_window_min); set
them to 0 to score the raw per-interval equations.
- Rules ran on the wrong kind of equipment (#61). Rules are gated by roles, so a VAV box's
discharge air ran supply_air_reset, supply_air_control and the SAT reset census as if it were
an air handler, an air handler's heating valve entered the terminal reheat census, and any
power point looked like a chiller. Every built-in rule is now classified
(camber.rules.applicability): air-handler rules, terminal-box rules and plant rules decline a
recognised equipment class they are not written for (an info finding that says why), fleet
rules leave it out of the batch, and the rules whose roles already say enough stay roles-only.
Class names are read by family (camber.model.equipclass: RTU and AHU are both air
handlers, HEAT_PUMP and WSHP are heat pumps, CHILLEDWATER_METER is a meter). An
unrecognised class is never declined; the rule runs on its roles with a caveat. The RCx report's
air side (the SAT reset census, the economizer, air distribution) holds air handlers only.
- The RCx OAT reference check compared only the first AHU's OAT (#61). Every distinct OAT source
is now compared, one row per source: units reading the same sensor share a row, and a unit
trending its own sensor gets its own sensor_drift:oat finding, scoped (scope_equips) to the
units that read it, so one AHU's offset no longer makes the other AHU's findings conditional.
- chw_plant_reset faulted a chiller that never ran (#62). It decided "running" from the supply
temperature alone, which a stopped chiller on a cold or shared loop satisfies. It now uses the
chiller's run status or command (compressor_status) when mapped: a chiller that never ran is
reported as not judged. Without a status it falls back to the temperature window and says so.
- free_cooling_missed counted an integrated economizer as missed free cooling (#63).
Mechanical cooling while the unit is already on ~100 % outside air is no longer counted. The test
is the RCx economizer page's, now shared as camber.freecooling.integrated_economizer_mask: the
measured OA fraction ≥ 80 % where |OAT − RAT| ≥ 5 °F, else the OA damper ≥ 90 %. The rule
takes OA_DAMPER, MIXED_AIR_TEMP and RETURN_AIR_TEMP as optional roles and reports the
integrated hours; without any of them it counts as before and caveats that it can't tell.
- reheat_penalty trusted contradictory valve data (#63). The reheat valve is cross-checked
against the discharge-air rise over the entering primary air (else the box's closed-valve
discharge, else a nominal 55 °F primary air). A valve at ≥ 90 % with a median rise under 5 °F is
declined: severity info, the valve-based shares withheld, so nothing is counted or costed. A
shut valve with a ≥ 10 °F rise on ≥ 25 % of its samples is caveated, and a confident ok
becomes info.
- static_pressure_reset called a one-time setpoint step a reset, and supply_air_reset read a
capacity shortfall as one (#63). The new camber.setpoint_reset.classify_setpoint_reset requires
a reset to move on at least 3 days and 10 % of the days judged (fan-on samples), and to move with
its driver (requests; else supply airflow for static, OAT for supply air; |Spearman rho| ≥ 0.3).
A flat or stepped static setpoint warns as "not reset"; a trended SAT setpoint that is flat or
stepped reads "NOT RESET (setpoint flat …); SAT deviates …" instead of "reset present". A
setpoint that moves but not with its driver is not confirmed (info). Without a SAT setpoint, a
reset read from the SAT shape alone carries a capacity-shortfall caveat.
- The RCx report's week selection was slow on long or irregular trends (#35). select_week
reindexed every series onto every candidate week's grid, so one 23-month sensor logged in
sub-second bursts took about 8 minutes. Each window's coverage, occupied days and evidence are
now counted once per series from the timestamps it holds: about 0.1 s on the same shape of data.
The chosen week, its scores and every candidate are byte-identical (a test holds the fast path to
the per-window reference).
- A store-backed run read the whole facility for every equipment (#35). Each one-equipment
read opened every part file of the facility, so a template run over thousands of equipment grew
with the square of their count (about 108 minutes for 2,460). ParquetStore keeps a
per-facility index of which part files hold which equipment (rebuilt when the facility's
partition changes) and reads only those files. A synthetic 600-equipment run fell from 363 s
to 45 s and a 2,460-equipment one takes 267 s, with identical findings.
- RCx reports for chiller and boiler plants declined the representative week (#32). The week
view considered only air-side roles. Water-side plants now have their own panels
(PLANT_FAMILIES: CHW / CW / HW supply and return temperatures, loop differential pressure, kW
and status). A report with no air handler chooses its week on the plants; a mixed site keeps its
air-side week and adds up to two plant panels. select_week takes a provisional roles_for for
per-equipment roles. Air-only reports are unchanged.
- The RCx report gave G36 advice to units with no declared G36 sequence (#32). A check that
assumes a G36 sequence (*_g36) now gets "engineer to specify" unless a soo entry with a
g36_* library covers the unit's class (a terminal unit counts when AHU has one), and any other
action worded as G36 practice is labelled a reference to check against the unit's own sequence.
- Two stale docs/VALIDATION.md cells (#33). cooling_tower_approach on the LBNL chiller plant
reads FPR 0/14 with 7 declined (was 0/2, 6 declined); the FPU drift row reads airflow recall 4/6,
0 false positives in 16, and reheat valve recall 1/7, 0 false positives in 15, 3 declined (was
3/3 and 1/2 on an earlier, smaller file set). The opt-in LBNL benchmark now writes the confusion
counts for the FPU and chiller subsets, records them in examples/lbnl_fdd/optin-measured.json
and says when a run differs from that record; a test checks the VALIDATION cells against it and
the SDAHU drift rows against the gated baseline. No gated metric moved.
- The RCx issue page now names a short plant as the upstream cause (#62). An air-handler issue
that link_findings ties to a chilled-water plant short of setpoint opens with an "Upstream cause:
plant short of setpoint" banner, and its recommended action (on the issue page and in the summary
table) starts with "Check the chilled-water plant first", before any advice on the unit's coil
valve. The plant's own page lists the downstream findings it may explain.
Added
g36_afdd: the G36 engine as a registered rule (#60).rules: ["g36_afdd"]in a config runs FC1-FC15 on every AHU. The results reachcamber run, the audit report and the RCx report, and the RCx evidence shades the reported fault hours. The rule gives one finding per AHU:- each FC's rate, hours and applicable hours;
- the fan gate;
- the ModeDelay suspension, which follows a fan start or a change in the occupancy, warm-up or cool-down points.
Coil semantics: - MAT/SAT stand in for the cooling-coil entering/leaving temperatures only on an AHU without a heating coil. The FC14 fan-heat term is signed for SAT downstream of the fan. - Otherwise FC14 and FC15 are declined with a caveat. - FC8/FC9 hours that coincide with a confirmed FC14 are attributed to FC14, so a passing chilled-water valve no longer reports under the free-cooling labels.
heating_coil=True declines an AHU whose heating valve isn't trended, rather than guessing.
min_oa_pct enables FC6. Severity is screening-grade (warn_pct 5 %, fault_pct 20 % of
applicable hours, with at least 24 applicable hours).
- chw_supply_tracking (#62): does chilled-water supply temperature reach its trended setpoint
while the plant runs? Gated on the run status (the first hour after each start left out as
pull-down), it reports the share of running time more than 3 °F above setpoint and the loop ΔT,
overall and while short. warn at 10 %, fault at 25 % of running time. 3 °F sits outside a
healthy loop's ~1 °F control band plus ~0.5 °F sensor accuracy and hourly staging transients;
a plant supplying 48 °F against a 40 °F setpoint clears it by a wide margin. Without a run status
it falls back to the temperature window, caveats that, and never goes beyond warn.
- Plant capacity in the cause chains (#62). link_findings attaches a chilled-water plant that
is short of setpoint as an UpstreamCause of an air handler's supply-air-too-warm finding
(supply_air_control running warm, or a G36 FC13) when the two coincide in the same hours (at
least 25 % either way) and the plant serves the unit (per the topology, else the site's plant).
Nothing is removed, demoted or re-costed; both issues say why they are linked.
- Served-by topology in the config (#61): a topology section with a {child: parent} map
and/or CSV schedules (vav_id,parent_ahu style), matched to the discovered equipment. It
replaces the naming heuristic for the grouping-aware fleet rules, feeds the plant link, and is
recorded with its provenance on RunResult.topology_source.
- The plant link reads the g36_afdd finding (#60, #62). The g36_afdd finding lists the fault
conditions it reports in metrics["flagged_fcs"], and is_sat_high treats FC13 (supply air too
warm with the cooling valve full open) as the supply-air-too-warm symptom a short plant produces
(triage.G36_SAT_HIGH_FCS). FC12 (SAT above MAT, which a coil that is simply off also trips)
and FC1 (duct static) are not plant symptoms and are not linked.
Changed
- One equipment-class table (#60, #61).
g36_afddis gated bycamber.rules.applicabilitylike every other class-gated built-in rule: it runs on the air-handler family (AHU, RTU, DOAS, MAU and their spellings) and declines a VAV box, a heat pump, a fan coil or a plant. Theequip_classesattribute thatsupply_air_reset_compliancehas carried since 0.90.1 (and thatg36_afddnow carries) is read from that table, so a built-in rule's classes are declared in one place. A custom rule's own attribute still wins. A declined finding names the family (air_handler) rather than the four class spellings. - The RCx economizer page uses
integrated_economizer_mask(#63), the helper thefree_cooling_missedrule uses, instead of its own copy of the test. The page's numbers are unchanged. - New provisional names:
fdd_g36.MODE_DELAY_MIN,ALARM_DELAY_MIN,AVG_WINDOW_MIN,FC_OMIT_NO_HEATING,FC_OMIT_NO_COOLING;G36Thresholds.fc14_fan_heat(the fan-heat term in FC14, signed for where the coil sensors sit);G36Resultfieldsfan_gate,n_fan_off,n_suspended,fault_hours,omitted,missing_inputs,caveats,declined,delaysandmasks(keep_masks=True). - The synthetic G36 scenarios (
faultlab.g36_accuracy) now show the supply fan running (FS100 %) unless a scenario sets it. Without that, every scenario but FC1 would be declined for lack of a fan signal. The G36 benchmark numbers did not move. - Synthetic benchmark baseline refreshed for the two new rules (maintainer sign-off for 0.91).
Added:
g36_afdd.tpr1.0 /g36_afdd.fpr0.0 andchw_supply_tracking.tpr1.0 /chw_supply_tracking.fpr0.0 (each rule has afaultlabscenario);coverage.n_scoredandcoverage.n_single36 -> 38. Every other synthetic key is byte-identical, and the fleet, LBNL, BDG2 and BDG2 savings baselines were not touched.
Known follow-ups
- #66: sensor trust has no run gate for plant equipment, so a chilled-water supply sensor that drifts while its chiller is off can score untrusted and make a genuine plant issue conditional. Also deferred there: cross-checking OAT sources against each other without a reference, and chiller power as a fallback run signal.
[0.90.1] — 2026-09-27
0.90.1 patch: fixes from two private real-data checks (#51-#59). Every fix has a synthetic
reproduction in the test suite. New names are provisional (docs/API-STABILITY.md). The synthetic,
fleet, LBNL, BDG2 and BDG2 savings benchmark gates did not move.
Fixed
- UTC
Ztimestamps were read as local time (#56).parse_timestampsdropped aZ/+hh:mmoffset without converting it, so at a US Central site a fan that ran 07:00-16:00 appeared to run 12:00-21:00 andnight_weekend_setbackreported "setback MISSING". Given the site's IANA zone (timezone=onparse_timestamps,load_csv,load_point/load_status/load_equipment, and the wide / long / per-point CSV, SQL, OPC-UA and BACnet adapters;source.timezonein a config), stamps that name an instant (ISO offsets, mixed offsets across a DST switch, a trailingUTC/GMT, epoch numbers) are converted to the site's wall clock and then made naive. The DST fall-back repeat is collapsed by the existing dedupe (timegrid.regularize, whose sort is now stable, so"first"keeps the earlier instant), and the spring-forward hour is a gap, as in a naive local export. Without a zone the clock as written is kept for back-compat, with aTimezoneWarningthat says schedule and occupancy rules will be shifted;strict_timezone=True(source.strict_timezone) refuses such data instead. A store facility ingested from the dataset catalog defaults to the entry'slocal_timezone. Re-ingesting catalog entries that declaresource_timezonegives byte-identical frames. Mixed offsets no longer raise inside pandas. - Lag-1 autocorrelation was never estimated for monthly or billing data (#51).
lag1_autocorrelationadmitted only pairs spaced within 1% of the median, which calendar months and billing cycles never are, so every monthly fit gotrho = Noneand a band left silently uncorrected (too narrow). When the modal spacing is month-like (25-36 days), every spacing in that window is adjacent; the optionalperiod_start/period_endtreat contiguous periods as neighbours. Forecast, backcast and chaining now caveat an unknown rho instead of silently using 0. select_methodnever proposed chaining on monthly rows (#52). It compared an intermediate window's row count with its length in days. The threshold is now 90% of the rows the window holds at the frame's own sampling interval (unchanged for daily rows).- The ISD weather source failed after its catalog end date and on one missing year (#53).
fetch_isdskips a station-year file that does not exist (404), warns, and lists it inattrs["isd_missing_years"](on_missing_year="raise"keeps the old abort).isd_nearest_stationwarns when the station catalog looks stale. - NASA POWER hours were local solar time read as UTC (#53). The request now sends
time-standard=UTC(the service's default is local solar time, about 6 h off at a US Central site), and a non-UTC payload is refused. POWER's not-yet-published trailing fill stays missing, and the last real hour is reported asattrs["power_coverage_end"]. An existing POWER cache misses once and re-fetches. _t_valuerejectedconfidence=0.68(#55). Any confidence in (0, 1) is accepted, so the G14 reporting criterion (savings uncertainty under 50% at 68%) can be evaluated. The 80 / 90 / 95% table stays the fast path, unchanged; other levels are the exact Student-t quantile from the incomplete-beta tail.- One very cold bill could make coverage severe on its own (#55).
ExtrapolationPolicy.min_points_outside(default 2): the share test makes coveragesevereonly when at least that many reporting points lie outside the baseline support; with fewer it ismoderatewith a caveat, and the distance test still applies. It binds only when a single row carries a quarter of the points or energy, which in practice means monthly or billing data.min_points_outside=1restores the old grading. - Short baselines on the config
mvpath (#59). A baseline window shorter than 365 days, or missing more than 10% of its days, is fitted with a caveat onmv_baseline(metrics.short_baseline) and on the savings or proposal findings that rest on it. It is the same rulefit_version(camber mv freeze) enforces, nowmvrun.baseline_window_check. - Degree-day models with a slope of the wrong sign (#59).
fit_degree_dayprefers a balance point whose heating and cooling slopes are >= 0; when none is, the fit is declined (fit.acceptfalse) with a caveat.logical_signsgives degree-day models their expected signs, so the SEP sign test catches them too. night_weekend_setbackflagged a nearly idle unit (#57). Unoccupied runtime belowmin_unoccupied_run_pct(default 5%) counts as an effective setback whatever the ratio, and both runtimes and their ratio are reported.supply_air_reset_compliancesaid "ok" with supply air far above the target (#57). Supply air more thantrack_gap_f(5 °F) above the G36 target in warm weather is a warn ("NOT tracking"), and the tracking error is reported. The rule declines on classes other than air handlers (a heat pump's discharge air, for example).- Sensor trust missed long stuck stretches (#58). Stuck runs are judged by absolute duration
against a per-role limit, so long series no longer dilute real outages, and the stuck
intervals are reported. A point that starts late is judged over its own span (
late_start,first_valid), not marked untrusted for low coverage. - The non-routine event detectors paired each bill with one day's temperature (#54).
detect_non_routine,detect_step_changeanddetect_step_changesnow pair billing-period energy with its own period's mean temperature, per day, andmin_days/min_segment_dayscount days of service instead of bills (each billing segment also needs at least 3 bills). Daily and hourly input is unchanged.
Added
camber.tsparse.TimezoneWarningandcheck_timezone;timezone=/strict_timezone=on the loaders and adapters listed above;EquipRef.timezone/strict_timezone;source.timezone/source.strict_timezonein a config (#56).lag1_autocorrelation(period_start=, period_end=)(#51).oat_reference_blendedandoat_reference_isd(..., fallback="nasa_power")(#53): the nearest ISD station, long gaps from the next-nearest stations, and the rest from NASA POWER at the snapped grid cell. POWER is corrected by a monthly mean offset against the station over the overlap, falling back to a seasonal or overall offset, or none, when there are too few pairs. Per-date sources, the correction and caveats are recorded inattrs["weather_provenance"]. Also added:power_grid_cell,isd_catalog_end,WeatherCacheMiss,fetch_nasa_power(snap_to_cell=), andcached_transport(offline=, should_cache=)/cached_bytes_transport(offline=)for offline, cache-first reads.ExtrapolationPolicy.min_points_outside(#55);mvrun.baseline_window_checkandDegreeDayModel.caveats(#59).- An
equip_classesrule attribute thatRegistry.rundeclines other equipment classes by (#57). SensorTrustfieldslongest_flat_hours,stuck_intervals,first_valid,window_coverage,n_state_changesandframe_checks, plussensorhealth.STUCK_HOURSandstuck_hours=. New status-point flagsnever_changesandfractional_status.sensorhealth.frame_checksadds fan-off pressure plausibility, status-vs-speed consistency and all-points-freeze detection, and the trust gate applies them (#58).camber.mandv.billing(#54):BillingSeriescarries each bill's start, end, days, estimated-read flag and units. It gives each bill's mean temperature and heating / cooling degree-days (from hourly temperatures when available) and a day-weighted total.daily_energy_vs_tempaccepts it, and the detector results reportbilling/n_periods.
[0.90.0] — 2026-09-27
M&V rebaselining: the rest of #21. A reported saving now declares its SEP method, restates its
baseline side through an explicit, attributed adjustments ledger, and names the frozen baseline
version it used. The baseline moves only on an operator's audited decision (phases 21b-21d). The
savings and their bands are validated on real BDG2 meters and in a Monte Carlo index of every
kernel, and the result is published in docs/VALIDATION.md (phase 21e). Three maintainer
decisions on #21 are applied: the published validation numbers, a refit for an adjusted backcast,
and G14 kept as the default kernel with a calibration caveat on every result. The config mv
path also gains the change-point + driver model form. The existing BDG2 acceptance and LBNL
benchmark gates did not move.
Added
M&V: the SEP methods and non-routine adjustments (issue #21, phases 21b and 21c; #46, #47)
One flow for a reported saving: the declared method gives the saving, the adjustments ledger
restates its baseline side, and the result carries both. Every new name below is provisional
(docs/API-STABILITY.md).
camber.mandv.methodsgains the rest of the DOE SEP 50001 M&V Protocol 2019 Ed. 2 §6.2 methods, all returningMethodResult:forecast_savings(wrapsavoided_energy_savings),standard_conditions_savings(wrapsnormalized_savings, exact kernel by default) andchained_savings.chained_savingsis exactly SEP's chaining: one intermediate period of the same length as the baseline and reporting periods, lying between them, whose model covers both. Its SEnPI is the Eq 6 product and its saving the Eq 11 sum.sequential_chainchains any number of results; it is a CAMBER extension, not an SEP method, and says so.select_methodproposes a method in SEP's order (forecast, backcast, chaining, standard conditions, decline), ranking candidate models by SEP validity and then adjusted R², as the DOE EnPI tool does. It only proposes: the proposal has no headline figure, only a sensitivity table of every valid method.MethodResultgains trailing fields:enpi(SEnPI) andenpi_uncertainty,links(ChainLink),sep_terms,sep_range_valid/sep_range,uncertainty_termsandbaseline_version.measuredmay now beNone(standard conditions has no measured total).backcast_savingsfills the new fields and takes a keyword-onlybaseline_version.- Uncertainty. The SEP chain uses the shared-model covariance
(g_r − g_b)′Σ_i(g_r − g_b)plus the noise of both measured periods. The SEnPI band comes from the delta method. Sequential chains combine their links by IPMVP 2012 B-19 / B-20. The kernel is recorded on every result: G14 by default for single-model results, exact for multi-model ones (decision D7). In a CI Monte Carlo (AR(1) ρ = 0, 0.4, 0.8), the chain band covers 90–92% at nominal 90%, and the independence form's variance is about 1.5× the exact one. camber.mandv.sep(new): primary energy (Eq 1) with the Protocol's Annex B multipliers as defaults, overridable by a user table;senpi(Eq 5),chained_senpi(Eq 6),improvement_pct(Eq 7),top_down_savings(Eq 8–11),bottom_up_reconciliation(Eq 12, the RF < 0.80 rule),aggregate_energy_types(the same method for every type, summed on primary energy), andsep_range_check. That last one is the SEP mean-in-range rule (§6.4.2.1), reported as a secondarysep_range_validverdict beside the per-point coverage tiers (decision D2).- Config:
mv[].method(forecast|backcast|chainingwithintermediate_period|standard_conditionswithnormal_year|auto) andmv[].kernel(g14|exact).mv_savingsfindings gainmethod,method_declared,basis,kernel,enpi,enpi_uncertaintyandsep_range_valid."method": "auto"gives anmv_method_proposalfinding instead of a saving. When no method is declared the run still uses the forecast, as before, and the finding carries a caveat saying no method was declared. camber.mandv.adjustments(new):NonRoutineAdjustmentandStaticFactorAdjustmentare explicit, attributed ledger entries.apply_adjustmentsrestates the baseline side of aSavingsResultor of anyMethodResult-- forecast, backcast, standard conditions (the baseline model's projection at standard conditions; the band split between the two models by their exact-kernel terms), the SEP chain andsequential_chain-- and returns anAdjustedResultwith the adjusted saving, SEnPI and bands, the resolved ledger and the waterfall components. An empty ledger reproduces the method's own numbers.- Chains are adjusted per link. Each entry restates the one link whose dates hold it; the
savings are re-summed (Eq 11) and the SEnPI re-multiplied (Eq 6). The SEP chain carries the
shared-model covariance through (a proportional factor on the forecast link scales it); an
entry dated in its intermediate period is refused, since both links share that model. A
sequential chain combines its adjusted links by B-19 / B-20.
ChainLinkgains trailingperiod,model_window,sep_terms,df,enpi_uncertaintyanduncertainty_terms. - NRA methods:
indicator(estimate_nre_indicator; +1 top; a baseline-period indicator replaces the projection and uses the joint covariance; a reporting-period one adds in quadrature, except on a backcast, where it refits the reporting model -- see Changed),engineering(estimate + SE, evidence required),exclude(SEP §6.5 anomaly mode) andsubmeter(Option B;nra_from_isolation). Static factors:proportionalwith an explicit affected share (no default) orengineering. - Guards: a meter-derived NRA dated within
settle_daysof an ECM date raisesConfoundedAdjustment(IPMVP 2012 §8.2). ECM dates and the settle window live in one place,EcmSchedule(DEFAULT_SETTLE_DAYS = 14), which the guard, the proposals and the config read.validity="sep"requiresevidenceandapproved_by.propose_adjustmentsturnsdetect_step_changesoutput into proposed entries that must be accepted explicitly. Materiality:|effect| >= max(threshold, 2 SE). camber.mandv.multivariable(new):fit_cp_driver_model/ChangePointDriverModel, a change-point + linear-driver baseline for continuously varying drivers (occupancy, production); it works with coverage, both savings kernels, the regression tests and model serialisation unchanged.camber.charts.adjustment_waterfall, and the config keymv[].adjustments(withecm_dates,settle_days,materiality_threshold). It applies after whichevermv[].methodis declared;mv_savingsfindings gain the adjusted saving and SEnPI, ledger and waterfall. With"method": "auto"each sensitivity row shows the adjusted figures beside the unadjusted ones and the proposal itself is unchanged. Monte Carlo coverage of the indicator band is indocs/MANDV.md(0.85-0.95 at nominal 90%; 0.80-0.95 at AR(1) rho 0.8, where a one-year lag-1 estimate is biased low).mv[].validity(g14|sep|both; decision D1) is one key for the whole entry: underseporboththe finding carries each projecting model's SEP §6.4.1 verdict (sep_valid,sep_validity) and every adjustment needsevidenceandapproved_by(§5.3.2).
M&V: versioned baselines and the rebaseline policy (issue #21, phase 21d; #48)
A reported saving now names the baseline version it used, and the baseline moves only on an
operator's attributed, audited decision. CAMBER never rebaselines automatically. Every new
name below is provisional (docs/API-STABILITY.md).
camber.mandv.rebaseline(new).MVBaselineStorewrapsBaselineStoreand keeps every version instate/<fid>/mv_baselines.json, a file with a"schema"field.- Each version carries a provenance record:
- the reason, the trigger ids, and
accepted_byplus the OS user and host; - the data window and a sha256 of the fit frame, so a later change to the data under a frozen baseline is reported;
- the model's
as_dict,FitStats,RegressionTestsand SEP verdict; - the declared method and kernel, the validity regime and the policy;
- the append-only adjustment ledger, the CAMBER version and a
content_sha256.
- the reason, the trigger ids, and
RebaselinePolicyandassess_triggerscover triggers T1-T6:- T1: a material step the declared ECMs do not explain. It is found by PELT on the relative deviation of every day since the baseline from the frozen projection.
- T2: a declared change.
- T3: a static factor beyond its tolerance.
- T4: an invalid model or a severe extrapolation.
- T5: an achievement period over 36 months.
- T6: a new ECM with 12 months of post-ECM data (advisory).
Outcomes follow BPA's taxonomy: NRA, or rebaseline then chain. The 20% line between an
indicator NRA and a rebaseline (major_step_frac) is CAMBER's choice.
- propose_rebaseline / new_baseline_window find the latest 12 consecutive months
that:
- start at least settle_days after the trigger, and after any later step;
- avoid ECM installation windows;
- miss at most 10% of their days;
- are valid under the entry's validity;
- cover the expected conditions.
Otherwise they decline, with the days still needed ("unresolved non-routine event on DATE;
rebaseline needs N more days").
- camber mv run | freeze | list | propose | rebaseline | adjust | report.
- run, list, propose and report never write.
- freeze, rebaseline and adjust need --reason and are dry runs unless --apply.
Inside a workspace they take the lock and are audited (mv.freeze, mv.rebaseline,
mv.adjust).
- rebaseline --from-proposal freezes a proposed model exactly, after checking its data has
not changed.
- The drift CLI's _drift_audit became the shared _state_audit.
- The run path reads frozen versions. camber run and camber mv run measure a meter with a
frozen version against it, never refitting. They record baseline_version, emit mv_trigger
findings and cut the saving at an unresolved trigger (partial, or declined, with a caveat).
Every MethodResult and AdjustedResult records the baseline_version it used.
- camber mv report chains savings across versions (sequential_chain, each link dated from
the store) with a new chained CUSUM (camber.charts.chained_cusum_plot): one segment per
version, rebaseline markers, and the unreported rebaseline windows shaded.
- Portfolio.
- A new mv_baselines retention class ("indefinite", all versions) and manifest kind.
- camber portfolio migrate moves a config's mv_store into state/<fid>/, every version
included.
- A non-active facility is skipped by every camber mv verb.
- Additive hooks. On BaselineStore: keyword-only model_types=, a trailing
BaselineRecord.provenance (left out of as_dict when empty, so drift baseline files are
unchanged) and LIST_KEY / SCHEMA. Also:
- IndicatorFit.from_dict, with adjustment_from_dict now rebuilding fit losslessly;
- AdjustedResult.baseline_version;
- MethodProposal.fitted (the chosen models' as_dict);
- sequential_chain(windows=);
- camber.config.run_mv_config;
- the config keys mv[].rebaseline (a settle_days there that differs from
mv[].settle_days is refused) and the top-level mv_store.
M&V validation: the BDG2 savings benchmark (issue #21, phase 21e; #49)
examples/bdg2/savings_benchmark.py(new) scores CAMBER's savings and their bands on real BDG2 meters: 2016 baseline, 2017 reporting, cleaned meters, whole days, and at least 328 whole days in each year (CalTRACK 2.0 §2.2.1.2). Data are fetched, never redistributed; every draw is seeded. Four experiments:- a placebo scored with Touzani et al. 2019's UICF and EUR, for forecast and backcast with both kernels and standard conditions with the exact kernel;
- injected 5/10/20% savings: forecast recovery is asserted as an exact identity, and backcast and standard conditions are measured (error, SEnPI-band coverage, significance);
- injected single and double steps:
detect_step_changesdetection and date error, spurious detections, and indicator-NRA recovery and interval coverage of δ; - an injected proportional static factor.
- A new baseline file,
examples/bdg2/savings-benchmark-baseline.json, is gated at--tol 0.05in themv-accuracyCI job and inscripts/gates.sh. It holds only the new metrics, becausebenchmark-baseline.jsonis untouched: the acceptance benchmark'sacceptdid not change, and none of its keys moved. Coverage is gated on regression, never on the nominal rate: real bands under-cover. - Placebo and injection run on every eligible meter. Steps and static factors run on a seeded
subsample of 150 meters per type (
--sample). --jobsruns buildings in worker processes; results are identical whatever the count.- Dossier track
bdg2_mv_savings(camber validate): the placebo UICF of the G14 and exact forecast kernels, cited and cross-checked exactly against the new baseline bytests/test_dossier.py. tests/test_mandv_mc_coverage.pyindexes Monte Carlo coverage for every savings kernel and references the cells already tested elsewhere.- New cells: G14 forecast and backcast, exact backcast and standard conditions, G14 standard conditions, the sequential chain, an adjusted static factor, and an adjusted backcast.
- It records that the G14 kernel under-covers on a year of daily data even with a correct model (82-88% at nominal 90%), that G14 standard conditions and the sequential chain are conservative, and that the adjusted backcast is on target once refitted (see Changed).
lbnl-b59data issues (catalog anddocs/DATASETS.md) forele.csv, which the catalog does not ingest:- from 2020 the file carries six meters under five column names, shifted one place;
- the HVAC panel meters read exactly 0 while the rooftop units run;
- the replaced heat pump was metered on
hvac_N, and its replacement is on no meter, so a 2018 vs later saving overstates the retrofit.
M&V: the change-point + driver form on the config mv path (#47, #48)
mv[].model: "cp_driver"withmv[].driversfits phase 21c's change-point + driver model instead of the temperature-only one, on everymvpath: the plain run, each declared method, validity verdicts, the adjustments ledger, and the versioned baselines ofcamber mv(freeze, rebaseline windows, triggers, adjust, the chained report). A driver is"weekday","occupied_day"(mv[].occupied_weekdays, default Monday to Friday, minusmv[].holidays), or any mapped numeric role (its daily mean).- Phase 21d found that an occupancy-driven building fails validity with the temperature-only
form, which left
mvunusable there; with a weekday driver the weekly cycle is modelled. mv_baselinegainsmodel_form,driversanddriver_coef. A frozen driver model reads its own drivers, and the fit-frame sha256 covers them."method": "auto"and"standard_conditions"are refused with this form, each with the reason. Withoutmv[].modelnothing changes.
Changed
The maintainer's decisions on #21
- Published: the BDG2 M&V savings validation result (
docs/VALIDATION.md). With nothing injected, the nominal-90% forecast band covers zero for 33% of 1,023 electricity and 52% of 334 chilled-water meters (G14; exact 36% / 54%). At nominal 95% the G14 figures are 38% / 57%, against the ~71% Touzani et al. 2019 found on 69 screened buildings. It also publishes the injected-saving recovery, step detection and static-factor figures. Every figure was regenerated from the 0.90 code, and all 321 gated metrics are identical to the committed baseline. A newtests/test_dossier.pycheck recomputes each published percentage from that file. Nolbnl-b59figures are published. - An adjusted backcast with a reporting-period indicator refits the reporting model with the
indicator (joint Σ,
p + 1), as a baseline-period indicator already does on the baseline side. Before, the band was the reporting model's, fitted through the event: about 20× too wide, covering 100% at nominal 90%. The refit covers 86% / 88% / 86% at AR(1) ρ = 0 / 0.4 / 0.8, unbiased, gated at [0.85, 0.95]. The refit needs the baseline rows'drivers=(the config path passes them). Without them, inside a chain, with a second such indicator, or with an indicator fitted on another window, the old conservative band is kept with a caveat. - G14 stays the default kernel, with a caveat on every result that uses it. In simulation
with a correct model the G14 forecast and backcast bands under-cover (about 82-88% at nominal
90%), and CAMBER's projected G14 kernel for standard conditions is conservative (99-100%), while
kernel="exact"is on target. EverySavingsResult,MethodResultandAdjustedResultwith a G14 band says so and recommendskernel="exact"for calibrated bands.docs/MANDV.mdgives the Monte Carlo evidence; the default will be revisited at 1.0. The caveat is text only: no band, saving or benchmark metric changes.
Fixed
- The step searches no longer hang on a constant, dead or noise-free series. A series the fit
reproduces exactly (one BDG2 chilled-water meter reads 0 all of 2017) gave PELT a 0/0 cost that
no penalty could prune, so the
max_stepsloop never ended.detect_step_changesand the rebaseline T1 step search now share one guard: they stop with the steps found so far (the detector adds a caveat). camber mv proposesays why it runs no SEP method proposal for acp_driverentry, and prints a proposal error instead of skipping it silently.