Visualization
CAMBER's charts fuse graphing and diagnostics: every chart can surface the faults inside it,
and every fault can render the trend that proves it. All charts are matplotlib, draw onto a
supplied Axes, and lazy-import pyplot. The 0.2 MVP is the A → B → E → I slice plus a
self-contained HTML assembler; the primitives (load carpet, CUSUM, energy signature) shipped in
0.1.
flowchart LR
df["role-frame (wide df)"] --> readiness["charts.readiness (A)"]
df --> multitrend["charts.multitrend (B)"]
df --> carpet["charts.carpet (E)"]
df --> quality["charts.quality_dashboard (I)"]
df --> diag["charts.diagnostic / oat_scatter"]
rules["rules"] --> ev["Evidence (pattern J)"]
diag --> ev
readiness --> dash["build_dashboard / build_site_report"]
multitrend --> dash
carpet --> dash
quality --> dash
ev --> dash
dash --> html["self-contained HTML (base64 PNG / inline SVG)"]
The role-frame feeds each chart primitive; rules render evidence, and the assembler inlines them into one HTML page.
The MVP slice
| Pattern | Module | What it shows |
|---|---|---|
| A Ingest readiness | camber.charts.readiness |
per-point presence ribbon (green = data present) + coverage % |
| B Fault-annotated trend | camber.charts.multitrend |
synchronized multi-trend with rule-violation spans shaded |
| E Load carpet | camber.charts.carpet |
hour-of-day × date heatmap (occupancy, setback, stuck-on days) |
| I Data-quality dashboard | camber.charts.quality_dashboard |
points × {coverage, score, flatline, outliers} heatmap |
A — readiness ribbon
from camber.charts.readiness import readiness_ribbon
readiness_ribbon(df, max_bins=240) # df: wide point/role frame
max_bins (time resolution), title, max_xticks. presence_matrix(df) returns the
raw (matrix, bin_starts, coverage) if you want the numbers.
B — fault-annotated multi-trend
from camber.charts.multitrend import fault_multitrend
fault_multitrend(df, ["load_kw", "sat"], spans={"high_load": df["load_kw"] > 95}, normalize=True)
spans is {label: boolean Series} — each rule supplies the timestamps where it tripped, and
each True run is shaded once. Flags: normalize (overlay disparate units 0–1), shade_color,
shade_alpha, title. mask_to_spans(mask) is the reusable mask→intervals helper.
I — data-quality dashboard
from camber.charts.quality_dashboard import quality_dashboard
quality_dashboard(df, metrics=("coverage", "score", "flatline_frac", "outlier_frac"))
camber.ingest.quality.assess.
An opt-in fifth metric, regime_outlier_frac ("outliers (in-regime)"), reports outliers judged
within each regime of a duty-cycled point. Showing it beside outlier_frac is how a reader
sees a two-regime read rather than having it applied invisibly: a large gap between the two columns
means the point cycles. A metric that could not be computed renders blank rather than as zero.
Deepening the catalog (0.3)
Beyond the MVP slice, 0.3 builds the pattern catalog toward rules as a chart engine — every rule renders its own evidence. First renderer: pattern D.
D — OAT cloud-shape scatter
from camber.charts.oat_scatter import oat_scatter, classify_shape, brush_back
ax, shape = oat_scatter(load_kw, oat, ylabel="kW") # overlays the change-point fit + guides
shape.shape # "linear" | "hockey-stick" | "v" | "scattered"
classify_shape(series, oat) labels it from the fitted change-point model + goodness of fit
(a weak fit → scattered, i.e. no OAT dependence) with no chart required, returning a
JSON-friendly CloudShape. brush_back(series, oat, x_range=…, y_range=…) maps a selected region
of the cloud back to the timestamps that produced it — the primitive the interactive-linking
layer (below) uses to answer "when did this cluster happen?".
Flags: changepoint ("auto"/a kind/False), classify, by (colour by season/occupancy),
min_max_avg (per-point min–max whiskers — provenance over a bare average), cmap. Generalizes the
energy_signature plot to any point (airflow, valve %, ΔT), not just energy.
G — templated subsystem diagnostic scatters
from camber.charts.diagnostic import diagnostic_scatter, TEMPLATES
ax, violating = diagnostic_scatter(role_frame, TEMPLATES["sat_reset"]) # violating: bool Series
DiagnosticTemplate names two roles and an
expected(x) -> (low, high) band, and diagnostic_scatter overlays that band, shades the points
outside it, and returns the violating mask — so the figure doubles as a rule's evidence (feeds
pattern J). Packaged TEMPLATES: sat_reset, chw_reset (reset schedules vs OAT — clamped at the
endpoints), economizer (OA damper open for free cooling, minimum when hot), no_simultaneous_hc
(heating valve must be ~0 when cooling is active). Build your own with band, reset_line,
economizer_template, no_simultaneous_template. Flags: shade, tolerance.
Plotting a drift baseline. Those templates all encode a band someone designed. fitted_band
encodes one the equipment earned — a frozen drift baseline's own fitted line
± k residual sigmas — so the comparison the drift detectors actually make (residuals against the
frozen line at matched load) becomes visible instead of staying inside the rule:
from camber.charts.diagnostic import diagnostic_scatter, fitted_band
from camber.store.modelstore import BaselineStore
model = BaselineStore.load("baselines.json").model_for("Site", "CH_1", "chiller_approach_cond")
ax, violating = diagnostic_scatter(current_frame, fitted_band(model, "tons", "approach_f", k=2))
Outside the load envelope the baseline was fitted on, the band is NaN: the shaded region shows a
gap and points there are not counted as violations. Judging a reading against an extrapolated
fit is the same asserted negative the drift rules refuse to make — pass within_envelope=False only
if you have a reason to extrapolate. k is a band width, not a severity threshold; the detectors'
own screening-grade sigma floors decide what warns or faults.
J — rules as a chart engine (the keystone)
Every rule that can mark its violating timestamps renders its own evidence — the chart is the audit evidence and the report figure. A rule opts in with an optional, duck-typed hook:
class SimultaneousHeatCool:
def evidence(self, equip, frame): # optional; rules without it are unaffected
from camber.charts.diagnostic import TEMPLATES
from camber.charts.evidence import Evidence
return Evidence(renderer="diagnostic", template=TEMPLATES["no_simultaneous_hc"])
Evidence names a renderer (diagnostic / multitrend / oat_scatter / carpet) and the
roles / mask / template it needs; render_evidence(evidence, frame, ax=…) dispatches to that
pattern primitive. finding_evidence(rule, equip, frame) calls the hook safely (returns None when
absent or declined), and evidence_descriptor(evidence) is the JSON-friendly payload (renderer +
roles + violating timestamps) for export/linking. Finding carries an optional evidence field.
Every rule renders evidence. Rules with a tailored hook map to the fitting renderer and shade
the specific violation: simultaneous_heat_cool & outdoor_air_fraction (diagnostic),
supply_air_reset (diagnostic reset), reheat_penalty (OAT scatter — heating in warm weather),
night_weekend_setback (carpet — the fault is the schedule), overcooling_min_flow /
unmet_setpoint_hours / supply_air_control / airflow_tracking (multitrend, violating spans
shaded). Every other rule falls back to a default multitrend of the roles it examined — so the
whole library (present and future rules) carries evidence, no per-rule map required. Fleet findings
(no single equipment frame) render none.
Drift rules are the one family the default would misrepresent. Their claim is not "these values
are wrong" but "these values have moved off a frozen line", and a trend of the raw roles shows the
levels while hiding exactly that. So each of them declares
drift_signature() -> (kind, load_col, metric_col) and drift_frame(frame) instead of a bespoke
hook, and drift_evidence(rule, equip, frame) builds the chart the detector reasons about: the
current period scattered on its frozen baseline's fitted_band.
finding_evidence tries it before the default trend. A rule with nothing frozen returns None —
a scatter with no band would invite the reader to judge it by eye, which is the comparison the frozen
baseline exists to make. From the CLI: camber drift report … --charts.
The dashboard wires it automatically — pass rules=:
html = build_dashboard(df, findings=findings, rules=registry) # evidence=True by default
AuditReport.to_html(rules=…, frames={equip: frame})
embeds each finding's evidence beneath the findings table (per-equipment frames, so a fleet audit
renders the right trend for each unit).
C — peer/cohort comparison + cohort-deviation rule
from camber.charts.cohort import cohort_small_multiples, cohort_deviation
fig, res = cohort_small_multiples(frames, Role.AIRFLOW) # frames: {equip: role-frame}
res.outliers # units > k robust-σ from the cohort norm
summary (mean / peak
/ load_factor), so a couple of odd units don't move the reference. The same score powers a FDD
rule — camber.rules.cohort.CohortDeviation(role, k=…, summary=…) is a fleet rule that flags "this
unit runs unlike its peers", a signal no per-unit absolute-bound rule can see. Flags: k, summary,
min_cohort, rank, ncols, max_units.
H — M&V baseline, savings & uncertainty
from camber.charts.savings import savings_chart
ax, res = savings_chart(
baseline_model, t_report, y_report, n_baseline=200, p_baseline=2, cv_rmse=0.08
)
res.avoided_energy, res.abs_uncertainty # e.g. 2983 ± 1318 at 90%
cumulative_savings(...) returns the raw
(index, cum_baseline, cum_actual, cum_avoided) arrays. Reuses mandv.stats.avoided_energy_savings
and any predict()-able baseline (mandv.models.best_model). Flags: confidence, rho
(lag-1 residual autocorrelation — raises the band; None, the default, leaves it unadjusted and
says so on the result), ylabel.
F — load profiles & load-duration curves
from camber.charts.loadprofile_chart import load_profile_chart, load_duration_chart
load_profile_chart(load_kw, split=True) # weekday vs weekend hour-of-day shape
load_duration_chart(load_kw, price=0.15) # LDC + energy-cost translation
load_profile_chart plots the average load by hour-of-day (weekday vs
weekend when split, exposing schedule gaps) with the base load annotated. load_duration_chart
sorts every interval high-to-low against the % of time it's exceeded — the area is energy, the left
edge the peak, the right shoulder base load — and with a price ($/kWh) adds an energy-cost figure.
Both return (ax, LoadMetrics) and reuse camber.loadprofile. Flags: split, annotate, price.
The HTML dashboard
camber.report.build_dashboard assembles the sections + the ranked findings into one
self-contained HTML page — matplotlib figures inlined as base64 PNG, no web framework, no
external assets.
from camber.report import build_dashboard
html = build_dashboard(
df,
findings=findings,
spans={"high_load": df["load_kw"] > 95},
carpet_col="load_kw",
rank_by="cost",
title="Site dashboard",
)
open("dashboard.html", "w").write(html)
Option flags — build_dashboard
| flag | default | effect |
|---|---|---|
sections |
("A","B","E","I") |
which sections to render, in order |
findings |
None |
listed ranked beneath the charts (actionable only) |
spans |
None |
{label: boolean Series} shaded in section B |
rank_by |
"severity" |
findings order — "severity" or "cost" (annual $ if present) |
top_n |
20 |
max findings listed |
carpet_col |
first column | which point the carpet (E) draws |
multitrend_cols |
all | which points section B overlays |
normalize |
True |
normalize the multi-trend overlay |
rules |
None |
Registry / {name: rule} / rules — enables per-finding evidence (pattern J) |
evidence |
True |
render each actionable finding's evidence chart when rules is supplied |
interactive |
False |
add a brush-able inline-SVG scatter (vanilla JS, no framework) |
link_x / link_y |
auto | the scatter's axes (default: an OAT-like x, the first other column) |
Interactive linking (brush → select)
html = build_dashboard(df, interactive=True) # link_x defaults to an OAT-like column
interactive=True the dashboard adds a brush-able scatter drawn by a small vanilla-JS
module (no framework, no CDN, CSP-safe) from an inline JSON payload. Drag a box over the cloud and the
selected points highlight while a linked readout lists their timestamps — the pattern-D brush-back
made live.
Cross-panel linking (0.8). The brush no longer stops at the scatter: a shared window.CAMBER
selection bus (a Set of selected timestamp strings) lets the selection propagate across every view.
Panels B (fault multitrend) and E (load carpet) are promoted from static PNG to inline SVG
that subscribe to the bus — brushing a cluster in the scatter shades the corresponding time ranges
in the multitrend and highlights the matching hour × date cells in the carpet. Every panel keys off
the same str(timestamp) from the frame index, so they interoperate without sharing a coordinate
system; panels A and I stay PNG (aggregate matrices, not timestamp-indexed). Still a single
self-contained CSP-safe file. selection_bus_html(), carpet_svg_html(series, …), and
multitrend_svg_html(df, cols, spans=…) build the pieces; interactive_scatter_html(...) the scatter.
Live web UI (0.72)
Everything above is a one-shot self-contained file. camber.api.ui adds its live counterpart:
a single vanilla-JS page served by the read-only API at GET /ui that fetches the running store
(/facilities, /points, /history) and polls, so the views refresh as new data lands
("continuous, not one-shot"). It reuses the same window.CAMBER selection bus — brushing the
synchronized multitrend links to a timestamp readout exactly like the static panels.
camber serve /path/to/store # read-only API + live dashboard at http://127.0.0.1:8080/ui
python -m camber.api.server /path/to/store 8080 # equivalent; JSON endpoints unchanged
Framework-free and dependency-light: stdlib http.server + inline vanilla JS/SVG, no framework, no
CDN, no external asset (a strict Content-Security-Policy header is sent on the HTML route). It is
read-only (GET-only) and binds 127.0.0.1 by default — exposing it on a public interface is your
decision and adds no auth (see SECURITY.md). The remaining nice-to-have is a live
carpet/heatmap panel; the live multitrend + selectors + cross-panel linking + polling ship now.
Scope
The static builders remain the dependency-light, self-contained artifact for reports and offline
sharing; the live /ui is for watching a running store. Agent narration on the live view is the
remaining item in the ROADMAP
Visualizations section.