Timezone-Aware Queries
Stream data is always stored in UTC. Many questions, however, are asked in civil (wall-clock) time for a specific place: "what were the values yesterday? this week? last month?" — meaning the local day / week / month boundaries of a chosen time zone, not a UTC day that is shifted against the local one.
This becomes essential the moment one chart compares metering points across zones — a site in Europe/Vienna next to one in Europe/Lisbon. A single UTC bucket boundary cannot represent "yesterday" for both. Timezone-aware queries resolve civil-time ranges and align rollup buckets to a chosen zone, DST-correctly, while storage stays UTC (AB#4190).
Computation (query): which UTC window is "yesterday in Europe/Vienna", and which rollup holds that zone's civil days. Display (UI): rendering the chart axis in local time. This page is about computation; display is a rendering concern handled independently.
Where the time zone comes from
Two independent inputs cooperate:
| Input | Set on | Role |
|---|---|---|
| Reference time zone | the rollup archive (ReferenceTimeZone) — see Stream Data Archives | A property of the series' physical location. Makes the stored calendar buckets (CalendarDay / Iso8601Week / CalendarMonth / CalendarQuarter / CalendarYear) DST-correct. Provisioned with the archive. |
| Query time zone | the query (timeZone) | The zone the query is answered in — used to resolve relative ranges and to select the calendar rollup whose civil days match. |
Both use IANA names only (Europe/Vienna, Europe/Lisbon) — never fixed offsets, which are wrong twice a year across DST.
Resolving a series query in a zone
The resolution-aware series resolver (streamData.resolveSeriesQuery, and the MCP tool resolve_series_query) accepts two optional inputs:
| Field | Values | Default | Meaning |
|---|---|---|---|
timeZone | IANA id, e.g. Europe/Vienna | (empty ⇒ UTC) | The zone calendar rollups are aligned to and selected against. |
comparisonPolicy | PerQuery / PerSeries | PerQuery | How civil boundaries resolve when a query spans series in different zones. |
query {
streamData {
resolveSeriesQuery(input: {
baseArchiveRtId: "…"
from: "2026-01-01T00:00:00Z"
to: "2027-01-01T00:00:00Z"
targetPoints: 365
requiredAggregation: SUM
sourcePath: "Amount.Value"
timeZone: "Europe/Vienna"
comparisonPolicy: PerQuery
}) {
archiveRtId
effectiveBucketMs
points
signal
}
}
}
The resolver returns the archive to query and the effective bucket width; you then run the downsampling query against archiveRtId.
Comparison policy
PerQuery(default) — one query zone is applied uniformly. "Yesterday" is one civil day in the query zone. A calendar rollup is a valid source only when its storedReferenceTimeZonematches the query zone; a rollup holding a different zone's civil days is excluded from selection (its buckets are not this query's civil days).PerSeries— each series resolves its own local day in its archive'sReferenceTimeZone("each site's local yesterday"). Reuses the existing per-source fan-out.
The query window [from, to) and the rollup's bucket boundaries must be computed against the same zone, or the window edges land mid-bucket. Under PerQuery both use the query zone; under PerSeries the range is resolved per series in that series' zone. This is what keeps side-by-side charts from being "off by hours".
Calendar rungs vs. sub-day rungs
- Civil day and coarser (
CalendarDay/Iso8601Week/CalendarMonth/CalendarQuarter/CalendarYear): a rollup carrying a matchingReferenceTimeZonealready stores DST-correct local buckets — one stored bucket is one civil unit, read directly. - Sub-day, fixed-size (1 h and finer,
FixedSize): time-zone-independent.DATE_BINbins are evenly spaced from a UTC epoch; the zone affects only the axis labels, not the bins. Civil-boundary semantics are a property of calendar-aligned rollups only.
If a civil-day query finds no matching-zone calendar rollup, the resolver returns a truthful signal (ResolutionLimited / NoSuitableRollup) rather than silently mis-aligning — provision a CalendarDay rollup in the required zone to enable it.
timeZone costs you the calendar rungs — and the coverage signal with themtimeZone defaults to UTC, and under the default PerQuery policy that excludes every calendar rollup whose ReferenceTimeZone is a real zone. What is left of the ladder is the fixed-size rungs, and the coverage filter only reports CoverageLimited when it changes the resolver's choice: with a single remaining rung there is nothing to change to, so the call answers Ok on that rung even when its measured coverage starts after the requested from. The history is not missing — it sits in the calendar rung the zone rule dropped.
Always pass the zone the question is asked in. A board does this for you (its Time Filter zone flows into the resolver); a hand-written resolveSeriesQuery or resolve_series_query call has to say so itself.
CalendarQuarter (since System.StreamData 1.8.0) behaves exactly like CalendarMonth: quarters start on 1 January, 1 April, 1 July and 1 October in the rollup's ReferenceTimeZone, so the quarter containing a DST switch is simply one hour shorter or longer in UTC. An empty zone yields UTC quarters.
Validity spans are read in the reference zone too
A rollup that draws from several source archives declares a half-open validity span per source, and every ValidFrom / ValidTo must land on a bucket boundary of the rollup, interpreted in its ReferenceTimeZone — the same zone the buckets themselves are aligned to. A boundary that is aligned in UTC but not in the reference zone (or vice versa) is rejected at create and at activation. That is what keeps every bucket entirely inside exactly one source's span, so a cutover between a legacy and a native source never splits a civil day, month or quarter.
See Stream Data Archives — Validity spans, and the engine decision doc octo-construction-kit-engine/docs/concept-multi-source-rollups.md for the rule-level description.
DST handling
A local day across a DST transition is 23 h or 25 h, never a fixed 24 h. Bucket boundaries are computed at civil boundaries, so a DST-day bucket is simply wider or narrower — never split, never duplicated. Example, Europe/Vienna:
| Day | Local start → end | UTC window | Length |
|---|---|---|---|
| 2026-03-29 (spring forward) | 00:00 CET → 00:00 CEST | 2026-03-28T23:00Z → 2026-03-29T22:00Z | 23 h |
| 2026-06-15 (no transition) | 00:00 CEST → 00:00 CEST | 2026-06-14T22:00Z → 2026-06-15T22:00Z | 24 h |
| 2026-10-25 (fall back) | 00:00 CEST → 00:00 CET | 2026-10-24T22:00Z → 2026-10-25T23:00Z | 25 h |
Client-side relative ranges
Dashboards resolve relative ranges (this year / this month / a picked day) to an absolute [from, to) window before sending them to the backend. That resolution must happen in the chosen IANA zone, not the browser's own zone — otherwise "yesterday in Europe/Lisbon" is wrong when viewed from a Vienna browser.
TimeRangeUtils (shared UI) resolves year / quarter / month / day / custom boundaries for an arbitrary IANA zone, DST-correctly and independent of the browser, using native Intl — no extra dependency. Passing 'local' or 'utc' keeps the historical behaviour.
// Same civil day, two zones → two different UTC windows (one hour apart):
TimeRangeUtils.getDayRange(2026, 5, 15, undefined, undefined, 'Europe/Vienna');
// from 2026-06-14T22:00:00Z (CEST, UTC+2)
TimeRangeUtils.getDayRange(2026, 5, 15, undefined, undefined, 'Europe/Lisbon');
// from 2026-06-14T23:00:00Z (WEST, UTC+1)
Storage is unchanged
Rows stay in UTC; the time zone is a query-time concern only. Nothing is double-bucketed, and no timezone metadata is backfilled onto historical rows.
Status & limitations
- Backend read-path (resolver
timeZone+comparisonPolicy, GraphQL + MCP) and the DST-correct civil-day resolution inTimeRangeUtilsare implemented. - MeshBoards support an explicit per-board IANA zone end to end: the Time Filter tab offers Local / UTC / Specific time zone (a filterable IANA dropdown), and the chosen zone drives both the civil-day range and, for resolution-aware stream-data widgets, the
timeZonesent toresolveSeriesQuery. See the MeshBoard timezone guide. comparisonPolicydefaults toPerQuery;PerSeriesis reachable through the GraphQL/MCP API but has no dedicated board toggle yet.- Cascade calendar rollups (a rollup sourced from another rollup, e.g. daily→weekly→monthly) are excluded from automatic selection in the current phase, independent of the time zone.