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] # run a config, print/write findings
camber report <config.json> --out site.html # run + write an HTML audit report
camber explain <config.json> [--llm-cmd CMD] # grounded plain-language explanation of findings
camber ask "<question>" --config <config.json> # grounded natural-language Q&A over the run
camber fleet '<glob>' [--ask Q] [--out f.html] # portfolio rollup across configs + triage
camber charts (--csv F | --demo reheat) [--ahu N] [--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] # read-only API + live /ui dashboard
camber drift run|report|freeze|list|accept <config.json> # baseline-vs-current drift
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.
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 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}}]
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).
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 · vav; each class must appear in
the config's equipment list. coils (AHU) adds one coil-valve detector per coil; plant (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)
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"
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.
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.
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/).