Skip to content

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 Host header 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 are 127.0.0.1, localhost and [::1] at the bound port; bound to an address, that address too. --allow-host NAME (repeatable), or CAMBER_API_ALLOWED_HOSTS=name1,name2, adds the names a proxy or DNS alias uses: a bare NAME matches any port, NAME:PORT only that port.
  • Every interface needs a list. --host 0.0.0.0 (or ::) refuses to start unless an allowlist is given. --allow-host '*' answers any Host and turns the rebinding check off; it is unsafe and meant only behind a proxy that checks Host itself.
  • Cross-site requests refused, always on (0.104). A browser request with Sec-Fetch-Site: cross-site, or with an Origin that is not an allowed name, is 403, as in camber 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, or localhost, ::1) with --auth left out, or with --auth token on any bind, every route needs this run's access token, as in camber lab: the command prints a launch URL ending in ?token=... and saves it, readable by you only, in serve-<port>.url (in $XDG_RUNTIME_DIR/camber, else ~/.config/camber). Opening it sets an HttpOnly; SameSite=Strict session cookie and redirects to the same page without the token. Scripts send Authorization: Bearer <token>. Without credentials a request is 401 (403 for a wrong token), except GET /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 --auth it 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.server reads the same choice from CAMBER_API_AUTH (none or token); unset, it follows the same default as camber 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). --port takes 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. ID must be one the config discovers. Add "period": [start, end] to use only part of its history (else the family's or section's baseline window, else all of it); the scored units use current, else their whole history.
  • {"period": [start, end]} scores each unit against a known-good window of its own history; current then 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, propose and report only read the store. run measures each meter against its version in force and prints the mv_savings and mv_trigger findings. propose prints 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. report writes the savings chained across versions with the chained CUSUM.
  • freeze, rebaseline and adjust are the only writers. Each needs --reason, and each is a dry run unless --apply is 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. freeze never overwrites a version. rebaseline refuses unless an unresolved trigger calls for it and the new window passes the window rule. --from-proposal freezes the proposed model exactly, after checking the data under the window has not changed since. adjust records 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 kind mv_bills. Their version keeps the bills, the weather basis and the degree-day bases (base_f: "auto" selects them at freeze). rebaseline records any change of bases. report adds the bases, the avoided cost and the calendarized months. See MANDV.md.
  • Billing entries, 0.95 (#74).
  • propose searches 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. Its window in --json also has n_bills and the model's bases.
  • So rebaseline no longer needs --period for bills. Without it, the window is searched as propose does; --from-proposal freezes the proposed model exactly. --period still 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), and propose / run report 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]
  • --config reads a BacnetClientConfig YAML or JSON file; the other flags override it.
  • --local-address is the interface to bind on a multi-homed host, --instance this host's BACnet device instance, and --timeout the 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; --out writes 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)
  • ingest registers the building as one facility. In a workspace that goes through the lifecycle (provisioning unless --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 --force re-ingest of a facility under a legal hold is refused (0.108), as datasets ingest refuses it.
  • inspect is the same read with nothing written.
  • findings runs 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 --store that belongs to one) makes each dataset a ds-<id> facility that follows the lifecycle (see DATASETS.md). Otherwise the lab writes to a plain store (--store, default ./lab_store).
  • The cache. --dir is the dataset cache, as for camber 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 DIR points at another docs tree. Without one, the link goes to the published docs site.
  • Security. The server binds 127.0.0.1 only; 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 send Authorization: Bearer <token>. The URL is printed to the terminal and saved, readable by you only, in lab-<port>.url under $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 lab exits with code 1 and says so, suggesting --port (0.103). --port 0 picks 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.

  • --store is the store the tools read (a portfolio workspace root works too).
  • --allow (repeatable) names the facilities the tools may read; --allow-all-active allows 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).
  • --config is a run or report config (.json, .yaml or .yml) for one facility; repeat it for more, one per facility (two for the same facility are refused). Each is checked at startup the way camber run checks it, and must point at the --store directory 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 --config runs 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 error busy. 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, answers unavailable with "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 -h shows 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. SIGTERM and SIGHUP stop 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 SECONDS on datasets fetch|ingest|remove, lab, interop openfdd ingest, store migrate-partitions and store normalize, and on retention apply (see below). 0 fails at once; a negative or non-numeric value is a usage error (exit 2);
  • else the CAMBER_LOCK_TIMEOUT environment 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|discard and decommission change nothing without --apply, which also needs --reason (audited with the OS user and host). discard and decommission also need --yes or the typed facility id (--confirm). Each takes the portfolio lock (and, on the device, the spool lock).
  • Legal holds refuse quarantine discard and a forced decommission.
  • Row checks at landing (0.107, #142; 0.108, #228, #230). land quarantines a part whose rows its partition already holds (a duplicate, 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 a conflict: 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 release of 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 (merges in the listing), and a disagreement in the overlap is a conflict. 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, and land --apply exits 1 (#247).
  • The spool on the device (0.108, #245). edge status shows the batches that failed delivery (STUCK) and those dead-lettered after dead_letter_after permanent rejections (DEAD, kept in <spool>/dead/); --json prints them all, --limit caps the dead list (default 20). edge requeue is 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 --key exits 1. A 401 or 403 from the sink is not a rejection: it stops the cycle with back-off and edge status shows AUTH FAILING since <time>. An unknown config key is refused by every edge command that reads the config, with the closest known key named (0.109, #271; 0.108 warned). edge status shows the forwarder's high-water mark (0.109, #266; high_water in --json). edge selftest runs on a temporary spool and never touches the configured one (#242).
  • Row duplicates already in the store (0.108, #229). A store reconcile lists 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 as edge.reconcile.dedupe before 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).
  • decommission without a reachable workspace retires the device and tells you to copy the spool's retired.json to the portfolio host for record-retirement. A decommissioned device's edge run and send-once exit 1, and edge status shows RETIRED.
  • bucket-rules reads the workspace's retention policy (or --policy FILE) and prints the provider lifecycle JSON with a summary; --out writes the JSON; --json prints 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/).