Time handling & DST
BAS trend exports arrive as naive local time (camber.realio strips the PDT/PST
abbreviation), so daylight-saving transitions leave two artifacts: the fall-back hour repeats
(duplicate timestamps) and the spring-forward hour is missing (a gap). Concatenated overlapping
exports duplicate timestamps too. camber.timegrid centralizes robust handling.
flowchart LR
raw["naive local BAS export"] --> realio["camber.realio (strip PDT/PST)"]
realio --> load["load_csv (dedupe='first')"]
load --> reg["regularize (sort + de-dup DST fall-back)"]
reg --> iv["interval_hours (modal width)"]
reg --> loc["localize (tz, resolve ambiguous/nonexistent)"]
loc --> anom["dst_anomalies (health check)"]
reg --> analytics["analytics (naive local time)"]
loc --> energy["hour-accurate energy across DST"]
Duplicate/gap DST artifacts are collapsed on ingest; timezone is attached only when hour-accurate energy needs it.
from camber.timegrid import interval_hours, regularize, localize, dst_anomalies
interval_hours(series.index) # modal width, ignores 0/duplicate gaps
clean = regularize(
df, dedupe="first"
) # sort + collapse duplicate timestamps ("first"/"last"/"mean")
aware = localize(
idx, "America/Los_Angeles"
) # tz-localize, resolving DST ambiguous/nonexistent times
dst_anomalies(
idx, "America/Los_Angeles"
) # {"duplicate_timestamps", "fallback_ambiguous", "springforward_nonexistent"}
interval_hoursuses the median of strictly-positive gaps, so a duplicate (0-gap) timestamp can't collapse the interval to zero (which would zero out any energy computed from it).regularizesorts and de-duplicates —"first"/"last"keep one row,"mean"averages the repeated hour,Noneleaves duplicates.localizeattaches a timezone to naive local data, mapping the fall-back repeated hour to PDT→PST and shifting the spring-forward skipped hour forward (rather than raising).dst_anomaliescounts duplicates and, given a timezone, the fall-back/spring-forward transitions — a DST health check for a series.
Wired into ingest: camber.io.load_csv(..., dedupe="first") now collapses duplicate timestamps
by default, and camber.ingest.quality.assess reports n_duplicate_ts. The robust outlier detector
also no longer crashes on a non-unique (duplicate-timestamp) index.
Order matters for the two-regime read. assess's temporal-coherence gate measures run lengths
in index order, so an unsorted export can make a real duty cycle look like scatter (and be
scored pooled, the conservative direction). Run timegrid.regularize — sort + de-duplicate —
before assess on any export whose ordering you don't control.
Still local time: analytics operate on naive local time (correct for occupancy/schedule logic).
For hour-accurate energy across a DST-transition day, localize to a tz first, or note the ~1-hour
difference on those two days a year.
Stamps that name an instant: the site time zone
Some exports stamp instants instead of local wall-clock readings: ISO 8601 with a Z or
+hh:mm offset (an open-data package's timestamp_utc, an OPC-UA SourceTimestamp), a trailing
UTC / GMT, or epoch seconds. CAMBER's analytics run on local time, so such stamps must be
converted to the site's zone first. Before 0.90.1 the offset was dropped without converting: at a
US Central site a fan that ran 07:00-16:00 appeared to run 12:00-21:00.
Pass the site's IANA zone to the parser, a loader or an adapter, or set it once in a config:
from camber.io import load_csv
df = load_csv("export.csv", timezone="America/Chicago") # Z / offset / epoch -> Chicago wall clock
{"source": {"kind": "perpoint_csv", "folder": "trends/", "timezone": "America/Chicago"}}
- Offset-bearing and epoch stamps are converted to the site's wall clock and then made naive.
Mixed offsets (a
-06:00/-05:00export across a DST switch) parse as the instants they are. - Naive stamps are taken to be local already (or in
assume_tz, and converted from it). - After conversion the DST fall-back hour repeats and the spring-forward hour is absent, exactly
as in a naive local export:
regularize(the loaders'dedupe) collapses the repeat. - Without a zone the clock is kept as written (UTC for
Z), for back-compat, and acamber.tsparse.TimezoneWarningsays that schedule, occupancy and hour-of-day rules will be shifted.strict_timezone=True(configsource.strict_timezone) refuses such data instead. - A store facility ingested from the dataset catalog is already on the site's clock (the entry's
source_timezone/local_timezone). Theresource.timezonedefaults to that zone and applies only to ashared_oatCSV file, and a different zone warns.
M&V frames across daylight saving
The M&V frame builders in camber.mandv.intervalfit take the site's zone as timezone= (a
config's source.timezone). See MANDV.md for the
energy side.
- Daily (
daily_energy_vs_temp, 0.93): a day is as long as its clock. The fall-back day sums 25 hours (a sample in the repeated hour counts for both passes,repeated_hour_weights) and the spring-forward day 23. The day-coverage rule (0.105,min_coverage) judges each day against that length; without a zone every day is 24 hours. - Hourly (
hourly_energy_vs_temp, 0.105): with a zone the samples are placed on their real instants,localize's convention. The repeated hour becomes two hours, both kept, and the frame has a tz-aware local index. A lone sample in the repeated hour (the store's hourly mean of the two passes) stands for both. Without a zone, duplicate timestamps are one hour: energy and temperature are averaged per stamp (regularize(dedupe="mean")), so the repeated hour is no longer counted double. ATimezoneWarninggives the count, as for offset stamps without a zone. - A rate meter's step comes from the positive gaps only (as
interval_hoursdoes), so duplicate stamps can no longer pull it to zero.
Resampling a run status
A run config's resample (default 1h) puts every point on one grid. Analog points take the
bin's mean. A run status or command (boiler_status, supply_fan_status, pump_status,
compressor_status and the other status roles) takes the bin's mean as well, which for a 0/1
signal is its duty: the share of the bin it was on (0 to 1). The rule is the same at every
step: ingest's 15-minute resample, the folder path (camber.realio.load_status(how="duty"), time
weighted over an event-logged point) and a run's read from a Parquet store, through
camber.resolve or ParquetStore.read_role_frame(resample=...). The only exceptions are the
warm-up and cool-down flags (warmup, cooldown), which take the bin's max: they exclude
samples, and a bin touched by warm-up at all is excluded whole.
Why a duty and not the max ("on at any moment of the bin")? The duty keeps strictly more: "on at
any moment" is duty > 0, but a max cannot give back how long the equipment ran. A max would
read a boiler firing 15 minutes an hour as firing the whole hour, inflating every run-time figure,
and it would not help a start count either: an hour with any firing in it reads on, so a boiler
that cycles all day reads as one long run.
What the rules read from a duty:
| Question | Read | Used by |
|---|---|---|
| How long did it run? | the duty itself (its mean, or duty-weighted shares) | runtime_pct of the start-counting rules; boiler_running_pct and summer_run_pct of boiler_summer_lockout; pump_running_pct, summer_run_pct and boiler_off_pct of hw_pump_summer_lockout from a pump status (0.105; a speed or flow stand-in has no duty and reads 0/1 against its running cut); night_weekend_setback |
| Was it running in this sample, to judge the other points of the sample? | duty > 0.5 (on for most of the sample) | the fan, pump and plant gates (camber.schedules.fan_on_mask, plant_run_mask); the loop delta-T and reset checks |
| How many times did it start? | the fewest starts consistent with the duties | boiler_short_cycle, compressor_short_cycle |
A sample-level gate at 0.5 never turns a mostly-on sample off or a mostly-off sample on: the analog points of a sample on for a quarter of it are mostly the off readings, so the gate leaves them out.
Counting starts on a coarse trend. A start count can only see a cycle as long as the trend's
interval. The start-counting rules count the fewest starts the duties allow (a sample on for part
of its interval holds a start or a stop inside it), which on a plain 0/1 trend is the off-to-on
count. On a trend coarser than the rule's max_interval_min (default 15 minutes; see
THRESHOLDS.md) that count is a floor:
- at or above
max_starts_per_daythe verdict stands (the floor already reaches the limit), with a caveat; - below it, when samples show the equipment on for only part of an interval, the interval hid
starts, so the rule declines (
info, "cannot confirm") instead of reportingok; - below it with no part-on samples,
okstands with the caveat.
chiller_staging counts starts from an averaged power trend, which has no part-on reading to show
hidden cycles; coarser than its max_interval_min it adds the caveat only. Run start-counting
rules at the interval the data was stored at (resample: 15min for a catalog dataset) to count
the cycles themselves.