Command-line interface
The camber console script (installed with the package; also python -m camber.cli) exposes the
analysis pipeline and the grounded agent as subcommands.
camber run <config.json> [--out DIR] [--no-persist] # run a config, print/write findings
camber report <config.json> --out site.html # run + write an HTML audit report
[--layout audit|rcx|<plugin>] # rcx: the printable retro-commissioning layout
[--no-persist] # record no fault history or manifest entry
camber explain <config.json> [--llm-cmd CMD] [--no-strict] # grounded explanation of findings
camber ask "<question>" --config <config.json> [--llm-cmd CMD] [--no-strict] # grounded Q&A
camber fleet '<glob>' [--ask Q] [--out f.html] [--llm-cmd CMD] [--no-strict] # rollup + triage
camber charts (--csv F | --demo none|reheat) [--ahu N] [--timestamp-col COL] [--resample R]
[--occupied-only] [--threshold PCT] [--out DIR] # legacy AHU HeC charts
camber validate [--html d.html] [--json d.json] [--full] # validation credibility dossier
camber serve <store> [--host H] [--port P] [--allow-host N] [--auth token|none] # read-only API + live /ui
camber drift run|report|freeze|list|accept <config.json> # baseline-vs-current drift
camber mv run|freeze|list|propose|rebaseline|adjust|report <config.json> # M&V baselines
camber bacnet-discover [--config bacnet.yml] [--device ADDR]... [--out inventory.json] # read-only
camber datasets list|info|fetch|ingest|status|remove|config|score # open dataset catalog
camber rules params [RULE] [--json|--yaml] # tunable thresholds + calibration (0.98)
camber lab [--workspace W | --store S] [--dir D] [--port P] [--docs DIR] # local catalog UI (127.0.0.1)
camber mcp --store S (--allow ID ... | --allow-all-active) [--config F ...] [--min-trust T] [--max-concurrent N] [--max-queued N] # MCP server (stdio)
camber portfolio init|adopt|status|audit|migrate # portfolio workspace
camber facility add|list|show|rename|activate|suspend|resume|offboard|archive|restore|purge
camber retention show|set|override|hold|release|apply # retention policy (0.95)
camber store migrate-partitions STORE [--apply --yes] # year= -> year=/month= partitions
camber store verify|normalize STORE [--facility ID] # canonical part files (0.104)
camber edge run|send-once|status|selftest|requeue|compact|decommission # edge forwarder (device)
camber edge reconcile|land|quarantine|record-retirement|bucket-rules # edge landing (central)
camber serve starts the stdlib read-only HTTP API and the live web dashboard at
http://127.0.0.1:8080/ui (facility/equip/role selectors + a synchronized multitrend, brush-linked
and polling). Read-only (GET-only), localhost-bound by default; see
VISUALIZATION.md and SECURITY.md.
Who may connect (0.103; the cross-site check and --auth none 0.104; token auth by default on
loopback 0.107):
- Host allowlist, always on. A request whose
Hostheader is not an allowed name is refused (403), so a web page on another site cannot read the store through your browser by pointing its own DNS name at your machine (DNS rebinding). Bound to loopback (the default), the allowed names are127.0.0.1,localhostand[::1]at the bound port; bound to an address, that address too.--allow-host NAME(repeatable), orCAMBER_API_ALLOWED_HOSTS=name1,name2, adds the names a proxy or DNS alias uses: a bareNAMEmatches any port,NAME:PORTonly that port. - Every interface needs a list.
--host 0.0.0.0(or::) refuses to start unless an allowlist is given.--allow-host '*'answers anyHostand turns the rebinding check off; it is unsafe and meant only behind a proxy that checksHostitself. - Cross-site requests refused, always on (0.104). A browser request with
Sec-Fetch-Site: cross-site, or with anOriginthat is not an allowed name, is 403, as incamber lab. Requests with neither header (curl, scripts, container and Kubernetes health probes) and the viewer's own requests are unaffected. A link to the viewer from a page on another site is refused: open the URL directly or from a bookmark. - Token auth, the default on loopback (0.107). Bound to loopback (the default
--host, orlocalhost,::1) with--authleft out, or with--auth tokenon any bind, every route needs this run's access token, as incamber lab: the command prints a launch URL ending in?token=...and saves it, readable by you only, inserve-<port>.url(in$XDG_RUNTIME_DIR/camber, else~/.config/camber). Opening it sets anHttpOnly; SameSite=Strictsession cookie and redirects to the same page without the token. Scripts sendAuthorization: Bearer <token>. Without credentials a request is 401 (403 for a wrong token), exceptGET /health, which then answers only{"ok": true}for health checks.CAMBER_API_TOKEN(16 or more characters) fixes the token instead of a fresh one per run; use an environment variable, never the command line, which other accounts can read. On loopback without--authit also prints a one-line note that token auth is the default. --auth none(0.104) turns authentication off: the open server that loopback gave by default before 0.107. Use it for a deliberately open read-only server, for example behind an authenticating proxy. See SECURITY.md section 3.python -m camber.api.serverreads the same choice fromCAMBER_API_AUTH(noneortoken); unset, it follows the same default ascamber serve.- Other binds keep no authentication by default. Bound to anything but loopback (a LAN
address, or
0.0.0.0/::as in the container image) without--auth token, the server has no authentication, as before 0.107, and prints a warning: anyone who can reach the address can read every facility. Put an authenticating reverse proxy in front. - Startup checks (0.107).
--porttakes 0 to 65535 (0 picks a free port); anything else is a usage error (exit 2). The store must exist: a missing path, a file, or a folder that holds no store partitions or store files exits 1 with "not a ParquetStore" before anything is bound. An empty folder is a store with nothing written yet and is served. - Errors (0.107). Only a malformed query parameter (
start,end,limit,max_points) is - A store or read failure, such as a corrupt part file, is 500 with the body
{"error": "internal error"}; the detail goes to the server's stderr. No error body names a server path.
camber serve ./store # prints the launch URL with ?token= (0.107)
camber serve ./store --auth token # the same, chosen explicitly (no note)
camber serve ./store --auth none # http://127.0.0.1:8080/ui, no token
camber serve ./store --host 0.0.0.0 --allow-host camber.example.org # behind a proxy
curl -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8080/facilities
A config is the same declarative JSON that drives camber.config.run_config (source, mapping,
equipment, rules — see the config examples). run/report execute it; explain/ask build the
grounded agent context from the run and answer over it.
Command map: a shared config drives run/report; the run context grounds explain/ask.
flowchart TD
camber["camber console script"]
cfg["config.json (run_config)"]
camber --> run["run: execute config, write findings"]
camber --> report["report: HTML audit or RCx report"]
camber --> explain["explain: grounded explanation"]
camber --> ask["ask: grounded Q&A"]
camber --> fleet["fleet: portfolio rollup + triage"]
camber --> charts["charts: legacy AHU HeC charts"]
camber --> validate["validate: validation dossier (text/HTML/JSON)"]
camber --> drift["drift: baseline-vs-current drift + baseline lifecycle"]
cfg -- "drives" --> run
cfg -- "drives" --> report
cfg -- "drives" --> drift
run -- "grounded run context" --> explain
run -- "grounded run context" --> ask
A rules entry is either a bare name or a {"name", "params"} object that overrides that rule's
constructor for the run — e.g. a high-outside-air building setting its design minimum:
"rules": ["simultaneous_heat_cool",
{"name": "economizer_high_limit", "params": {"high_limit_f": 75, "min_damper": 0.45}}]
YAML configs and tunable thresholds (provisional, 0.98)
YAML. Every command that takes a config (run, report, explain, ask, fleet, drift,
mv, python -m camber.config, the portfolio migration and the edge forwarder's config) also
reads a .yaml / .yml file. YAML needs the optional extra, pip install
'camber-toolkit[yaml]' (PyYAML); without it the command stops with an error that says so. JSON
stays the dependency-free default, and the two formats are equivalent: a YAML config and the JSON
config that says the same thing give identical results. YAML's point is comments, so a calibration
note can sit beside its value. The loader reads values the way JSON does: 2018-07-01 and 07:00
stay strings, no / on stay strings, and only true / false are booleans.
camber rules params. Every numeric, flag or enumerated constructor parameter of every
built-in rule, including the extra instances (cohort_airflow, sat_reset_effectiveness, ...),
is documented in camber/rules/param_docs.py:
camber rules params # every rule: default, unit, range, basis, how to calibrate
camber rules params leaking_valve # one rule, plus a ready-to-paste JSON config snippet
camber rules params leaking_valve --yaml # the snippet as YAML, each note a comment
camber rules params --json # machine-readable: {"rules": [...], "config": {...}}
Defaults are read from the rule constructors, never copied, and every parameter is settable from a
config's params. The extra instances are tunable too; their identity arguments (the cohort
role, the reset kind) are fixed. A rule entry may also carry a basis map ({"fan_heat_f":
"calibrated on ..."}), which is copied into each of its findings as metrics["param_basis"].
THRESHOLDS.md is the generated reference and TUNING.md is the
calibration guide.
Site elevation (site_elevation_ft). A rule that derives the wet-bulb from outdoor air
temperature and RH assumes sea-level pressure unless it knows the site's elevation. One top-level
config key sets it for the site, in feet above sea level:
{"site": "Plant", "site_elevation_ft": 5280, "rules": ["cooling_tower_approach", "condenser_water_reset"]}
It reaches cooling_tower_approach and condenser_water_reset (as their elevation_ft) and,
through the drift section, cooling_tower_approach_drift and cooling_tower_fan_effort_drift.
A rule's own elevation_ft or pressure_psia param wins over it, and a measured wet-bulb point
ignores it. Without it a derived wet-bulb is caveated as sea-level. Set it before freezing a
drift baseline: a baseline frozen at one elevation and scored at another shifts by the wet-bulb
difference (about 2.6 °F at 1,600 m in hot, dry air).
--no-persist (0.110, #276) on camber run and camber report computes the same findings and
report but records nothing: the config's faults fold is computed in memory and not saved (the
summary line ends "not saved: --no-persist"), the config's report.out_text / out_html files
are not written, nothing is listed in a workspace manifest, and an M&V billing entry fetches no
weather. The --out file you name is still written. In the API it is run_config(...,
persist=False); see Fault lifecycle.
Report layouts
camber report writes the audit report by default. It is titled as an ASHRAE Std-211 audit
only when the config gives the Std-211 inputs (report.benchmark, and at Level 2 report.ecms);
otherwise "Building analytics report" (0.96, #78; report.title sets one). Since 0.103 (#125) it
includes a Finding evidence section: the chart of each of the 12 worst findings, drawn with
the run's own rules and data. --layout rcx writes the printable
RCx report instead. Without the flag, the config's report.layout decides, and
any other name is looked up in the camber.reports plugin group.
camber report site.json --out rcx.html --layout rcx
[--week auto|evidence|oat-range|typical|YYYY-MM-DD] # the representative week
[--notes notes.json] [--notes-template slots.json] # engineer notes in, empty slots out
[--paper letter|a4] [--lifecycle] # page size; fault-store notes too
[--prose prose.json] [--prose-prompts prompts.json] # verified prose in, its prompts out
[--llm-cmd CMD] # a model for the slots --prose lacks
camber report site.json --out rcx.pdf --layout rcx # PDF directly (the [pdf] extra)
An rcx --out path ending in .pdf writes the same report as PDF through WeasyPrint, the
optional pdf extra (pip install "camber-toolkit[pdf]", plus the Pango system libraries; see
Direct PDF output). Without it the command exits 2 and names
the extra.
Generated prose is off by default (0.107, #194; see
Generated prose and the fact index).
--prose reads a JSON file of pre-generated prose keyed by slot (exec_summary,
issue:<key>). --prose-prompts writes each slot's prompt, with the facts it may cite, for
offline generation. --llm-cmd asks a model for the slots the prose file does not cover, through
the same stdin/stdout seam as explain and ask. A slot is shown only when every sentence cites
the report's fact index and every figure is in a fact that sentence cites; otherwise the page
keeps its standard text and Appendix A lists the slot. All three flags are for --layout rcx
only and exit 2 with any other layout.
The rcx options also live in the config, under "report": {"layout": "rcx", "rcx": {...}}:
top_n, week, paper, chart_format, sections, price, loads, occupancy,
oat_reference, sequence, notes and prose. A flag overrides the config value. report.loads sizes
equipment for the cost estimators. An unknown layout exits with code 2 and lists the known ones.
Grounded agent from the shell
explain and ask are useful with no LLM — they fall back to the deterministic template answer,
fully grounded with [id] citations. To wire a model, pass --llm-cmd a shell command that reads the
prompt on stdin and writes the completion on stdout:
camber ask "which zones are uncomfortable and why?" --config site.json \
--llm-cmd 'my-llm-cli --model whatever'
This is deliberately vendor-neutral: CAMBER names and imports no provider. The subprocess wrapper
lives in the CLI, not in camber.agent, so the agent package stays free of I/O (enforced by
tests/test_agent_readonly_guard.py). Every answer is verified against the fact whitelist; ungrounded
claims are repaired (or the answer falls back to the template). --no-strict (on explain, ask
and fleet) turns the repair off: the check still flags ungrounded sentences and unknown
citations, but the model's text is printed as written, which helps when debugging a model.
Portfolio triage
camber fleet 'sites/*/config.json' --ask "which building wastes the most?" runs each config, builds a
fleet rollup, and answers the question grounded in per-building facts (EUI, fault
counts, recoverable $/yr). Add --out fleet.html for the rollup report.
Drift & baselines
The drift detectors compare a current window against a frozen baseline
one, so they need two things an ordinary run does not: explicit windows, and a durable place to keep
the reference. Both live in a drift section of the same config:
"drift": {
"store": "baselines.json",
"baseline": ["2025-03-01", "2025-05-31"],
"current": ["2026-06-01", "2026-08-31"],
"families": [
{"class": "AHU", "family": "ahu", "coils": ["cooling", "heating"]},
{"class": "CH", "family": "chiller", "sustained_alarm": true},
{"class": "CHWP", "family": "pump", "plant": "CHW plant"},
{"class": "VAV", "family": "vav", "baseline": ["2025-04-01", "2025-05-31"]}
]
}
family is one of ahu · chiller · condenser · evaporator · pump · hw_pump · vav · boiler · tower · dx;
each class must appear in the config's equipment list. coils (AHU) adds one coil-valve detector per coil; plant (pump, hw_pump)
adds the cross-pump roll-up; sustained_alarm (chiller) appends the opt-in CUSUM alarm rule. A
family may override baseline / current — a chiller re-commissioned later has its own reference
window. Any trust_gate, shared_oat and resample settings apply unchanged.
With that section present, camber run scores drift alongside the ordinary rules and folds the
verdicts into the audit report. The drift subcommands drive it directly:
camber drift freeze config.json # establish the references (the only create path;
# add --reason R inside a portfolio workspace)
camber drift list config.json # what is frozen, and on whose say-so
camber drift run config.json --out d/ # score current vs baseline; writes drift.json + findings.json
camber drift report config.json --out drift.html
camber drift report config.json --out drift.html --charts # + each finding's evidence chart
When a fix lands, the reference should move — on someone's say-so:
camber drift accept config.json --equip AHU_1 --by "A. Engineer" --reason "filter replaced"
Every option:
camber drift run <config.json> [--out DIR] [--run-id ID]
camber drift report <config.json> --out drift.html [--charts] [--run-id ID]
camber drift freeze <config.json> [--reason R] [--dry-run] [--run-id ID]
camber drift list <config.json> [--equip E]... [--kind K]... [--json FILE]
camber drift accept <config.json> --equip E... --by NAME --reason R [--kind K]...
[--period START END] [--dry-run] [--run-id ID]
--run-id stamps an id of your choosing (a scheduler's run id, say) on the results of run and
report, and as the frozen_at of what freeze and accept write (default: now, UTC).
--dry-run shows what freeze or accept would write without writing it. --kind narrows
list and accept to one model kind (repeatable). accept --period re-fits over that window
instead of the config's drift.current.
The write policy is a verb, not a setting
A run that mints the baseline it scores against is circular: whatever the equipment is doing now
becomes, by construction, normal. So only freeze creates a reference, and it refuses to
overwrite one that already exists (--dry-run shows what it would do). run and report open the
store read-only and leave the file byte-identical.
accept moves. --by and --reason are required at the argparse level, so the command exits
before any code runs without them (BaselineStore.accept_new_normal re-rejects an empty one as the
backstop), and --equip is explicit and repeatable — there is no blanket "accept everything".
--kind narrows further; --period START END picks the window to re-fit over, defaulting to
drift.current, because accepting a new normal means what it is doing now is the reference.
--dry-run shows the moves without writing. The superseded record is kept in history, so what was
normal, when, and on whose authority stays answerable.
The re-fit is not a second copy of the fitting logic: camber.driftrun.refit_baselines runs the
family against a scratch in-memory store and harvests what it froze, so each model comes from its
own detector — same metric and load columns, same minimum-load filter and plausibility bounds — and
cannot drift away from the rule it will be compared against. A detector that cannot fit over the
window is reported (could not refit … — leaving it frozen) rather than skipped silently.
There is deliberately no --reason on freeze: the initial reason string lives inside each
detector, so the flag would not be honoured.
A declared reference
Sometimes the reference is not this equipment's own past but something known to be healthy: a sister unit, or a season you have evidence was clean. A family can declare it instead of reading a frozen baseline:
"drift": {
"families": [
{"class": "CHW_PLANT", "family": "tower", "reference": {"equip": "PLANT__fault_free"}},
{"class": "HW_PLANT", "family": "boiler", "reference": {"period": ["2025-10-01", "2026-01-31"]}}
]
}
{"equip": ID}scores every other unit of the class against that unit.IDmust be one the config discovers. Add"period": [start, end]to use only part of its history (else the family's or section'sbaselinewindow, else all of it); the scored units usecurrent, else their whole history.{"period": [start, end]}scores each unit against a known-good window of its own history;currentthen defaults to everything from the end of that period on.
The reference is fitted in a scratch in-memory store on every run and never saved, so a run
still cannot mint the baseline it scores against: someone declared the reference, in the config,
where a reviewer can see it. When every family declares one, the section needs no store and no
windows. Each finding carries baseline_source (reference:<equip> or period:<start>..<end>)
and a caveat naming the reference, and its summary reads "vs the reference ..." rather than "vs
frozen baseline". The reference unit itself declines as is_reference (it is the yardstick, not
a result). A reference that cannot serve a detector declines every target with the reason
(reference_missing_inputs, reference_untrusted, empty_reference) rather than scoring against
nothing.
camber drift freeze refuses a config whose families declare a reference (exit 1): there is
nothing to freeze. drift accept and the re-fit leave those families out. A declared reference
is only as good as the evidence that it is healthy; on a real plant prefer the unit's own frozen
baseline, and keep the reference's provenance in the config's _comment. The lbnl-chiller and
lbnl-boiler templates use {"equip": "PLANT__fault_free"}, because each labelled fault there is
its own year-long run with no before-and-after on one unit (see
PLANT-DETECTORS and TUNING).
Leak drift (opt-in)
An ahu family entry can add the coil-valve leak drift detector, coil_leak_drift
(provisional, 0.100). It fits the coil's valve-shut air rise (the coil's leaving air, or the supply
air, minus the mixed air, on fan-on hours with every coil valve shut) against the mixed air on
the baseline. It then flags a shift at the same mixed-air temperature: down for a cooling leak,
up for a heating leak. The unit's fan heat is part of the baseline, so nothing has to be
calibrated.
{"class": "AHU", "family": "ahu",
"reference": {"period": ["2025-04-01", "2025-09-30"]},
"coil_leak": ["cooling", "heating"],
"coil_leak_params": {"warn_sigma": 3.0}}
coil_leak names the coils to judge. coil_leak_params overrides the detector's defaults, which
are listed in THRESHOLDS. It works with a frozen store or a
declared reference. Without the key the family is unchanged.
Only current hours inside the baseline's mixed-air range are judged. The rest are counted and named in a caveat, so a baseline from one season says nothing about another. A shifted supply-air or mixed-air sensor reads exactly like a leak, and so does a valve stuck partly open. Every warn says to check the sensors first. A leak shows in the AHU roll-up on the coil side. Measured results are in VALIDATION.
Untested is not steady
Two things can leave an equipment unscored — no detector's required roles resolved, or every
detector declined (nothing frozen yet, an untrusted input, an empty window). Both would roll up to
severity=ok, locus=steady, which asserts a negative nobody tested. Neither is diagnosed: the
equipment is listed under Equipment not evaluated with the reason, in the terminal, in
drift.json, and in the HTML.
Every finding shows its own evidence
--charts embeds, per finding, the current period scattered on that detector's frozen baseline
band — the comparison the rule actually made, rather than a trend of the raw points, which would
show the levels and hide the movement. A loading filter reads as ~100% of the period outside the
band while a healthy unit sits near the ~5% you'd expect outside ±2σ, so the chart separates the two
cases rather than decorating the verdict.
Equipment with nothing frozen gets no chart rather than a scatter with no line to judge it by,
and the plain page (without --charts) stays pure text and tables with no matplotlib import.
Severities are screening-grade
Every drift command prints, and every drift page renders, the two-class threshold-confidence note: magnitude floors are screening-grade (characterized for the signal class, not established on your machines) and the CUSUM timing parameters are provisional-untuned. There is no flag to suppress it. Read a drift finding as "worth a walkdown", not as a dispatch-grade verdict — see CHILLER-DRIFT.md for how to calibrate.
M&V baselines
An mv entry that declares its method can freeze its baseline and report against it across
years, with every version kept and every move attributed. See
MANDV.md for the triggers and the window rule.
camber mv freeze config.json --reason "2016 baseline" [--by NAME] [--apply] [--allow-short]
camber mv list config.json [--json versions.json]
camber mv run config.json [--out d/] # read-only
camber mv propose config.json [--as-of 2018-12-31] [--json proposal.json]
camber mv rebaseline config.json --equip METER --by NAME --reason "wing closed" \
[--period START END | --from-proposal proposal.json] [--trigger T2] \
[--as-of DATE] [--apply]
camber mv adjust config.json --equip METER --spec ledger.json --by NAME --reason R \
[--as-of DATE] [--apply]
camber mv report config.json --out mv.html [--json mv.json] [--as-of DATE] [--no-charts]
Every mv command takes --store FILE (the baseline store; see below). All but run take
--equip METER (repeatable) to act on those meters only; rebaseline and adjust require it.
The writers (freeze, rebaseline, adjust) take --run-id ID, stamped as the new version's
frozen_at (default: now, UTC).
freeze --allow-short is the only way past the baseline-length rule. A baseline window must
span min_baseline_days (365) consecutive days with at most max_missing_frac (10 %) of them
missing (SEP 2019 §4.2, IPMVP §4.5.2, CalTRACK §3.1.3); freeze refuses a shorter or gappier
window and names the override. With --allow-short the version is frozen with a caveat that
records the shortfall, and the SEP methods are still declined on it.
run,list,proposeandreportonly read the store.runmeasures each meter against its version in force and prints themv_savingsandmv_triggerfindings.proposeprints the triggers T1–T6, the rebaseline proposal (a new window, an NRA template or a decline with the days still needed) and SEP's method proposal.reportwrites the savings chained across versions with the chained CUSUM.freeze,rebaselineandadjustare the only writers. Each needs--reason, and each is a dry run unless--applyis given. Inside a portfolio workspace each takes the workspace lock without waiting, and each applied change appends one audit line (mv.freeze,mv.rebaseline,mv.adjust) with the OS user, the host, the reason and what changed.freezenever overwrites a version.rebaselinerefuses unless an unresolved trigger calls for it and the new window passes the window rule.--from-proposalfreezes the proposed model exactly, after checking the data under the window has not changed since.adjustrecords accepted NRA / static-factor entries on the live version after the confounding guard and SEP's evidence rule.- A facility that is not
active(suspended, provisioning) is skipped, with a message. - Billing entries (0.94, #72) are meters too, named by the entry's
name(--equip NAME), with the store kindmv_bills. Their version keeps the bills, the weather basis and the degree-day bases (base_f: "auto"selects them at freeze).rebaselinerecords any change of bases.reportadds the bases, the avoided cost and the calendarized months. See MANDV.md. - Billing entries, 0.95 (#74).
proposesearches a new window of whole bills for a rebaseline-class trigger, as it does for days. The window covers a full service year, and the latest one that meets every rule wins. Itswindowin--jsonalso hasn_billsand the model'sbases.- So
rebaselineno longer needs--periodfor bills. Without it, the window is searched asproposedoes;--from-proposalfreezes the proposed model exactly.--periodstill names a window by hand, under the same rules. "rebaseline": {"bill_steps": "scan"}opts a billing entry into a step test on its bills (trigger T1), andpropose/runreport what it finds. Declared events stay the recommended way to record a change. See MANDV.md.
Energy units (provisional, 0.92). A top-level "units": {"system": "ip" | "si"} reports M&V
energy in kBtu or kWh. The fits stay in the meter's unit, and the findings carry energy_unit and
meter_unit. A trended mv entry then names its meter's rate unit ("units": "kW"). A billing
entry's gas volumes need bills.heat_content. Without the block nothing changes. See
UNITS.md.
Day coverage (0.105). A trended mv entry keeps a day only when its meter data cover at least
"min_coverage" of it (default 0.9; 0 keeps every day, as before 0.105). The mv_baseline
finding reports the days dropped as n_days_dropped, with a caveat. Temperatures outside -40 to
140 °F are treated as missing and counted as n_temp_screened. See
MANDV.md.
Outside a workspace, name the store with the config's top-level "mv_store" (or --store).
camber portfolio migrate moves it, every version included, to state/<fid>/mv_baselines.json.
BACnet discovery
camber bacnet-discover walks a BACnet/IP network read-only (Who-Is, then each device's
object-list and its objects' names, units and descriptions) and bootstraps a role mapping
from what it finds. It needs the bacnet extra (pip install "camber-toolkit[bacnet]"). See
INGEST-PROTOCOLS.md.
camber bacnet-discover [--config bacnet.yml] [--local-address IP[:PORT]] [--instance N]
[--timeout S] [--range-low N --range-high N] [--device ADDR]...
[--out inventory.json]
--configreads aBacnetClientConfigYAML or JSON file; the other flags override it.--local-addressis the interface to bind on a multi-homed host,--instancethis host's BACnet device instance, and--timeoutthe per-request timeout in seconds.--range-low/--range-high(both together) limit the Who-Is to that device-instance window.--device ADDR(repeatable) names a known device address: discovery goes to those devices by unicast and skips the broadcast Who-Is, for networks a broadcast does not cross.- It prints the count of devices, objects and mapped roles;
--outwrites the inventory and the roles as JSON. It only reads: no property on a device is ever written.
open-fdd interop (provisional, 0.99)
camber interop openfdd reads open-fdd building packages (openfdd_package_v1, a folder or a
.zip) or open-fdd's historian Parquet layout, and writes CAMBER's findings back as an
engine-labelled JSON document. --timezone and --units are required: a package carries neither.
See INTEROP-OPENFDD.md.
camber interop openfdd ingest PKG --timezone TZ --units ip|si (--store DIR | --workspace ROOT)
[--activate] [--facility-id ID] [--name NAME] [--building B]
[--resample 15min|native] [--equip-types FILE] [--reason TEXT]
[--force] [--config-out run.json] [--json result.json]
[--lock-timeout SECONDS]
camber interop openfdd inspect PKG --timezone TZ --units ip|si [--building B]
[--resample 15min|native] [--equip-types FILE] [--json FILE]
camber interop openfdd crosswalk [--json] # the open-fdd -> CAMBER role table
camber interop openfdd findings CONFIG --out FILE # findings-exchange JSON ('-' = stdout)
ingestregisters the building as one facility. In a workspace that goes through the lifecycle (provisioningunless--activate) and is audited. It records provenance (file sha256s, crosswalk version, zone, units) and prints the column coverage. An unchanged package is skipped. It holds the store lock while it writes (0.104 for a plain store; see Store and cache locks). Into a workspace it does not wait by default, like every workspace change. A changed or--forcere-ingest of a facility under a legal hold is refused (0.108), asdatasets ingestrefuses it.inspectis the same read with nothing written.findingsruns a config (rules, M&V, drift) and writes one JSON document for a separate process to read back.
Open datasets
camber datasets fetches open building datasets from their publishers, verifies them, ingests
them into a Parquet store and writes a ready-to-run config (see DATASETS.md):
camber datasets list [--licence commercial|all] [--kind simulated|real|lab|synthetic] [--labeled] [--json]
camber datasets info <id> [--json] # summary, licence, citation, subsets, data issues
camber datasets fetch <id>... | --all [--subset S] [--dir D] [--licence all] [--accept-noncommercial]
[--quiet] [--lock-timeout SECONDS]
camber datasets ingest <id>... | --all --store DIR [--subset S] [--dir D] [--force] [--no-corrections]
[--from-dir DIR] [--accept-noncommercial] [--reason R]
[--quiet] [--lock-timeout SECONDS]
camber datasets status [--dir D] [--store DIR] [--json]
camber datasets remove <id> [--dir D] [--store DIR --purge-store [--reason R --yes|--confirm ID]]
[--lock-timeout SECONDS]
camber datasets config <id> --store DIR [--out cfg.json|cfg.yaml] [--format json|yaml]
[--facility ID] [--exercise EX]
camber datasets score <id> --store DIR [--findings findings.json] [--facility ID] [--json]
--dir names the dataset cache (default: $CAMBER_DATA_DIR, else ~/.cache); --quiet
turns off the progress output of fetch and ingest.
Workspace stores (0.107, #212). When --store belongs to a portfolio workspace, ingest and
remove --purge-store follow the facility lifecycle, as camber lab does. ingest needs
--reason, and it refuses a facility that is not provisioning or active, and a forced (or
changed) re-ingest that would replace the data of a facility under a legal hold. remove
--purge-store needs --reason and --yes (or --confirm ID). It refuses, before anything is
deleted, while a facility of the dataset is under a legal hold or not provisioning /
active. Both are audited (datasets.ingest, datasets.remove, datasets.purge). A plain store
needs neither flag.
config --out (0.107, #218) writes a config only for a dataset ingested in --store; without
--out it prints the template with a warning.
config --exercise <exercise-id> (0.97) writes a workbook exercise's tuned
template instead of the dataset's own; the template names the dataset it was tuned for, and any
other dataset is refused.
config --format yaml (0.98) writes the template as YAML, with its _comment notes turned into
comments; --out with a .yaml / .yml suffix picks YAML too. Writing YAML needs no extra;
running the YAML config needs the [yaml] extra.
list shows each entry's licence tier (open / research-only) and marks manual downloads.
fetch --all takes the open tier only; research-only (NC/ND) datasets also need --licence all
and --accept-noncommercial (--all --licence all without it is refused before anything
downloads). A named research-only id needs --accept-noncommercial on every fetch; each acceptance
is recorded in acknowledgements.json. There is no environment-variable bypass. ingest of
research-only data needs that recorded acknowledgement (or its own --accept-noncommercial).
A synthetic entry (0.103) is generated, not downloaded: fetch writes it from CAMBER's own
generator (list shows its download as generated), and ingest generates it when it is not in
the cache yet; see DATASETS.md.
A manual entry (files you download yourself, e.g. from a portal with terms) is never fetched:
fetch <id> exits 1 with the instructions and fetch --all skips it; download the files, then
ingest <id> --from-dir DIR --store STORE, which verifies every pinned file (size + SHA-256)
before using it -- --from-dir works for any entry whose files you already have. An entry with
Excel workbooks needs the xlsx extra (pip install "camber-toolkit[xlsx]"); without it ingest
exits 1 and says so. ingest --no-corrections skips the catalog's fixes for problems in
the published data (info <id> lists them) and ingests it exactly as published -- use a second
store to compare the two; ingest warns when the store's disk is smaller than the subset's
estimated size. Exit codes: 2 checksum mismatch (a download is kept as .bad; a
--from-dir file is left untouched), 3 licence gate, 4 not enough disk, 1 any other error
(including a manual entry named to fetch, a missing extra, a busy lock or a read-only cache).
Locks and a read-only cache (0.103). fetch and remove take a single-writer lock on the
cache (<dir>/_lock); ingest takes it only while it writes to the cache (placing --from-dir files,
extracting an archive member that is not there yet, or recording a licence acknowledgement), so
ingests that only read the cache run side by side. ingest and remove --purge-store also take the store's lock
(<store>/_lock, or the portfolio workspace's lock for a workspace store) for the whole write. A second writer waits
up to 30 seconds (--lock-timeout SECONDS or $CAMBER_LOCK_TIMEOUT changes that; see
Store and cache locks), then exits 1 with
dataset cache … is locked by <pid>@<host> since <time> (or store … is locked by …). Reads such
as status take no lock. A cache you cannot write works for
fetch of files that are already there and verify (nothing is written) and for ingest; see
DATASETS.md. A typical session:
camber datasets fetch lbnl-sdahu
camber datasets ingest lbnl-sdahu --store lab_store
camber datasets config lbnl-sdahu --store lab_store --out sdahu.json
camber report sdahu.json --out sdahu.html # carries the dataset's citation + licence
camber report sdahu.json --out rcx.html --layout rcx # the printable RCx layout
camber datasets score lbnl-sdahu --store lab_store
The config it writes uses a store source -- "source": {"kind": "store", "store": "...",
"facility_id": "ds-lbnl-sdahu"} -- which works for any Parquet store, not only catalog data:
equipment is discovered by the class recorded at ingest ({"class": "AHU", "marker_role":
"mixed_air_temp"}), no mapping is needed (the store already holds roles), and shared_oat may name
a stored equipment ({"equip": "weather", "role": "oat"}). Any equipment entry may add
"equip": [names] to keep only those equipment, for example one scenario of a dataset. An mv section fits a daily
change-point M&V baseline per meter ({"class": "CHILLEDWATER_METER", "role": "energy_rate",
"period": [start, end]}), reported as an mv_baseline finding with its fit statistics and OAT
support (oat_fit_min/oat_fit_max, oat_support_lo/oat_support_hi). Add
"reporting_period": [start, end] for an mv_savings finding per meter (avoided energy, fsu,
coverage); a reporting period outside the baseline's conditions is declined rather than reported,
and "extrapolation": {...} tunes that policy (see
MANDV.md). "method" declares the SEP
method (forecast, backcast, chaining, standard_conditions, or auto for a proposal
only), and an "adjustments": [...] ledger then restates that method's saving for non-routine
events and static factors, guarded by ecm_dates / settle_days; "validity" (g14, sep or
both) governs both (see MANDV.md).
The local catalog UI: camber lab (provisional, 0.96)
camber lab [--workspace W | --store S] [--dir D] [--port 8765] [--docs DIR] [--lock-timeout SECONDS]
This starts a local web page for the same catalog and prints its launch URL,
http://127.0.0.1:8765/lab?token=<this run's access token>: open that whole URL. For a
step-by-step guide, with screenshots, a classroom setup and troubleshooting, see
Using the lab. From it you can:
- filter the datasets by licence tier, kind, labels, or what you have ingested;
- tick one or more, compare what they download, extract and take in the store with the free disk (with the fetch's 5 % headroom; 0.103), and press Fetch & ingest, or Ingest (already fetched), with force re-ingest to re-run an unchanged ingest;
- see the optional extras a dataset needs; one that is not installed blocks its fetch, with the install command (0.103);
- ingest a manual download From a folder… (
ingest --from-dir), and Remove… a dataset's cache files, optionally purging its facilities (datasets remove [--purge-store]), after typing its id (0.103); - follow each job's progress, and cancel it;
- open the trends (the live viewer at
/ui?facility_id=ds-<id>), the report (the dataset's config template, run on demand, with its evidence charts), or the publisher's page.
A research-only dataset opens a dialog: tick the terms and type the dataset id to acknowledge
them. The acknowledgement goes to the same ledger as --accept-noncommercial.
- Where the data goes.
--workspace(else$CAMBER_PORTFOLIO, else the current directory if it is a workspace, or a--storethat belongs to one) makes each dataset ads-<id>facility that follows the lifecycle (see DATASETS.md). Otherwise the lab writes to a plain store (--store, default./lab_store). - The cache.
--diris the dataset cache, as forcamber datasets. - Exercise links (0.97). A dataset used by the re-tuning workbook has an
exercise link. In a source checkout the lab serves the page itself from
docs/workbook/(at/lab/docs/workbook/<id>.md), so it opens offline;--docs DIRpoints at another docs tree. Without one, the link goes to the published docs site. - Security. The server binds
127.0.0.1only; there is no--host. Every route needs the access token (0.102): the launch URL swaps it for a session cookie, and a script can sendAuthorization: Bearer <token>. The URL is printed to the terminal and saved, readable by you only, inlab-<port>.urlunder$XDG_RUNTIME_DIR/camber/(else~/.config/camber/); it is deleted when the lab stops. There is no option that takes the token on the command line. Writes also need a per-run CSRF token, from the lab's own origin, as JSON of at most 16 KiB (see SECURITY.md). - Port in use. When the port is taken,
camber labexits with code 1 and says so, suggesting--port(0.103).--port 0picks a free port; a port outside 0 to 65535 is a usage error (exit 2, 0.107). - Stopping. Ctrl-C stops it. A running download stops with its partial file kept, and the next fetch resumes it.
MCP server: camber mcp (provisional, 0.110)
camber mcp --store PATH (--allow FACILITY [--allow FACILITY ...] | --allow-all-active)
[--config PATH ...] [--min-trust 0.5] [--max-concurrent 2] [--max-queued 8]
A Model Context Protocol server on stdin / stdout, for AI clients such as Claude
Desktop and the claude command line: CAMBER's read-only capabilities as tools and prompts. The
client starts it; you do not run it by hand.
--storeis the store the tools read (a portfolio workspace root works too).--allow(repeatable) names the facilities the tools may read;--allow-all-activeallows the store's active facilities at startup. One of the two is required; every other facility is refused, and so is research-only data (a warning on stderr names each allowed facility that will be refused).--configis a run or report config (.json,.yamlor.yml) for one facility; repeat it for more, one per facility (two for the same facility are refused). Each is checked at startup the waycamber runchecks it, and must point at the--storedirectory and name an allowed facility; a config that fails stops the server (exit 2). The tools use the copy read at startup, so editing the file while the server runs changes nothing until a restart. A catalog-dataset facility without a--configruns with its dataset's packaged config template.--min-trust(0 to 1, default 0.5) is the trust floor.--max-concurrent N(1 to 64, default 2) is how many tool calls run at once, each in its own worker process;--max-queued N(0 to 1024, default 8) is how many more may wait for a free worker. A call beyond that is refused at once with the retryable tool errorbusy. Each worker holds the memory of the call it runs, so raise these with care on a large store.- A call past its time cap (120 s) is stopped with the tool error
timeout; a call whose worker ended under it, or that a re-ingest of its facility overlapped, answersunavailablewith"retryable": true. - Any launch mistake (a missing store, a bad config, a value out of range, an empty
--allow, an unknown option) exits 2 with one line on stderr;camber mcp -hshows the usage. - Logs go to stderr; stdout carries protocol messages only. The server exits when the client
closes stdin or its end of stdout, stopping every call still running.
SIGTERMandSIGHUPstop it like Ctrl-C (exit status 143, 129 and 130).
See MCP.md for client setup and the protocol details.
Store and cache locks
A store has one writer at a time (0.103 for the dataset commands, 0.104 for everything that writes
a plain store). Every command that writes a Parquet store holds the store's single-writer lock for
the whole operation: datasets ingest and remove --purge-store, the lab's ingest and remove
jobs, interop openfdd ingest, store migrate-partitions --apply and store normalize --apply,
and any script that writes
through ParquetStore (each write method takes the lock, and re-enters it when its caller already
holds it). A plain store's lock is <store>/_lock; a store inside a portfolio workspace uses the
workspace's lock. The dataset cache has its own, <dir>/_lock. Reads take no lock.
A second writer waits, then exits 1 naming the holder:
store <path> is locked by <pid>@<host> since <time>. The wait is:
--lock-timeout SECONDSondatasets fetch|ingest|remove,lab,interop openfdd ingest,store migrate-partitionsandstore normalize, and onretention apply(see below).0fails at once; a negative or non-numeric value is a usage error (exit 2);- else the
CAMBER_LOCK_TIMEOUTenvironment variable (same rules; a bad value exits 2 before anything runs); - else 30 seconds.
Workspace administration keeps its own default of not waiting: camber facility, camber
portfolio and retention apply refuse at once unless given a wait (retention apply
--lock-timeout S, formerly --wait S), and interop openfdd ingest --workspace waits 0 unless
--lock-timeout is given. The lab never waits on a workspace's lock. See
DATASETS.md.
Portfolio and facility lifecycle
camber portfolio manages a workspace: one directory holding a portfolio's store, retention
policy, append-only audit log and single-writer lock. camber facility moves facilities through
their lifecycle (see PORTFOLIO.md):
camber portfolio init <root> # create a workspace (idempotent)
camber portfolio adopt <store> [root] --reason R # wrap an existing store; moves nothing
camber portfolio status [--json] # counts by state, holds, lock, policy
camber portfolio audit [--facility ID] [--json] # who did what, when, and why
camber portfolio migrate [FILE...] [--config CFG]... [--map "SITE=ID"]... [--json] # dry run
camber portfolio migrate ... --apply --reason R # re-key site-keyed state to facility_id
camber facility add <name> [--id ID] [--owner O] [--tag T]... [--activate] [--private] --reason R
camber facility list [--state S] [--json]
camber facility show <id> [--json] # record, retention, state manifest, audit
camber facility rename <id> "<new display name>" --reason R
camber facility activate|suspend|resume <id> --reason R
camber facility private <id> [--off] --reason R # weather requests default to offline
camber facility offboard <id> [--apply --reason R --yes|--confirm ID] # 0.95: bundle + grace
camber facility archive <id> [--apply --reason R --yes|--confirm ID] [--skip-grace]
camber facility restore <id> [--apply --reason R --yes|--confirm ID] [--bundle BUNDLE_ID]
camber facility purge <id> [--apply --reason R --confirm ID] # typed id only; irreversible
camber facility export <id> --reason R # a verified bundle now; nothing else changes
camber facility bundles <id> [--verify] [--json] # list / re-hash export bundles
camber retention show [--facility ID] [--json] # 0.95: policy, overrides, holds
camber retention set CLASS KEY=VALUE... --reason R # e.g. set raw_trends keep_months=36
camber retention override ID CLASS (KEY=VALUE... | --clear) --reason R
camber retention hold|release ID --reason R # legal hold
camber retention apply [--facility ID] [--now DATE] [--dry-run] [--json] # dry run: the plan
camber retention apply --apply --reason R (--yes | --confirm WORD) [--lock-timeout S] # prune
camber store migrate-partitions STORE [--apply --yes] [--dry-run] [--reason R] [--lock-timeout S] [--json]
camber store verify STORE [--facility ID] [--strict] [--limit N] [--json] # read-only, no lock
camber store normalize STORE [--facility ID] [--apply --yes] [--dry-run] [--reason R] [--lock-timeout S] [--json]
Every command except init and adopt takes --workspace PATH, else $CAMBER_PORTFOLIO, else
the current directory. Every change requires --reason, which is audited with the OS user and
host. A change takes the workspace lock without waiting. A second concurrent change is refused
with portfolio is locked by <pid>@<host> since <ts> (exit code 1).
add creates a facility as provisioning unless --activate is given. Its id defaults to one
derived from the name. Ids are never reused: a removed id is tombstoned. rename (0.108) refuses
a name that is another facility's id, registered name, or current or past display name, live or
tombstoned, compared exactly and loosely (exit code 1).
offboard, archive, restore and purge (0.95) are dry runs unless --apply: without it
they print the plan (the footprint, the bundle, every path they would delete) and change nothing;
--json prints it as JSON. --apply needs --reason and a confirmation: --yes, or the typed
facility id (--confirm ID, or a prompt when stdin is a terminal). purge takes only the typed
id. offboard writes a verified export bundle to archive/<fid>/ and starts a 30-day grace
period; archive (after it, or with --skip-grace) deletes the hot data and keeps the bundle;
restore brings a facility back, re-verifying every checksum; purge leaves only the tombstone
and the audit record. A legal hold refuses archive and purge, and a restore from archived
that would delete or change a live file (0.108; a restore also empties the live trees the bundle
does not list, and its plan lists them under deletes). See
PORTFOLIO.md and
Legal holds on every path.
retention apply (0.95) enforces the retention policy: it rolls raw month partitions up into
hourly and daily rollups, verifies the rollups against the raw row count, and only then prunes;
it also trims old 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, skips
facilities under a legal hold, is idempotent, and is safe from cron: exit code 0 (done or nothing
to do), 1 (a verification kept data), 75 (the lock is held; retry next run). Its lock wait is
--lock-timeout S (0.104; --wait S is the older name), default 0. store migrate-partitions
converts a pre-0.95 year-only store to month partitions; --apply holds the store lock (a plain
store's own since 0.104), waiting as described in Store and cache locks.
See PORTFOLIO.md.
store verify (0.104) reports, per part file, whether it is in the canonical layout of 0.103 (the
camber.layout marker, no pandas metadata, one row group, rows sorted by the canonical key, the
store columns first) and which parts fail to read, with counts by origin: legacy (no marker:
written before 0.103), edge (an edge forwarder's part) and rollup. A workspace's store includes
its rollup stores. It reads every part, takes no lock and changes nothing. Since 0.107 (#213) it
also lists what an interrupted ingest left behind (a partition swap, a moved-aside partition, a
staging directory; the next ingest recovers them) and registered facilities with no part files
in a state that should have some. Exit code 0 when every part is readable, canonical or not; 1
when a part fails to read or a facility's data is stranded outside its partition, or, with
--strict, when a part is not canonical or anything is left over.
store verify, normalize and migrate-partitions (0.107, #215) refuse a path that is not a
store: missing, a file, or a directory of other things (no partitions, registry or workspace
marker). They exit 1 and create nothing. --facility must name a facility the store knows. An
empty directory is an empty store (0.108, #233), as it is for camber serve: store verify
reports 0 parts and exits 0, and normalize and migrate-partitions do nothing and create
nothing, not even a lock file (dot-files such as .DS_Store do not count).
store normalize (0.104) rewrites the non-canonical parts in the canonical layout. It is a dry run
unless --apply --yes (and --reason, audited as store.normalize, inside a workspace), holds
the store lock (--lock-timeout as above), and reports the parts, rows and bytes before and after
per partition. Each part keeps its name and its rows: it is written to a stage inside its
partition, read back and compared, then moved over the original. A crash at any point leaves every
part either old or new, never lost or twice, and the next run finishes; a re-run with nothing to do
changes nothing. It leaves alone, with a reason: canonical parts, edge parts (their name is the
sha256 of their bytes; see EDGE-DEPLOY.md), year-only
parts (migrate-partitions rewrites those), unreadable parts, and facilities that do not accept
data (suspended, offboarding, archived, retired ids) or are under a legal hold. Asked for one of
those facilities with --facility, it refuses (exit 1). See
SCALE.md.
Analyses skip facilities that are not active. A store-backed camber run on a suspended facility
warns and finds no equipment unless the config source sets "include_inactive": true.
migrate moves fault and baseline files written before 0.86, which were keyed by the free-text
site name, into state/<facility_id>/. It is a dry run unless --apply is given. It maps each
site label to a facility through the registry (name, display name, earlier display names). It
refuses ambiguous labels, unknown labels and labels of tombstoned facilities, with exit code 1 and
nothing written, until you resolve them with --map. Each legacy file is replaced with a redirect
stub, so configs that name it keep working. Re-running is a no-op. See
PORTFOLIO.md.
Runs inside a workspace. A config whose store source belongs to a workspace, or a folder
config with "workspace": "<root>" (and ideally a "facility_id"), keys its drift baselines and
its optional faults history by the facility id. Both default to state/<facility_id>/ when the
config names no path, and every file the run writes is listed in the facility's manifest. camber
drift freeze then needs --reason, and freeze / accept take the lock and are audited.
Renaming the facility changes none of it. See
PORTFOLIO.md.
Edge lifecycle (provisional, 0.95)
The edge forwarder's device commands (run, send-once, status, selftest) are described in
EDGE-DEPLOY.md. 0.95 adds commands for the device's end of life and for the
central side, where uploads land (see
EDGE-DEPLOY.md):
# on the edge device
camber edge status <edge.json> [--json] [--limit N] # pending, stuck and dead-lettered batches,
# and the high-water mark (0.109)
camber edge requeue <edge.json> [--key K]... [--apply] # queue dead-lettered batches again (0.108)
camber edge compact <edge.json> [--dry-run] [--lock-timeout S] [--json] # shrink the spool journal
# (--lock-timeout: wait for a busy spool, default 30 s)
camber edge decommission <edge.json> [--device ID] [--wait S] # dry run: what would be flushed
camber edge decommission <edge.json> --apply (--yes | --confirm FACILITY_ID) --reason R
[--force] [--workspace W] [--json] # flush, wait for acks, retire
# central, in the portfolio workspace (--workspace, else $CAMBER_PORTFOLIO, else the current dir)
camber edge reconcile [--landing DIR | --keys LISTING] [--prefix P] [--json] # read-only
camber edge reconcile [--landing DIR] --apply --reason R # quarantine what it flags
camber edge reconcile --apply --yes --reason R # ... and remove row duplicates
camber edge land <inbox> [--apply --reason R] [--json] # route uploads: store or quarantine
camber edge quarantine list [--facility ID] [--json]
camber edge quarantine release (--facility ID | --key K...) [--apply] --reason R
camber edge quarantine discard (--facility ID | --key K...) [--apply] (--yes | --confirm ID) --reason R
camber edge record-retirement <retired.json> --reason R # a device's receipt, recorded centrally
camber edge bucket-rules --provider s3|gcs|azure [--policy FILE] [--facility ID]... [--prefix P]
[--container C] [--out rules.json] [--json] # text only, no cloud call
- Dry runs by default.
land,reconcile --apply,quarantine release|discardanddecommissionchange nothing without--apply, which also needs--reason(audited with the OS user and host).discardanddecommissionalso need--yesor the typed facility id (--confirm). Each takes the portfolio lock (and, on the device, the spool lock). - Legal holds refuse
quarantine discardand a forceddecommission. - Row checks at landing (0.107, #142; 0.108, #228, #230).
landquarantines a part whose rows its partition already holds (aduplicate, whatever its bytes), replaces a stored part whose rows the new part holds in full (the month in progress, re-sent bigger on every poll, its last hour revised; the partition is swapped in whole, so a reader sees the old part or the new one, never both), and quarantines aconflict: a part that overlaps a stored one (the same timestamp, equipment and role) without holding all of its rows. A year-only part from a pre-0.95 forwarder is also compared with the month partitions of its year. Under a legal hold the replaced part is copied into quarantine first.quarantine releaseof a conflict stores it beside the other part. Since 0.108 a part that lacks the oldest rows of a stored part but agrees with it on every shared observation (a rolling window, such as a trend-log ring buffer) is stored as the union of the two (mergesin the listing), and a disagreement in the overlap is aconflict. An upload at a key quarantine already holds with other content stays in the inbox (quarantine_collision); one object that fails is listed as an error, the run goes on, andland --applyexits 1 (#247). - The spool on the device (0.108, #245).
edge statusshows the batches that failed delivery (STUCK) and those dead-lettered afterdead_letter_afterpermanent rejections (DEAD, kept in<spool>/dead/);--jsonprints them all,--limitcaps the dead list (default 20).edge requeueis a dry run without--apply; with it, the dead batches (or those named by--key) are queued again with a fresh count, and an unknown--keyexits 1. A 401 or 403 from the sink is not a rejection: it stops the cycle with back-off andedge statusshowsAUTH FAILING since <time>. An unknown config key is refused by everyedgecommand that reads the config, with the closest known key named (0.109, #271; 0.108 warned).edge statusshows the forwarder's high-water mark (0.109, #266;high_waterin--json).edge selftestruns on a temporary spool and never touches the configured one (#242). - Row duplicates already in the store (0.108, #229). A store
reconcilelists the groups of parts of one facility that hold identical rows under different keys (landed before the row check), with their keys, sizes and landing times.--apply --yes(with--reason) keeps the oldest copy of each group and deletes the others, under the store lock, audited asedge.reconcile.dedupebefore anything is deleted. A facility under a legal hold has its extra copies moved to quarantine instead; facilities that do not accept data are left alone. A store reconcile reads every part once to digest it (the digests are cached by--apply). - Exit codes: 0 on success or a dry run; 1 for a refusal (a busy lock, a missing reason or
confirmation, an unacknowledged spool without
--force, every selected object refused). decommissionwithout a reachable workspace retires the device and tells you to copy the spool'sretired.jsonto the portfolio host forrecord-retirement. A decommissioned device'sedge runandsend-onceexit 1, andedge statusshowsRETIRED.bucket-rulesreads the workspace's retention policy (or--policy FILE) and prints the provider lifecycle JSON with a summary;--outwrites the JSON;--jsonprints only the JSON. Applying it (with the provider's own tool) replaces the bucket's lifecycle configuration.
Weather privacy audit
camber weather audit prints what CAMBER sent to weather and price services (NOAA ISD, NASA POWER,
Open-Meteo, EIA, OpenEI URDB): one line per request with the time, the service, the privacy mode,
whether it was sent or served locally from the cache, and the URL as sent (API keys redacted).
See WEATHER.md.
camber weather audit [--facility ID] [--since YYYY-MM-DD] [--json] # in a workspace
camber weather audit --cache-dir DIR # the log next to a cache
camber weather audit --file weather_audit.ndjson
--facility reads state/<facility_id>/weather_audit.ndjson in the workspace (--workspace,
else $CAMBER_PORTFOLIO, else the current directory), and exits 1 outside one. Without it, a
workspace's logs are merged. Outside a workspace, the default weather cache's log is read.
camber facility private <id> --reason R marks a facility private, so its weather requests default
to offline; --off clears the flag. Both changes are audited.
Backward compatibility
Before 0.5 the CLI took --csv/--demo at the top level; those AHU heating-vs-cooling charts now live
under camber charts (e.g. camber charts --demo reheat --ahu 1 --out out/).