STT Time Model — Specification
Scope: the normative model for STT's temporal axis — how time is represented on a feature, how features are bucketed into tiles, how the coarser-bucket pyramid works, and how a reader prunes by time. The spatial axis (WebMercator tile pyramid) and the byte container are specified in
stt-packed-format.md; the per-tile payload is in../architecture/data-format.md. This page makes normative what those documents reference in passing.
STT is a spatial tile pyramid crossed with a temporal axis: a tile is addressed
by (zoom, x, y, bucket). This document specifies the bucket half.
This document is normative. The reference implementations are
crates/stt-build/src/tiler.rs (bucketing, covering bounds, LOD validation)
and crates/stt-core/src/metadata.rs (time_range, temporal_lod) on the
Rust side, and packages/core/src/archive.ts (LOD selection, time-window
pruning) plus packages/playback/src/time-controller.ts (the playhead) on the
TS side. If an implementation and this document disagree, that divergence is a
bug in one of them — resolved by an erratum to whichever is wrong, never by
silently redefining the spec to match the code. Spec revisions follow the
stability promise in
packed spec §9.1.
1. Time base — Unix milliseconds, UTC#
All times in STT are i64 Unix epoch milliseconds, UTC. There is no
timezone field and no local-time concept anywhere in the format: the directory
key columns, the per-feature start_time / end_time columns, the metadata
time_range, every temporal_bucket_ms, and the playhead clock are all the
same scalar. A producer MUST convert wall-clock / calendar input to Unix-ms UTC
at build time; a consumer that wants local time applies the offset at the
presentation edge only.
This single-scalar choice is what lets the directory delta-code the time column
to ~1 byte per entry (§4 of the packed spec) and lets the GPU do per-frame time
filtering with one f64/i64 uniform.
Leap seconds (normative). Times are Unix milliseconds, which have no representation for a leap second (there is no
23:59:60). Whether an upstream feed smeared or stepped across a leap second is the producer's presentation concern: STT stores the Unix-ms scalar it is given and never reinterprets or adjusts it.
Non-negativity (normative). Every absolute feature and metadata time MUST be non-negative ms-since-epoch (
t ≥ 0); the reference builder rejects pre-1970 (negative) timestamps in both strictness modes, because the in-memory temporal index is unsigned (TimeRange.start/endandTileId.tareu64). This narrows — it does not contradict — thei64payload/wire representation: the tilestart_time/end_timecolumns areInt64and the directory codec stores signedi64values, so acover_t_mindelta againsttime_startand a per-leaft_min/t_maxdescriptor may be negative even though the absolute times they encode are not.
2. Feature time — instants and intervals#
Every feature carries two absolute timestamps in its tile payload (see the payload schema):
| column | type | meaning |
|---|---|---|
start_time | Int64 | inclusive start of the feature's validity |
end_time | Int64 | inclusive end of the feature's validity |
- An interval feature (a trip, a storm cell's lifetime, a feature edit
window) has
start_time < end_time. - An instantaneous feature (an earthquake, a single ping, a dropoff) MUST
be represented as
start_time == end_time. When the input has no end-time field, the builder setsend_time = start_time(crates/stt-build/src/columnar.rs).
For LineStrings built with an end-time field, each vertex additionally carries
its own timestamp (the vertex_time column) so a trip animates along its path;
that per-vertex encoding is specified in the
payload spec.
start_time / end_time remain the feature's overall span and are what
bucketing and pruning use.
3. Buckets — fixed-width, start-anchored#
A bucket is a half-open interval [b, b + temporal_bucket_ms) on the
Unix-ms line. Buckets are fixed-width (the same temporal_bucket_ms
everywhere in a tier) and anchored to the epoch, not to any calendar unit:
bucket(t) = floor(t / temporal_bucket_ms) * temporal_bucket_ms
temporal_bucket_ms MUST be > 0 — a zero or negative width makes
bucket(t) undefined — so a writer MUST NOT emit temporal_bucket_ms == 0.
The reference builder enforces this only when a temporal-LOD pyramid is
declared (validate_temporal_lod, metadata.rs); on the plain path a zero
width is silently coerced to 1 ms (chunk_by_temporal_bucket, tiler.rs)
rather than rejected, so a conformant reader MUST NOT rely on the builder to
catch it.
§3, §3.1 and §3.2 specify fixed-bucket mode — the default, and the only
mode a reader may assume. stt-build --adaptive-temporal replaces the grid
with data-driven windows; see §3.3.
Boundary semantics (normative). Bucket assignment is half-open; feature validity is inclusive. An instant exactly on a bucket boundary (
start_time == b) belongs to bucketb— never to the preceding bucket — because assignment is the floor formula above over half-open[b, b + temporal_bucket_ms). Meanwhile a feature's[start_time, end_time]interval is inclusive at both ends (§2), so an interval feature from an earlier bucket whoseend_time == bis still valid at the boundary instant even though no feature is ever assigned to a bucket by its end. The two rules never conflict: assignment places bytes, validity drives pruning and rendering.
Calendar caveat (normative). Buckets are fixed millisecond widths, never calendar-aware. A "1 month" LOD is
2_592_000_000 ms= exactly 30 days, not a calendar month; "1 year" would be31_536_000_000 ms= 365 days, with no leap-year handling. Producers that need calendar-aligned aggregation must pre-aggregate upstream and present the result as fixed-width buckets, or use the summary tier (shipped —stt-build --summary-tier). A reader MUST NOT assume bucket boundaries fall on calendar units.
3.1 Feature → bucket assignment#
At build time a feature is placed in exactly one base-tier bucket — the
bucket containing its start_time (tiler.rs, chunk_by_temporal_bucket):
feature_bucket = floor(start_time / temporal_bucket_ms) * temporal_bucket_ms
A writer MUST NOT duplicate a feature into every bucket its [start_time, end_time] interval overlaps. This keeps the format lossless and compact (one
physical copy per feature), and pushes interval-overlap handling to read time
via the covering bound (§5). The consequence a reader MUST account for: a
long-lived interval feature lives in the tile of its start bucket, so a query
window that opens after the feature started must look back far enough to
find it — which is exactly what cover_t_min (§5) and the directory's per-leaf
t_min/t_max (§4.1 of the packed spec) make cheap.
3.2 The dataset bucket size#
metadata.temporal_bucket_ms records the base-tier bucket width (set by
stt-build --temporal-bucket, default 1h). It is load-bearing for
prefetch: the client enumerates exactly these bucket boundaries when looking
ahead in time during animation, so the chosen width is also the animation cache
granularity. Smaller buckets = finer scrub + more directory entries; larger
buckets = coarser scrub + fewer, fatter tiles.
Feature B spans three buckets but is stored once, in its start bucket (00:00).
metadata.time_range.start is the first event time floored to the coarsest
declared bucket width (the base, or the largest temporal_lod[].bucket_ms
when a pyramid exists), so the range bounds the bucket-aligned tile starts
rather than naming the earliest feature; time_range.end is not aligned.
Adaptive-window builds (§3.3) apply no alignment.
3.3 Adaptive temporal windows (--adaptive-temporal)#
stt-build --adaptive-temporal <N> replaces the fixed grid, per spatial
cell, with contiguous windows of ~N features each — dense periods get fine
windows, sparse periods coarse ones. In-memory (non-streaming) builds only. In
this mode the grid of §3 does not apply, and the following holds instead:
- A tile's
taddress is the window's first feature timestamp, not a multiple of any width. With--adaptive-boundary-count <N>(default256) that timestamp is snapped down onto a dataset-wide candidate set derived as quantiles of the timestamp distribution, so adjacent spatial cells land on the same fetch instants;0disables snapping, and a window whose snap would collide with the previous window's key keeps its exact timestamp. - Features sharing one exact timestamp within a cell are inseparable — a
window is never closed mid-run — so every
(z, x, y, t)key stays distinct (chunk_adaptive_dp/chunk_adaptive_by_count,tiler.rs). - The shared candidate instants are published as the additive manifest field
adaptiveBoundaries(an ascendingi64array, omitted when empty). That is how a client keeps §3.2's enumerate-ahead prefetch contract with no fixed width to step by. A reader that ignores the field still decodes every tile correctly; it merely prefetches less well. metadata.temporal_bucket_msstill records the--temporal-bucketvalue but does not describe the emitted windows; notime_rangealignment is applied (§3.2); and trajectories are not sliced at bucket boundaries — a segment is assigned to a window by its start time. A bucket pyramid has no base width to multiply here, so the auto-tuner refuses to apply a budgeted--temporal-lodspec in this mode.stt-serverejects--adaptive-temporalper request: a window is sized across a cell's whole time range, which a single-tile request cannot see. Pre-bake it withstt-build.
Status (normative scoping). This mode is a shipped builder flag whose relationship to the fixed-grid model above is under adjudication — it is the highest-priority open semantic verdict in the project's formal-semantics register (
docs/roadmap/formal-semantics-2026-08.md§5.5), whose candidate resolutions are to declare it as a tier variant or to withdraw it. Until that resolves, the fixed-grid MUSTs in §3 and §3.1 are scoped to fixed-bucket mode, and a reader MUST NOT assume an adaptive archive'stvalues are multiples ofmetadata.temporal_bucket_ms.
4. Temporal LOD — the coarser-bucket pyramid#
A multi-year dataset animated at the base bucket would stream an impractical
number of fine tiles when zoomed out. stt-build --temporal-lod emits one or
more coarser-bucket tiers alongside the base, each a strict aggregation of
the base buckets.
Invariants (enforced at build, tiler.rs):
- Each LOD level's
bucket_msMUST be strictly greater than the base and an exact integer multiple oftemporal_bucket_ms. - Levels MUST be stored sorted ascending by
bucket_ms. - Each level MUST carry a
max_zoom_level: the deepest spatial zoom at which that coarse tier is still the right choice.
Content contract (normative). Each level declares what a reader may assume
about its tiles in temporal_lod[].contract:
contract | a tile at this level contains |
|---|---|
"union" (or absent) | exactly the base features, re-bucketed at the coarser width — lossless |
"reduced" | fewer features than the base tier, derived by the declared method |
contract is optional and absent means "union", so a manifest written
before the field existed stays valid and unchanged.
A "union" level MUST hold, for a given spatial cell, the union of the
features of every base bucket the coarse bucket [B, B + bucket_ms) spans —
identical features, identical geometry and times, no reduction, aggregation,
or thinning. It trades request count for bytes and nothing else, and a reader
may rely on feature identity between tiers (the same feature id resolves to
the same feature in every tier that contains it). A "union" level MUST NOT
carry method — a method there is a category error.
A "reduced" level is explicitly not lossless; the base tier stays
complete and addressable beside it. It MUST name method, one of "m4" or
"minmaxlttb". A reduced level with no method is unreadable, because a reader
cannot know what was dropped. Both rules are enforced by
validate_temporal_lod (metadata.rs).
Reader negotiation (normative). A reader MUST NOT substitute any non-base
tier for base content unless it understands the declared contract (and
method, when reduced). An unrecognised contract or method is simply never
substituted — the reader falls back to the base tier — which keeps the
conservative-superset guarantee as the vocabulary grows.
Recorded in metadata as:
"temporal_lod": [{ "bucket_ms": 86400000, "max_zoom_level": 8 }, // 1 day, up to z8 (absent = union){ "bucket_ms": 2592000000, "max_zoom_level": 4, // 30 days, up to z4"contract": "reduced", "method": "minmaxlttb" } // fewer features, by MinMaxLTTB]
LOD tiles are tagged in the directory by their temporal_bucket_ms field, so a
base tile and its coarse-tier counterpart for the same cell are distinct
directory entries (and distinct blobs). A tile whose directory entry has no
temporal_bucket_ms belongs to the base tier (readers fall back to
metadata.temporal_bucket_ms).
4.1 Reader LOD selection#
LOD tiers are opt-in via the reader API — unlike the summary tier, the
tileset does not switch to them automatically. An application calls
STTArchive.pickTemporalLodForZoom(zoom) (archive.ts), which returns:
the coarsest level (largest
bucket_ms) whosemax_zoom_level >= zoom, orundefinedif the zoom is deeper than every level (→ use the base tier).
then reads with getTilesInBoundsForTemporalLod(...). The contract: zoomed out
and scrubbing a decade, read 30-day aggregates; zoomed in, read per-hour base
tiles. A reader that opts into the pyramid SHOULD select its tier that way — the
coarsest level whose max_zoom_level covers the current zoom.
5. Read-time temporal pruning — cover_t_min#
Because a feature lives only in its start bucket (§3.1), a query needs a tight lower bound per tile to know whether to look at it. The directory carries one per entry:
time_start— the tile's bucket boundary (the entry's nominal start).cover_t_min— the earlieststart_timeof any feature actually in the tile (tiler.rs). It may be<or>time_startand is≤ time_end. Stored as a signed delta againsttime_startin the directory's covering section (§4 of the packed spec);Noneon pre-covering archives, where a reader MUST fall back totime_start.time_end— the tile's inclusive upper temporal bound. A writer MUST set it to the maximum featureend_timeactually in the tile — the tight bound, not the nominal bucket endtime_start + temporal_bucket_ms - 1. This is load-bearing for correctness, not an optimization: an interval feature lives only in its start bucket (§3.1), so it is findable by a later query window only becausetime_endwas widened to cover it. A writer emitting nominal bucket ends would produce a dataset that decodes cleanly yet silently loses every interval feature from queries after its start bucket — the reader's prune below would discard the tile. (When every feature ends inside the bucket, the tight bound is below the nominal end and additionally saves wasted fetches.)
A reader MUST keep a tile for a query window [w_start, w_end] iff:
time_end >= w_start AND (cover_t_min ?? time_start) <= w_end
(archive.ts, getTileIdsInBounds). The upper test uses the tight time_end;
the lower test uses the tight cover_t_min, so a tile whose data lies entirely
after the window is skipped without a fetch. At the page level, the paged
directory lifts the same idea to per-leaf [t_min, t_max] descriptors (§4.1 of
the packed spec) so a cold reader prunes whole leaf pages by time before
fetching them.
6. The playhead#
Playback is driven by TimeController (packages/playback). Its time model:
currentTime— the playhead, a single Unix-ms scalar.speed— a signed effective rate in sim-ms per wall-ms (direction × magnitude).speed: 3600plays one hour of data per wall-clock second; negative plays backward.timeRange— optional{ start, end }(Unix-ms) clamp; the playhead loops, bounces, or clamps within it.- Frame-delta clamp — a wall-clock gap is capped at
MAX_FRAME_DELTA_MS(250 ms) before being scaled byspeed, so a backgrounded tab resumes without teleporting the playhead. A longer gap is treated as dropped frames, never as a seek.
The clock advances currentTime; layers GPU-filter their resident tiles against
a window around it (no re-decode per frame); and the
PlaybackGovernor gates the clock on a buffered
runway so the playhead never silently outruns loaded data. Those mechanics are
loading concerns, documented with the governor; the time semantics above are
what every consumer shares.
7. Mapping to OGC Tile Matrix Sets (normative)#
STT's (zoom, x, y, bucket) addressing is an OGC WebMercatorQuad tile matrix
set with one additional, regularly-spaced time dimension — the shape
sketched (informatively) in Annex J of the OGC Two Dimensional Tile Matrix Set
standard. STT makes that shape concrete and normative. The machine-readable
definition ships as
tile-matrix-set.json (the standard WebMercatorQuad
matrices plus an STT dimensions block); the mapping is:
| STT concept | OGC TMS concept |
|---|---|
zoom | tileMatrix identifier within the TileMatrixSet (WebMercatorQuad, z0–z22 — MAX_ZOOM, tile.rs) |
x, y | tileCol, tileRow (WebMercatorQuad, top-left origin) |
bucket start, width temporal_bucket_ms | an extra dimension { "id": "time", "unitSymbol": "ms", "resolution": temporal_bucket_ms, "default": metadata.time_range.start } |
metadata.time_range | the dimension's [start, end] interval (start is bucket-aligned, §3.2) |
--temporal-lod level | a per-tileMatrix coarser dimension resolution (bigger step at lower zoom) |
{"id": "time","unitSymbol": "ms","resolution": 3600000,"interval": [1577836800000, 1735689599000],"default": 1577836800000}
This is a documentation and convergence mapping, not a compliance claim: OGC
API – Tiles has no normative multi-dimensional conformance class today (its only
temporal hook is a datetime filter parameter, not an addressed axis). If one
is ratified, STT's directory is already expressible in its terms; until then the
tile-matrix-set.json artifact and this table are how
an external tool can reason about STT addressing in OGC vocabulary. See also
OGC Moving Features for
the per-vertex trajectory lineage.