Introduction
infrastore is a Rust library for managing time-series data in power-systems and energy simulations. It separates persistence into two concerns: numerical arrays are stored in HDF5, while the metadata that associates each array with an owning component lives in SQLite. Identical arrays are stored once and shared through content addressing.
The library ships native Rust, Python (via PyO3), and Julia (via a C ABI) interfaces, plus the
infrastore command-line tool and a read-only gRPC server with a Rust client.
flowchart TB
subgraph clients["Language Interfaces"]
RUST["Rust<br/>(native crate)"]
PY["Python<br/>(PyO3 wheel)"]
JL["Julia<br/>(C ABI)"]
CLI["infrastore<br/>(CLI)"]
end
subgraph core["infrastore-core"]
STORE["Store"]
STORE --> NC[("HDF5<br/>arrays")]
STORE --> SQL[("SQLite<br/>metadata")]
end
subgraph remote["Remote Access"]
SRV["gRPC server<br/>(read-only)"]
RC["Rust client"]
end
RUST --> STORE
PY --> STORE
JL --> STORE
CLI --> STORE
SRV --> STORE
RC -->|"gRPC"| SRV
style RUST fill:#4a9eff,color:#fff
style PY fill:#17a2b8,color:#fff
style JL fill:#9558b2,color:#fff
style CLI fill:#fd7e14,color:#fff
style STORE fill:#28a745,color:#fff
style NC fill:#28a745,color:#fff
style SQL fill:#28a745,color:#fff
style SRV fill:#ffc107,color:#000
style RC fill:#ffc107,color:#000
Key Features
- One array, stored once — Arrays are addressed by a SHA-256 content hash, so identical series shared across components are written to disk a single time (content addressing)
- HDF5 for arrays, SQLite for metadata — Numerical data lands in a compact, chunked HDF5 file; queryable associations live in a catalog SQLite database (storage model)
- Feature-tagged associations — Each association carries an arbitrary map of typed features
(
int/float/bool/str) so multiple variants of a series can coexist under one owner - Typed, N-dimensional arrays — Store
f64,f32,i64,i32,u64, orboolvalues, with an optional per-step element shape (e.g. the coefficient tuple of a cost curve) - Three language bindings — Use it from Rust, Python, or Julia with the same on-disk format
- An
infrastorecommand-line tool — Load time series from CSV, and list, read, export, plot, diff, and inspect a store straight from a terminal, withtable/json/jsonl/csvoutput (CLI guide) - Read-only gRPC service — Serve a store over the network for remote readers, with optional API-key authentication
- Designed for power-systems data — The data model maps onto InfrastructureSystems.jl and infrasys owners, categories, and time-series concepts
Who Should Read This
Most people reading this are building a package on top of infrastore — infrasys or InfrastructureSystems.jl, or something like them — rather than calling it directly. That is the first row, and it is the one page to read if you read only one.
| Audience | Start here |
|---|---|
| Developers of a package on top | Embedding in a Parent Package |
| Deciding how to model your data | Time-Series Types |
| Python package developers | Python Developer Guide |
| Julia package developers | Julia Developer Guide |
| Rust developers | Rust Developer Guide |
| Command-line users | CLI Developer Guide |
| Anyone deploying the server | gRPC Server & Client |
| Tooling & forensics | On-Disk File Format |
Next Steps
- Setting up? Start with Installation.
- Want the 60-second tour? Read the Quick Start for Python, Julia, or the CLI. Rust users can go straight to the Rust Developer Guide.
- Not sure which type your data is? See Choosing a Type.
- Want to understand how it works? Read the Architecture.
- Need exact bytes on disk? See the On-Disk File Format.
Embedding in a Parent Package
This guide is for developers of a package that uses infrastore as the time-series layer behind its own component model — the shipped examples are infrasys (Python) and InfrastructureSystems.jl (Julia). It collects the contracts such a package has to honor and the patterns both consumers already use, in one place. The per-language guides (Python, Julia, Rust) show each call; this page says which calls to reach for and why. The reasoning behind the trade-offs is in Design Choices.
What the Store Owns, and What You Do
infrastore is deliberately narrow. It stores arrays, associates each one with an owner, and records a few relationships between owners. Everything that makes those owners mean something lives in the parent package.
| Concern | Owner |
|---|---|
| Array bytes, dedup, hashing, compression | infrastore |
| Which series belongs to which owner, under what key | infrastore |
| Component ↔ attribute and parent ↔ child edges | infrastore |
| The components and attributes themselves | parent package |
| The type hierarchy (abstract types, subtypes) | parent package |
| Mapping your object identities to integer ids | parent package |
| Unit conversion, per-unit bases | parent package |
| Partial / fuzzy lookups over features | parent package |
The store never parses a component, never walks a type tree, and never rescales a value. Every filter it offers takes concrete strings and exact scalars; a parent package that exposes anything richer builds it on top.
Map Your Model Onto the Catalog
A series is addressed by an owner plus a key (Data Model § Identity). Deciding how your objects project onto those fields is the first integration decision, and the one hardest to change later because it is written into every user's artifact.
owner_id is a stable i64. The store keys on it and never sees your object identities. If
your components are identified by UUIDs (both shipped consumers are), you allocate an integer per
component, persist that mapping alongside the store, and keep it stable across save/load — the store
cannot reconstruct it. Component ids and supplemental-attribute ids are independent streams, so the
same integer may name one of each; the owner_category (Component / SupplementalAttribute)
is part of the owner identity and every owner-scoped call takes it.
owner_type is the concrete type name and nothing more. It is descriptive (not part of the
uniqueness constraint) and the store has no view of subtyping, so a query for "every Generator"
must expand the abstract type into its concrete subtypes yourself and pass each one — this is what
get_all_subtype_names does on the InfrastructureSystems.jl side. An empty type list is a
deliberate "none of these" and matches nothing.
name identifies, component_field describes. name is part of the key; component_field
names the field on the owning component whose time-varying form these values are
("max_active_power"). They often coincide by convention, but a component may carry several series
for one field — an actual and a forecast, several weather years — and only name plus features
distinguishes them. Set component_field whenever your model knows it: it is the one descriptor
that is also a filter, and a parent package that wants "every series that varies rating" gets it
for free.
features are typed scalars (int / float / bool / str) and part of the key. A handful
of names are reserved because consumers
spread feature maps into keyword queries; the store refuses them on write.
units, quantity_kind, unit_system describe the values and affect neither identity nor
storage. unit_system is a label the store never acts on — component_base means "per-unit against
a base the owning component holds in your object graph", and converting back is your job. Unset
means unspecified, which is not the same as natural_units; do not read one as the other. See
Optional Descriptors.
time_reference records how the timestamps were spelled — an instant in UTC, an instant at a
fixed offset, an instant in a named IANA zone, or a wall clock naming no instant. Each binding
infers it from the input type, so nothing takes a new argument; a native Rust caller declares it,
having no naive datetime type to infer from. It changes nothing about the stored instants, the grid,
or either content hash — but the store does hold a series to its claim: a query bound must be
spelled the way the series is, and a zoneless series cannot share one reader axis or one ranged bulk
read with instant-bearing ones. See Time references.
application_data is yours. It is an opaque payload stored and returned verbatim, so a parent
package can carry whatever it needs per association (a serialized type tag, a provenance record)
without the store knowing. Because the store never validates it, version it yourself if its shape
may change.
Exact Keys, Subset Filters
A filter matches features two ways, and a parent package that offers its own resolution must know which it asked for:
featuresmatches as a subset: a series matches when it carries every requested pair, whatever else it carries. This is the useful default for a listing — "every series taggedscenario=high".exact_featurespins the whole set by its content hash. An existence check posed against a complete identity wants this one, or a sibling carrying an extra feature would answer yes about a series that does not exist. It is an equality on an indexed column, so it is also the cheaper of the two.
Both are on the same ListFilter, which every listing,
existence probe, filtered removal and reader build takes.
InfrastructureSystems.jl resolves user queries by subset, so it cannot delegate that resolution to a keyed lookup; it lists with the filter and then decides what more than one match means. A parent package that inherits the same semantics should do the same, and treat ambiguity as its own error to raise — the store will happily return two rows.
Two more identity facts that surface in parent-package code:
- A
DeterministicSingleTimeSeriesis derived from a storedSingleTimeSerieswithtransform_single_time_seriesand reads back as aDeterministic. The tag stays visible in keys, metadata, and counts, and aDeterministicfilter matches both. Your reads should not special-case it; your catalog displays may. - Descriptors are outside the key, so two adds that differ only in
unitsorapplication_dataare a duplicate. Changing a descriptor means remove and re-add.
The Store Lifecycle Inside a System Object
Both consumers follow the same shape, and the API was shaped around it. The arrays stay on disk
rather than in RAM — a system with hundreds of thousands of series does not fit — so the working
store is on-disk from the start, in a scratch directory that lives as long as the system object.
There is an in-memory backend (in_memory=True), but it holds every array in RAM and is meant for
tests and small stores; see the note below.
Build: scratch directory, in-memory catalog
# Python
store = Store.create(scratch / "time_series.h5", catalog="memory")
# Julia
store = Store(; path=joinpath(scratch, "time_series.h5"), catalog=:memory)
catalog="memory" keeps the SQLite half in RAM and skips the per-commit journaling an attached
catalog pays. That is the right trade for a store beside volatile in-process state: a crash loses
the system under construction regardless, so durability of the scratch catalog buys nothing. Arrays
still stream to the HDF5 file, so memory use does not grow with the data. The scratch directory
holds no .sqlite until the first save — a half-artifact by design, and one the store refuses
to open as attached later, because arrays without a catalog naming them are not a store. See
Where the Catalog Lives.
Use in_memory=True (no file at all) for unit tests and for stores you know are small. It is not a
substitute for the scratch-directory store in a real system.
Save: one call, atomic pair
store.persist_to(dest) # Python
persist!(store, dest) # Julia
persist_to writes both halves to uniquely named temporaries, fsyncs, and renames them into place,
stamping the pair with a fresh generation so a save interrupted between the two renames is detected
on the next open (MismatchedArtifact) rather than read as a valid store. This replaced the
copy-both-files-by-hand dance both consumers once carried, including the close/reopen needed for
HDF5's Windows file lock. Two things to carry into your own save path:
- A failed save may have destroyed the destination. The renames replace whatever was there. Retry from the still-live store rather than assuming the old artifact survived.
- Saving an attached store onto its own path is a no-op, while the in-memory-catalog case is the
real work — the arrays are already at
pathand the save is what writes the catalog beside them.persist_catalogdoes only that half when the arrays are already where they belong, and is a checkpoint rather than a mode switch: the catalog stays in RAM afterwards.
Load for editing: always a copy
store = Store.open_copy(src, scratch / "time_series.h5", catalog="memory") # Python
store = open_copy(src, joinpath(scratch, "time_series.h5"); catalog=:memory) # Julia
open defaults to read-write in every binding, and a read-write open on a user's artifact is the
one way this library will damage a file they care about: HDF5 has no journal and no repair tool, so
an interrupted in-place write is unrecoverable. open_copy copies both halves and opens the copy;
the original is only replaced by the final atomic rename of the next persist_to. Both consumers
did this by hand before the call existed, and a test in infrasys asserts the loaded directory
differs from the source — keep an assertion like that, because the copy is load-bearing.
For a read-only load (a viewer, a reporting script), open(path, read_only=True) is the right call:
nothing is copied and any mutation raises ReadOnlyStoreError.
Create: refuse to clobber
Store.create on a path that already holds either half raises StoreExists. The failure mode it
prevents is a re-run build script producing an empty array file paired with last week's catalog — a
store that opens cleanly and has nothing behind any row. Pass overwrite=True (overwrite=true in
Julia) only on a path your package owns and means to discard. See
Protecting a Saved Artifact.
Close, and move the pair together
Close the store explicitly when the system object is done (both bindings offer a context-manager /
do-block form). The .h5 and .h5.sqlite files are one artifact: move, copy, and delete them
together, and never ship one without the other — the paired generation stamp makes a lone half a
MismatchedArtifact on open.
One writer, local disk
A Store handle is not thread-safe. The Python class is unsendable — touching it from a thread
other than the one that created it raises — and the Julia one is unsynchronized, so concurrent calls
from two tasks are undefined behavior; confine a store (and any reader built from it) to one thread
or task, or guard every call with your own lock. On disk, assume a single writer, and keep a live
store off network filesystems — HDF5's file lock is best-effort and silently absent on Lustre, GPFS,
and NFS, and SQLite's WAL is unsafe there too. Build locally, then copy the finished artifact to
shared storage. See
One writer, and not on a network filesystem.
Writing: Batch, and Make Multi-Step Changes Atomic
Two mechanisms compose, and neither substitutes for the other:
- Bulk add (
add_time_series_bulk/AddBatch+add_time_series_bulk!) commits a whole batch in one catalog transaction and takes the block-sized HDF5 write path. Series sharing a(dtype, element_shape, length, resolution)pack into one dataset whose chunks span every column, which is what makes the simulation read below fast — so land same-shaped series in the same batch. A loop of single adds outside a transaction is an order of magnitude slower and fills chunks one column at a time; inside one it is buffered and written as a block instead. - Transactions (
with store.transaction():/transaction(store) do … end) make several operations succeed or fail together. Inside one, a removal is reversible; outside one it is not, because the array bytes are reclaimed immediately. A transaction holds the SQLite write lock until it ends, and does not batch anything by itself.
Values are immutable: there is no API to edit a value, slice, or column in place, in any binding. "Update this series" in a parent package is add the new array, remove the old one — inside a transaction if the user must never observe the gap. Content addressing makes the add cheap when the data did not actually change. See Design Choices.
Reading in a Simulation Loop
The layout is optimized for "every component at one timestamp", and the readers are the API for that
access: build a StaticReader or ForecastReader once, then step it. A ForecastReader reads each
distinct backing array once per step and fans it out to every component referencing it, so a
forecast shared by a hundred components costs one decompression; entry_slot lets your own
per-component work dedup the same way. The inverse access — one component's full history — is the
slow direction, and read_by_ids over many ids is the right call when you need it, not a loop of
read_by_id. See the per-language sections:
Python,
Julia,
Rust. The concepts are in
Readers.
Time
- Every stored instant is a whole number of milliseconds; the write path raises
InvalidParameterrather than truncating, because the C ABI exchanges instants as Unix milliseconds while Python'sdatetimeis microsecond. Quantizenow()before storing it. Query bounds are unconstrained. - Python requires timezone-aware
datetimes everywhere and returns UTC; a naive value raises. - Julia reads a bare
DateTimeas a wall clock, recordingZonelessReference(); withusing TimeZonesaZonedDateTimeis accepted anywhere a timestamp goes and converted to the instant it names. Reads still return a bareDateTimeholding the instant, because InfrastructureSystems.jl destructures them.
A parent package that has a notion of local time therefore converts at its own boundary and stores instants. See Timestamp precision.
Associations Beyond Time Series
The catalog records two relationship tables that have nothing to do with time series:
supplemental-attribute attachments (component ↔ attribute, with counts and grouped summaries) and
parent/child edges (directed component ↔ component). Both hold only the relationship — bare ids
and type names — so a parent package keeps the objects in its own graph and uses the store as the
index. replace_owner / reassign renumbers a component in every catalog at once. Bulk inserts are
all-or-nothing, removals return a count (matching nothing is 0, not an error), and the *_types
filters take concrete type names, exactly like owner_type above. See
Associations Between Entities.
Versions and Errors
Pin a minor range. The on-disk format is governed by DATA_FORMAT_VERSION; opening an artifact
written by an incompatible version raises IncompatibleFormat rather than misreading it. The
bindings track the workspace version, so a parent package depends on a compatible range
(infrastore>=0.11,<0.12 in pyproject.toml; a [compat] entry in Project.toml) and bumps it
deliberately. Do not cite core source line numbers in your own docs — they move.
Map the error taxonomy, do not flatten it. Every binding exposes the core's TimeSeriesError
variants as distinct types (NotFound, DuplicateTimeSeries, DuplicateAssociation,
InvalidParameter, ReadOnlyStore, StoreExists, MismatchedArtifact, IncompatibleFormat,
Integrity, …). The ones a parent package typically translates into its own exceptions are
NotFound and DuplicateTimeSeries (user-facing), and StoreExists / MismatchedArtifact /
IncompatibleFormat (artifact-level, usually re-raised with the path). See
Python exceptions and
Julia errors.
Testing an Integration
- Use
in_memory=Truestores in unit tests; they exercise the same core. verify_integrityre-hashes every array and time axis the catalog references;is_emptyis the cheap "nothing here" probe.- The
infrastoreCLI reads any artifact your package writes.infrastore listandinfrastore infoare the fastest way to see what a consumer actually stored, andinfrastore diff --againstcompares two artifacts by hash without reading arrays — a usable CI gate for "the rewrite produced the same store". See Use theinfrastoreCLI. - To test unreleased core changes from a consumer checkout, install the binding into the consumer's
environment (
maturin develop --manifest-path crates/infrastore-py/Cargo.tomlwith the consumer's venv active;Pkg.develop(path=...)plusINFRASTORE_LIBfor Julia).
Checklist
- Integer owner ids are allocated by the package, persisted with the system, and stable across save/load.
-
Abstract-type queries expand to concrete
owner_typenames before reaching the store. - Feature resolution knows whether it wants exact-key or subset semantics, and ambiguity is the package's error.
-
The working store is on disk in a scratch directory with an in-memory catalog;
in_memorystores are for tests. -
Save goes through
persist_to; load-for-edit goes throughopen_copy; read-only loads passread_only. - Multi-series writes use bulk add; multi-step changes that must be atomic use a transaction.
- Timestamps are instants at millisecond precision, converted at the package boundary.
- The dependency pins a compatible minor range of infrastore.
Installation
Most users install a published package and need no build tools at all:
| Language | Install |
|---|---|
| Rust | cargo add infrastore-core |
| Python | pip install infrastore |
| CLI | download a binary, or cargo install infrastore-cli |
| Julia | pkg> add InfraStore — see Julia |
The Python wheels and the Julia binary artifact are prebuilt and self-contained. The Rust crates
build HDF5 and zlib from vendored sources and link them statically, so they need cmake and a C
compiler but no system HDF5. The same vendored, statically linked stack backs every channel, so
the HDF5 version behind the on-disk format is pinned by infrastore rather than by the target
environment.
The infrastore CLI
Download a prebuilt binary
Each tagged release attaches archives to the Releases page. The executables are linked statically against HDF5 and zlib, so there is nothing else to install.
| Archive | Contents |
|---|---|
infrastore-x86_64-unknown-linux-musl.tar.gz | Linux x86_64 — infrastore, infrastore-server |
infrastore-x86_64-unknown-linux-gnu.tar.gz | Linux x86_64 — libinfrastore_ffi.so + infrastore.h |
infrastore-aarch64-apple-darwin.tar.gz | macOS Apple Silicon — executables and C library |
infrastore-x86_64-pc-windows-msvc.zip | Windows x86_64 — executables and C library |
Linux ships two archives because they serve different consumers. The executables are built against musl and linked statically, so they run on any distribution regardless of its glibc version — including an HPC login node much older than the build machine. The C library is built against glibc instead: it gets loaded into a running Julia or Python process, and a musl shared library there would put two C libraries in one address space.
VERSION=v0.14.0 # pick a release from the Releases page
BASE=https://github.com/NatLabRockies/infrastore/releases/download/$VERSION
curl -fsSLO $BASE/infrastore-aarch64-apple-darwin.tar.gz
tar xzf infrastore-aarch64-apple-darwin.tar.gz
./infrastore --version
Move infrastore onto your PATH to finish. Every archive carries a .sha256 sidecar if you want
to verify the download first:
curl -fsSLO $BASE/infrastore-aarch64-apple-darwin.tar.gz.sha256
shasum -a 256 -c infrastore-aarch64-apple-darwin.tar.gz.sha256 # sha256sum -c on Linux
macOS. The binaries are not notarized, so Gatekeeper blocks the first run of a downloaded executable. Clear the quarantine flag with
xattr -d com.apple.quarantine ./infrastore.
Install from crates.io
cargo install infrastore-cli # installs the `infrastore` binary
This compiles HDF5 from vendored sources, so it needs cmake and a C compiler (see
Build Prerequisites) and takes a few minutes on the first build.
Parquet support (export -f parquet, add --parquet) is on by default, which is what pulls in the
Arrow dependency tree. To leave it out -- a smaller binary and a shorter build, at the cost of those
two flags:
cargo install infrastore-cli --no-default-features --features vendored
That binary still accepts the flags and names the feature to rebuild with, rather than reporting
parquet as an unknown format. The Arrow tree reaches only the CLI: infrastore-core, the Python
wheel, and the FFI cdylib never link it.
Julia
InfraStore.jl is registered in the Julia General registry:
using Pkg
Pkg.add("InfraStore")
The package does not link a system HDF5 or HDF5_jll. Its Artifacts.toml points at the
libinfrastore_ffi tarball attached to the matching GitHub Release, so Pkg.add downloads a
prebuilt, statically linked library for the platform — Linux x86_64 and aarch64 (glibc), macOS
x86_64 and Apple Silicon, and Windows x86_64 — and nothing else needs installing. To run against a
locally built library instead (a working tree, or a platform outside that list), set
INFRASTORE_LIB; it takes precedence over the artifact. The
Julia guide has both recipes, and
Releasing explains why the binary is self-hosted rather than a
JLL.
The rest of this page covers building the workspace from a checkout.
Build Prerequisites
A Rust toolchain, cmake, a C compiler, and protobuf for the gRPC codegen. The Python and Julia
bindings additionally need a Python interpreter (3.11+) or Julia (1.11+).
brew install cmake protobuf maturin # macOS
sudo apt-get install cmake protobuf-compiler # Linux (Debian / Ubuntu)
The first build compiles HDF5 from source — a few minutes — and then caches the result.
Do not set
HDF5_DIR. The vendored build forwards it to cmake asHDF5_ROOTwhile still requesting static libraries, which fails against a shared-only install. To build against system libraries instead, turn vendoring off with--no-default-featuresand install them (brew install hdf5/apt-get install libhdf5-dev).Because
hdf5-metno-sysdeclareslinks = "hdf5", Cargo's feature unification makes the vendored-versus-system choice all-or-nothing across the whole dependency graph — an individual crate cannot opt out on its own.
Rust Toolchain
The workspace targets edition 2024 and declares an MSRV of Rust 1.94 — that is the oldest
toolchain it is guaranteed to build on. (rust-version in the root Cargo.toml is the authority;
the repo pins no rust-toolchain file, and CI builds on stable.)
rustup update stable
Build the Workspace
git clone https://github.com/NatLabRockies/infrastore
cd infrastore
cargo build --workspace --all-features
cargo test --workspace --all-features
cargo clippy --workspace --all-targets --all-features -- -D warnings
The workspace Cargo config (.cargo/config.toml) sets macOS linker flags so
cargo build --workspace can link the PyO3 cdylib without maturin. On Linux those flags are inert.
Build the Native Library
InfraStore.jl and any other C consumer load the C ABI cdylib. Building it is the one target most
people need out of a checkout:
cargo build -p infrastore-ffi --release
# -> target/release/libinfrastore_ffi.{dylib,so,dll}
# Regenerates the C header at crates/infrastore-ffi/include/infrastore.h
Point consumers at it with INFRASTORE_LIB, which takes precedence over the artifact Pkg
installed:
export INFRASTORE_LIB=$PWD/target/release/libinfrastore_ffi.dylib # .so on Linux
Add it to your shell profile to make it permanent. The Python wheel is built separately with
maturin — see the Python guide.
Crates in the Workspace
| Crate | What it builds |
|---|---|
infrastore-core | Types, HDF5 + SQLite storage, hashing, Rust API |
infrastore-proto | Protobuf service definition + tonic codegen |
infrastore-server | gRPC server binary + Rust client |
infrastore-py | PyO3 bindings, abi3-py311 wheel |
infrastore-ffi | C ABI cdylib (the foundation of the Julia binding) |
infrastore-cli | infrastore CLI binary (CSV add/read, inspect on-disk stores) |
infrastore-parquet | Parquet export/import behind the CLI's default-on parquet feature |
infrastore-bench | infrastore-bench binary (bulk-ingest + simulation-read benchmarks) |
Next Steps
- Build a store and round-trip a series in the Python, Julia, or CLI Quick Start.
- Read the developer guide for your language: Python · Julia · Rust · CLI.
- Stand up the gRPC server.
Quick Start (Python)
This walkthrough creates an in-memory store, adds a SingleTimeSeries, and reads it back — the
shortest path to a working round-trip. It assumes the infrastore wheel is installed in the active
environment; if import infrastore fails, see Integrate with Python.
A Minimal Round-Trip
from datetime import datetime, timedelta, timezone
import numpy as np
from infrastore import OwnerCategory, SingleTimeSeries, Store
# `in_memory=True` means no filesystem I/O. Pass `path=` instead to write a
# HDF5 file plus its SQLite catalog.
store = Store.create(in_memory=True)
# The name and the units live on the series object, not on `add_time_series`.
ts = SingleTimeSeries(
datetime(2024, 1, 1, tzinfo=timezone.utc), # initial timestamp (timezone-aware)
timedelta(hours=1), # resolution
np.arange(24, dtype=np.float64) + 100, # 24 hourly values
"load", # name
units="MW", # optional, like every descriptor
)
# The owner is identified by an integer id, an owner type, and a category.
# Features are optional.
series_id = store.add_time_series(
owner_id=42,
owner_type="Generator",
owner_category=OwnerCategory.Component,
time_series=ts,
features={"model_year": 2030},
)
got = store.read_by_id(series_id)
print(f"read {got.length} values @ {got.resolution} from {got.initial_timestamp}")
# read 24 values @ PT1H from 2024-01-01 00:00:00+00:00
assert np.array_equal(np.asarray(got.data), np.asarray(ts.data))
A read hands back the values and the timeline side by side. To fuse them into a dataframe, convert
the series to a pyarrow.Table with to_arrow() and hand it straight to Polars — the two-column
table is exactly a dataframe's shape, so the conversion is zero-copy and needs no glue:
import polars as pl
df = pl.from_arrow(got.to_arrow())
print(df.head(3))
# shape: (3, 2)
# ┌─────────────────────────┬───────┐
# │ timestamp ┆ value │
# │ --- ┆ --- │
# │ datetime[ms, UTC] ┆ f64 │
# ╞═════════════════════════╪═══════╡
# │ 2024-01-01 00:00:00 UTC ┆ 100.0 │
# │ 2024-01-01 01:00:00 UTC ┆ 101.0 │
# │ 2024-01-01 02:00:00 UTC ┆ 102.0 │
# └─────────────────────────┴───────┘
print(df.group_by_dynamic("timestamp", every="6h").agg(pl.col("value").mean()))
# 102.5, 108.5, 114.5, 120.5 — one row per six-hour block
The timestamp column arrives typed in the series' own spelling (datetime[ms, UTC] here; an IANA
zone or no zone at all for a series written that way), so Polars' time-aware operations work without
you relabelling anything. The descriptive attributes ride along in got.to_arrow().schema.metadata
— name, units, resolution, and the rest — which Polars does not carry onto the dataframe, so
read them off the table when you need them.
to_arrow() needs pyarrow, which the wheel does not install by default:
pip install 'infrastore[arrow]'.
What Just Happened
Store.create(in_memory=True)built a store backed by an in-memory array backend and an in-memory SQLite metadata database.add_time_serieshashed the array, wrote it to the backend (deduplicating on the hash), and recorded a catalog association filed under(owner_id, owner_category, type, name, resolution, interval, features). It returned that row's id — the handle to record in your own object model, and what every read and removal takes from here on.read_by_id(series_id)looked up the row by primary key, read the array back by its content hash, and reconstructed aSingleTimeSeries.
The array is any NumPy array whose dtype is float64, float32, int64, int32, int16, int8,
uint64, uint32, uint16, uint8, or bool — whatever you pass round-trips unchanged. Shapes
beyond (length,) attach a per-step element shape, such as the coefficient tuple of a cost curve.
Slice and List
Pass a (start, end) tuple of datetimes to read a window instead of the whole series (end is
exclusive):
(window,) = store.read_by_ids_range(
[series_id],
(
datetime(2024, 1, 1, 6, tzinfo=timezone.utc),
datetime(2024, 1, 1, 12, tzinfo=timezone.utc),
),
)
print(window.length) # 6
list_metadata is how you find a series you did not just write: it returns one dict per catalog
row, filtered by any combination of arguments, and each row carries the id to read it by:
for m in store.list_metadata(owner_id=42):
print(m["name"], m["resolution"], m["units"], m["features"])
# load PT1H MW {'model_year': 2030}
Writing to Disk
Swap the constructor to persist:
store = Store.create(path="system.h5")
# ... add_time_series ...
store.flush() # sync buffered HDF5 writes to disk
This produces two files that travel together:
system.h5— the HDF5 file holding the arrays.system.h5.sqlite— the catalog holding the metadata associations.
Reopen them later with Store.open("system.h5", read_only=True).
Next Steps
- Work through the Python Developer Guide for forecasts, bulk reads, associations, and error handling.
- Understand the Data Model: owners, keys, and features.
- Browse the full Python API reference.
Quick Start (Julia)
This walkthrough creates an in-memory store, adds a SingleTimeSeries, and reads it back — the
shortest path to a working round-trip. It assumes InfraStore.jl can find the native library; if
the first store call errors, see Integrate with Julia.
A Minimal Round-Trip
using Dates, InfraStore
# `in_memory=true` means no filesystem I/O. Pass `path=` with `in_memory=false`
# to write an HDF5 file plus its SQLite catalog.
store = Store(in_memory=true)
# The name and the units live on the series struct, not on `add_time_series!`.
ts = SingleTimeSeries(
DateTime(2024, 1, 1), # initial timestamp
Hour(1), # resolution
collect(100.0:123.0), # 24 hourly values
"load"; # name
units = "MW", # optional, like every other descriptor
)
# The owner is identified by an integer id, an owner type, and a category.
# Features are optional.
id = add_time_series!(
store,
42, # owner_id
"Generator", # owner_type
Component, # owner_category
ts;
features = Dict("model_year" => 2030),
)
got = read_by_id(store, id)
println("read $(length(got)) values @ $(got.resolution) from $(got.initial_timestamp)")
# read 24 values @ 3600000 milliseconds from 2024-01-01T00:00:00
@assert got.data == ts.data
What Just Happened
Store(in_memory=true)built a store backed by an in-memory array backend and an in-memory SQLite metadata database.add_time_series!hashed the array, wrote it to the backend (deduplicating on the hash), and recorded a catalog association filed under(owner_id, owner_category, type, name, resolution, interval, features). It returned that row's id — the handle to record in your own object model, and what every read and removal takes from here on.read_by_id(store, id)looked up the row by primary key, read the array back by its content hash, and reconstructed aSingleTimeSeries. Note thatresolutioncomes back as aMillisecond.
features is serialized to JSON, so its values must be JSON scalars (Int, Float64, Bool,
String). The data is any AbstractArray of Float64, Float32, Int64, Int32, UInt64, or
Bool; dimensions beyond the first attach a per-step element shape, such as the coefficient tuple
of a cost curve.
Finding a Series You Did Not Just Write
The store splits identify from act. list_metadata is the identify half — it answers which
series exist and hands back the id that addresses each — and every read and removal takes that id.
A caller that records ids in its own object model does the first half once and skips it from then
on:
row = only(list_metadata(store; owner_id = 42, name = "load", resolution = Hour(1)))
got = read_by_id(store, row.id)
for m in list_metadata(store; owner_id = 42) # Vector{TimeSeriesMetadata}
println(m.name, " ", m.resolution, " ", m.units)
end
# load 3600000 milliseconds MW
list_metadata matches features as a subset; pass exact_features when you mean the whole set.
There is deliberately no separate attribute-to-id resolver — a caller that wants exactly one row
poses the filter and checks that it got one, which is what only does above.
Writing to Disk
Swap the constructor to persist. The do-block form closes the store on exit, including on throw:
Store(in_memory=false, path="system.h5") do store
add_time_series!(store, 42, "Generator", Component, ts)
flush!(store) # sync buffered HDF5 writes to disk
end
This produces two files that travel together:
system.h5— the HDF5 file holding the arrays.system.h5.sqlite— the catalog holding the metadata associations.
Reopen them later with open_store("system.h5"; read_only=true), which has a do-block form too:
open_store("system.h5"; read_only=true) do store
rows = list_metadata(store; owner_id = 42, owner_category = Component)
series = read_by_id(store, rows[1].id)
end
Next Steps
- Work through the Julia Developer Guide for forecasts, readers, associations, and error handling.
- Understand the Data Model: owners, ids, and features.
- Browse the full Julia API reference.
Quick Start (CLI)
The infrastore binary reads and writes an on-disk store directly — no server, no binding, no
Python or Julia environment. This is the shortest path from a CSV to a store you can inspect.
Get the binary from the Releases page (the
executables are statically linked, so there is nothing else to install) or with
cargo install infrastore-cli. See Installation for the
per-platform archives.
A Minimal Round-Trip
Two files: the values, and a descriptor saying what they mean.
# load.csv
value
100.0
101.5
103.0
104.2
{
"owner_id": 42,
"owner_type": "Generator",
"owner_category": "Component",
"name": "load",
"type": "SingleTimeSeries",
"element_type": "f64",
"units": "MW",
"csv": "load.csv",
"initial_timestamp": "2024-01-01T00:00:00Z",
"resolution": "PT1H"
}
Save that as load.json, then add and read it back:
infrastore --store demo.h5 add --descriptor load.json
infrastore --store demo.h5 list
infrastore --store demo.h5 get --owner-id 42 --name load
╭────┬───────┬────────────┬───────────┬──────────────────┬──────┬──────────┬──────────────┬────────────┬──────────┬────────┬───────┬──────────────╮
│ ID │ Owner │ Owner Type │ Category │ Type │ Name │ Features │ Element Type │ Resolution │ Interval │ Length │ Units │ Hash │
├────┼───────┼────────────┼───────────┼──────────────────┼──────┼──────────┼──────────────┼────────────┼──────────┼────────┼───────┼──────────────┤
│ 1 │ 42 │ Generator │ Component │ SingleTimeSeries │ load │ - │ f64 │ PT1H │ - │ 4 │ MW │ 09ec58683de3 │
╰────┴───────┴────────────┴───────────┴──────────────────┴──────┴──────────┴──────────────┴────────────┴──────────┴────────┴───────┴──────────────╯
╭───────────────────────────┬───────╮
│ timestamp │ value │
├───────────────────────────┼───────┤
│ 2024-01-01T00:00:00+00:00 │ 100 │
│ 2024-01-01T01:00:00+00:00 │ 101.5 │
│ 2024-01-01T02:00:00+00:00 │ 103 │
│ 2024-01-01T03:00:00+00:00 │ 104.2 │
╰───────────────────────────┴───────╯
What Just Happened
demo.h5anddemo.h5.sqlitewere both created. They are one artifact: the arrays are in the HDF5 file, the catalog row in the SQLite one. Move, copy, and delete them together.- The values came from the CSV; everything else came from the descriptor. A flat grid of numbers fits a CSV; an owner, a resolution, and a feature map do not.
- The header row is required.
addreads it to tell a hand-written value-only file from oneinfrastore exportwrote, so a file whose first row is data is rejected rather than silently losing that row. - The store assigned
id1. That id is how every later read and removal addresses the series — see Association IDs. - Timestamps must name an instant.
2024-01-01T00:00:00Zdoes; a bare2024-01-01 00:00:00does not, and is rejected. Pass--assume-timezone UTCto say what a zoneless file meant.
Print a starting descriptor for any of the five writable types with infrastore template:
infrastore template NonSequentialTimeSeries > outages.json
Look Around
infrastore --store demo.h5 names # distinct series names
infrastore --store demo.h5 get --name load --plot # a terminal sparkline
infrastore --store demo.h5 store-info # format version, compression, catalog state
infrastore --store demo.h5 -f json list # every read command honors -f
Next Steps
- The whole workflow — wide CSVs, forecasts, charts, associations,
diffandmerge— is in the CLI Developer Guide. - Every flag and the descriptor schema: CLI Reference.
- Doing this from a program instead: Python · Julia.
Explanation
This section explains how infrastore is put together and why. It is understanding-oriented: read it to build a mental model, not to accomplish a specific task. For the calls that do the work see the Developer Guides; for exhaustive listings see the Reference.
- Architecture — The crates, the two-file storage split, and how the language bindings sit on top of a single core.
- Design Choices — What infrastore optimizes for and why, written for developers of parent packages like IS.jl and infrasys.
- Time-Series Types — The seven types, which one your data wants, and the vocabulary they share: periods, timestamp precision, typed arrays.
- Data Model — Owners, features, identity, association ids, and the associations between catalog entities.
- Time References — How a series' timestamps are spelled, what that does and does not change, and why a named zone is safe.
- Readers — The columnar bulk-read surface: why it exists, what one timeline per reader means, and when to reach for something else.
- Storage Model — Why arrays go to HDF5 and metadata goes to SQLite, and how the two stay consistent.
- Content Addressing — How arrays are hashed, deduplicated, and verified.
- Language Bindings — How the Python, Julia, and gRPC interfaces wrap the Rust core.
Architecture
infrastore is a Rust workspace with one core library and a ring of interface crates around it. Every
interface — native Rust, Python, Julia, the infrastore CLI, and the gRPC server — ultimately
drives the same Store type in infrastore-core, and every interface reads and writes the same
on-disk format.
Crate Layout
flowchart TB
subgraph ifaces["Interface crates"]
PY["infrastore-py<br/>PyO3 wheel"]
FFI["infrastore-ffi<br/>C ABI cdylib"]
SRV["infrastore-server<br/>gRPC server + Rust client"]
CLI["infrastore-cli<br/>infrastore binary"]
end
PYMOD["infrastore<br/>(Python module)"]
JL["InfraStore.jl<br/>(Julia package)"]
PROTO["infrastore-proto<br/>protobuf + tonic"]
PARQ["infrastore-parquet<br/>Parquet export/import"]
subgraph core["infrastore-core"]
STORE["Store"]
META["MetadataStore<br/>(SQLite)"]
BACK["StorageBackend<br/>(trait)"]
NC["Hdf5Backend"]
MEM["MemoryBackend"]
STORE --> META
STORE --> BACK
BACK --> NC
BACK --> MEM
end
PY --> STORE
FFI --> STORE
SRV --> STORE
CLI --> STORE
PYMOD -->|"import"| PY
JL -->|"ccall"| FFI
SRV --> PROTO
CLI --> PARQ
PARQ --> STORE
style STORE fill:#28a745,color:#fff
style META fill:#28a745,color:#fff
style BACK fill:#1e7e34,color:#fff
style NC fill:#1e7e34,color:#fff
style MEM fill:#1e7e34,color:#fff
style PY fill:#17a2b8,color:#fff
style PYMOD fill:#17a2b8,color:#fff
style FFI fill:#9558b2,color:#fff
style JL fill:#9558b2,color:#fff
style SRV fill:#ffc107,color:#000
style PROTO fill:#ffc107,color:#000
style CLI fill:#fd7e14,color:#fff
style PARQ fill:#fd7e14,color:#fff
| Crate / package | Role |
|---|---|
infrastore-core | The whole engine: types, storage backends, hashing, the Store API |
infrastore-proto | The .proto service compiled with tonic; shared message types |
infrastore-server | A tonic gRPC server wrapping a Store, plus an async RemoteClient |
infrastore-py | PyO3 classes exposing Store as the infrastore module |
infrastore | The importable Python module — user-facing surface of the PyO3 wheel |
infrastore-ffi | A extern "C" cdylib with an opaque-handle API over Store |
InfraStore.jl | A Julia package that ccalls into the FFI cdylib |
infrastore-cli | The infrastore binary: read+write access to an on-disk store from a terminal |
infrastore-parquet | Partitioned Parquet export/import, linked only by the CLI (parquet feature) |
infrastore-bench | The infrastore-bench binary: ingestion and simulation-read benchmarks |
The Core: Store
Store is a thin orchestration layer that composes two collaborators:
- A
StorageBackendthat holds the numerical arrays, addressed by content hash. - A
MetadataStore(SQLite) that holds the associations between owners and arrays.
flowchart LR
CALL["add_time_series(...)"] --> HASH["array_hash()"]
HASH --> PUT["backend.put_array(hash, data)"]
HASH --> INS["MetadataStore::insert(association)"]
PUT --> NC[("HDF5")]
INS --> SQL[("SQLite")]
style CALL fill:#4a9eff,color:#fff
style HASH fill:#6f42c1,color:#fff
style PUT fill:#28a745,color:#fff
style INS fill:#28a745,color:#fff
style NC fill:#1e7e34,color:#fff
style SQL fill:#1e7e34,color:#fff
The backend is chosen behind the StorageBackend
trait. There are two implementations:
MemoryBackend— arrays in a hash map; selected whenin_memory = true. No filesystem I/O.Hdf5Backend— arrays in an HDF5 file; selected when a path is given.
Because the seam is a trait, the metadata layer, the hashing, and every binding are identical no matter where the arrays live. Tests run against the memory backend; production uses HDF5.
Why Two Files
Numerical arrays and their descriptive metadata have different access patterns. Arrays are large, append-mostly, and read by content; metadata is small, frequently queried, and benefits from indexes and transactions. infrastore puts each where it is strongest:
- Arrays → HDF5. Chunked, compressed, columnar storage that the whole HDF5 ecosystem reads.
- Metadata → SQLite. A queryable, transactional catalog at
<path>.h5.sqlite.
The Storage Model page covers the trade-offs and the consistency protocol that keeps the two files in agreement.
Read Paths: Local and Remote
Writes always require local filesystem access — they go straight through a Store. Reads can happen
two ways:
flowchart LR
subgraph local["Local process"]
APP["Your code"] --> STORE["Store"]
end
subgraph network["Over the network"]
APP2["Reader"] --> RC["RemoteClient"]
RC -->|"gRPC"| GS["gRPC server"]
GS --> STORE2["Store (read-only)"]
end
style STORE fill:#28a745,color:#fff
style STORE2 fill:#28a745,color:#fff
style GS fill:#ffc107,color:#000
style RC fill:#ffc107,color:#000
The gRPC server exposes a read-only subset of the API (list, get, keys, resolutions, counts, existence checks, integrity). It never writes. See Language Bindings and the gRPC Server guide.
Concurrency
Within a process, Hdf5Backend guards its HDF5 handle with a Mutex, so the storage backend itself
is Send + Sync. The Store as a whole, however, is Send but not Sync: its MetadataStore
wraps a single rusqlite::Connection, which is internally a RefCell and therefore cannot be
shared between threads. In practice this means a Store can be moved to another thread, but
sharing one across threads requires external synchronization — the gRPC server holds its store as an
Arc<Mutex<Store>>, and the PyO3 binding marks the class unsendable so a Python Store stays on
the thread that created it.
MetadataStore uses transactions for atomic multi-row writes. The library does not coordinate
multiple processes writing the same file concurrently — a single writer owns the files at a time.
Within one process the rule is enforced: opening an artifact that another Store in the process
already holds — read-only or not — fails with StoreInUse, and so do create_replacing,
open_without_catalog, persist_to, and persist_arrays_to aimed at a held path. Each handle
indexes the HDF5 file's packed columns once at open, and libhdf5 shares one file object between two
opens of a file, so a second handle would read and write the wrong columns. Drop the handle before
opening another.
Design Choices
infrastore is a foundation library. End users rarely call it directly — they reach it through a parent package such as InfrastructureSystems.jl (IS.jl) or infrasys, which embed infrastore to persist the time-series data behind their component models. This page records the decisions that shape the API and the on-disk format, and the reasoning behind them, so that developers of those parent packages understand what infrastore optimizes for — and, just as importantly, what it deliberately does not. The practical counterpart — which calls a parent package should make, and in what order — is Embedding in a Parent Package.
Data Orientation: Optimize for Reading Every Component at One Timestamp
The decision. In the HDF5 file, SingleTimeSeries arrays that share a
(dtype, element_shape, length, resolution) are packed as columns of one dataset: columns are
series, rows are timesteps, and the HDF5 chunking spans the whole width —
(rows, cols, *element_shape), one row unless the dataset is narrow enough that a single timestamp
row would make an uneconomically small chunk
(file format). A chunk therefore holds one timestamp, or
a few consecutive ones, across every column. We optimize for reading all components' values at a
given timestamp, and accept that reading one component's entire array is comparatively slow.
Why. The workload that matters is simulation. A production-cost or power-flow model steps
through time and, at each step, needs the value of every generator, load, and branch for that one
timestamp — a slice across series, not down one. With this layout that slice is one chunk read
per dataset, and a sweep of them costs the same whatever the chunk's row count, since it visits
every chunk anyway; the ForecastReader / StaticReader columnar surface is built
directly on it. The inverse access — pulling one component's full history — has to touch every chunk
band and is slow by design. That trade is deliberate: the simulation read path is the hot one, and
it is the one parent packages hand to their users.
What this means for parent-package developers.
- Lay out bulk writes so that series sharing a shape land in the same dataset — that is what fills
whole chunks in one pass and keeps the timestamp-slice read fast. See
add_time_series_bulkandbulk_add. - Do not build a user-facing feature whose common path is "read this one component's entire array" and expect it to be cheap. It works, but it is the slow direction. If a downstream workload genuinely needs that orientation, that is a signal to raise with infrastore, not to work around with many single-series reads.
- The orientation is a property of the packed HDF5 layout only.
NonSequentialTimeSeriesand the dense forecast types are stored as standalone per-array variables and do not participate in it.
Values are immutable. There is no API — in any binding, by design — to edit a single value, slice, row, or column of an array already in the store. A stored array is added or deleted as a whole; changing data means writing a new array (content-addressed, so unchanged neighbors are not rewritten) and deleting the old one. This falls out of the two priorities above. A chunk holds one timestamp across many series, so editing one value would force a read-modify-write of a whole chunk band — the slow direction turned into the write path. And because arrays are content-addressed, an array's identity is its bytes: mutating it in place would invalidate the hash that every association row and every dedup reference depends on. Parent packages that expose "update this value" to their users must implement it as replace-the-array, not edit-in-place.
Forecast Storage: Chunked So One Window Is Cheap
Dense forecasts (Deterministic, Probabilistic, Scenarios) do not use the packed,
cross-component layout above — each forecast is stored as its own standalone array, [H, count, *E]
for a Deterministic, where H is the horizon length and count the number of forecast windows.
But reading one window at a time is a first-class access pattern (a simulation stepping the forecast
timeline, one issue time per step), so the array is chunked in bounded blocks along the count
axis rather than as one whole-array chunk. A window read then decompresses one block instead of
the entire year of windows.
Two consequences a parent-package developer should know:
- A window sweep is cheap; naive per-window reads are not, unless you go through the reader.
Because a chunk is the decompression unit, reading a single window still pulls its whole block.
The
ForecastReadersizes its in-memory cache to that same block width, so stepping the window timeline decompresses each block exactly once. Reach forForecastReader(or read the whole array once and index it) rather than issuing an independent whole-array read per window — the latter re-decompresses overlapping data. - Cross-component savings come from dedup, not packing. Where static series share storage by
packing many components into one dataset, forecasts share it by
content addressing: identical forecast arrays are stored once, and
ForecastReaderreads each unique array a single time and fans it out to every component that references it.
The block width is a write-time storage choice only — it reads transparently regardless of the width a store was written with, so it does not change the on-disk format version.
Split Arrays From Metadata
Numerical arrays live in HDF5 and metadata associations live in a companion SQLite catalog, because the two have opposite size, access, and mutation profiles and each format is strongest at one of them. The full rationale, the consistency ordering that keeps the two files in step, and the compaction behavior are covered in the Storage Model.
Content-Address and Deduplicate Arrays
Arrays are keyed by the SHA-256 of their contents, so two associations with identical data share one stored array and writes are idempotent on hash. This is what lets many components reference the same profile without duplicating storage, and it is why deletes are reference-counted. See Content Addressing.
Keep the Multi-Language Surface Consistent
infrastore-core is the single source of truth; the Rust, Python (PyO3), Julia (C ABI), CLI, and
gRPC interfaces are thin wrappers over the same Store. A capability is not considered done until
it behaves the same across the bindings that support it, and unsupported operations return an
explicit error rather than silently changing semantics. This keeps a parent package free to move
between bindings — for example, Julia via the C ABI and Python via the wheel — without the data
model shifting underneath it. See Language Bindings.
Make Transactions Span Operations, Without Enlisting HDF5
Every mutating entry point is atomic on its own, and a bulk add commits a
whole batch in one catalog transaction. Neither helps when several operations have to succeed or
fail together — add a series and remove the one it replaces, or write a batch and derive a forecast
from it. Store::begin_transaction opens a unit of work spanning any number of operations, and only
its outermost commit makes anything durable.
The obstacle is that a store is two artifacts and only one of them has transactions. SQLite rolls back its own statements; HDF5 has nothing to enlist. Rather than trying to give HDF5 a transaction, the array store is made append-only for the transaction's duration, which content addressing makes cheap:
- Writes are recorded as they happen and removed on rollback. An array is recorded only if it was physically written — a write of content that already exists is a no-op on hash, so there is nothing to undo.
- Frees are deferred to the outermost commit. While the transaction is open, an array whose last association was removed must keep its bytes, because a rollback restores the rows that point at it. At commit the reference count is rechecked against the state the commit is about to make permanent, so a hash removed and re-added inside the same transaction is never freed.
That deferral is what makes removals reversible inside a transaction, which they are not outside one. It is the capability a caller cannot build for itself: a client-side undo log can re-insert a catalog row, but it cannot bring back array bytes the store already reclaimed.
Two consequences fall out of the mechanism rather than being designed in. Reads inside a transaction see its uncommitted writes, because they go through the same connection — so a binding needs no staging overlay to give a caller read-your-own-writes. And nesting is free: each level is a SQLite savepoint, so an inner failure unwinds only its own work and leaves the enclosing transaction usable.
Append-only also means "not yet written". A packed single add outside a transaction fills one slot of a thousand-column growth pool, and because that pool is chunked one timestamp row across every column, filling one column rewrites every chunk in it. Inside a transaction that write is owed to nobody until the outermost commit, so it is buffered per pool instead and the buffered arrays are written together with the same block writer a bulk add uses — at the commit, when a read needs one of them to have a physical position, or when the buffer hits one of the bounds below. A loop of single adds inside one transaction therefore produces the file one bulk add of the same items produces: same dataset names, same widths, same chunking. Nothing about the format changes; these are layouts the bulk path already wrote.
Two edges keep that from being a worse trade than the one it replaces. A block of one is not a
block: a span holding a single array for a pool fills a growth-pool slot, because a dataset sized
to one column is chunked (1, 1) and gives a scalar f64 series an eight-byte chunk per timestep,
whose per-chunk overhead dwarfs the data — the same reason add_time_series_bulk sends a batch of
one down the single-add path. That matters because "several operations atomic together" is most
often a removal and its replacement, not an ingest. The same rule settles whether an irregular
series packs with a cohort or stands alone: that is a bet on the axis being shared, and inside a
span only the block knows the answer, so a cohort added one series at a time pools exactly as the
bulk add of it does. And the buffer is bounded, per pool at the width the block writer spills a
batch at and across every pool at a fixed byte ceiling; crossing either writes a block out early,
which costs an extra dataset and nothing else. The per-pool bound is the chunk budget rather than
the growth pool's thousand columns because a bulk add inside a transaction is buffered too, and a
narrower cap gave it ten times the datasets — and a columnar read ten times the chunks — of the same
add outside one.
One failure the mechanism cannot see coming is SQLite ending the transaction itself: a statement
that fails on a full disk or an I/O error can roll the whole transaction back, savepoints included,
and the store's bookkeeping does not learn of it. So the store reads it off the connection after the
fact — nesting levels still recorded while the connection is back in autocommit — and from then on
refuses every write and the commit until rollback_transaction discards the dead transaction, every
level at once. The alternative, letting the next write open an implicit transaction of its own and
commit on its own, would make part of the span durable behind the caller's back.
The costs are real and bound where this is worth using. A transaction holds the SQLite write lock
until it finishes, so a concurrent writer on the same artifact blocks and then fails on its busy
timeout. The buffer holds its not-yet-written arrays in memory — the same memory the equivalent bulk
add allocates, bounded per pool by the width it spills at. And a transaction still does not replace
batching for everything: feature-set dedup across a batch comes from bulk_add, which is also the
direct spelling when the whole cohort is already in hand as a list. The two compose — batch each
operation, and use a transaction when several of them must be atomic together.
Upgrade a Store In Place Rather Than Bricking It
DATA_FORMAT_VERSION used to be checked by strict equality on open, which made every bump a wall:
"re-create the store." It was bumped six times between 0.12 and 0.19 and each one meant exactly
that. The reason it had to be a wall is that the catalog DDL is CREATE TABLE IF NOT EXISTS —
idempotent, so a new table or index lands on an existing store for free, but not
version-agnostic: it will not alter a table that already exists, so a new column or a changed
CHECK never reaches an old catalog at all.
This release replaces the wall with a three-tier compatibility model plus a migration ladder.
Two revisions, answering different questions
DATA_FORMAT_VERSIONdescribes the artifact as a whole and is stamped on the HDF5 root. It moves for anything that changes the meaning of bytes already on disk: the array layout, the dtype encoding, the timestamp encoding, a hash domain.CATALOG_SCHEMA_REVISIONdescribes the SQLite catalog alone and lives in itsschema_versiontable. Any catalog change the idempotent DDL cannot make to an existing table — a new column, a changedCHECK, a rebuilt table, a backfill — needs aCATALOG_SCHEMA_REVISIONbump plus an append-only entry inMIGRATIONS.
MIN_UPGRADABLE_VERSION says how far back the ladder reaches. A stamp between it and
DATA_FORMAT_VERSION is Upgradable; anything older, anything newer, and anything unparseable is
Incompatible and is refused exactly as before. When a bump genuinely does strand older stores,
MIN_UPGRADABLE_VERSION is raised to match it; when the ladder can absorb it, it is left alone.
A writable open upgrades; a read-only open reports
Opening a store for writing runs every migration above the catalog's recorded revision, in
order, each in its own transaction. Opening it read-only cannot change anything, so it reports
CatalogMigrationRequired — an error that names the remedy (open it once for writing) instead of a
raw no such column from inside some later query. This is what a read-only consumer such as the
gRPC server now sees against a store that has not yet been upgraded.
A catalog written by a newer build is CatalogTooNew and is refused in both directions. There is
no downgrade path, and this build's DDL and ladder both describe an older shape.
The ordering that makes it safe
Three steps, and each one is load-bearing:
- Open the HDF5 half and evaluate its version stamp. This must come first so a store too old to
migrate reports
IncompatibleFormatrather than a confusing SQLite error, and so a bad path does not leave a freshly created empty.sqlitebehind. An upgradable stamp is noted, not yet rewritten. - Open the catalog, which is where the ladder runs.
- Only if (2) succeeded, and only for a writable open, re-stamp the HDF5 half.
The two stamps cannot be written atomically together, so the order decides which half is ahead when something fails in between. Catalog-first leaves a migrated catalog under an older array-file stamp: an older build then opens the store and simply never writes a row of a type it does not know, which is harmless. The reverse would leave a store claiming the new format over an un-migrated catalog — the exact failure the ladder exists to eliminate.
Migration is not a save: the paired generation stamps that pair the two halves are carried across untouched.
Append-only, never edited
A landed MIGRATIONS entry is frozen. Stores in the wild have already run it, so editing it changes
nothing for them and silently diverges the shape a fresh store gets from the shape an upgraded one
gets. Add a new entry instead. For the same reason each migration carries a frozen snapshot of
the table shape it produces rather than deriving it from the live DDL, which will keep moving.
Revision 1 is defined as whatever a pre-ladder build stamped — which is exactly what every
existing store already says, since the schema_version table was seeded with a literal 1 and
never read back. That is why the ladder needs no detection heuristic. It is deliberately not a claim
about one particular table shape, though: nothing stamped a revision while the catalog was still
moving, so 1 spans several, and a migration must tolerate any of them. The ladder starts there
rather than trying to resurrect the 0.12–0.16 formats, which changed the meaning of bytes on disk
and are still rejected outright.
The first rung
Revision 2 widens the time_series_associations time_series_type CHECK from BETWEEN 0 AND 5
to >= 0. TimeSeriesType::from_code is the real gate on that domain and runs on every write and
every read, so the numeric bound bought nothing SQLite had to enforce — while turning the eventual
appending of a seventh type into a table rebuild. Moving the domain onto the enum leaves the CHECK
as a non-negativity test, which still refuses a corrupted value.
SQLite has no ALTER TABLE … DROP CONSTRAINT, so applying it is the table rebuild, and the
rebuild is what makes the two subtle parts of the ladder concrete: the time_series_readable view
has to be dropped first (SQLite refuses to drop a table a view still names), and the AUTOINCREMENT
high-water mark has to be carried across by hand, because DROP TABLE takes the old table's
sqlite_sequence row with it and the copy would restart the counter at max(id) — handing back an
id a deleted row already used, which is the one thing AUTOINCREMENT is there to prevent.
Time-Series Types
infrastore stores seven time-series types. Three are static — a value per instant, on a grid, on explicit timestamps, or held forward from the last breakpoint — and four are forecasts, where each entry is a window of values issued at one time. This page covers what each one means, when to reach for it, and the vocabulary they share: periods, timestamp precision, and typed arrays.
For how a series is filed and addressed once stored — owners, features, identity, the association id — see the Data Model. For how the timestamps are spelled, see Time References.
Choosing a Type
The static types differ in one thing: what value the series yields at an instant you did not store. That question, not the shape of your input data, is what picks one.
| If your data is… | Use |
|---|---|
| sampled on a fixed grid — hourly load, 5-minute dispatch | SingleTimeSeries |
| at explicit instants, and undefined between them — outages, events | NonSequentialTimeSeries |
| changing only at breakpoints and holding until the next — a monthly fuel price | PersistentTimeSeries |
| a local-clock grid — hourly by the wall clock, across DST | NonSequentialTimeSeries (why) |
| windows of values issued at successive times | a forecast type |
Two follow-ups that come up every time:
- The value shape is a separate axis. A series of cost curves is not a new type — it is one of
the types above with an
element_typenaming the curve. See the element axis. - A grid you cannot fill densely is not automatically irregular. A
SingleTimeSeriescosts one value per step and packs into a shared dataset; the irregular types carry an explicit timestamp vector. Prefer the grid when there is one.
The Seven Types
All seven are present in the TimeSeriesType enum and in the metadata schema, and all seven can be
read from every interface: the Rust core, the C ABI, Python, Julia, the infrastore CLI, and
the gRPC server. The write paths differ, because the read-only gRPC server accepts none of them and
one type is never written directly at all:
| Type | Write path | Description |
|---|---|---|
SingleTimeSeries | add_time_series | One array sampled at a fixed resolution |
NonSequentialTimeSeries | add_time_series | Values at explicit, irregular timestamps |
PersistentTimeSeries | add_time_series | Sparse step function: breakpoints carried forward |
Deterministic | add_time_series | Forecast: a (horizon × count) window matrix |
DeterministicSingleTimeSeries | derived by transform_single_time_series | Forecast view over an underlying SingleTimeSeries |
Probabilistic | add_time_series | Forecast with percentile bands |
Scenarios | add_time_series | Forecast with discrete scenarios |
Every write path in the table is available in the Rust core, the C ABI, Python, Julia, and the CLI.
No interface adds a DeterministicSingleTimeSeries directly: it only ever comes into existence by
transforming a stored SingleTimeSeries.
DeterministicSingleTimeSeries is a storage-level view, and reads always return a
Deterministic. This is by design in every binding: TimeSeriesData has no
DeterministicSingleTimeSeries variant, and a read synthesizes the windowed Deterministic from
the underlying static array without copying it. The DeterministicSingleTimeSeries tag stays
visible in catalog surfaces — keys, metadata rows, counts, summaries — so callers can see which of
their forecasts are synthetic, and can address, copy, or remove the association.
It is never something you must ask for. A request for Deterministic matches both storage
forms — in reads, key resolution, catalog filters, and reader builds alike — so which one a store
holds stays an internal detail. Requesting DeterministicSingleTimeSeries narrows to the derived
form, which is how a caller audits what it has. This mirrors InfrastructureSystems.jl, where a
Deterministic request lowers to both concrete type names.
See Forecasts below for how the four windowed types are laid out.
SingleTimeSeries
A SingleTimeSeries is an initial_timestamp, a resolution (a period), and an array
of values:
value
^
| *
| * *
| * *
+--+--+--+--+--+--> time
t0 t0+r ... t0+(n-1)r
The timestamps are implied — sample i is at initial_timestamp + i * resolution — so only the
values are stored.
NonSequentialTimeSeries
A NonSequentialTimeSeries pairs each value with an explicit UTC timestamp. Timestamps must be
strictly increasing and their count must match the data length.
The timestamp vector is stored in the HDF5 file, content-addressed and shared: series sampled at
the same instants — an outage schedule, a set of event times, a market timeline — hold one copy
between them rather than one each. That shared vector is also the series' cohort: the values of
every series on it are column-packed into one timestamp-major HDF5 dataset, exactly as
SingleTimeSeries at one resolution are, so a StaticReader can sweep them a
timestamp at a time. A series alone on its time axis keeps a standalone array instead — packing only
pays once a cohort is several columns wide. See the storage model for the
layout.
PersistentTimeSeries
A PersistentTimeSeries is a sparse step function: a strictly increasing vector of
breakpoints plus one value each, where the value at an arbitrary instant is the one belonging to
the greatest breakpoint at or before it.
value
^
| +------------------->
| +------+
| +--+
| ?
+--+------+--+---------+----------> time
b0 b1 b2
Formally the values define a right-continuous step function:
- constant on
[b_k, b_{k+1})— a read between breakpoints returns the previous breakpoint's value; - extending to
+∞past the last breakpoint — a read after the end returns the last value; - undefined before the first breakpoint — a read there is an error, never a clamp. A value there was never declared, and inventing one would be a guess.
Because the function is total on [b_0, +∞), asking a series in hand for its value at an instant is
spelled plainly: value_at in every binding (PersistentTimeSeries::value_at::<f64>(t) in Rust,
with row_at for a shaped per-step element). It is not an approximation of a value — it is the
value. Only the row that value came from sits earlier, which is what index_at and breakpoint_at
report.
Structurally it is identical to a NonSequentialTimeSeries — same fields, same validation, same
storage. The two even share arrays: a persistent series and an irregular one on the same
breakpoints, dtype, element shape, and values occupy one content-addressed array in one nsts_…
dataset, because PackGroup is keyed by the time axis and never by the series
type. The difference is entirely in read semantics:
NonSequentialTimeSeries | PersistentTimeSeries | |
|---|---|---|
| value at a stored instant | that instant's value | that instant's value |
| value between stored instants | a hard error | the previous value |
| value after the last instant | a hard error | the last value |
| value before the first instant | a hard error | a hard error |
That is why it is a separate type rather than a read flag. "An irregular timeline has no value
between its timestamps" is a guarantee NonSequentialTimeSeries's docs and error messages lean on;
making it conditional would take it away from everyone.
The motivating data is a monthly fuel or gas price curve: a dozen breakpoints spanning a year, read
at simulation timestamps that almost never coincide with one. Read as a NonSequentialTimeSeries
that would error at nearly every step.
A time range slices on the step function's own terms. The returned series begins at the
breakpoint in force at start, not the first breakpoint at or after it, so the result always
defines a value at the start of the caller's window. A start before the first breakpoint is an
error. The one exception is a window with no instants in it at all: a zero-width range
(end == start) selects nothing, here as for every other type, and that includes a zero-width range
before the first breakpoint, which is empty rather than an error.
Scalar-collapse policy belongs to the application, not the store. A consumer that needs to know
whether a curve should be expanded to a full series or evaluated once at a midpoint carries that in
application_data, the opaque package-owned payload the
store never interprets. infrastore records breakpoints and values, and nothing else; there are no
catalog columns for expansion policy and there will not be.
It is an infrastore-local extension, not a Sienna type, and does not travel in an OpenAPI
document. The vendored sienna_schemas/TimeSeries/TimeSeriesAssociation.json is a oneOf over a
closed set of six canonical types owned by the data layer, and there is no upstream schema for a
seventh — so the wire form has no way to spell one. The export therefore omits persistent rows:
an unfiltered export of a mixed store emits its six-type rows and drops these, and an export whose
filter names the type is refused rather than answered with an empty array. The import refuses the
type independently, since a document from elsewhere can still name it: every incoming row is checked
against the schema its own time_series_type selects, and none selects this one.
Ask the catalog what an export leaves behind — list_metadata filtered to PersistentTimeSeries —
and carry those series in the artifact itself, which holds them in full.
Reading a step function in a columnar sweep
A StaticReader over PersistentTimeSeries columns is the one place the "one
timeline per reader" rule bends, and only because a step function makes it safe to: every column has
a value at every instant from its own first breakpoint onward, so the columns need not share a
breakpoint vector. This is deliberate — the motivating data is per-fuel monthly price curves whose
breakpoints do not line up.
Such a reader interns the distinct vectors and gives each column the one it resolves against. Its
public axis is the sorted union of every column's breakpoints — every instant at which some
column changes value — so a sweep over reader.timestamps() sees every distinct combination of
column values. There is still no presence mask: a carried-forward value always resolves once the
read instant is at or after a column's first breakpoint, and an instant before some column's first
breakpoint is a hard error naming that column rather than a hole in the result.
index_at on such a reader reports a position on the union axis and is not a storage row index
for any column; the read path resolves each column on its own vector instead.
Forecasts
The four forecast types store their values as a content-addressed TypedArray in its native
shape (the dense types as standalone HDF5 variables; a DeterministicSingleTimeSeries reuses its
backing SingleTimeSeries array), while the windowing parameters live in metadata. A forecast
association records horizon (the span each window covers), interval (the spacing between
successive window start times), count (the number of windows), and — for Probabilistic — a
percentiles vector.
| Type | Conventional array shape | Extra metadata |
|---|---|---|
Deterministic | (horizon_count, count) | — |
DeterministicSingleTimeSeries | the backing SingleTimeSeries array | — |
Probabilistic | (percentile_count, horizon_count, count) | percentiles |
Scenarios | (scenario_count, horizon_count, count) | — |
The store does not interpret the layout — the caller owns the array shape (the Rust core takes a
native-shape TypedArray inside a Deterministic / Probabilistic / Scenarios object; the C ABI
takes a row-major byte buffer with explicit dims, and the Julia wrapper accepts a native array and
serializes it row-major), and a DeterministicSingleTimeSeries deduplicates against the static
series it forecasts. A DeterministicSingleTimeSeries is not added directly — it is derived from
every stored SingleTimeSeries by transform_single_time_series (Rust core, C ABI, Python, Julia),
sharing the underlying array. Because a DeterministicSingleTimeSeries is a synthetic view of a
SingleTimeSeries, it is mutually exclusive with a real Deterministic for the same family
(owner, name, resolution, features, regardless of interval): adding a Deterministic when a
DeterministicSingleTimeSeries view exists — or deriving one when a Deterministic exists — raises
InvalidParameter. Forecast values read back through the same path as everything else: read_by_id
returns the forecast object matching the row's stored type, in every binding and over gRPC — a read
names only an id, so there is no requested type to disagree with what is stored. The low-level
metadata + array path remains available for raw access. See the
Rust API and C ABI.
Shared Vocabulary
Periods
resolution, and the forecast horizon/interval, are calendar-aware periods, not plain fixed
spans. A period is one of two kinds:
- fixed — a fixed nanosecond span (
Hour,Minute,Day,Week), backed by a duration; - calendar — a count of calendar months (
Month= 1,Quarter= 3,Year= 12), whereinitial_timestamp + i * resolutionis computed by calendar arithmetic (so a monthly grid lands on the same day-of-month each step rather than everyNmilliseconds).
A fixed period is never equal to a calendar one, even when their spans coincide for a given
month. Periods are encoded as ISO-8601 duration strings (PT1H, P1M, P1Y) on disk and across
every binding (the Python/gRPC surfaces accept a timedelta/duration for fixed periods and an
ISO-8601 string for either kind, and return the ISO-8601 string).
Timestamp precision
Every instant the store records — a SingleTimeSeries or forecast initial_timestamp, every entry
of a NonSequentialTimeSeries timestamp vector, and every breakpoint of a PersistentTimeSeries —
is a whole number of milliseconds, the same floor a fixed period has. One millisecond is the
finest resolution a period can express, and it is likewise the finest instant a series can be
written at.
The rule is enforced on write, in the core, for all six addable types: a finer instant is rejected
with an InvalidParameter error rather than truncated. This is what makes a timestamp mean the same
thing in every consumer. The bindings do not share one precision — the C ABI and Julia exchange
instants as i64 Unix milliseconds, Python's datetime is microsecond, and gRPC and the Rust core
carry a full RFC 3339 string — so a finer instant would be silently truncated at some boundaries and
not others, putting the same series on different instants depending on who read it. For a
NonSequentialTimeSeries whose timestamps are less than a millisecond apart it is worse: two
distinct timestamps collapse into one, and the vector stops being strictly increasing on the way
back out. The same reasoning applies breakpoint for breakpoint to a PersistentTimeSeries.
A leap second is refused by the same rule, for the same reason, though it is not a matter of
precision. Chrono spells one as a sub-second component at or above one second (23:59:60), which is
a whole number of milliseconds and would otherwise pass — but a Unix millisecond count cannot
express a leap second at all, so writing one would store the following second. That is not merely
lossy: a leap second and the second after it are distinct instants that would become one stored
instant, so two genuinely different NonSequentialTimeSeries time axes would share a content hash
and be interned as one, and a single vector holding 23:59:60 followed by 00:00:00 would go in
strictly increasing and come back out with a duplicate. Use the second either side of it.
A series needing a finer grid should scale its unit and record it in units, exactly as it must for
a sub-millisecond resolution: a 500 µs series is a 500-unit series.
Two things are deliberately not constrained. A query bound — a time_range end, a reader's
when — may be arbitrarily fine; it is not stored, and the read paths already say what an off-grid
bound does (see reading a time range). And reads
stay permissive: an artifact written before this rule may hold finer instants and still reads back
exactly as written, which is why the rule does not change DATA_FORMAT_VERSION.
The element axis is not the time axis
Two independent things decide what a series is, and requests to add a type usually conflate them:
| Axis | What it says | Where it lives |
|---|---|---|
| series type | the time semantics — a fixed grid versus explicit instants | the seven types above |
| element type | the value shape — a scalar, a tuple, a piecewise curve | element_type |
So a cost curve that varies over time is not another type: it is one of the types above with
element_type = piecewise_linear — the dates on the time axis, the curve points as the values, and
the non-curve fields that are constant across the curve (a volume window, a curve-kind tag) in
application_data. A read decodes the element type back into curves rather than handing back a
packing.
What does not belong in a value is a JSON blob: anything the store cannot describe cannot be deduplicated, hashed, or read columnar, and it puts the consumer back in the business of parsing its own storage.
Typed, N-dimensional arrays
Every series' values are a TypedArray: an element dtype (f64, f32, the integer widths,
or bool) and a shape [length, k1, k2, …]. The first axis is time; the trailing axes are a fixed
per-step element shape, so a step can hold a scalar (empty element shape) or a small tuple — for
example the 3 coefficients of a quadratic cost curve (element shape [3]). The association's
element_type says what those elements mean and how a ragged value (a piecewise curve, say) is
packed into a fixed-width row — see Element types. The optional
application_data payload travels alongside for a binding's own use; the store never interprets it.
Data Model
The data model mirrors the time-series concepts originally developed in InfrastructureSystems.jl: a component (or supplemental attribute) owns one or more named time series, and each time series may exist in several variants distinguished by features.
This page covers the catalog side of that — who owns a series, what distinguishes two series that share a name, how a row is filed, and how it is addressed once stored. Two neighbors cover the rest: Time-Series Types for what the six types mean and which to reach for, and Time References for how a series' timestamps are spelled.
Owners
Every time series belongs to an owner, identified by three fields:
| Field | Type | Meaning |
|---|---|---|
owner_id | i64 | Stable identity of the owning object (a component identifier) |
owner_type | string | The owner's concrete type, e.g. "Generator" |
owner_category | OwnerCategory | Component or SupplementalAttribute |
owner_id is a signed 64-bit integer identifier. The owner identity is the pair
(owner_id, owner_category): both participate in the association's uniqueness constraint, while
owner_type is descriptive. Component and supplemental-attribute integer-id streams are
independent, so the same owner_id can name a component and a supplemental attribute at once —
the category disambiguates them, keeping the two owners' series distinct. Owner-scoped operations
therefore take the category alongside the id (see Identity).
Features
Two series can share an owner and a name yet differ — for example a load profile for model year 2030 versus 2050. Features disambiguate them. A feature map is a set of typed key/value pairs:
features = {"model_year": 2030, "scenario": "high", "calibrated": True}
Feature values are one of four kinds: int, float, bool, or str. Internally the map is sorted
by key (a BTreeMap), which gives a stable order for hashing and for the uniqueness constraint.
Reserved feature names
A feature name may not collide with a field of a time series or of the identity a row
is filed under. Consumers routinely spread a feature map into a keyword-argument query — for example
list_metadata(...; name = "load", model_year = 2030) — and a feature called name or resolution
would shadow the real field there and silently change what the query means. Adding a time series
with one of these names raises InvalidParameter:
application_data, component_field, count, data, data_hash, dtype, element_shape, element_type, ext,
features, horizon, id, initial_timestamp, interval, length, name, owner_category, owner_id,
owner_type, percentiles, quantity_kind, resolution, scenario_count, time_reference,
time_series_type, timestamps, unit_system, units
dtype and ext no longer name metadata fields — element_type and application_data replaced
them — but both stay reserved. dtype is still how every binding spells a TypedArray's physical
type, and a consumer still passing the retired ext= should fail loudly rather than have it
silently accepted as an ordinary feature.
The match is exact and case-sensitive, like every other identifier in the catalog: resolution is
rejected, while Resolution and resolution_hours are ordinary feature names. The rule applies to
writes only, so a store written before it existed stays readable and its series can still be listed
and removed.
Identity
Every association is filed under a tuple that must be unique:
identity = (owner_id, owner_category, time_series_type, name, resolution, interval, features)
This is what the catalog de-duplicates on — it is not how a caller addresses a series. That is
the association id below. Two series with the same identity cannot coexist —
attempting to add a duplicate raises DuplicateTimeSeries. Change any element of the tuple (a
different name, a different model_year feature, a different resolution, a different forecast
interval, or a different owner_category) and you have a distinct series. interval is NULL
for the static types (which never carry one); for forecasts it lets two series of one variable at
the same resolution but different intervals (e.g. a day-ahead and a real-time forecast) coexist as
distinct series. Because owner_category is part of the key, a component and a supplemental
attribute that share a numeric owner_id keep entirely separate sets of series.
flowchart LR
OWNER["owner_id=42, category=Component, type=Generator"]
OWNER --> K1["name=load<br/>year=2030"]
OWNER --> K2["name=load<br/>year=2050"]
OWNER --> K3["name=max_active_power"]
K1 --> A1[("array A")]
K2 --> A2[("array B")]
K3 --> A1
style OWNER fill:#4a9eff,color:#fff
style K1 fill:#17a2b8,color:#fff
style K2 fill:#17a2b8,color:#fff
style K3 fill:#17a2b8,color:#fff
style A1 fill:#28a745,color:#fff
style A2 fill:#28a745,color:#fff
Note that two different series (K1 and K3 above) can point at the same underlying array. An
identity is a metadata concept; the array is shared by
content addressing.
Association IDs
Every catalog row has an id: a plain integer, assigned by the store, that names that row. It
is the way to address a series — every read, removal and copy takes one.
The identity above describes a series: owner, name, resolution, features. An id names the row the store filed it under. That difference is the point: a consumer that wants to record "this generator's cost curve is that series" inside its own object model would otherwise have to embed the whole identity tuple, and keep it in step with every change to it. An id is one integer, and nothing moves it: a name is fixed once written, so the two can never drift apart.
id = add_time_series!(store, 42, "ThermalStandard", Component, cost_curve)
generator.operation_cost.variable = id # one integer, stored in the model
The surface splits in two along that line. Identify — list_metadata and its by-id companions —
answers which series exist and hands back the id for each. Act — every read, removal and copy —
takes that id. A caller that knows a series only by its attributes does the first half once and
keeps the id; there is deliberately no combined resolver, because the two halves have different
costs and a caller that repeats a lookup it could have cached should be able to see that it is.
Three properties make an id safe to persist:
- It is never reissued. Deleting a row strands its id permanently. A reference to a deleted series stops resolving — it can never come back meaning a different series, which is the failure a recycled row number would cause silently, with no foreign key anywhere to catch it.
- It survives the operations that change a series' description. A rename or a reassignment to a
new owner keeps the id, as do
compactand a save-and-reopen. Those areUPDATEs and file copies, not new rows. - It is not part of identity. Two series differing only in id are the same series to the uniqueness rule and to both content hashes. It describes the row, not the data.
The store assigns it; no add accepts one. Not add_time_series, not a bulk add, and not either
association catalog's attach / link. This is what makes "never reissued" a guarantee rather than
a convention: AUTOINCREMENT only ratchets its counter upward, so an assigned id is never handed
out twice, while a caller free to name one could re-file a retired id and make a stale reference in
some consumer's model quietly resolve to a different series. The association row types carry an id
field because a listing populates one, but it is an output — an add ignores it, so a row read from
one store and attached to another is filed under a fresh id there.
What an id does not do is travel between stores. It is the row's number in one catalog, so a
merge assigns fresh ids in the destination, and two stores holding identical content will disagree
about them. The one place ids do cross a boundary — and the one writer that files rows under ids it
was given — is the OpenAPI document round trip, where preserving them
is the whole point: an import that assigned fresh ids would leave every reference the document
carries pointing at the wrong series. That wire form spells the field association_id — in a
document traveling beside components and supplemental attributes, an unqualified id would not say
which id it is — and the schema requires it on every time-series row. Because the import is the only
door, the guarantee holds there too: a supplied id must sit above the destination catalog's counter
(DuplicateAssociationId otherwise, so a document's ids fit a fresh store but not one that has
issued ids of its own), and a document supplies one for every row or for none. Neither association
catalog's wire form carries an id at all, so both always assign.
The same round trip carries two fields the schema gained late
(crates/infrastore-core/sienna_schemas/SOURCE.md records the upstream commit vendored here):
array_shape, the stored array's full native shape ([length, *element_shape] in the catalog's
terms, where the schema's element_shape is only the per-step trailing shape), and
time_reference. Both exist so an imported row is identical to the exported one — the forecast
layouts above are conventions the caller owns, so the native shape cannot be rebuilt from horizon
and count. Neither is required: a producer predating them writes rows without them, a reader that
does not know them ignores them, and an import that finds them absent falls back to the schema's own
fields.
Reading an artifact back from arrays plus a document
Those two fields exist so that a document can stand in for the catalog entirely. A consumer that
already ships the association rows in JSON of its own — PowerSystems writes a system.json beside a
time_series.h5 — carries the .sqlite half for nothing: the arrays are the part that cannot be
reconstructed, and the catalog can be replayed. Store::open_without_catalog is the way in. It
opens the array half of an artifact whose catalog is absent and mints an empty one carrying the
array file's own generation stamp, so the rebuilt pair opens normally ever after; an ordinary
open cannot, because a stamped array file beside an unstamped catalog is exactly the half-finished
save the paired-stamp check exists to catch.
All six of the types the wire contract defines make the trip, including NonSequentialTimeSeries —
which needs one field the others do not. (A PersistentTimeSeries does not: it is an
infrastore-local extension outside the vendored oneOf, so the export omits its rows and the import
refuses them. A store holding them still exports the rest, but a restore from arrays plus a document
alone will not carry them.) Its timestamp vector lives in the store rather than the document,
content-addressed so a cohort sharing one axis stores it once, and the values cannot imply which
axis a row is on: arrays are content-addressed too, so two irregular series with byte-identical
values on different axes share one stored array and only the catalog's timestamps_hash
distinguishes them. An import that guessed would hand back another series' timestamps. So the wire
form locates the axis, as timestamps_uri — the same kind of field uri is for the values, and
a locator rather than the vector itself precisely because the axis is shared, where inlining it
would repeat the whole vector on every row of the cohort. The axis ships in the array file alongside
the arrays, so the import resolves the locator against the store; a row missing it, or naming an
axis the store does not hold, is refused.
Both imports validate every incoming row against those vendored schemas before decoding it, so a
document that drifted from the contract is refused in the schema's own terms — the row, the field,
and what was expected — rather than by whatever the Rust struct happens to notice first. A
time-series row is checked against the per-type schema its own time_series_type selects, which is
what the wrapper's discriminator prescribes and what keeps the error specific: a failed oneOf
can only report that nothing matched.
Each of the three catalog tables keeps its own independent counter, so an id is only meaningful alongside the table it came from.
Writes report the id they used, and reads take one:
| Direction | Rust | Python | Julia |
|---|---|---|---|
| Write | add_time_series → TimeSeriesId | → int | → Int64 |
| Identify | list_metadata | list_metadata | list_metadata |
| …by id | list_metadata_by_ids | list_metadata_by_ids | list_metadata_by_ids |
| Resolve | get_metadata_by_id | get_metadata_by_id | get_metadata_by_id |
| Validate | association_exists | association_exists | association_exists |
| Read | read_by_ids | read_by_ids | read_by_ids |
| Read one | read_by_id | read_by_id | read_by_id |
| Read range | read_by_ids_range | read_by_ids_range | read_by_ids |
| Remove | remove_by_ids | remove_by_ids | remove_by_ids! |
In the Rust core an id is the newtype TimeSeriesId, so an owner_id cannot be passed where a
series id belongs; the dynamic bindings exchange a plain integer.
association_exists fetches no row, so a consumer can check every reference in its model on load
rather than discovering a dangling one mid-simulation.
read_by_ids and remove_by_ids both refuse a set containing an id that names no row — the read
returns nothing, the removal removes nothing. That is deliberate: a caller working from references
it recorded earlier has a model that disagrees with the store, and since an id is never reissued the
disagreement will not resolve itself. Sift the set with association_exists first when some
references are expected to have gone.
read_by_id is the single-id read, and it also takes the slice: a start_time plus a len of
timesteps or a count of windows. Both halves happen in one call because the primary-key lookup
already returns the row the window resolves against — a consumer holding an id spends nothing to
learn a series' resolution or count before asking for the second day of it. A window is
checked, where read_by_ids_range clips: a start off the series' own grid, or an extent running
past its end, is an error rather than the smaller answer a range would return. A range says
"whatever lies between these bounds" — which is what an export wants, knowing the bounds and not the
step count — while a window says "these exact steps", and a caller that asked for 24 and silently
received 3 has a bug the store can see and it cannot. What a range clips to is type-specific: a
regular series' value covers its step, so a start inside a step selects that step and the sliced
initial_timestamp can precede start; an irregular series' value is an instant, so only
timestamps at or after start are selected; and a forecast window is a whole array with nothing
partial to return, so a forecast's start must be a window boundary at or before the last window
(an error otherwise) and only its end clips. A start earlier than the first window is the one
exception, and it clips: there is no partial window before the first one, only no window at all, and
refusing it would fail every export window wider than the data. See
Reading a time range.
A calendar period is not closed under slicing
A SingleTimeSeries and a dense forecast are each stored as an anchor, a period, and a count, so a
sliced read has to describe its answer the same way — anchored at the slice's own first point. For a
fixed period that is exact. For a Period::Months it is not, because the end-of-month clamp is not
associative: a grid stepping monthly from Jan-31 is Jan-31, Feb-29, Mar-31, but re-anchored at its
own Feb-29 it becomes Feb-29, Mar-29, Apr-29 — the stored values under dates the store does not
hold. No anchor fixes it, because the sub-grid keeps the original anchor's day of month and no
instant in the slice carries it.
So such a slice is refused (InvalidParameter) rather than answered with a grid that is not the
one stored — the values would be right, the dates wrong, and nothing would signal it. The refusal is
narrow: it needs a calendar period, an anchor on a day some month is too short for, and a slice
starting at a clamped point and running past it. Read the series whole and slice the materialized
timestamps, or store the instants explicitly with NonSequentialTimeSeries.
transform_single_time_series is held to the same rule at write time, since each window of the
view it derives is a run of the source's own steps described that same way.
The owner guard
read_by_id and remove_by_ids each take an optional expected owner — read_by_id_for_owner /
remove_by_ids_for_owner in the Rust core, an owner=(id, category) keyword in Julia, keyword-only
owner_id / owner_category in Python, has_owner beside the two across the C ABI. The row is
held to that owner, and one belonging to anyone else is OwnerMismatch rather than a read or a
delete.
It exists because the two halves cannot be checked separately. An id is the whole address and it
survives replace_owner, so a consumer whose model says "this component's series" — and which
therefore wants to confirm the owner before acting — has a window between the confirming call and
the acting one. A reassignment landing in that window makes the removal retire the new owner's
series, which is exactly what checking the owner was meant to prevent. Passing the owner into the
call closes the window: the check and the act are one transaction. On the read side there is no
window either way, but the guard is still the cheaper spelling — the owner comes off the same row
the values are materialized from, so it costs nothing, where a separate check is a second round
trip.
Optional Descriptors
Each association can also carry:
units— a free-form, end-user-facing label such as"MW". No dimensional analysis is performed.quantity_kind— what kind of physical quantity the values measure, e.g."ActivePower","Energy","Length". Free-form; the recommended vocabulary is a QUDTQuantityKindlocal name. It sits aboveunitsrather than duplicating it, for two reasons. A units library's dimensional analysis cannot separate active from reactive power — both are[M L^2 T^-3]— but a quantity kind can. And whenunit_systemiscomponent_basethe values are per-unit and therefore dimensionless, so this is the only surviving record of what they measure and which base converts them back. The column is deliberately unconstrained: the composite economic quantities an energy modeler needs ($/MWh,MMBtu/MWh) are exactly where QUDT's coverage thins out.unit_system— which basis the values are expressed in:natural_units(the units named byunits) orcomponent_base(per-unit against the owning component's own base). This is the per-unit declaration power-systems modelers know as the unit system; PowerSystems.jl spells the same ideaUnitSystem, withNATURAL_UNITSandDEVICE_BASE. It is a label, not a conversion: the store holds no base value and rescales nothing, so convertingcomponent_basevalues back to natural units is the consumer's job, using the base that lives on the owning component in its own object graph. Unset means unspecified, which is deliberately not the same asnatural_units— every association written before this field existed is unset, and reading those as natural units would assert a basis nobody declared.component_field— the field on the owning component whose value these values are the time-varying form of, e.g."max_active_power"or"rating". Free-form and never interpreted: it names a field in the consumer's own object model, which the store has no view of. It records what the values are for, wherenameonly says which series they are — the two coincide by convention in many models but are not the same thing, since one component may carry several series for one field (a forecast and an actual, a set of weather years) andnameis part of a series' identity where this is not. Named for the common case; when the owner is a supplemental attribute it names a field on that attribute.time_reference— how this series' timestamps were spelled:utc,zoneless, a fixed offset (-07:00), or an IANA zone name (America/Denver). See Time References, which this one deserves a page of its own for.application_data— an opaque, package-owned extension payload stored verbatim (typically JSON) that a binding writes and reads for its own purposes. The store never parses or interprets it, and end users are not expected to set it. Element typing does not live here: that iselement_typebelow, a first-class column the store owns and validates. This is also where application policy about a series belongs — a consumer's rule for collapsing aPersistentTimeSeriesto a scalar, say. Such a rule is not storage, and it gets no catalog column.element_type— what the array's elements mean, in the store's own language-neutral vocabulary: a dtype spelling (f64,i64, …) for plain numbers, elsetuple(N,dtype)or one of the function-data kinds (linear_function,quadratic_function,piecewise_linear,piecewise_step). It supersedes a separate physicaldtype: the dtype of the stored bytes is derived from it. Unlikeunitsandapplication_datait is not inert — the write path validates the array's dtype and per-step shape against it. See Element types.
units, quantity_kind, unit_system, time_reference, component_field, and application_data
are recorded in metadata and returned on read, but they do not affect identity or storage: they are
absent from the key and from both content hashes, so two series differing only in a descriptor are a
duplicate.
component_field is the one descriptor that is also a filter (ListFilter::component_field,
and its equivalent in every binding): "every series that varies this field", alone or scoped to one
owner. Being descriptive rather than identifying, it narrows a listing but never addresses a single
row on its own — one component may carry several series for one field, distinguished by name or
features. It matches exactly and case-sensitively, and a series that declares no component_field
matches no value, so the filter cannot select the rows that left it unset.
Associations Between Entities
Beyond owning time series, catalog entities can be related to each other. The catalog records two such relationships, in two separate tables, because they are not the same kind of thing: attaching an attribute to a component and wiring one component to another have different identities and different query patterns.
Supplemental attributes attached to components
| Field | Meaning |
|---|---|
component_id, component_type | The component carrying the attribute |
attribute_id, attribute_type | The supplemental attribute being carried |
Identity is the (component_id, attribute_id) pair. The type names are denormalized labels, not
part of identity: re-attaching the same pair under different type names is a duplicate and is
rejected. One attribute may be attached to many components, and one component may carry many
attributes; only the exact pair is constrained.
Parent/child edges between components
| Field | Meaning |
|---|---|
parent_id, parent_type | The parent component, e.g. a generator |
child_id, child_type | The child component, e.g. the bus it connects to |
Both endpoints are always components, so unlike time-series owners there is no category to
disambiguate. Identity is the ordered (parent_id, child_id) pair — the reversed pair is a
different edge. There is no relationship-kind column, so a given pair may be related at most once.
Properties shared by both
Two consequences of the deliberate absence of foreign keys and cascades:
- Associations and time series are independent in both directions. Removing a component's time series does not remove its attribute attachments or its edges, and removing either does not touch any series. A consumer that wants both effects makes both calls.
- The store never observes a deletion it did not perform. Components and attributes live in the
consumer's object graph, so a cascade could never fire; consumers call the matching
remove_*with the appropriate filter instead.
Filtering takes lists of concrete type names, rendered into SQL IN (…). Expanding an abstract
type into its subtypes stays in the calling language, where the type hierarchy lives.
Terminology: rows of the
time_series_associationstable — the owner-to-time-series records described above — are also called "associations" throughout this documentation and the code. They are unrelated to the entity-to-entity tables described in this section.
Both are available in the Rust core, the C ABI, Julia, Python, and the infrastore CLI (attach /
detach / link / unlink); neither is exposed over the read-only gRPC server. The
supplemental-attribute surface is the wider of the two (it carries counts and a grouped summary)
because each of its operations is driven by an existing consumer; the parent/child surface is
deliberately narrower for now.
Store attributes
Everything above describes a row: a series' application_data, a component's supplemental
attributes, an edge between two components. Store attributes describe the artifact itself.
A store attribute is a key/value pair recorded once per store — who built it, from what source
system, under which of the consumer's own schema versions. The store never interprets a value, in
exactly the same spirit as application_data: keys and values are TEXT, nothing here participates
in any identity, hash, or query, and a caller wanting structure stores JSON.
store.set_store_attribute("creator", "sienna-build")?;
store.set_store_attribute("source_system", "WECC 2032 ADS")?;
assert_eq!(store.get_store_attribute("creator")?.as_deref(), Some("sienna-build"));
Note the term and the prefix. In this project a bare "attribute" means a supplemental attribute
(above) and a bare "metadata" means a TimeSeriesMetadata row, so every identifier for this feature
carries store_: set_store_attribute, list_store_attributes, the CLI's store-attr, the
store_attributes table. Neither misreading is available.
Four rules are worth stating outright:
- A set replaces.
keyis the primary key, so an artifact records one creator rather than a history of them. - An absent key is
None, not an error. A consumer asking whether the artifact carries a key is asking a question, the same readingget_metadata_by_idtakes. A key set to the empty string is present; every binding keeps the two distinguishable. infrastore.is reserved, refused on removal as well as on write, so the store can stamp facts of its own later without colliding with a consumer's keys — and so a reserved key cannot be worked around by deleting it.- Attributes are content.
is_emptyreportsfalsefor a store holding nothing but provenance: those rows are the consumer's own text, recoverable from nowhere else, and a consumer that skips writing an "empty" store would drop them with no error.
They live in the catalog, so they travel with persist_to and persist_catalog, survive compact
(which rewrites only the array half), and come across with open_copy. open_without_catalog mints
an empty set, since there is nowhere else for them to have been. They are not carried by the
OpenAPI export or import: the vendored schema has no place for them.
Available in the Rust core, the C ABI, Julia, Python, and the CLI (store-attr, plus
store_attributes in store-info); the read-only gRPC server carries the read half
(ListStoreAttributes, GetStoreAttribute).
The CLI's two cross-store commands take a position rather than ignoring the table. merge is
additive with the destination winning: a merge brings data into an artifact that already has an
identity, so a key the destination lacks is copied, a key both sides agree on is a no-op, and a
disagreement is reported and left as the destination has it. diff gives them a section of their
own — there is no series identity to pair them on — and counts a difference toward its nonzero exit.
Time References
The store records instants. A time_reference records what those instants were written as, so
a series comes back the way it went in instead of being relabeled UTC at every boundary.
| Spelling | Meaning |
|---|---|
utc | An instant, written as UTC. |
-07:00 | An instant, written at a fixed offset from UTC. |
America/Denver | An instant, written in a named IANA zone. Held opaquely. |
zoneless | A wall clock. Names no instant; the store holds it as if UTC. |
| unset | Unspecified — not a claim the timestamps were written as UTC. |
Three of the four name an instant; zoneless does not, and most rules below split on that binary
rather than on the four spellings. An unset reference groups with the zoned ones.
Each binding infers the spelling from the input type, so nothing takes a new required argument:
| Binding | utc | fixed offset | named zone | zoneless |
|---|---|---|---|---|
| Python | timezone.utc | fixed-offset tzinfo | tzinfo exposing a key (ZoneInfo) | naive datetime |
| Julia | UTC ZonedDateTime | FixedTimeZone | VariableTimeZone, by its name | bare DateTime |
| CLI | Z in text, or the flag | -07:00 in text or flag | --assume-timezone America/Denver | bare timestamp, --zoneless |
| Rust | DateTime<Utc> | declare it | declare it | declare it — no naive type |
ZoneInfo("UTC") records the zone UTC, not the literal utc. The two render identically
forever; the difference shows up only in what the catalog reports back, which is the point of
recording a spelling at all.
A spelling is not a grid
A reference records how timestamps were written. It does not change how the grid is stepped:
resolution and interval are durations, so an hourly series has hourly instants whatever its
reference says. Rendering an hourly America/Denver series across the November fall-back gives
01:00-06:00, 01:00-07:00, 02:00-07:00 — two identical wall clocks, two distinct instants,
correctly ordered.
That is the difference between two things "store this in Denver time" can mean:
- Instants, displayed in Denver. Storage is untouched — UTC instants plus a label. This is what a named zone means here.
- A local-clock grid — hourly by the clock, so a 23-hour day in March and a 25-hour one in
November. This is inexpressible in
SingleTimeSeriesand the dense forecasts, whose grid is aPeriod: a fixed count of milliseconds. UseNonSequentialTimeSeries, which carries an explicit instant per value, so the caller derives those days and the data records them rather than arithmetic implying them.
The store now checks, when you give it something to check
Asserting initial_timestamp + resolution is a claim the store cannot verify — the vector it
describes is never supplied. Two rules close that:
- A calendar-scale period on a named zone is refused. A period of a day or more is a span of
instants, so on a zone that observes DST it drifts away from the local clock it looks like: a
P1Dseries stepped from local midnight in Denver lands onNov 3 23:00after the November transition, on the same calendar day as its predecessor, and stays an hour off forever.P1Mis worse — it steps the UTC calendar. Both areInvalidParameterat the write. - Sub-daily periods stay legal, and that is not a compromise. DST moves the offset, not the length of an hour: Denver has 8784 hours in 2024, exactly as UTC does, every gap exactly one hour, and the 23- and 25-hour days fall out of instant stepping on their own. An hourly grid in a DST zone is the local clock. Refusing it would push callers onto a fixed offset, which is silently wrong for half the year.
A forecast's horizon is exempt: it is a window length, only ever divided
(H = horizon / resolution) and never added to an instant, so horizon = P1D — the canonical
day-ahead shape — stays legal in any zone. resolution and interval do step, and are covered.
The constructive half is
from_timestamps: hand over the
timeline you actually have and the store infers the period and proves the instants lie on it, or
refuses naming the entry that broke the pattern. This is how a local-clock timeline reaches the
store — you materialize it in your own date library, where the policy for a nonexistent or ambiguous
wall clock belongs, and the store records what you have rather than what a resolution implies.
Someone with 8760 naive Denver timestamps who localizes only the first and passes resolution = 1h
still gets labels shifted by an hour after each transition if their file was not a local-clock walk,
and nothing in the values distinguishes that from a correct series — so hand the vector over rather
than assert the step.
Months step on the UTC calendar
Period::Months is calendar arithmetic, so unlike a fixed period it has to be told which
calendar. It uses the stored UTC one, and the reference does not redirect it. TimeZones.jl steps
the local clock instead, so the two disagree by an hour at every DST transition and by up to a day
at a month boundary.
Local-frame stepping is refused for three independent reasons: it is the local → instant direction
the store deliberately never runs (below); it would let a spelling decide which instants a series
contains; and it would need a time-zone database in the core, which would make a stored series'
instants depend on which IANA release built the reader. A calendar period on a named zone is
therefore refused on write, and on a fixed offset — which has no DST to drift against, only a month
boundary — it is warned about. A caller who wants months on a local calendar wants a local-clock
grid, and the answer is the one above: NonSequentialTimeSeries, or from_timestamps, which picks
between the two for you.
Why a named zone is safe
The ambiguity a named zone is feared for lives in the local → instant direction, and the core never runs it.
- On input that direction has already happened, in the caller's own datetime library. Julia
refuses an ambiguous local time outright; Python resolves it through
fold. Either way the binding is handed a value that already names one definite instant. The CLI is the exception, because it is handed text — see below. - On output the store runs only instant → local, which is total and single-valued: one instant maps to exactly one wall clock in a named zone, and converting it back yields the same instant.
So a year-long Denver series stamped -07:00 renders every timestamp after the March transition an
hour wrong, while the same series stamped America/Denver renders all of them correctly. Recording
"the offset in effect at initial_timestamp" is the one option that is quietly incorrect, which is
why it is not among the four spellings.
Two caveats belong here rather than in the type. Rendering a named zone is
tz-database-dependent, so a retroactive rule change moves the displayed local time of an
already-stored instant — the store records the instant, and the label is a rendering hint. And a
zone name's existence is audited, never gated: the core checks only that a name is shaped like
an IANA name and cannot be read as an offset or as either literal. Every layer that has a database
— the CLI via chrono-tz, Python via zoneinfo, Julia via TimeZones — warns on a name it does
not recognize and stores it anyway, and infrastore store-info reports the catalog's distinct
spellings with unrecognized zones flagged. Gating would turn a rare read-time error into a
write-time error coupled to our release cadence: when IANA adds a zone, a caller whose own
database already has it would be refused until they upgraded.
The CLI is where local → instant actually happens
Every other binding is handed an already-resolved datetime. The CLI is handed text, so
--assume-timezone America/Denver over a zoneless column is the one place in the system that runs
local → instant itself, and chrono-tz answers in three values — each with its own behavior, per
row:
| Result | Meaning | CLI behavior |
|---|---|---|
| a single instant | the ordinary case | ingest it |
| two candidates | the repeated fall-back hour | error, naming the row and both candidates |
| none | the skipped spring-forward hour | error, naming the row |
Rejecting loudly, per row, with both candidates named is what makes a named zone acceptable here;
silently picking one is not. Reading is unaffected: rendering a stored instant in a named zone is
the total direction, so --assume-timezone plays no part in it.
Query bounds and mixed selections
A bound must be spelled the way the series is, and a mismatch is refused rather than coerced:
| Series reference | Wall-clock bound | Instant bound |
|---|---|---|
utc / offset / zone | error | accept — any offset names the same instant |
zoneless | accept | error |
| unset | error | accept |
An off-grid bound still names an unambiguous instant, so flooring it is well-defined — that is why
time_range snaps. A wall-clock bound against a series that records instants is a category
error: there is no defined mapping to fall back on. Bounds stay unconstrained in precision,
though: a sub-millisecond bound names a real instant even though a stored one may not.
The same partition drives two rejections and one filter:
- A ranged bulk read over a selection spanning both groups is refused — no single bound is valid for all of it. An unranged one is unaffected: without a bound there is nothing to disagree about, and each series carries its own spelling back.
- A
StaticReadermaterializes one timestamp axis, so a mixed cohort is refused at build time, where the error can name the series that disagree. Mixingutc, an offset, and a named zone in one cohort is fine — all three name instants, and the axis is spelled with the cohort's reference when every member agrees andutcwhen they merely agree on naming instants. ListFilter::zonelessis the constructive half:trueselects the wall-clock series,falseselects everything that accepts an instant bound — the three zoned spellings and the rows that left the reference unset. It is a binary predicate rather than a match on a specific spelling because an exact match cannot name that second group at all (the trapcomponent_fielddocuments), and here those rows are a coherence group rather than an oversight.
Readers
A reader is the columnar bulk-read surface: build one once over a filter, then walk the timeline and take every matching series' value at each instant. It is the access pattern the on-disk layout is built for, and the one a parent package hands to its users, so it is worth understanding as a concept rather than as two type names.
There are two, and they are described signature-by-signature in each language's reference (Rust, Python, Julia, C ABI):
| Reader | Sweeps |
|---|---|
StaticReader | all three static types — one value per column per instant |
ForecastReader | the four forecast types — one whole window per column per issue time |
Neither is exposed over gRPC.
Why They Exist
Static arrays that share a shape are packed as columns of one dataset, chunked across the whole
width — (rows, cols, *element_shape), with rows = 1 unless the dataset is narrow enough that one
timestamp row would make a chunk too small to be worth one
(file format). So a chunk holds one timestamp, or a few
consecutive ones, across every column: "every generator's output at hour 4 371" is one chunk read
per dataset, while "this one generator's whole year" has to touch every chunk band in the dataset.
That is the asymmetry these readers are built on, and it is a property of the width, not of the row count. A sweep — which is what a reader does — pays the same either way: it visits every chunk regardless, and when a chunk carries several rows they are the next ones the sweep asks for. Scattered access to a single timestamp is the case where extra rows are decompressed and discarded.
That asymmetry is deliberate (Design Choices), and a reader is the API that spends it correctly. A loop of whole-series reads walks the slow direction once per component; a reader walks the fast one once per timestep.
The forecast case is the same argument with a different unit. A dense forecast array is chunked in
bounded blocks along the window axis, so reading one window decompresses its whole block. A
ForecastReader sizes its cache to that block width, so a sweep over the window timeline
decompresses each block exactly once — where independent per-window reads re-decompress overlapping
data every step.
A Reader Is a Plan, Not a Cursor
Building one resolves the filter to a fixed set of columns, pins the timeline, and allocates the buffers each read will overwrite in place. It holds no borrow on the store and advances no position of its own: the caller names the instant, the store fills the buffers, the caller walks the columns. A tight simulation loop therefore allocates nothing after the build.
Two consequences worth planning around:
- The column set is frozen at build time. A series added afterwards is not in the reader; build a new one.
- The cost is paid up front. Building resolves metadata for every matching row, so build once outside the loop — never per timestep.
One Timeline Per Reader
A reader materializes one timestamp axis shared by every column, because that is what makes a read a single positional lookup rather than a per-column search. What "one timeline" requires depends on the type the filter names:
| Filtered type | Resolution | The columns must… |
|---|---|---|
SingleTimeSeries | pinned | share one grid — initial_timestamp and length, unless the caller names the span (see below) |
NonSequentialTimeSeries | none | lie on one timestamp vector (the on-disk cohort) |
PersistentTimeSeries | none | nothing — each column carries its own breakpoints |
A mismatched cohort is refused at build time, where the error can name the series that disagree, rather than at the first read. The same applies to time-reference coherence: a reader whose matched series mix wall clocks with instants is refused, because no single axis can be spelled for both.
The one exception: step functions
A PersistentTimeSeries is the row that does not
have to agree with its neighbors, and only because a step function makes that safe: it has a value
at every instant from its own first breakpoint onward, so a column need not carry the instant
being read in order to answer for it. Such a reader interns the distinct breakpoint vectors its
columns sit on, records for each column the vector it resolves against, and takes their sorted
union as its public axis. A read then carries values forward per vector rather than once for the
whole reader.
Two consequences follow from the union being a public axis rather than a storage layout:
- A position on it is not a storage row.
index_atreports a position on the union, which belongs to no column in particular; the values come from each column's own row in force there. Nothing else in the reader surface indexes this way. - There is still no presence mask. An instant before some column's first breakpoint has no value that column could report, so the read is a hard error naming that column rather than a gap the caller has to test for. The rule the other two types get from a shared timeline — every column has a value at every readable instant — is preserved, just enforced at read time instead of build time.
The motivating data is per-fuel monthly price curves whose breakpoints do not line up. Forcing them onto one cohort would mean either inventing breakpoints or building one reader per fuel, and both lose the single chunk-aligned sweep a reader exists to give.
Naming the span instead of inheriting it
Sharing a grid is a property of whole series, and a simulation usually wants a span they agree on rather than the whole of each. A year of load beside a week of an outage schedule, or one component logged from an hour later than the rest, has no shared grid — and under the rule above, no reader at all. That refusal was correct and not useful.
So a SingleTimeSeries reader can be given its axis: a start, and optionally an extent. Each column
then records how many of its own steps precede that anchor, and a read adds that offset to the
reader's index. The one-timeline rule is untouched — there is still exactly one axis, and every
column still has a value at every instant on it — but the axis is now the caller's span rather than
whatever the series happened to have in common. Without an extent the span runs as far from the
anchor as every matched column reaches, which is the widest one on which nothing has to be dropped.
The window is where a reader could most easily hand back a full, plausible, wrong row, so all three of its edges are checked at build time rather than smoothed over:
- A column that does not cover the span is an error naming it. Dropping it instead would be invisible at read time: the sweep would return a complete row, one column short, and nothing in the result would say so. Excluding a series is a decision only the caller can make, and the filter is where they make it.
- The anchor is checked against each column's own grid, never floored onto it. This is the one
place a reader is stricter than
read_by_id, which floors a start inside a step because a value covers its step. A reader answers for a whole cohort at one instant, so flooring per column would shift columns against each other by up to a step. - A calendar period is still not closed under re-anchoring. A window is a re-anchoring, so it
meets the same
Period::sub_grid_is_anchorablerule a sliced read does: a monthly grid from Jan-31 read from its own Feb-29 would report Feb-29, Mar-29, Apr-29 against the values of Feb-29, Mar-31, Apr-30 — right values, wrong dates. No anchor fixes it, so the window is refused.
Mechanically the offsets ride the machinery the persistent case already needed: a per-column row index and the scattered backend read. A window whose columns all start at the anchor drops back to the single-index read, so the ordinary uniform sweep costs exactly what it did.
Or dropping the series that are not on the grid
A window assumes every matched series belongs in the sweep. Often one does not: a stray day of data beside a year of it, under the same name, is usually a different component rather than a shorter view of the same thing. Forcing it into a window costs the whole year — the span is capped by the shortest column — for a series nobody asked about.
So the filter vocabulary can name a grid too. ListFilter::initial_timestamp and
ListFilter::length join resolution to select the cohort on one grid, and the series that are not
on it are not matched at all. This is the same move
ListFilter::zoneless makes for spelling:
a rule that refuses a divergent selection is only half an answer, and the other half is a way to
construct a coherent one.
The two remedies are complementary, not competing, and the question they answer is different:
| The window | The grid filter | |
|---|---|---|
| Ragged series | all take part, each at its own offset | only those on the named grid |
| The axis | the span you named | the grid the matched series share |
| Reach | build_static_reader only | every filter-taking call |
| A mismatch | an error naming the series | that series is simply not matched |
The last row is the one to keep in mind. A window is a bound, so it is checked and a series that cannot answer it is an error; a filter is a selection, so a grid no row is on is an empty result. It is the same distinction the store draws everywhere between a bound and a predicate.
Both are descriptive rather than identifying: a grid is not part of KeyIdentity, so two series
differing only in start or length are one row to the catalog, and an identity probe never narrows by
either.
Sharing Is Resolved Once
Where the static side shares storage by packing many components into one dataset, forecasts share it
by content addressing: identical arrays are stored once. A
ForecastReader inherits that at read time — it reads each distinct backing array a single time per
step and fans the result out to every column referencing it, so a forecast shared by a hundred
components costs one decompression, not a hundred.
That fan-out is visible to the caller: an entry's slot identifies the underlying array, so per-component work downstream of the read can dedup the same way the read did rather than repeating itself once per referencing component.
When Not to Use One
A reader is the wrong tool for the inverse access — one component's full history, an export, a plot
of a single series. Reach for read_by_ids over the ids you want, which reads packed series in one
decompress-once pass per dataset. It is still the slow direction against this layout, but it is far
cheaper than a read_by_id per series, and much cheaper than building a reader you will step once.
Storage Model
A persistent store is two files that travel together:
system.h5 # HDF5 — the numerical arrays
system.h5.sqlite # SQLite — the metadata associations
The catalog path is derived by appending .sqlite to the HDF5 file name. This page explains the
split and how the two halves stay consistent. For the exact bytes, dataset names, and table columns,
see the On-Disk File Format reference.
Why Split Arrays From Metadata
Arrays and metadata pull in opposite directions:
| Concern | Arrays | Metadata |
|---|---|---|
| Size | Large (thousands of values each) | Small (a row plus a shared feature set) |
| Access pattern | Bulk read by content | Filtered queries by owner, name, features |
| Mutation | Immutable; whole-array add/delete, dedup-on-write | Insert / delete with constraints |
| Best tool | HDF5 (chunked, compressed) | SQLite (indexes, transactions) |
Forcing both into one format would compromise one of them. Instead, each lives where it is
strongest, and the Store layer coordinates them.
The Array Side: HDF5
Arrays live under time_series/single/ in the HDF5 file, in one of two storage modes.
Stored arrays are immutable: a value array is added or deleted as a whole and is never edited in place — there is no API to mutate a row, slice, or column of an array already in the store. Changing data means writing a new array (content-addressed and deduplicated on write) and, if desired, deleting the old one.
Packed mode holds SingleTimeSeries (and the backing array of a
DeterministicSingleTimeSeries). Arrays that share a (dtype, element_shape, length, resolution)
are packed together as columns of one dataset named sts_{dtype}_{shape}_{length}_{res}, with shape
(length, cols, *element_shape). The column count cols is sized to the batch that created the
dataset (capped so one chunk stays within a byte budget) — where "the batch" is a bulk add's items
or the whole span of an open transaction; only an incremental one-at-a-time write outside a
transaction uses the default width of 1,000:
flowchart TB
subgraph ds["dataset sts_f64_s_8760_PT1H shape (8760, cols)"]
direction LR
C0["col 0<br/>series A"]
C1["col 1<br/>series B"]
C2["col 2<br/>(free)"]
CN["col cols-1<br/>(free)"]
end
H["companion sts_f64_s_8760_PT1H_h<br/>cols hash strings"]
C0 -.hash.-> H
C1 -.hash.-> H
style C0 fill:#28a745,color:#fff
style C1 fill:#28a745,color:#fff
style C2 fill:#6c757d,color:#fff
style CN fill:#6c757d,color:#fff
style H fill:#6f42c1,color:#fff
- Columns are series, rows are timesteps. Chunking spans the whole width,
(rows, cols, *element_shape)— one row, so that one chunk is one timestamp across every column, unless the dataset is narrow enough that a single row would make the chunk too small to be worth one (file format). The layout favors bulk writes (a batch fills whole chunks in one pass) and reads across series by timestamp (one timestamp is a chunk per dataset, and a sweep costs the same whatever the row count). The reverse directions are the slow ones, by design: reading a single series in full touches every chunk band, and adding one series at a time rewrites a chunk band per timestep — which is why a run of single adds belongs inside a transaction, where they are buffered and written as one block instead. - A companion dataset holds the hashes. For each packed dataset there is a sibling
{dataset}_h, a(cols, 64)array ofu8; rowiholds the SHA-256 hex of columnias raw bytes, or is all-zero if the slot is free. This is the on-disk index the backend rebuilds on open. - Datasets spill when full into
…__1,…__2, and so on — when a batch exceeds the per-dataset column cap, or when incremental writes fill a default-width (1,000-column) dataset. - Compression is configurable at store creation and applies to every data variable (packed and
standalone). The default is DEFLATE (zlib) level 3 with the byte-shuffle filter; you may change
the level (0–9), disable shuffle, or turn compression off entirely. The choice is recorded in a
compressionglobal attribute and restored when the store is reopened for appends, so later writes reuse the same filter. Compression is a storage detail only — arrays decode transparently regardless of the filter, so stores written with different settings stay mutually readable and the data-format version is unaffected. In-memory stores ignore the setting.
Packed mode holds every static series. SingleTimeSeries (and the array behind a
DeterministicSingleTimeSeries) pool by resolution into sts_… datasets; the explicit-time-axis
types (NonSequentialTimeSeries and PersistentTimeSeries) pool by their timestamp vector into
nsts_… datasets, because the chunking is timestamp-major and so only means something for arrays on
a common time axis. Such a series carries that axis explicitly, and the store content-addresses it —
one tsv_{hash} dataset of unix milliseconds per distinct axis, in the file's own timestamps
group — so the interned hash is the cohort key, which is what lets a StaticReader sweep irregular
series the same way it sweeps regular ones.
Standalone mode holds the dense forecast arrays (Deterministic, Probabilistic, Scenarios)
and any explicit-time-axis series alone on its axis — a pool spreads one array over length chunks,
so a cohort of one is not worth packing. Each is its own typed, multi-dimensional variable
arr_{hex_hash} — no column packing and no companion hash (the variable name carries the hash).
Lone irregular series are shaped [length, *element_shape] and chunked whole; dense forecasts are
shaped [H, count, *element_shape] (with extra leading axes for Probabilistic / Scenarios) and
chunked in bounded blocks along the count (window) axis, so reading one forecast window
decompresses a single block rather than the whole array. The ForecastReader caches a block at a
time to match.
The file-format reference gives the precise naming and dimension scheme. Nothing on the array side distinguishes a forecast from a static series of the same physical shape — the type, timestamps, and windowing parameters all live in metadata.
The Metadata Side: SQLite
The catalog holds six tables. The first four describe time series; the last two record relationships between catalog entities and have nothing to do with time series at all.
time_series_associations— one row per(owner_id, owner_category, time_series_type, name, resolution, interval, features)association, including thedata_hashthat links it to a packed column or standalone variable, the array typing (dtype,element_shape), the opaque package-ownedapplication_datapayload, plus temporal fields, forecast parameters (horizon,interval,count,percentiles), the unit descriptors (units,quantity_kind,unit_system), thetime_referencerecording how the timestamps were spelled, thecomponent_fieldthe values vary over time, and thefeatures_hash.feature_sets— the expanded key/value pairs of a feature map, one row per key, typed by avalue_kinddiscriminator. The table is content-addressed, exactly as arrays are: its primary key is(features_hash, key)— the same hash the association row already carries, so no join column is needed. A feature set is therefore stored once and shared by every association whose hash matches, not copied per association; an empty map stores no rows at all. Because the rows are shared, there is deliberately no foreign key totime_series_associationsand noON DELETE CASCADE(see Compaction below).schema_version— a singleversioncolumn holding the catalog schema revision (CATALOG_SCHEMA_REVISION, currently2). Its own contract, independent of the artifact'sdata_format_version: a catalog change the idempotent DDL cannot make to an existing table needs a revision bump and an append-only migration. A catalog predating the stamp reads as revision1. See Upgrade a store in place.supplemental_attribute_associations— which supplemental attributes are attached to which components, as(component_id, component_type, attribute_id, attribute_type). Identity is the(component_id, attribute_id)pair.parent_child_associations— directed edges between components, as(parent_id, parent_type, child_id, child_type). Identity is the ordered(parent_id, child_id)pair.
The last two are described in
Associations Between Entities. They carry no
foreign keys and no cascade, and they are independent of time_series_associations in both
directions: removing a time series never touches them, and removing an association never touches a
series. They were also added without a data_format_version bump, so a store written before they
existed simply gains them on its first writable open — which is why every read of them tolerates the
table being absent.
A unique index over
(owner_id, owner_category, time_series_type, name, resolution, interval, features_hash) enforces
the identity uniqueness invariant at the database level.
owner_category is part of the key, so a component and a supplemental attribute that share an
owner_id are independent; interval is part of the key too, so forecasts of one variable that
differ only by interval are distinct. Because SQLite treats NULL as distinct in a UNIQUE index,
a second index folds NULL resolution and interval to a sentinel so series without them (e.g.
NonSequentialTimeSeries and PersistentTimeSeries, or any static series, which carry no interval)
are still constrained. Indexes on data_hash, (owner_id, owner_category), and resolution keep
lookups fast.
Keeping the Two Files Consistent
Because a write touches both files, Store follows a careful ordering so a failure cannot leave a
dangling reference:
sequenceDiagram
participant C as add_time_series
participant B as HDF5 backend
participant M as SQLite
C->>B: put_array(hash, data)
Note over C,B: idempotent on hash — staged for rollback
C->>M: BEGIN
C->>M: INSERT association
alt insert succeeds
C->>M: COMMIT
C-->>C: return TimeSeriesId
else insert fails (duplicate etc.)
C->>M: ROLLBACK
C->>B: remove_array(staged hashes)
C-->>C: return Err
end
- Array first, metadata second.
put_arrayis idempotent — calling it with an already-present hash is a no-op — so staging the array before committing metadata is safe. - Metadata commit is the point of no return. If the SQLite insert fails (most commonly a
DuplicateTimeSeriesconstraint violation), the transaction rolls back and any array column staged in this call is removed, returning the store to its prior state. - Bulk writes are all-or-nothing.
add_time_series_bulk(and the bufferedbulk_addsession) group packed series by shape and stage each group as one batch-sized block — filling whole chunks — then insert every association in one transaction; any error rolls the whole batch back and removes the staged arrays. A pool the batch gives a single array is the exception: sizing a dataset to it would claim a one-column dataset, so that array fills a shared pool's slot instead, as a single add does — or, for an irregular series, is written as a standalonearr_dataset unless the file already holds a pool for its time axis (see Packed mode). A one-item batch is the commonest case of it. - Single adds inside a transaction take the block path too. Nothing a transaction wrote is durable until its outermost commit, so its packed adds are buffered per pool and written together by the same block writer at the commit — bounded per pool by a column width and across all pools by a byte ceiling, and with a span holding one array for a pool falling back to a growth-pool slot by the same rule. See Make Transactions Span Operations.
On delete, the order reverses and is reference-counted: the association rows are removed inside a
transaction, then an array column is only zeroed/freed if no remaining association references that
hash. This is what lets two keys share one array safely. Feature sets are
shared too, but they are not reference-counted — deleting an association never deletes its
feature set; the set is left unreachable for a later compact() to sweep.
Persistence and Copying
The HDF5 backend buffers writes. Call flush() (which issues H5Fflush) before copying the files
for backup or archival; afterward both system.h5 and system.h5.sqlite can be copied as a pair
without closing the handle. The two files must always be kept together — neither is usable alone.
Protecting a Saved Artifact
The dangerous moment for a store is not the crash. Every path that writes an artifact stages to a temporary sibling and renames, so a power loss leaves either the old file or the new one, never a half-written one. What actually destroys a saved store is an ordinary call aimed at a path that already holds one.
Creating over an existing store is refused. Creating truncates the HDF5 file but only opens
the catalog beside it, then stamps both halves with one fresh generation. Left unguarded, a build
script re-run against last week's output produced an empty array file paired with the old catalog's
rows — a store that opens cleanly, reports every series still present, and has nothing behind any of
them. Nothing short of verify_integrity() notices. create therefore fails with StoreExists if
either half is already at the path; the check covers both, because an orphaned catalog poisons fresh
arrays exactly as an orphaned HDF5 file poisons a fresh catalog. Discarding the destination on
purpose is a separate, explicitly named call (Store::create_replacing, overwrite=True in Python,
overwrite=true in Julia), which removes both halves and the catalog's sidecars first.
Working on a Copy
open() defaults to read-write in every binding. That is the one place the library will damage
a file you care about: mutations land in the artifact directly, and HDF5 has neither a journal nor a
repair tool, so an interrupted write there is unrecoverable.
open_copy(src, dest) copies both halves and opens the copy, leaving the source byte-for-byte
alone. Change the copy, then persist_to(src) — the original is only replaced by the final atomic
rename. Both shipped consumers (infrasys and InfrastructureSystems.jl) already work this way; the
call exists so the pattern lives in one place rather than being reimplemented per consumer.
Reserve a read-write open() on a user's artifact for when in-place mutation is genuinely what you
mean, and prefer read_only=True for anything that only reads.
One writer, and not on a network filesystem
The store assumes a single writer. On an ordinary filesystem HDF5's file lock enforces most of that
for you — this build links HDF5 2.0 with locking on. But it is configured best-effort: where the
filesystem reports that locking is unavailable, HDF5 proceeds without it and says nothing. Lustre,
GPFS, and NFS without lockd all land there, and they are exactly where large runs live. SQLite's
WAL journaling is likewise unsafe over NFS.
So: keep a live store on local disk, let one process write it, and copy the finished artifact to shared storage afterwards. Two concurrent writers on a filesystem without working locks will corrupt the HDF5 file, and no amount of care inside this library can prevent it.
Inside one process the library enforces the rule itself, and more strictly than the lock does: a
second Store on a path that is already open fails with StoreInUse whatever its mode, and so do
create_replacing, open_without_catalog, persist_to, and persist_arrays_to aimed at a path
another handle holds. A read-only handle is not exempt because its map from content hash to packed
column is built once at open — after the writer removes a series and reuses the slot, that map
points a live hash at another series' values, and libhdf5 sharing one file object between the two
opens makes the reader's cache agree with it. The HDF5 lock does not see this case at all, and it is
the easier mistake to make: an unclosed handle in a notebook or REPL, a fixture and a test body, a
read-only handle opened for a report beside the writer. Close the handle you hold before opening
another.
verify_integrity() is the backstop. Every array carries a SHA-256 companion, and the check
re-reads and re-hashes all of them, so corruption is detectable even when it is not preventable —
worth running against an artifact whose history you do not trust. The CLI exposes it as
infrastore verify.
Where the Catalog Lives
The catalog does not have to be the .sqlite file while a store is open. CatalogMode picks
between two placements, independently of where the arrays live:
| Mode | Catalog | Durability |
|---|---|---|
Attached | is the <path>.sqlite file | every commit, as soon as the OS writes back |
InMemory | held in RAM, loaded on open | only at persist_to — a crash loses it |
Attached is the default and what a long-lived on-disk store wants: the CLI mutates one command per
process and relies on each one landing.
The array half has its own moment of durability, because libhdf5 writes its caches back lazily while
a catalog commit lands at once. Every write call that put new arrays into the file flushes it before
committing the rows that name them, so a process killed right after the call returns leaves both
halves agreeing. Inside a transaction the flush is deferred to the outermost commit, and a call that
wrote nothing new (a re-add of content the store already holds) skips it. The flush is not free — a
caller adding series one at a time pays it per call — and a bulk add or a transaction is how to pay
it once for many writes. A transaction also holds that span's packed adds in a per-pool buffer and
writes them as one block at the commit, so they land in the file the way a bulk add's do. InMemory
suits a consumer that builds a store in a scratch directory beside its own volatile state — a
System under construction, say. A crash loses that state regardless, so journaling the scratch
catalog buys nothing, and skipping it removes per-commit WAL and fsync work. Arrays still stream to
the HDF5 file, so this does not require the data to fit in memory. Nothing is durable until
persist_to.
Two caveats. Opening with InMemory reads <path>.sqlite into RAM but still opens the HDF5 half
in place, so mutations land in the original file; a caller that means to leave the source
untouched until an explicit save wants open_copy (see Working on a Copy).
And a scratch store that never reached its first save is a half-artifact: a stamped HDF5 file with
no catalog. Reopening it as Attached creates a fresh, unstamped catalog beside it, and the
paired stamp rejects that combination — which is the right answer,
because the arrays are there but nothing names them.
persist_catalog() is the cheap way to land an in-memory catalog when the arrays are already in
their final place. persist_to aimed at another path has to write the arrays again;
persist_catalog writes only the .sqlite half, stamped to match the HDF5 file already sitting
beside it. That is what makes InMemory usable for what it is good for — skipping per-commit
journaling during a bulk load — without paying a full copy of the arrays to land the result. It is a
checkpoint, not a mode switch: the catalog stays in RAM, and later changes are again RAM-only until
the next call.
Saving: One Pair, Two Renames
persist_to writes both halves to temporary siblings, fsyncs them, and only then renames them into
place, so a crash before the first rename leaves the destination untouched.
The renames cannot be made atomic together — POSIX renames one path at a time. A generation
stamp covers the gap: each save mints a fresh value and writes it into both the HDF5
catalog_generation root attribute and the catalog's catalog_identity table. A crash between the
two renames therefore leaves halves whose stamps disagree, and the next open fails with
MismatchedArtifact instead of reading a store that quietly contradicts itself. The same check
catches one half being copied without the other.
What the stamp does not give you is a destination that survives a failed save. The renames replace
the target, so a crash between them destroys whatever pair was there before. That is a deliberate
trade — loud, detectable loss beats silent corruption. Do not assume the destination is intact
after a failed persist_to; recover by saving again from the store, which is still live and
unchanged.
A store written before the stamp existed carries neither half of it. That is the one legitimate
unstamped state, and it opens. One stamped half is not: every path that writes a stamp writes
both halves together (create, persist_to, and compact, which carries the existing one across),
so a lone stamp means a half was replaced, copied, or created without its partner. It is rejected as
a mismatch, which closes the migration-window hole where a save interrupted between its two renames,
onto a destination predating the stamp, would otherwise have paired new arrays with an old catalog
in silence.
Each save stages through a sibling named uniquely to itself (<target>.persist-<tag>). A fixed name
would be a corruption vector rather than a convenience: nothing locks a persist_to
destination, so two processes saving to one path would each clear the other's in-flight temp
while the stamping and rename that follow still resolve that name — publishing a partially written
file as a finished save. The cost of uniqueness is that an interrupted save's temps are no longer
swept by the next one; a temp belonging to a live concurrent save cannot be told apart from an
abandoned one, so they are left for you to delete once no save is in flight.
The swap also clears any -wal/-shm sidecar beside the destination catalog. A sidecar there
belongs to the catalog being replaced — a writer that crashed at that path leaves one — and SQLite
would recover it over the database renamed into its place, resurrecting the replaced catalog's pages
so the save silently would not take.
Saving an Attached store onto its own path is a no-op: the destination already is that store,
and the flush at the start of persist_to has made it durable. The InMemory counterpart is real
work rather than a no-op — the arrays are already at path and the save is what writes the catalog
beside them, which is exactly the scratch-directory workflow.
Compaction rewrites only the HDF5 half, so it carries the existing stamp into the rewritten file rather than minting a new one — a fresh stamp there would manufacture exactly the mismatch the stamp exists to detect.
Compaction
compact() reclaims space in both halves of the artifact, and the two halves behave differently.
On the array side, compaction rewrites the file. Deleting a series frees its column slot (reused
transparently by the next compatible write, via first_free) or unlinks its standalone dataset, but
HDF5 cannot hand either back to the filesystem in place, so neither shrinks the .h5. Compaction
therefore materializes every array the catalog still references into a fresh sibling file and
renames it over the original, reopening the store on the result. What does not survive the trip:
freed slots, datasets nothing references, and the slack in packed pools sized for growth rather than
for the cohort actually stored. The report says how much went — slots_reclaimed,
datasets_dropped, and bytes_reclaimed (how much smaller the file got).
Because the file is replaced, compaction assumes the compacting process is its only user. That is the store's single-writer model in general; the difference is that here a concurrent reader on Unix silently keeps reading the pre-compaction file, and on Windows the rename fails outright.
Compaction also sweeps the shared sets. Because feature sets and timestamp vectors are shared,
deleting an association cannot cascade into them: removing the last association that referenced one
leaves it unreachable. compact() deletes both — the feature set as a catalog row, the timestamp
vector as an unlinked dataset — and reports the counts as feature_sets_reclaimed and
timestamp_sets_reclaimed, before the rewrite, so the rewrite's liveness scan sees what survived.
(Clearing is the exception on both counts: it orphans every feature set and every axis by
construction, so it reclaims them outright rather than waiting for a compaction a cleared store may
never get.)
See compact.
Content Addressing
Every array is identified by the SHA-256 hash of its contents, not by a name or an ID. Two series with byte-identical values therefore resolve to the same hash and are stored exactly once. This is the mechanism that lets many series share one underlying array.
The Array Hash
array_hash produces a deterministic 32-byte digest from a
TypedArray. The hashed byte stream is, in
order:
- A dtype tag: the dtype name (
f64,f32,i64,i32,i16,i8,u64,u32,u16,u8,bool) followed by a NUL byte. - The shape: the rank as a little-endian
u64, then each dimension as a little-endianu64. - The elements in row-major order. Float dtypes canonicalize
NaNto a single quiet-NaNbit pattern before hashing; integer andboolbytes are hashed verbatim.
flowchart LR
T["dtype tag<br/>e.g. f64\\0"] --> S["rank + dims<br/>(u64 LE each)"]
S --> E["elements<br/>(typed LE, row-major)"]
E --> H["SHA-256"]
H --> D["32-byte hash"]
style T fill:#6f42c1,color:#fff
style S fill:#6f42c1,color:#fff
style E fill:#6f42c1,color:#fff
style H fill:#4a9eff,color:#fff
style D fill:#28a745,color:#fff
Identity is therefore (dtype, shape, content):
- dtype and shape are part of the identity. Two arrays with the same numbers but a different dtype, or a different shape, never collide. Reshaping or retyping changes the hash.
NaNis canonicalized (float dtypes only). AnyNaN, regardless of its payload bits, hashes as a single canonical quiet-NaNpattern, so semantically equal float arrays never collide onNaNrepresentation.
The Features Hash
Feature maps are hashed by features_hash with the same discipline: a domain tag, the entry count,
then, in the BTreeMap's sorted-by-key order, a length-prefixed key plus a kind tag and the value
bytes for each entry. Because the map is always sorted, insertion order does not affect the hash:
{"model_year": 2030, "scenario": 1} # hashes identically to
{"scenario": 1, "model_year": 2030}
The features hash does double duty in the catalog:
- It identifies the association. It is stored in the
features_hashcolumn and is part of the metadata uniqueness index — it is how the database distinguishes two otherwise-identical associations that differ only in their features. - It is the key of the feature set. The
feature_setstable is keyed by(features_hash, key), so a feature map is content-addressed exactly as an array is: stored once and shared by every association whose hash matches. The association row already carries that hash, so no join column, foreign key, or association id is needed. An empty map stores no rows at all.
Sharing is not a rare case, it is the common one. Thousands of components typically carry the same
feature set (often the empty one, or a single scenario tag), and it collapses to one copy. The
sharpest example is a DeterministicSingleTimeSeries: it is a view over the SingleTimeSeries it
was derived from and has exactly the same features, so transform_single_time_series writes
zero feature rows — it reuses the set the source series already stored, just as it reuses the
source's array.
The Timestamps Hash
A NonSequentialTimeSeries carries its timestamps explicitly — as does a PersistentTimeSeries,
whose breakpoints are the same thing under a different reading — and those are content-addressed
too, by timestamps_hash: a domain tag, the count, then each instant as a unix millisecond count —
the same values the store writes, so the hash addresses the stored form rather than a second
serialization of it. Two vectors hash equal exactly when they hold the same instants in the same
order.
Like the features hash, it does double duty:
- It names the stored vector. Each distinct time axis is one
tsv_{timestamps_hash}dataset in the HDF5 file, so an axis shared by a thousand irregular series is stored once. Sharing is again the common case, not the rare one: irregular series in this domain sit on a schedule — outage windows, market intervals, event times — that many components observe together. Unlike the features hash, this one addresses something in the array half of the artifact: a timestamp vector is data, and the catalog holds only its hash. - It is the cohort key of the packed array layout. Column-packing is only meaningful for arrays
on a common time axis, since the chunking is timestamp-major; the interned hash is precisely the
answer to "do these two series share one?", so the arrays of a cohort pool into one
nsts_{dtype}_{shape}_{length}_{timestamps_hash}dataset. This is what lets aStaticReadersweep irregular series a timestamp at a time, exactly as it does regular ones.
Deduplication on Write
All three hashes deduplicate, by the same mechanism, on the same write.
The array. Store hashes the array and asks the backend whether that hash is already present:
- Present → the existing array is reused; no new array bytes are written. Only a new metadata association row is inserted.
- Absent → the array is written. A packed array (either static type) goes into the first free
column of a compatible
sts_…/nsts_…dataset, recording its hash in the companion hash variable; a standalone array (a dense forecast, or an irregular series alone on its time axis) becomes a newarr_{hash}variable.
The feature set and the timestamp vector. The association insert
(MetadataStore::insert_batched) writes each under its hash with an INSERT OR IGNORE, so one some
other association already stored is a no-op — equal hash implies equal content, so an ignored
conflict cannot hide a different set behind the same hash. Within one batch a SharedSetCache
remembers what has already been written, so a bulk add issues one write per distinct set rather
than a no-op statement per row. This is what keeps a transform flat in feature count instead of
linear in it, and a cohort of irregular series flat in timestamp count.
So storage cost scales with the number of distinct arrays, feature sets, and time axes, while metadata cost scales with the number of associations. A profile shared by a thousand generators costs one array, one feature set, and a thousand small rows.
Deletion is Reference-Counted
Because arrays are shared, deleting an association cannot blindly delete its array. On
remove_by_ids (and clear_time_series), Store:
- Deletes the matching association rows inside a SQLite transaction, collecting their
data_hashes. - For each freed hash, counts how many associations still reference it.
- Only frees the HDF5 column for hashes whose reference count has dropped to zero.
This keeps shared arrays alive until the last referencing key is gone.
The shared catalog rows are the deliberate exception
Feature sets and timestamp vectors are shared by the same mechanism but are not reference-counted, and the symmetry stops there. Counting references on every delete would make deletion pay for a scan the array side already pays for, to reclaim a handful of rows; the schema instead accepts unreachable rows and sweeps them in bulk. Concretely:
- No foreign key, no
ON DELETE CASCADE. A cascade would be actively wrong: rows are shared, so deleting one association must not delete a set another association still uses. - Deleting the last user leaves the row unreachable, rather than deleting it — the same end-state as the HDF5 side's freed packed slots and unlinked datasets, whose space also lingers until a compaction.
Store::compactsweeps them. It deletes every feature set and every timestamp vector no association references any more, reporting the row counts asfeature_sets_reclaimedandtimestamp_sets_reclaimed. On an on-disk store the same call then rewrites the.h5file, so the array side is reclaimed too.- Clearing the whole store drops them all outright, since a cleared store orphans every row by construction and may never see a compaction.
The practical consequence: an unreachable row is never read (every lookup goes through an
association's features_hash or timestamps_hash), so it costs bytes, not correctness — and a
re-added association with the same features or timestamps silently adopts the row that was still
sitting there.
Discovering Shared Series
Sharing is observable on read, not just an internal write optimization. Every metadata row carries
its array's data_hash, so a caller can group series by that hash to learn which ones resolve to
the same stored array — without reading any array bytes. Two cases land in the same group: arrays
that were deduplicated because their content was identical, and a SingleTimeSeries together with
any DeterministicSingleTimeSeries derived from it (the DST shares the backing array).
In the Julia binding, list_metadata returns TimeSeriesMetadata rows, each carrying a data_hash
field (the 32 raw bytes, which hash and compare by content and so work directly as a Dict key);
group by it to find the shared sets. count_array_references reports, for one hash, how many
SingleTimeSeries and DeterministicSingleTimeSeries associations point at it.
groups = Dict{Vector{UInt8}, Vector{TimeSeriesMetadata}}()
for row in list_metadata(store)
push!(get!(groups, row.data_hash, eltype(values(groups))[]), row)
end
shared = filter(((_, rows),) -> length(rows) > 1, groups) # arrays referenced by >1 series
This read-side view is what lets a downstream caller collapse work across owners that share data.
The ForecastReader builds on the same grouping internally: forecasts that share an
array (and read plan) are read from disk once per timestamp no matter how many components
reference them — see
Window-read deduplication. (This is exactly
how InfrastructureSystems.jl backs its own get_shared_time_series and forecast reader.)
Stability is a Contract
These hashes are part of the on-disk format. Any change to a hashing domain above that perturbs a
stored hash is a format-breaking change and must bump
DATA_FORMAT_VERSION. Treat the hashing rules above
as fixed, not as an implementation detail.
The golden_hash_pin integration test guards the array domain by pinning the exact SHA-256 of one
fixed input (an f64 array of [0, 1, 2, 3]). That is a tripwire, not a proof: it covers a single
dtype, shape, and value set, and it does not pin features_hash at all. A change to the hashing
rules can therefore break the format without breaking that test — the reasoning above, not the test,
is the contract.
Integrity Verification
Because the stored hash and the stored data are independent on disk, they can be cross-checked.
verify_integrity takes the catalog as the statement of what should be there, then checks the HDF5
half against it: it collects every array and every explicit timestamp vector the catalog references,
reads each one back, recomputes array_hash / timestamps_hash from the stored values, and reports
any mismatch between the recorded hash and the recomputed one — detecting silent corruption. A
reference the file cannot satisfy is reported too, as a dangling one. See
verify_integrity.
What it does not cover
The check runs from the catalog into the HDF5 file, and only in that direction. It never checks the catalog against itself, so a clean report is a statement about the stored content, not about the store as a whole. Three things fall outside it:
- a catalog row whose
dtype,element_shape, orlengthmisdescribes the array it points at. The hash addresses the array's own content, so an array that matches its hash passes the check while the row goes on lying about it. - a missing catalog entirely. The two artifacts are one logical store, but nothing enforces that:
opening read-write with the
.sqlitehalf deleted silently recreates it empty, and the resulting store — zero time series, every array still present and now unreachable — verifies clean, because a catalog that references nothing is a clean bill of health here. - anything the catalog does not reference. The sweep never reaches it, whatever state it is in; an
unreachable array is the expected state after a delete, and
compactis what reports it.
This is a deliberate scope rather than an oversight: content corruption is what happens silently,
and the catalog has its own purpose-built checks — check_static_consistency for per-resolution
grid agreement, and compact for the unreachable arrays and feature sets a delete leaves behind,
which it reclaims and reports (an expected state, documented in the
file format, not corruption). SQLite also enforces a good deal
itself: the NOT NULL and CHECK constraints, and the two unique indexes that guarantee identity
uniqueness.
If you need end-to-end assurance that a copied or restored store is intact, the operational rule in
the file format is the one that matters: move, copy, and delete the
.h5 and its .sqlite together.
Language Bindings
Every interface wraps the same Store. Understanding how each binding bridges to the core explains
why the APIs look the way they do, how errors propagate, and what each layer owns.
flowchart TB
PYAPP["Python code"] --> PYO3["PyO3 classes<br/>(infrastore_py)"]
JLAPP["Julia code"] --> JLPKG["InfraStore.jl"]
JLPKG -->|"ccall"| CABI["C ABI<br/>(infrastore_ffi)"]
RUSTAPP["Rust client code"] --> RC["RemoteClient"]
RC -->|"gRPC / HTTP2"| GS["gRPC server"]
PYO3 --> STORE["Store"]
CABI --> STORE
GS --> STORE
style STORE fill:#28a745,color:#fff
style PYO3 fill:#17a2b8,color:#fff
style CABI fill:#9558b2,color:#fff
style JLPKG fill:#9558b2,color:#fff
style GS fill:#ffc107,color:#000
style RC fill:#ffc107,color:#000
Python (PyO3)
infrastore-py uses PyO3 to expose Store as native Python classes in a module
importable as infrastore. The binding:
- Converts Python
datetime/timedeltatochronotypes and NumPy arrays (any shape) toTypedArrays at the boundary, supporting the full dtype set (f64,f32, the integer widths,bool). - Translates the typed
TimeSeriesErrorvariants into a Python exception hierarchy rooted atTimeSeriesError(NotFoundError,DuplicateTimeSeriesError,InvalidParameterError,IntegrityError,ReadOnlyStoreError). - Builds an
abi3-py311wheel, so one wheel works across CPython 3.11+ without recompiling. - Converts a static series to a
pyarrow.Tablewithto_arrow(), behind the optionalarrowextra. This is the one place a binding reaches past numpy, and it is optional for that reason: pyarrow is several times the size of the wheel that would pull it in. What makes Arrow worth the seam is that itstimestamp(unit, tz)is the same shape as the store's own model — an instant plus the spelling it was written in — so a table keeps a distinction pandas would flatten.
The metadata side is owned entirely by Rust; Python never touches SQLite directly. See the Python guide and Python API reference.
Julia (C ABI)
Julia does not call Rust directly. Instead, infrastore-ffi compiles a C-compatible cdylib with an
opaque-handle API, and InfraStore.jl ccalls into it.
flowchart LR
JL["InfraStore.jl<br/>structs hold Ptr{Cvoid}"] -->|"ccall infrastore_store_*"| LIB["libinfrastore_ffi"]
LIB --> STORE["Store"]
LIB -.->|"infrastore_last_error_message"| JL
style JL fill:#9558b2,color:#fff
style LIB fill:#6f42c1,color:#fff
style STORE fill:#28a745,color:#fff
The conventions that shape the Julia API:
- Opaque handles.
InfraStoreandInfraStoreKeyare pointers; the Julia structs wrap them and register finalizers (close!,_finalize_key) that call the matchingts_*_freefunction. - Status codes plus thread-local error messages. Every C function returns an
int32_tcode. On a non-zero code, Julia callsinfrastore_last_error_messageto retrieve the detail string and raises the matching Julia exception type. - Out-parameters and caller-owned buffers. Arrays come back through an out-pointer plus a length
and a dtype code; Julia copies them into a
Vector{T}for the requested element type and frees the Rust buffer with the deallocator matching the buffer's element type —infrastore_buffer_free_f64,infrastore_buffer_free_u8,infrastore_buffer_free_i64, orinfrastore_buffer_free_u64(shape/dims buffers). - Features cross as JSON. Julia serializes the feature dict to a JSON string, which the FFI
layer parses into a
Featuresmap. - Forecasts are wrapped.
InfraStore.jlexposesDeterministic/Probabilistic/Scenariosstructs passed to the genericadd_time_series!, id-addressedread_by_idgetters, andtransform_single_time_series!, so all four forecast types are usable from Julia. - Bulk reads use a result handle.
read_by_idsreads many fullSingleTimeSeriesat once: the FFI fetches them in one decompress-once pass per dataset into aInfraStoreBulkReadHandle(infrastore_store_bulk_read_single), and Julia reads each element out, then frees the handle. Python'sstore.read_by_idsexposes the same operation directly.read_by_idsaddresses the same read by catalog association id and fills the same handle, so both reads decode by the same route:infrastore_bulk_result_item_namehands each item's name back beside its values, asinfrastore_bulk_result_item_typedoes its type. Managed bulk writes already take the fast block-write path through the existing batch /add_time_series_bulkAPIs.
InfraStore.jl loads the cdylib from the INFRASTORE_LIB environment variable when it is set, and
otherwise from the libinfrastore_ffi artifact its Artifacts.toml pins to the matching GitHub
Release (see Integrate with Julia). See the
Julia guide, the C ABI reference, and the
Julia API reference.
InfrastructureSystems.jl Integration
The model was shaped to drop into InfrastructureSystems.jl: owners are identified by integer
component identifiers (i64), owner categories map to Component / SupplementalAttribute, and
features accept string values so InfrastructureSystems.jl's feature dictionaries round-trip
unchanged. The FFI exposes an attribute-based existence probe (infrastore_store_has_any_by_filter)
and removal (infrastore_store_remove_by_ids), a whole-record metadata read
(infrastore_store_get_metadata_by_id, reachable from attributes through
infrastore_store_list_metadata), and a hash-based array fetch
(infrastore_store_get_array_by_hash) so an InfrastructureSystems.jl-side store can keep its own
key objects and reach the array layer directly.
gRPC Server and Client
infrastore-server wraps a Store in a tonic gRPC service generated from infrastore-proto. It
exposes a read-only slice of the API and adds optional API-key auth. The matching async
RemoteClient mirrors the read methods and maps gRPC Status codes back to
TimeSeriesError::ConnectionError, so remote calls surface the same error type as local ones.
Writes are deliberately not exposed over gRPC — they require local filesystem access. The server is for fan-out reads of an existing store. See the gRPC Server guide and the gRPC API reference.
CLI (infrastore)
infrastore-cli builds the infrastore binary, a thin wrapper over the core Store for use from a
terminal. Unlike the gRPC server it is not read-only: it opens the on-disk .h5 + .h5.sqlite
pair directly and supports both reads and writes. Its shape:
- CSV in, store out. Numeric values come from a CSV; the metadata that does not fit a flat grid (owner, name, type, dtype, resolution, timestamps, units, features) is described in a descriptor JSON. All six dtypes and all six writable types are supported, forecasts included.
- A global
-f/--formatselectstable(default),json,jsonl, orcsv. Read commands render their results in it; write commands report their outcome in it (prose undertable, a one-object status document underjson/jsonl). Onlytemplateignores it. - Store access is isolated. All store opening lives behind one module, so a future remote/gRPC mode can be added without touching the command handlers; today there is no remote mode.
See the CLI guide and the CLI reference.
What Every Binding Shares
| Concern | Single source of truth |
|---|---|
| Types & validation | infrastore-core (Store, TimeSeriesId, Features) |
| On-disk format | Hdf5Backend + MetadataStore — identical regardless of caller |
| Hashing | array_hash / features_hash — the cross-language contract |
| Error taxonomy | TimeSeriesError, re-projected into each language's idiom |
A file written by Python reads identically from Julia, Rust, or the server, because none of the bindings reimplement storage — they all funnel through the one core.
Feature Coverage Varies by Binding
The bindings funnel through one core, and the surface is now broadly consistent. Both static series types are available everywhere (read+write, except the read-only gRPC server), and forecasts read back across every interface. The remaining asymmetry is that the read-only gRPC server does not accept any writes:
| Capability | Rust core | C ABI | Python | Julia | CLI | gRPC |
|---|---|---|---|---|---|---|
SingleTimeSeries r/w | ✅ | ✅ | ✅ | ✅ | ✅ | read-only |
NonSequentialTimeSeries r/w | ✅ | ✅ | ✅ | ✅ | ✅ | read-only |
PersistentTimeSeries r/w | ✅ | ✅ | ✅ | ✅ | ✅ | read-only |
dtypes beyond f64 | ✅ | ✅ | ✅ | ✅ | ✅ | read-only |
| Create forecasts | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ |
| Read forecast values | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| Forecast metadata / counts | ✅ | ✅ | ✅ | ✅ | ✅ | list/counts |
| Readers (columnar sweep) | ✅ | ✅ | ✅ | ✅ | grid | ❌ |
| Association catalogs | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ |
| Store attributes | ✅ | ✅ | ✅ | ✅ | store-attr | read-only |
| Materialized timestamps | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ |
from_timestamps (verified) | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ |
Arrow tables (to_arrow) | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ |
| Parquet files | crate | ❌ | ❌ | ❌ | -f parquet | ❌ |
Store summary (show) | ❌ | ❌ | ✅ | ❌ | store-info | ❌ |
| Forecast windows as Arrow | ❌ | ❌ | Deterministic | ❌ | ❌ | ❌ |
The only gap is by design: writes (including forecasts added through add_time_series) require
local filesystem access, so the read-only gRPC server serves forecast reads but not writes.
show() is Python-only for now: it is a REPL affordance, and the REPL each binding is used from
already has one of its own — Julia has Base.show, and the CLI has store-info plus the list
family. It composes existing catalog aggregate queries and adds no core API, so any binding that
wants it can grow one without a change underneath.
Parquet lives in a crate of its own, infrastore-parquet, which the CLI depends on behind a
cargo feature that is on by default — the infrastore binary anyone installs can read and write
Parquet, because handing an analyst a file for DuckDB or polars is an ordinary reason to reach for
the CLI. The feature stays switchable (--no-default-features --features vendored builds a lean
binary), and the line it draws is between the binary and the libraries: infrastore-core,
infrastore-py, and infrastore-ffi never link Arrow, which cargo tree --edges normal on each is
the check for. The CLI is the only surface that reads and writes Parquet files
(export -f parquet, add --parquet): a normalized, partitioned layout of two files per
partition — a values file holding each distinct array once and a series file holding the catalog
rows that name it, joined on (data_hash, time_axis) — specified in the
Parquet layout reference. Python's to_arrow() / from_arrow are
a different, in-memory thing — one two-column table per series, with the descriptors in the schema
metadata rather than in columns — and are not a reader or writer for the CLI's files; a Python user
who wants one writes the per-series table with pyarrow.parquet, or hands the CLI's directory to
DuckDB or polars. The relationship between the two is one sentence: a series file's columns are
to_arrow()'s metadata keys turned into columns, and the values file is its two columns keyed by
the array. Nothing else has it: the C ABI and Julia would need the whole Arrow tree in the cdylib
for a format their host languages already have readers for, and the gRPC server serves values, not
files.
Materialized timestamps and from_timestamps both run in the core and reach Julia through
two stateless ABI entry points, infrastore_grid_timestamps and infrastore_infer_period. That
matters more than it looks: Julia is the one binding whose date library has calendar arithmetic of
its own, and whose TimeZones overload steps a local clock the core deliberately does not — so a
binding-side reimplementation would agree with the core only by luck. There is one implementation of
"which instants does this series contain" in the project, and it is Period::add_to. Arrow
tables are Python-only because Arrow is where the Python data ecosystem meets; the Julia
counterpart would be a Tables.jl interface, which is a different contract and not yet asked for. A
Deterministic converts through to_arrow_windows() into one table per window rather than one
table, because its two grids — windows stepping by interval, rows stepping by resolution —
overlap; Probabilistic and Scenarios wait on a decision about how to spell their third axis.
Developer Guides
These guides are written for developers building on infrastore from a specific language. Each one starts at installation and walks the full workflow — create or open a store, add series, query, read, and persist — with the idioms of that language, then points at the matching reference for exact signatures.
- Rust — Embed
infrastore-coredirectly. - Python — Use the
infrastorePyO3 wheel. - Julia — Use the
InfraStore.jlpackage over the C ABI. - CLI — Load and inspect a store from a terminal.
- gRPC Server & Client — Serve a store for remote readers.
- Benchmarks — Measure bulk-add and simulation-loop read performance.
If you are building a package on top of infrastore, read Embedding in a Parent Package first: it collects the contracts and the lifecycle these guides only show the individual calls for.
For the concepts underneath all of them, see the Explanation section — especially Time-Series Types, Data Model, and Readers, which apply equally to every binding.
Rust Developer Guide
This guide covers using infrastore-core from Rust. For exact signatures see the
Rust API reference.
For a runnable end-to-end round-trip, crates/infrastore-core/examples/basic.rs creates an
in-memory store, adds a SingleTimeSeries, and reads it back:
cargo run --manifest-path crates/infrastore-core/Cargo.toml --example basic
Add the Dependency
The crate is part of this workspace. From another crate in the workspace:
[dependencies]
infrastore-core = { path = "../infrastore-core" }
chrono = "0.4"
You will use chrono::Duration/DateTime<Utc> for time and the crate's
TypedArray for values, since those are the types
the API speaks.
Open or Create a Store
#![allow(unused)] fn main() { use std::path::Path; use infrastore_core::Store; // In-memory (tests, scratch work): no filesystem I/O. let mut store = Store::create(None, true)?; // On disk: writes system.h5 and system.h5.sqlite. let mut store = Store::create(Some(Path::new("system.h5")), false)?; // Reopen later, read-only. let store = Store::open(Path::new("system.h5"), /* read_only */ true)?; }
Add a Series
#![allow(unused)] fn main() { use chrono::{Duration, TimeZone, Utc}; use infrastore_core::{ Features, FeatureValue, OwnerCategory, SingleTimeSeries, TimeSeriesData, TypedArray, }; let initial = Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap(); let values: Vec<f64> = (0..24).map(|i| 100.0 + i as f64).collect(); let data = TypedArray::from_f64(vec![24], &values); // shape [length]; or [length, k1, ...] // The name is part of the series object, not an argument to `add_time_series`. let ts = SingleTimeSeries::new(initial, Duration::hours(1), data, "load"); let mut features = Features::new(); features.insert("model_year".into(), FeatureValue::Int(2030)); let key = store.add_time_series( 42, // owner_id "Generator", // owner_type OwnerCategory::Component, TimeSeriesData::SingleTimeSeries(ts), features, )?; }
add_time_series returns a TimeSeriesId:
the catalog row's id, which is both how every read and removal addresses the series and the one
integer to store in your own object model when it needs to point at it (see
Association ids). Adding a series whose identity
already exists returns TimeSeriesError::DuplicateTimeSeries.
Descriptors
The descriptive attributes ride on the series object, not on the write call, so a read returns what the write declared:
#![allow(unused)] fn main() { use infrastore_core::{TimeReference, UnitSystem}; let ts = SingleTimeSeries::new(initial, Duration::hours(1), data, "load") .with_units("MW") .with_quantity_kind("ActivePower") .with_unit_system(UnitSystem::NaturalUnits) .with_component_field("max_active_power") .with_time_reference(TimeReference::Zone("America/Denver".into())) .with_application_data(r#"{"source": "weather_year_2012"}"#); }
A native Rust caller declares time_reference itself. Every other binding infers it from the
input type — Python from tzinfo, Julia from DateTime versus ZonedDateTime — but
DateTime<Utc> is already an instant, so there is nothing to infer from. Leaving it unset means
unspecified, which is not a claim the timestamps were written as UTC. It is not inert: a query
bound must be spelled the way the series is, and a selection spanning both coherence groups is
refused. See Time References.
None of these descriptors is part of a series' identity or of either content hash, so two adds differing only in one are a duplicate.
Bulk inserts
For many series at once, add_time_series_bulk takes a Vec<AddRequest> and commits the whole
batch atomically — any error rolls back every array and association in the call:
#![allow(unused)] fn main() { use infrastore_core::AddRequest; let added = store.add_time_series_bulk(vec![ AddRequest { owner_id: 42, owner_type: "Generator".into(), owner_category: OwnerCategory::Component, data: TimeSeriesData::SingleTimeSeries(ts_a), // the series carries its own name features: Features::new(), units: Some("MW".into()), application_data: None, // opaque package-owned payload (e.g. JSON) ..AddRequest::new(42, "Generator", OwnerCategory::Component, TimeSeriesData::SingleTimeSeries(ts_b)) // the remaining descriptors unset }, // ...or just AddRequest::new(...).with_features(...) ])?; // `added` is one TimeSeriesId per request, in push order. }
A bulk insert is also the fast write path: packed SingleTimeSeries are grouped by shape and
written into batch-sized datasets so the timestamp-major HDF5 chunks are filled whole, rather than a
slow column-at-a-time write. When you'd rather stream requests instead of building one big Vec,
use a buffered session — it accumulates in memory and writes the same way on commit, discarding
the buffer if dropped without committing:
#![allow(unused)] fn main() { let mut bulk = store.bulk_add(); for (owner_id, ts) in many_series { // `add` builds the AddRequest from its parts (application_data = None); // `push` takes a prebuilt AddRequest when you need to set application_data. bulk.add(owner_id, "Generator", OwnerCategory::Component, TimeSeriesData::SingleTimeSeries(ts), Features::new(), Some("MW".into())); } println!("staging {} series", bulk.len()); // also: bulk.is_empty() let keys = bulk.commit()?; // consumes the session; keys in push order }
BulkAdd buffers requests in memory and does no validation or
I/O until commit, which is all-or-nothing. Dropping the session without committing writes nothing.
Adding series one at a time with add_time_series instead packs them incrementally into shared
default-width datasets; that stays space-efficient but writes each column with a read-modify-write,
so prefer a bulk insert or session when loading in volume.
The irregular types
NonSequentialTimeSeries carries an explicit instant per value instead of a grid. Its constructor
validates that the timestamps are strictly increasing and match the data length, and returns
Result<Self, String>:
#![allow(unused)] fn main() { use infrastore_core::NonSequentialTimeSeries; // Values that exist only at these instants — asking between them is an error. let outages = NonSequentialTimeSeries::new(instants, data, "forced_outage")?; store.add_time_series( 42, "ThermalStandard", OwnerCategory::Component, TimeSeriesData::NonSequentialTimeSeries(outages), Features::new(), )?; }
PersistentTimeSeries takes the same arguments and stores the same way — the vector is a set of
breakpoints — but reads as a step function: the value at an instant is the one belonging to
the greatest breakpoint at or before it, held forward past the last. Reaching back before the first
breakpoint is an error, not a clamp.
#![allow(unused)] fn main() { use infrastore_core::PersistentTimeSeries; // A monthly fuel price: twelve breakpoints, read at any simulation instant. let prices = PersistentTimeSeries::new(month_starts, data, "fuel_price")?; store.add_time_series( 42, "ThermalStandard", OwnerCategory::Component, TimeSeriesData::PersistentTimeSeries(prices), Features::new(), )?; }
PersistentTimeSeries::value_at reads one instant — prices.value_at::<f64>(t)? — carrying the
last breakpoint's value forward to it, with row_at as the shape-generic form and index_at /
breakpoint_at for the row it came from. A time-range read begins at the breakpoint in force at
start, so the slice always defines a value there. A columnar sweep is
StaticReader, which for this type alone lets its columns sit on
independent breakpoint vectors.
Values that are not numbers
A timestep's value can be a function rather than a number — a cost curve re-offered every hour, a
pair of coefficients, a fixed-width tuple. The array still holds f64; what those elements mean
is the series' element_type, and the values themselves are a
DecodedValues.
Build such a series with from_values rather than new. It encodes the values into the array and
declares the element type they imply:
#![allow(unused)] fn main() { use infrastore_core::{DecodedValues, ElementType, XyPoint}; // One input-output cost curve per hour; point counts may differ per timestep. let curves = DecodedValues::PiecewiseLinear(vec![ vec![XyPoint { x: 30.0, y: 1155.0 }, XyPoint { x: 100.0, y: 4120.0 }], vec![XyPoint { x: 30.0, y: 1353.0 }, XyPoint { x: 65.0, y: 2730.0 }, XyPoint { x: 100.0, y: 4223.0 }], ]); let ts = SingleTimeSeries::from_values(initial, Duration::hours(1), &curves, "variable_cost")?; assert_eq!(ts.element_type, ElementType::PiecewiseLinear); // derived, not declared }
That pairing is the point. An element_type and the array it describes are two independent things a
caller can get out of step — add_time_series rejects the mismatch, but only after the fact.
Deriving both from one input leaves nothing to get out of step, which is why from_values is
preferred over new + with_element_type for every composite series.
The read side is decoded_values, which takes the element type and the leading-axis count off the
value rather than asking for them:
#![allow(unused)] fn main() { let data = store.read_by_id(ts_id, ReadWindow::full())?; assert_eq!(data.decoded_values()?, curves); }
A plain numeric series decodes to DecodedValues::Raw — the stored elements already are the values,
so the array is the answer. That is a result, not an error.
Every series type has from_values. On a forecast it carries more weight, because the leading axes
are derived too: Deterministic::from_values fills [H, count] with H computed from
horizon/resolution, so the values are one flat list in row-major order over those axes and
nothing is reshaped by hand.
#![allow(unused)] fn main() { // H = 2 (a two-hour horizon at hourly resolution) x count = 2 windows = 4 curves. let forecast = Deterministic::from_values( initial, Duration::hours(1), Duration::hours(2), Duration::hours(1), 2, &curves4, "offer", )?; }
Probabilistic::from_values and Scenarios::from_values are the same with a third leading axis,
taken from percentiles.len() and scenario_count.
See Choosing a Type if you are deciding
between these and a SingleTimeSeries.
Read a Series
Every read takes a TimeSeriesId — the one an add returned, or one off a list_metadata row. The
row's own type decides what comes back:
#![allow(unused)] fn main() { use infrastore_core::ReadWindow; let data = store.read_by_id(id, ReadWindow::full())?; let single = data.as_single().expect("this row holds a SingleTimeSeries"); println!("{} values starting {}", single.length, single.initial_timestamp); }
Slice on the time axis with a range; end is exclusive, it clips to what is there, and the
returned series' initial_timestamp/length reflect the slice:
#![allow(unused)] fn main() { let start = Utc.with_ymd_and_hms(2024, 1, 1, 6, 0, 0).unwrap(); let end = Utc.with_ymd_and_hms(2024, 1, 1, 12, 0, 0).unwrap(); let window = store.read_by_ids_range(&[id], TimeRange::new(start, end))?.remove(0); }
A ReadWindow is the other half of that pair: it names exact steps (start plus a len of
timesteps or a count of windows) and is checked, so an over-long request is an error rather
than the smaller answer a range would clip to.
To read many whole series at once — say, loading everything for an interactive plot —
read_by_ids takes a slice of ids and returns a TimeSeriesData per id in order. It reads packed
SingleTimeSeries in one decompress-once pass per dataset, which is much cheaper than a
read_by_id each (a single full-series read otherwise touches every chunk under the timestamp-major
layout):
#![allow(unused)] fn main() { let series = store.read_by_ids(&ids, ReadWindow::full())?; }
Query Metadata
list_metadata takes a ListFilter builder; every clause is
ANDed, and the features clause is a subset match:
#![allow(unused)] fn main() { use infrastore_core::{ListFilter, OwnerCategory, TimeSeriesType}; let metas = store.list_metadata( ListFilter::new() .owner_id(42) .owner_category(OwnerCategory::Component) .time_series_type(TimeSeriesType::SingleTimeSeries) .name("load"), )?; for m in &metas { println!("{} {:?} units={:?}", m.name, m.resolution, m.units); } // Every row for one owner, an existence check, distinct resolutions, counts. // The owner is the (owner_id, owner_category) pair. let rows = store.list_metadata( ListFilter::new().owner_id(42).owner_category(OwnerCategory::Component), )?; let ids: Vec<_> = rows.iter().filter_map(|m| m.id).collect(); let present = store.association_exists(ids[0])?; let resolutions = store.get_resolutions(Some(TimeSeriesType::SingleTimeSeries))?; let counts = store.get_time_series_counts()?; }
Each row carries the 32-byte content hash of the array it resolves to, so grouping a listing by
data_hash finds the series that share stored data — one query, where this used to be a second
key-shaped listing:
#![allow(unused)] fn main() { for m in store.list_metadata(ListFilter::new().owner_id(42))? { println!("{} -> {}", m.name, infrastore_core::hash_hex(&m.data_hash)); } }
The low-level read path
To read values without reconstructing a full SingleTimeSeries — for example when bridging to
another store that holds its own keys — resolve metadata and fetch the array by hash:
#![allow(unused)] fn main() { let meta = store.get_metadata_by_id(id)?.expect("the id still resolves"); let array = store.get_array_by_hash(&meta.data_hash)?; }
Forecasts
Dense forecasts are written through the generic add_time_series by wrapping a Deterministic,
Probabilistic, or Scenarios object in TimeSeriesData. Each forecast object holds a
TypedArray in its native shape (the data model
lists the conventional shapes per type); the store content-addresses it and records the windowing
parameters:
#![allow(unused)] fn main() { use infrastore_core::{Deterministic, TimeSeriesData, TimeSeriesError, TypedArray}; // A Deterministic forecast: a [H, count, *E] array (here scalar steps, so [H, count]). let (horizon_count, count) = (24, 7); let values: Vec<f64> = vec![0.0; horizon_count * count]; // row-major, shape (horizon_count, count) let data = TypedArray::from_f64(vec![horizon_count, count], &values); // `new` returns Result<_, String>; map it if your function returns TimeSeriesError. let forecast = Deterministic::new( initial, Duration::hours(1), // initial_timestamp, resolution Duration::hours(24), // horizon Duration::hours(24), // interval count, data, "load_forecast", // name ).map_err(TimeSeriesError::InvalidParameter)?; let key = store.add_time_series( 42, // owner_id (i64) "Generator", OwnerCategory::Component, TimeSeriesData::Deterministic(forecast), Features::new(), Some("MW".into()), // units )?; }
Probabilistic::new additionally takes the percentiles vector, and Scenarios::new takes a
scenario_count; wrap them in TimeSeriesData::Probabilistic / TimeSeriesData::Scenarios the
same way.
A DeterministicSingleTimeSeries is not added directly. Call transform_single_time_series to
derive one from every stored SingleTimeSeries (it shares the backing array and derives count
from the series length); it returns the number of series transformed. The trailing owner_category
and resolution arguments are optional filters that restrict which series are transformed:
#![allow(unused)] fn main() { let n = store.transform_single_time_series( Duration::hours(24), // horizon Duration::hours(24), // interval Some(OwnerCategory::Component), // owner_category filter (None = every category) Some(Duration::hours(1).into()), // resolution filter (Option<Period>; None = every one) )?; }
read_by_id reconstructs forecasts too, returning a TimeSeriesData::Deterministic,
Probabilistic, or Scenarios variant (a DeterministicSingleTimeSeries is synthesized into a
Deterministic). Match on the variant or use the as_deterministic / as_probabilistic /
as_scenarios accessors:
#![allow(unused)] fn main() { if let Some(d) = store.read_by_id(id, ReadWindow::full())?.as_deterministic() { // d.data is the TypedArray; d.horizon, d.interval, d.count carry the forecast parameters } }
The low-level path is still available when you only need the raw array: get_metadata_by_id exposes
horizon, interval, count, and percentiles, and get_array_by_hash returns the TypedArray
in its stored shape (to_f64_vec returns a Result<_, String>, so map the error if your function
returns TimeSeriesError):
#![allow(unused)] fn main() { let meta = store.get_metadata_by_id(id)?.expect("the id still resolves"); let arr = store.get_array_by_hash(&meta.data_hash)?; // arr.shape == [horizon_count, count] let values = arr.to_f64_vec().map_err(TimeSeriesError::InvalidParameter)?; }
When a forecast is known by its attributes rather than by an id you already hold, list_metadata is
the identify half. Its type filter reads a request: TimeSeriesType::Deterministic matches a
stored Deterministic or a DeterministicSingleTimeSeries, so a caller need not know which form
the store holds, and each row still reports the concrete time_series_type that matched. Its id
is what every read and removal takes:
#![allow(unused)] fn main() { use infrastore_core::TimeSeriesType; let rows = store.list_metadata( ListFilter::new() .owner_id(42) .owner_category(OwnerCategory::Component) .name("load_forecast") .time_series_type(TimeSeriesType::Deterministic), )?; // A caller wanting exactly one checks that it got one -- there is no separate // resolver, so ambiguity is the caller's to name rather than the store's. let [meta] = &rows[..] else { panic!("expected exactly one match: {rows:?}") }; }
Per-Timestamp Reads (Simulation Loop)
The layout is optimized for "every component's value at one timestamp", and a reader is the API
for that. Build one once outside the loop, then step it; the buffers are reused, so a tight loop
allocates nothing. A reader is a passive plan — it does not borrow the Store, so the read goes
through Store::static_read / Store::forecast_read, which fill it. See
Readers for the concepts.
Static series
#![allow(unused)] fn main() { use infrastore_core::{ListFilter, TimeSeriesType}; let mut reader = store.build_static_reader(ListFilter::new().resolution(Duration::hours(1)))?; for at in reader.timestamps().collect::<Vec<_>>() { store.static_read(&mut reader, at)?; for group in reader.groups() { let bytes = group.values(); // [num_columns, *element_shape], row-major LE // group.keys()[j] identifies column j; group.dtype(), group.element_shape() } } }
build_static_reader covers both static types, and the filter decides what must hold:
SingleTimeSeries (the default) must pin a resolution and share one grid; NonSequentialTimeSeries
must pin no resolution — reader.resolution() is then None — and its columns must lie on one
timestamp vector:
#![allow(unused)] fn main() { let mut reader = store.build_static_reader( ListFilter::new().time_series_type(TimeSeriesType::NonSequentialTimeSeries), )?; }
Whatever the kind, reader.timestamps() walks the timeline, so the loop body above is unchanged.
Coherence is validated at build time, where the error can name the series that disagree — there
is no presence mask, and static_read errors rather than clamps on an off-grid instant.
When the matched SingleTimeSeries share no grid, name the span instead of inheriting one.
build_static_reader_over gives each column an offset of its own, so ragged series sweep together:
#![allow(unused)] fn main() { use infrastore_core::ReadWindow; let mut reader = store.build_static_reader_over( ListFilter::new().resolution(Duration::hours(1)), ReadWindow::from(anchor), // .with_len(n) to pin the extent )?; }
Without a len the reader runs as far from the anchor as every matched series reaches. With one, a
series that does not cover the span is an error naming it.
When the odd series out should not take part at all, filter to one grid instead —
ListFilter::initial_timestamp and ListFilter::length match only the series already on it:
#![allow(unused)] fn main() { let mut reader = store.build_static_reader( ListFilter::new().resolution(Duration::hours(1)).initial_timestamp(anchor).length(8784), )?; }
See the Rust API reference for how the two remedies differ.
Forecasts
#![allow(unused)] fn main() { let mut reader = store.build_forecast_reader( ListFilter::new() .time_series_type(TimeSeriesType::Deterministic) .resolution(Duration::hours(1)), )?; for k in 0..reader.count() { let at = reader.interval().add_to(reader.initial_timestamp(), k as i64).unwrap(); store.forecast_read(&mut reader, at)?; for (i, entry) in reader.entries().iter().enumerate() { let slot = reader.entry_slot(i); let bytes = slot.window(); // window of slot.window_shape(), row-major LE // entry.id() names the forecast; get_metadata_by_id resolves its owner } } }
Entries that reference the same array and slice it the same way share one WindowSlot, and
forecast_read performs one backend read per slot — so a forecast shared by a hundred owners is
decompressed once per step. Dedup your own per-entry work by slot the same way. Note that
entry_slot(i) takes the entry index, while entry.slot() is that slot's index into slots().
Copy, Remove, and Maintain
copy_time_series re-points an existing association at another owner, optionally renaming it. It
writes a metadata row only — arrays are content-addressed, so no data is duplicated — and it
preserves the source's time_series_type (a DeterministicSingleTimeSeries stays one instead of
being materialized into a dense Deterministic, which is what a read-then-write copy would
produce):
#![allow(unused)] fn main() { let copied = store.copy_time_series( id, 99, // dst_owner_id "Generator", // dst_owner_type Some("load_copy"), // new_name; None keeps the source name )?; // returns the copy's own id; the source's is untouched }
#![allow(unused)] fn main() { store.remove_by_ids(&[id])?; // one series, or many in one transaction // The owner is the (owner_id, owner_category) pair. store.clear_time_series(Some((42, OwnerCategory::Component)))?; // all series for an owner store.clear_time_series(None)?; // everything let report = store.compact()?; // reports reusable slots let integrity = store.verify_integrity()?; // stored content vs. the hashes the catalog names assert!(integrity.errors.is_empty()); }
Removal is reference-counted:
a shared array survives until its last referencing row is gone. count_array_references(&hash)
returns the (SingleTimeSeries, DeterministicSingleTimeSeries) association counts on one array,
which is how you tell whether removing a SingleTimeSeries would orphan a forecast derived from it.
Associations
Two catalog tables record relationships between entities the store does not otherwise model, wholly independently of time series: which supplemental attributes are attached to which components, and directed parent/child edges between components. Removing a time series never touches either, and vice versa — see Associations Between Entities.
Attachments are keyed on the (component_id, attribute_id) pair. The type names ride along for
filtering and are not part of identity, so re-attaching the same pair under different type names is
a duplicate and fails with DuplicateAssociation:
#![allow(unused)] fn main() { use infrastore_core::{SupplementalAttributeAssociation, SupplementalAttributeFilter}; store.add_supplemental_attribute_association(SupplementalAttributeAssociation { component_id: 42, component_type: "Generator".into(), attribute_id: 100, attribute_type: "GeographicInfo".into(), })?; // Bulk add is one all-or-nothing transaction. store.add_supplemental_attribute_associations(vec![SupplementalAttributeAssociation { component_id: 43, component_type: "Generator".into(), attribute_id: 100, attribute_type: "GeographicInfo".into(), }])?; }
Filters are all-optional and ANDed; the default matches everything, which is what makes a bulk export/import round trip. Queries run in both directions:
#![allow(unused)] fn main() { // The attributes on one component... let attrs = store.list_supplemental_attribute_ids(&SupplementalAttributeFilter::new().component_id(42))?; assert_eq!(attrs, vec![100]); // ...and the components carrying one attribute. let owners = store.list_components_with_attributes(&SupplementalAttributeFilter::new().attribute_id(100))?; assert_eq!(owners, vec![42, 43]); // `*_types` filters take CONCRETE type names, rendered as SQL `IN (…)`. Expanding an // abstract type into its subtypes is the caller's job — the store has no type hierarchy. // An empty list is a deliberate "none of these" and matches nothing. let geo = SupplementalAttributeFilter::new().attribute_types(["GeographicInfo"]); assert_eq!(store.count_supplemental_attributes(&geo)?, 1); // distinct attributes assert_eq!(store.count_components_with_attributes(&geo)?, 2); // distinct components for row in store.supplemental_attribute_summary()? { println!("{} on {}: {}", row.attribute_type, row.component_type, row.count); } // Removal returns a count. Matching nothing is `Ok(0)`, not an error: assert on the // count yourself if you expected a hit. let removed = store .remove_supplemental_attribute_associations(&SupplementalAttributeFilter::new().component_id(43))?; assert_eq!(removed, 1); }
Parent/child edges work the same way, except that identity is the ordered pair — the reverse of an edge is a different edge — and both endpoints are always components, so there is no category:
#![allow(unused)] fn main() { use infrastore_core::{ParentChildAssociation, ParentChildFilter}; store.add_parent_child_association(ParentChildAssociation { parent_id: 42, parent_type: "Generator".into(), child_id: 7, child_type: "Bus".into(), })?; assert_eq!(store.list_children(&ParentChildFilter::new().parent_id(42))?, vec![7]); assert_eq!(store.list_parents(&ParentChildFilter::new().child_id(7))?, vec![42]); // Renumbering a component rewrites both ends of every edge in one statement, so an // edge that names it twice is counted once. let updated = store.replace_parent_child_component_id(42, 99)?; assert_eq!(updated, 1); }
Neither table is reachable over gRPC or the infrastore CLI.
Persist to Disk
The HDF5 backend buffers writes. Call flush before copying the files for backup:
#![allow(unused)] fn main() { store.flush()?; // H5Fflush; afterwards system.h5 + system.h5.sqlite can be copied as a pair }
persist_to writes the whole store to a new path — both halves of the artifact, path and
<path>.sqlite, overwriting anything already there. It works for an on-disk store (it flushes and
copies the pair) and for an in-memory store, which is how you materialize a scratch store built
with Store::create(None, true):
#![allow(unused)] fn main() { let mut store = Store::create(None, true)?; // in-memory // ... add series ... store.persist_to(Path::new("system.h5"))?; // writes system.h5 + system.h5.sqlite }
Always keep the .h5 and .h5.sqlite files together — neither is usable alone.
Error Handling
Every fallible method returns Result<T, TimeSeriesError>. Match on the variant to react:
#![allow(unused)] fn main() { use infrastore_core::TimeSeriesError; match store.read_by_id(id, ReadWindow::full()) { Ok(data) => { /* ... */ } Err(TimeSeriesError::NotFound) => { /* missing */ } Err(TimeSeriesError::ReadOnlyStore) => unreachable!("this is a read"), Err(e) => return Err(e.into()), } }
Threading
Store is Send but not Sync: the SQLite catalog holds a rusqlite::Connection, which is
not shareable across threads. A store can therefore be moved into another thread, but it cannot be
shared by reference — not even for concurrent reads.
To use one store from several threads, wrap it in external synchronization and serialize every
access, reads included: Arc<Mutex<Store>> (this is exactly what the gRPC server does). The library
does not coordinate multiple processes writing the same files either.
Python Developer Guide
This guide covers building on the infrastore PyO3 module, from installing the wheel to the calls a
consumer package makes. For exact signatures and return shapes, see the
Python API reference.
For complete programs rather than isolated snippets, use the repository's runnable Python examples. They cover static, non-sequential, deterministic, probabilistic, and scenario data; fixed tuples; every function-valued element type; feature-based selection; and conversion to Polars data frames.
Install
Python 3.11 or newer. The wheels are prebuilt and statically linked, so a consumer package such as infrasys needs nothing else:
pip install infrastore
The wheel is built against the abi3-py311 stable ABI, so one wheel works on CPython 3.11 and
every newer 3.x without recompiling.
to_arrow() needs pyarrow, which is not installed by default — it is several times the size of
everything else here, and nothing but that one method uses it:
pip install 'infrastore[arrow]'
From a checkout
Building from source needs the build tools
(cmake, a C compiler, protobuf) — but no system HDF5. The binding is built with
maturin; maturin develop compiles the extension and installs it into
the active virtual environment:
cd crates/infrastore-py
python3 -m venv .venv && source .venv/bin/activate
pip install maturin pytest numpy tzdata # tzdata: zoneinfo on Windows
pip install netCDF4 h5py # only for the HDF5-interop tests
maturin develop
python -c "import infrastore; print(infrastore.__version__)"
pytest ../../python/tests
To produce a wheel you can install elsewhere:
maturin build --release
# -> target/wheels/infrastore-<version>-cp311-abi3-<platform>.whl
Installing an unreleased core into a consumer's environment is the same maturin develop, run with
that consumer's venv active.
If it does not import
ImportErrorfor the extension — Ensure you ranmaturin developin the active venv, or that youpip install-ed the wheel into the interpreter you are running.- HDF5 build errors with
HDF5_DIRset — Unset it. The vendored build compiles its own HDF5 and the variable redirects it at an external install while static libraries are still requested (see Build Prerequisites). InvalidParameterErroron add — Pass a NumPy array (any shape) whose dtype is one offloat64,float32,int64,int32,int16,int8,uint64,uint32,uint16,uint8, orbool; any other dtype (e.g.complex128or a string dtype) raises. Feature values must beint/float/bool/str. Timestamps for aNonSequentialTimeSeriesmust be strictly increasing.
Import
from datetime import datetime, timedelta, timezone
import numpy as np
from infrastore import Store, SingleTimeSeries, OwnerCategory, TimeSeriesType
The module exposes Store and Transaction; the static series classes SingleTimeSeries and
NonSequentialTimeSeries, and PersistentTimeSeries; the forecast classes Deterministic,
Probabilistic, and Scenarios; the readers StaticReader and ForecastReader; the association
records SupplementalAttributeAssociation and ParentChildAssociation; the TimeSeriesType and
OwnerCategory enums; the init_tracing, encode_element_values, and decode_element_values
functions; __version__; and an exception hierarchy rooted at TimeSeriesError.
If you are building a package on top of infrastore — the way
infrasys does — read
Embedding in a Parent Package alongside this guide: it covers the lifecycle
(scratch store, persist_to, open_copy), id mapping, and lookup semantics that this page only
shows the calls for.
Open or Create a Store
# In-memory: no filesystem I/O.
store = Store.create(in_memory=True)
# On disk: writes system.h5 and system.h5.sqlite.
store = Store.create(path="system.h5")
# Reopen read-only.
store = Store.open("system.h5", read_only=True)
Build a Series
SingleTimeSeries takes a timezone-aware datetime, a resolution (a timedelta or an ISO 8601
duration string such as "PT1H" — the string form is required for calendar periods like "P1M"),
and a NumPy array:
ts = SingleTimeSeries(
datetime(2024, 1, 1, tzinfo=timezone.utc),
timedelta(hours=1),
np.arange(24, dtype=np.float64) + 100,
"load", # name (required)
)
Use timezone-aware datetimes (UTC is stored). The binding is dtype-generic — it accepts and returns
NumPy arrays of float64, float32, int64, int32, int16, int8, uint64, uint32,
uint16, uint8, or bool, and whatever dtype you pass round-trips unchanged. The array may be
multi-dimensional: shape (length,) for scalar steps, or (length, k1, …) to attach a per-step
element shape (such as cost-curve coefficients). The required name is an association attribute
carried on the object — the same array can be added under different names. Use
NonSequentialTimeSeries(timestamps, data, name) for explicitly timestamped series, and
PersistentTimeSeries(timestamps, data, name) for a sparse step function whose value holds forward
between breakpoints (worked through in Step Functions).
Add a Series
series_id = store.add_time_series(
owner_id=42,
owner_type="Generator",
owner_category=OwnerCategory.Component,
time_series=ts, # name and descriptors come from ts
features={"model_year": 2030, "scenario": "high"},
)
# `series_id` is the catalog row's id: how every read and removal
# addresses the series, and one integer to keep in your own model.
features is a plain dict whose values are int, float, bool, or str. Adding a series whose
identity already exists raises DuplicateTimeSeriesError.
The add returns the id and nothing else. To see the rest of the row — owner_id, owner_category,
time_series_type, name, resolution, interval, features, and the descriptors below — ask
store.get_metadata_by_id(series_id), or store.list_metadata(...) for a set of them (resolution
and interval come back as ISO 8601 duration strings or None).
Descriptors
Beyond units, a series can carry quantity_kind (what the values measure — "ActivePower"; the
one record of what per-unit values mean), unit_system ("natural_units" or "component_base";
unset means unspecified, not natural units), component_field (the field on the owning component
these values vary — "max_active_power"; also a filter), and application_data (an opaque string
the store returns verbatim — the package-owned slot). All of them are set on the series object,
not on the add, and each is a read-only property there:
ts = SingleTimeSeries(
datetime(2024, 1, 1, tzinfo=timezone.utc), timedelta(hours=1), values, "load",
units="MW", quantity_kind="ActivePower", unit_system="natural_units",
component_field="max_active_power",
application_data='{"source": "weather_year_2012"}',
)
series_id = store.add_time_series(
owner_id=42, owner_type="Generator", owner_category=OwnerCategory.Component,
time_series=ts,
)
assert store.read_by_id(series_id).quantity_kind == "ActivePower"
Keeping them on the object is what makes a read-then-add lossless: a series read from one store can be added to another unchanged, with no descriptor to re-supply and none the write could silently replace.
A series also records a time_reference — how its timestamps were spelled — inferred from the
datetime it was built with: timezone.utc gives "utc", a fixed-offset tzinfo gives
"-07:00", a ZoneInfo gives its name, and a naive datetime gives "zoneless". A naive
datetime is accepted (it names a wall clock, not an instant) precisely because the read hands one
back — naive and aware datetimes are never equal in Python, so returning the other kind would break
every == a caller writes.
None of them is part of the key or of either content hash, so two adds that differ only in a descriptor are a duplicate. See Optional Descriptors and Time references.
Add many series at once
add_time_series_bulk takes a list of dicts mirroring add_time_series's keyword arguments and
commits them in one catalog transaction, taking the block-sized HDF5 write path, so same-shaped
series land in the same packed dataset.
An item carries exactly the keys add_time_series takes as parameters — owner_id, owner_type,
owner_category, time_series, and optionally features — and any other key raises, as the
misspelled keyword it almost always is. Everything that describes the values (units,
quantity_kind, unit_system, component_field, application_data, element_type,
time_reference) rides on the time_series object, exactly as it does on the single-series path.
ids = store.add_time_series_bulk([
{"owner_id": i, "owner_type": "Generator", "owner_category": OwnerCategory.Component,
"time_series": series[i]}
for i in range(len(series))
]) # one catalog id per item, in input order; all-or-nothing
This is an order of magnitude faster than a bare loop of single adds, which pays one catalog
transaction and one HDF5 flush per series. It is not faster than that same loop inside a
transaction, which buffers and writes the identical datasets. What separates the
two is one thing: this call writes the batch as a single block whatever its size, because you are
already holding it, where the transaction holds its buffered adds to
write_buffer_bytes and spills past it. Reach for this when
the whole cohort is in hand as a list; reach for the loop when you would rather build the series one
at a time than materialize every one of them first, and raise the budget if you want the single
dataset back.
Transactions
Several operations that must take effect together — replacing a series is an add plus a remove — go inside a transaction. Removals are reversible only there; outside one the array bytes are reclaimed immediately.
with store.transaction():
new_id = store.add_time_series(owner_id=42, owner_type="Generator",
owner_category=OwnerCategory.Component,
time_series=updated)
store.remove_by_ids([old_id])
# committed on a clean exit, rolled back if the block raised
Blocks nest (each level is a savepoint), and the store holds the SQLite write lock until the
outermost one ends. begin_transaction / commit_transaction / rollback_transaction are the
explicit form.
A transaction is also where a run of single adds belongs. Nothing it writes is durable until the
outermost commit, so packed adds inside one are buffered per shape group and written as one block at
the commit — the datasets add_time_series_bulk of the same series would produce, without having to
hold the batch yourself. Two things qualify it. The buffer spills a block early — an extra
dataset, nothing else, the same split a batch that wide gets — once a group reaches the narrower of
two widths, or once the unwritten arrays across every group cross 128 MiB:
- the columns one chunk row holds: 131,072 for a scalar
float64; - 128 MiB divided by one column's bytes, which for anything but a short series is the ceiling that
actually binds — a 30,500-step
float64series is 244 KB a column, so its group spills at 550.
And a span holding a single array fills a shared-pool slot rather than claiming a dataset one column wide.
How wide a dataset a span writes
The 128 MiB is a default, not a law. write_buffer_bytes sets it, and through it how wide a dataset
a run of single adds can produce:
store.write_buffer_bytes = 1 << 30 # 1 GiB
with store.transaction():
for s in series:
store.add_time_series(owner_id=..., owner_type="Generator",
owner_category=OwnerCategory.Component, time_series=s)
# one dataset, however many series that was
Raised far enough, the loop writes exactly what add_time_series_bulk of the same series writes —
and the memory it costs is the memory the bulk call's caller was holding anyway. Measured on 2,000
hourly year-long float64 series: at the default they are ~0.26 s as one bulk call against ~0.31 s
as a loop, one (8760, 2000) dataset against a (8760, 1915) and a (8760, 85); raise the budget
and the loop lands the single dataset too.
The figure belongs to the Store object, not to the artifact — nothing is persisted, and a store
reopened elsewhere is back to 128 MiB. It is a budget for the writing process, so a machine that
cannot afford another machine's choice does not inherit it. Lowering it mid-transaction writes out
whatever the buffer already holds beyond the new figure; zero raises InvalidParameterError, since
a pool's width floors at one column and a zero budget would mean a dataset per array rather than no
buffering at all. The chunk-row ceiling above it does not move.
Read a Series
got = store.read_by_id(series_id)
assert np.array_equal(np.asarray(got.data), np.asarray(ts.data))
print(got.length, got.initial_timestamp, got.resolution)
Slice on the time axis with a (start, end) tuple of datetimes (end exclusive). A range clips
to what is there:
(window,) = store.read_by_ids_range(
[series_id],
(
datetime(2024, 1, 1, 6, tzinfo=timezone.utc),
datetime(2024, 1, 1, 12, tzinfo=timezone.utc),
),
)
read_by_id takes the other kind of slice: start_time plus a len of timesteps or a count of
windows, checked rather than clipped, so an over-long request raises rather than quietly
returning less.
To read many whole series at once — e.g. loading everything for a plot — read_by_ids takes a
list of ids and returns the typed series objects in the same order. Packed SingleTimeSeries are
read in one decompress-once pass per dataset, which is much faster than a read_by_id each:
series = store.read_by_ids(ids)
window = store.read_by_ids_range(ids, (start, end)) # the same clip on every series
As a table
A read hands back the values as a numpy array with the timeline beside it, not fused into it. When
you want the two together — to plot, to write Parquet, to hand to pandas or polars — to_arrow()
builds a two-column pyarrow.Table of timestamp and value:
table = store.read_by_id(series_id).to_arrow()
table.to_pandas()
It works on all three static types, and needs the arrow extra. The timestamp column is
typed in the series' own spelling — timestamp[ms, tz=America/Denver] for a zoned series, an
unzoned timestamp[ms] for a zoneless one — and the descriptive attributes ride in
table.schema.metadata. A SingleTimeSeries grid is materialized calendar-aware, so a monthly
series lands on month ends rather than on a multiple of 30 days. See
to_arrow().
Without pyarrow, timestamps is the same timeline as a plain list of datetimes:
got = store.read_by_id(series_id)
list(zip(got.timestamps, got.data))
A Deterministic converts to one table per window instead, keyed by issue time:
windows = store.read_by_id(forecast_id).to_arrow_windows()
windows[datetime(2024, 1, 2, tzinfo=timezone.utc)] # that window's forecast
Each value looks exactly like a static series' table. It is a dict rather than one table because a
forecast has two grids that overlap — windows step by interval, rows inside a window by
resolution — so a day-ahead forecast reissued hourly shares 23 of every 24 instants between
neighbouring windows. The dict iterates chronologically. See
to_arrow_windows().
Back from a table
from_arrow is the inverse, on the same three types — and it reads a table anything wrote, not just
one to_arrow() produced:
import pyarrow.parquet as pq
series = SingleTimeSeries.from_arrow(pq.read_table("load.parquet"))
store.add_time_series(42, "Generator", OwnerCategory.Component, series)
A table to_arrow() wrote round-trips with no arguments, because the metadata is the descriptor. A
foreign table — from a dataframe, or a Parquet file someone else wrote — needs a name= at
minimum, since a name is part of a series' identity; everything else is inferred from the Arrow
schema. Note what a table does not carry: the owner and the catalog id, because to_arrow() is a
method on a value object and a series built here is not filed anywhere. You supply the owner to
add_time_series, as you would for any other series.
The inference rules and the four things that are refused rather than coerced (nulls, sub-millisecond
timestamps, rows that leave a declared grid, decoded struct/list value columns) are in the
reference.
to_arrow() and from_arrow() are per-series, in-memory conveniences -- one series, one table.
They are not the CLI's file format: infrastore export -f parquet writes
a values/series file pair per partition, each distinct array once
and one catalog row per series, which from_arrow does not read. To move a whole store through
Parquet, use the CLI at both ends.
Datetimes and precision
Every datetime must be timezone-aware (any zone; converted to UTC on the way in, UTC on the way
out), and a naive one raises InvalidParameterError. A stored instant — an initial timestamp, a
NonSequentialTimeSeries timestamp or PersistentTimeSeries breakpoint — must also be a whole
number of milliseconds, so quantize datetime.now(timezone.utc) before storing it; query bounds
such as time_range are unconstrained. See Datetimes.
Per-Timestamp Reads (Simulation Loop)
read_by_id hands back a whole series or forecast. Simulations instead walk the timeline and, at
each timestamp, want the value of every series at that instant. For that, build a reader once
and drive it in a loop — it pins one resolution and reuses its output buffers, so the loop allocates
almost nothing. StaticReader serves SingleTimeSeries; ForecastReader serves forecasts. (Full
signatures: Python API reference.)
Static series
Series are grouped by (dtype, element_shape); each group's group_values is one dense
(num_columns, *element_shape) array whose columns line up with that group's ids. All matched
series must share one grid (initial_timestamp + length), validated at build.
reader = store.build_static_reader(timedelta(hours=1))
grid = reader.grid() # {"initial_timestamp", "resolution", "length", "time_series_type"}
groups = reader.groups() # each: {"dtype", "element_type", "element_shape", "ids"}
for ts in reader.timestamps():
store.static_read(reader, ts)
for i, g in enumerate(groups):
vals = reader.group_values(i) # (num_columns, *element_shape); column j ↔ g["ids"][j]
When the series do not share a grid
Most real systems do not meet that requirement — a year of load beside a week of an outage schedule, or one component logged from an hour later than the rest — and the build then raises, naming the series that diverges. Give the reader a span instead of letting it inherit one:
reader = store.build_static_reader(
timedelta(hours=1),
window_start=datetime(2024, 1, 1, 7, tzinfo=timezone.utc),
window_length=8760, # optional: without it, as far as *every* matched series reaches
)
Each column then reads at an offset of its own, so ragged series sweep together. The span is
checked, not clamped: a matched series that does not cover it raises InvalidParameterError naming
that series rather than dropping its column, and the anchor must land on each series' own step
boundaries. See reader windows.
Sometimes the odd series out should not take part at all — a stray day of data beside a year of it
is a different component, not a shorter view of the same sweep. Then filter to one grid instead,
with initial_timestamp and length:
reader = store.build_static_reader(
timedelta(hours=1),
initial_timestamp=datetime(2024, 1, 1, 7, tzinfo=timezone.utc),
length=8784,
)
The window sweeps a span across whatever matched; the filter matches only the series already on that
grid. static_summary() shows which grids a store holds, and the filter reaches every other
filter-taking call too — list_metadata, remove_by_filter, and the rest. See
selecting one grid.
Forecasts
entry_values(i) returns the window backing entries()[i], shaped (horizon, *element_shape) for
Deterministic/DeterministicSingleTimeSeries, (num_percentiles, horizon, *element_shape) for
Probabilistic, and (scenario_count, horizon, *element_shape) for Scenarios. A Deterministic
reader is abstract — it also includes any DeterministicSingleTimeSeries (read into identical
windows).
reader = store.build_forecast_reader(TimeSeriesType.Deterministic, timedelta(hours=1))
tl = reader.timeline() # {"initial_timestamp", "resolution", "interval", "count", ...}
entries = reader.entries() # list[int]: catalog ids, parallel to entry_values
for ts in reader.timestamps():
store.forecast_read(reader, ts)
for i, entry_id in enumerate(entries):
window = reader.entry_values(i) # the window for that id's series
Shared forecasts are read once
Forecasts that share a backing array (deduplicated identical data, or several
DeterministicSingleTimeSeries over one SingleTimeSeries) collapse to a single window slot.
forecast_read reads each slot from the .h5 file once per timestamp, so a forecast shared by 10
components costs one read, not ten. reader.num_slots() is the physical read count, and
reader.entry_slot(i) says which slot an entry uses — group by slot to materialize each unique
window only once on the Python side too:
store.forecast_read(reader, ts)
windows: dict[int, np.ndarray] = {}
for i, key in enumerate(entries):
window = windows.setdefault(reader.entry_slot(i), reader.entry_values(i))
Step Functions (PersistentTimeSeries)
A PersistentTimeSeries is a sparse step function: a strictly increasing vector of
breakpoints plus one value each, where the value at an arbitrary instant is the one belonging to
the greatest breakpoint at or before it. The motivating data is a monthly fuel or gas price curve —
a dozen breakpoints a simulation reads at timestamps that almost never land on one. The same curve
stored as a NonSequentialTimeSeries would raise at nearly every step, because an irregular series
has no value between its timestamps; that difference in read semantics is the whole reason this
is a separate type. See
Time series types for the model.
Build and add
Construction is identical to NonSequentialTimeSeries — same arguments, same validation, same
spelling inference from tzinfo:
from infrastore import PersistentTimeSeries
breakpoints = [datetime(2024, m, 1, tzinfo=timezone.utc) for m in (1, 4, 7, 10)]
prices = PersistentTimeSeries(
breakpoints,
np.array([3.5, 4.25, 5.0, 4.75]),
"gas_price",
units="USD/MMBtu",
component_field="fuel_cost",
# Whether a curve is expanded to a full series or collapsed to one scalar is
# your application's policy, and rides here where the store never reads it.
application_data='{"as_time_series": false, "force_scalar_mode": "midpoint"}',
)
price_id = store.add_time_series(
owner_id=7,
owner_type="ThermalStandard",
owner_category=OwnerCategory.Component,
time_series=prices,
)
read_by_id(price_id) hands back a PersistentTimeSeries whose timestamps are the breakpoints
and whose data holds one value each — the same dtype and shape rules as every other static series,
multi-dimensional per-breakpoint values included.
Read a window
A range read slices on the step function's own terms: the result begins at the breakpoint in force
at start, so it always defines a value at the start of the window you asked for.
(window,) = store.read_by_ids_range(
[price_id],
(datetime(2024, 4, 10, tzinfo=timezone.utc), datetime(2024, 9, 1, tzinfo=timezone.utc)),
)
window.timestamps # [2024-04-01, 2024-07-01] — the April step, not the first one inside the window
window.data # array([4.25, 5. ])
Past the last breakpoint the last value holds forever, so a window opening after the end comes back
with that one row. Before the first breakpoint a step function is undefined: a non-empty window
starting there raises InvalidParameterError rather than clamping. (A zero-width range,
end == start, selects nothing — here as for every type.) read_by_id's start_time + len
window is checked rather than sliced, so it must name one of the breakpoints; reach for
read_by_ids_range when the instant is arbitrary.
Sweep step functions in the simulation loop
A StaticReader filtered to the type is the per-timestamp path, and it is the one place the
one-timeline-per-reader rule bends: a step
function has a value at every instant from its own first breakpoint on, so the columns need not
share a breakpoint vector. Per-fuel curves whose breakpoints do not line up still build one reader.
reader = store.build_static_reader(
time_series_type=TimeSeriesType.PersistentTimeSeries, # no resolution — passing one raises
component_field="fuel_cost",
)
grid = reader.grid() # grid["resolution"] is None: a step function has no step
groups = reader.groups()
for at in reader.timestamps(): # the sorted union of every column's breakpoints
store.static_read(reader, at)
for i, g in enumerate(groups):
vals = reader.group_values(i) # the value in force at `at`; column j ↔ g["ids"][j]
timestamps() is the union of the columns' breakpoints, so a position on it is not a storage
row for any one column — each column independently reports the value in force there. There is still
no presence mask: reading at an instant before some column's first breakpoint raises
InvalidParameterError naming that column's association id. Either filter the reader down to
columns that start early enough, or begin the sweep at the latest first breakpoint among them.
The sweep need not follow that union axis at all. static_read accepts any instant every column
defines a value at, so driving this reader at your SingleTimeSeries grid's timestamps — the
simulation's own clock — works and is usually what an application wants:
for at in load_reader.timestamps(): # the hourly grid the simulation runs on
store.static_read(load_reader, at)
store.static_read(reader, at) # each fuel price, held forward to this hour
These rows do not travel in an OpenAPI document
PersistentTimeSeries is an infrastore-local extension, and the vendored wire contract is a oneOf
over six canonical Sienna types with no schema for a seventh. So
export_time_series_associations_openapi omits persistent rows — a mixed store still exports
its six-type rows — a filter naming the type raises InvalidParameterError rather than answering
with an empty array, and an import refuses a document that carries one. The series themselves are
unaffected: they live in the artifact, which holds them in full. Ask the catalog what a document
leaves behind:
left_behind = store.list_metadata(time_series_type=TimeSeriesType.PersistentTimeSeries)
Custom Element Types
By default an array's elements are plain numbers of its dtype. An element_type says otherwise:
what the trailing per-step dimension of the array actually means. It is metadata, not a different
storage format — the array is still the same typed HDF5 dataset — but it is what lets a reader turn
the raw floats back into the values you meant. See Element types
for the full grammar and the byte layout each kind produces; this section works through each one
from Python.
from_values and decoded_values
Every series type has a from_values classmethod that takes the values themselves. It encodes them
and records the element type they imply, so the two cannot disagree:
curves = [
[{"x": 0.0, "y": 1.0}, {"x": 1.0, "y": 3.0}],
[{"x": 0.0, "y": 2.0}],
]
ts = SingleTimeSeries.from_values(
datetime(2024, 1, 1, tzinfo=timezone.utc), timedelta(hours=1), curves, "cost_curve",
)
assert ts.element_type == "piecewise_linear" # nobody declared it
series_id = store.add_time_series(
owner_id=42, owner_type="Generator", owner_category=OwnerCategory.Component, time_series=ts,
)
assert store.read_by_id(series_id).decoded_values() == curves
decoded_values() is the read-side half: the element type and the number of leading axes both come
off the series, so there is nothing left to pass. It returns None for a scalar element type and
for any array whose dtype is not float64 — there the stored elements already are the values, and
.data is the answer.
Which element type a payload implies is read off the shape of a row; the five shapes are disjoint:
values entry | element type |
|---|---|
{"proportional": …, "constant": …} | linear_function |
{"quadratic": …, "proportional": …, "constant": …} | quadratic_function |
list[{"x": …, "y": …}] | piecewise_linear |
{"x": list, "y": list} | piecewise_step |
list[float] of length N | tuple(N,f64) |
element_type= is still accepted on from_values, as an assertion rather than an override: it
raises InvalidParameterError if it disagrees with the values. Where the values name nothing it is
the only thing to go on — an empty values, or rows that are all empty and read equally as a curve
with no points or a tuple with no fields.
Underneath sit encode_element_values(values, element_type, leading_dims) and
decode_element_values(array, element_type, leading_dims), which the rest of this section uses to
show what each element type packs into. Reach for them directly when there is no series to hang the
values on — decoding an array that arrived on its own — or for the one series from_values cannot
name: an empty tuple(N,f64), whose arity lives in rows it does not have.
Composite values: tuple(N,dtype)
A tuple(N,dtype) is bytes-identical to a plain array shaped (length, N) — declaring it changes
nothing about what is stored, only how a reader should group the trailing N values: as one
composite value (three cost-curve coefficients), not N independent samples. Build it like any
other multi-dimensional series and declare the type on the constructor:
coeffs = np.array([[1.0, 0.5, 12.0], [1.1, 0.4, 11.5]]) # (length=2, N=3)
ts = SingleTimeSeries(
datetime(2024, 1, 1, tzinfo=timezone.utc), timedelta(hours=1), coeffs, "cost_coeffs",
element_type="tuple(3,f64)",
)
This is the one element type where the constructor stays the natural call: there is no packing to
build, so an array you already hold in numpy needs no encoding step. from_values accepts the same
values as a list of list[float] rows, and is the better fit when that is the shape you have.
decoded_values() and decode_element_values only unpack f64 arrays — for any other dtype
(tuple(3,i32), say) they return None, because there is nothing to unpack: the stored rows
already are the tuples, and you read them straight off .data. encode_element_values is a
convenience for the f64 case only; it also always builds f64, so neither it nor from_values
can produce a tuple of any other dtype.
Fixed-width coefficients: linear_function, quadratic_function
These give every timestep a small, fixed number of function coefficients — a proportional and a
constant term for a line, plus a quadratic term for a parabola — packed as f64 regardless of the
rest of the series. The values are a list of per-timestep dicts, matching keys to the function's
coefficients:
from infrastore import encode_element_values, decode_element_values
curves = [
{"proportional": 1.0, "constant": 2.0},
{"proportional": 1.2, "constant": 1.8},
]
array = encode_element_values(curves, "linear_function") # shape (2, 2), f64
assert decode_element_values(array, "linear_function") == curves
Or, without naming the type or holding the array at all:
ts = SingleTimeSeries.from_values(
datetime(2024, 1, 1, tzinfo=timezone.utc), timedelta(hours=1), curves, "marginal_cost",
)
series_id = store.add_time_series(
owner_id=7, owner_type="ThermalStandard", owner_category=OwnerCategory.Component,
time_series=ts,
)
assert store.read_by_id(series_id).decoded_values() == curves
quadratic_function is the same shape, with a "quadratic" key added and row width 3 instead of
2. Both raise if a row doesn't match the required width exactly — there is no padding for these
two, because every timestep genuinely has the same number of coefficients.
Ragged curves: piecewise_linear, piecewise_step
These are the element types built for a variable number of points per timestep — a case covered
end to end in the runnable
single_custom_elements.py
example. encode_element_values finds the widest row across the whole array, then packs every
timestep as a leading count n followed by its points, zero-padded out to that common width;
decoding reads n back off each row and returns exactly that many points, ignoring the padding.
A piecewise_linear timestep is a list of {"x", "y"} knots:
curves = [
[{"x": 0.0, "y": 1.0}, {"x": 1.0, "y": 3.0}, {"x": 2.0, "y": 5.0}], # 3 points
[{"x": 0.0, "y": 2.0}, {"x": 1.0, "y": 4.0}, {"x": 2.0, "y": 6.0}, {"x": 3.0, "y": 8.0}], # 4 points
]
ts = SingleTimeSeries.from_values(
datetime(2024, 1, 1, tzinfo=timezone.utc), timedelta(hours=1), curves, "cost_curve",
)
assert ts.data.shape == (2, 1 + 2 * 4) # widest row wins; the 3-point row is padded
series_id = store.add_time_series(
owner_id=42, owner_type="Generator", owner_category=OwnerCategory.Component, time_series=ts,
)
back = store.read_by_id(series_id)
assert back.decoded_values() == curves # 3- and 4-point rows both exact
A piecewise_step timestep decodes to a different shape — one dict of parallel arrays rather
than a list of points, since a step function has one fewer y than x: each y is the value
between two adjacent x's, so n coordinates bound n - 1 steps and the last coordinate is the
right-hand end of the curve rather than the start of an open final step. Nothing is held forward
past it — that is a PersistentTimeSeries, which is a time series type rather than an element type:
steps = [
{"x": [0.0, 1.0, 2.5], "y": [10.0, 20.0]}, # 3 x's, 2 steps
{"x": [0.0, 5.0], "y": [7.5]}, # 2 x's, 1 step
]
array = encode_element_values(steps, "piecewise_step")
The padded width is fixed by whatever encode_element_values saw in that one call. Add a timestep
with more points later than any seen so far, and the wider row width makes it a different packed
HDF5 dataset (element_shape differs), not an in-place resize of the array you already wrote — the
same packing rule every other same-shaped-series pooling in this store follows.
Forecasts (Deterministic, Probabilistic, Scenarios) use these same element types over their
extra leading axes. This is where from_values saves the most: the leading dimensions come from
arguments the forecast constructor already takes, so nothing is computed by hand.
# H = horizon / resolution = 2, so [H, count] = [2, 2] wants four curves, entry
# `i * count + j` being window `j`'s step `i`.
forecast = Deterministic.from_values(
start, timedelta(hours=1), timedelta(hours=2), timedelta(hours=1), 2, curves * 2,
"cost_curve",
)
assert forecast.data.shape == (2, 2, 9)
Scenarios.from_values takes scenario_count explicitly, where its constructor reads it off the
array's first axis — there is no array yet to read it from. Going through encode_element_values
instead means passing leading_dims=[horizon, count] (or [percentiles, horizon, count] /
[scenarios, horizon, count]) yourself, in place of the default single-axis case. See
Forecasts above for the shapes those types read back as.
Query Metadata
list_metadata returns a list of plain dicts, filtered by any combination of arguments (the
features argument is a subset match):
for m in store.list_metadata(
owner_id=42,
owner_category=OwnerCategory.Component,
time_series_type=TimeSeriesType.SingleTimeSeries,
):
print(m["name"], m["resolution"], m["units"], m["features"])
# The owner is the (owner_id, owner_category) pair.
rows = store.list_metadata(owner_id=42, owner_category=OwnerCategory.Component)
ids = [r["id"] for r in rows]
exists = store.association_exists(ids[0])
resolutions = store.get_resolutions() # list[str] (ISO 8601 durations)
counts = store.get_time_series_counts() # dict
What is in here?
Before querying anything in particular, show() prints the shape of the whole store — the
time-series associations by type, the arrays behind them, the owners, and both association catalogs:
store.show()
# Store: system.h5 (read-write)
# Time series: 128 associations over 128 distinct arrays
# SingleTimeSeries 100
# PersistentTimeSeries 8
# Deterministic 20
# Owners with time series: 108 components, 0 supplemental attributes
# Supplemental attribute attachments: 12
# Parent/child edges: 5
It is aggregate catalog queries only, so it stays fast on a large store, and it takes file= like
print does. For the numbers themselves rather than the rendering, use counts_by_type(),
time_series_counts_detailed(), num_distinct_arrays(), and the count_* methods — see
show().
Remove and Maintain
store.remove_by_ids([series_id]) # one series, or many in one transaction
# The owner is the (owner_id, owner_category) pair.
n = store.clear_time_series(owner_id=42, owner_category=OwnerCategory.Component) # one owner; returns count
store.clear_time_series() # remove everything
# Reassign every series from one owner to another; returns the number moved.
moved = store.replace_owner(42, 43, OwnerCategory.Component)
report = store.compact() # rewrites the .h5 from the live set; the report includes
# "slots_reclaimed", "datasets_dropped",
# "feature_sets_reclaimed", "timestamp_sets_reclaimed",
# "bytes_reclaimed"
integrity = store.verify_integrity() # {"ok": True, "errors": []} when every array and time
# axis the catalog names matches its recorded hash
Associations
Two catalog tables record relationships between entities the store does not otherwise model, wholly independently of time series: which supplemental attributes are attached to which components, and directed parent/child edges between components. Removing a time series never touches either, and vice versa — see Associations Between Entities.
Filter arguments are keyword-only, all optional, and ANDed; passing none matches everything.
from infrastore import (
SupplementalAttributeAssociation,
ParentChildAssociation,
DuplicateAssociationError,
)
store.add_supplemental_attribute_association(
SupplementalAttributeAssociation(42, "Generator", 100, "GeographicInfo")
)
# Bulk add is one all-or-nothing transaction.
store.add_supplemental_attribute_associations([
SupplementalAttributeAssociation(43, "Generator", 100, "GeographicInfo"),
SupplementalAttributeAssociation(43, "Generator", 101, "Outage"),
])
# Queries run in both directions, returning distinct ids in ascending order.
assert store.list_supplemental_attribute_ids(component_id=43) == [100, 101]
assert store.list_components_with_attributes(attribute_id=100) == [42, 43]
assert store.has_supplemental_attribute_association(component_id=42, attribute_id=100)
# `*_types` filters take CONCRETE type names. Expanding an abstract type into its
# subtypes is the caller's job — the store has no type hierarchy. An empty list is a
# deliberate "none of these" and matches nothing.
assert store.list_supplemental_attribute_ids(
component_id=43, attribute_types=["Outage"]
) == [101]
assert store.count_supplemental_attributes() == 2 # distinct attributes
assert store.count_components_with_attributes() == 2 # distinct components
store.supplemental_attribute_counts_by_type()
# [('GeographicInfo', 2), ('Outage', 1)]
store.supplemental_attribute_summary()
# [{'component_type': 'Generator', 'attribute_type': 'GeographicInfo', 'count': 2}, ...]
Identity is the (component_id, attribute_id) pair. The type names ride along for filtering and are
not part of it, so re-attaching the same pair under different type names is still a duplicate:
try:
store.add_supplemental_attribute_association(
SupplementalAttributeAssociation(42, "Load", 100, "Outage")
)
except DuplicateAssociationError as e:
print(e) # attribute 100 is already attached to component 42
# Removal returns a count. Matching nothing returns 0 rather than raising, so assert on
# the count yourself if you expected a hit.
assert store.remove_supplemental_attribute_associations(component_id=43) == 2
Parent/child edges work the same way, except that identity is the ordered pair — the reverse of an edge is a different edge — and both endpoints are always components:
store.add_parent_child_association(ParentChildAssociation(42, "Generator", 7, "Bus"))
store.add_parent_child_associations([ParentChildAssociation(43, "Generator", 7, "Bus")])
assert store.list_children(parent_id=42) == [7]
assert store.list_parents(child_id=7) == [42, 43]
assert store.count_parent_child_associations() == 2
# Renumbering a component rewrites both ends of every edge.
assert store.replace_parent_child_component_id(42, 99) == 1
assert store.list_parents(child_id=7) == [43, 99]
Neither table is reachable over gRPC or the infrastore CLI.
Persist to Disk
store.flush() # sync buffered writes; afterwards system.h5 + system.h5.sqlite can be copied
Keep the two files together — the .h5 and .h5.sqlite pair is a single logical store.
To change a store you did not build in this process, open a copy: Store.open defaults to
read-write, and HDF5 has no journal, so an interrupted in-place write is unrecoverable.
store = Store.open_copy(src, scratch / "time_series.h5") # src is never opened for writing
...
store.persist_to(src) # one atomic rename replaces it
Store.open(path, read_only=True) is the right call when nothing will be written.
Where the Catalog Lives
By default the catalog is system.h5.sqlite, and every commit is durable. Passing
catalog="memory" keeps it in RAM instead, so it reaches disk only when you call persist_to():
# Build in a scratch directory; nothing is durable until the explicit save.
store = Store.create(scratch / "time_series.h5", catalog="memory")
store.add_time_series(...)
store.persist_to(destination) # writes both halves as a matched pair
store.persist_catalog() # or: land only the .sqlite half beside the arrays already at path
Arrays still stream to the HDF5 file, so this does not require the data to fit in memory. It suits
building a store beside volatile in-process state — a crash loses that state anyway, so journaling
the scratch catalog buys nothing. Read store.catalog to see which mode a store is in.
Store.open(path, catalog="memory") loads an existing catalog into RAM the same way. Note that the
HDF5 half is still opened in place, so mutations land in the original file; open a copy if you
mean to leave the source untouched until an explicit save.
persist_to() stages both halves and renames them into place, and stamps the pair so that a save
interrupted between the two renames is caught on the next open rather than read as a valid store. It
does replace the destination, though, so a failed save may have destroyed what was there — recover
by calling persist_to() again on the still-live store rather than assuming the target survived.
Error Handling
The store's own exceptions inherit from TimeSeriesError, so you can catch broadly or narrowly:
from infrastore import NotFoundError, DuplicateTimeSeriesError, TimeSeriesError
try:
store.add_time_series(...)
except DuplicateTimeSeriesError:
... # key already exists
except TimeSeriesError as e:
... # anything else from the store
Argument validation stays inside the hierarchy: a malformed ISO 8601 duration string, a naive
datetime, a sub-millisecond stored timestamp, and an unsupported NumPy dtype all raise
InvalidParameterError. The one exception is a period argument that is neither a timedelta nor a
str, which raises a plain TypeError that except TimeSeriesError will not catch.
One gotcha: because Python's bool is a subclass of int, the binding deliberately checks bool
first, so True/False feature values are stored as booleans (not as 1/0 integers).
A Complete Round-Trip
from datetime import datetime, timedelta, timezone
import numpy as np
from infrastore import Store, SingleTimeSeries, OwnerCategory
store = Store.create(in_memory=True)
ts = SingleTimeSeries(
datetime(2024, 1, 1, tzinfo=timezone.utc),
timedelta(hours=1),
np.arange(24, dtype=np.float64) + 100,
"load",
units="MW",
)
series_id = store.add_time_series(
owner_id=42, owner_type="Generator",
owner_category=OwnerCategory.Component,
time_series=ts,
features={"model_year": 2030},
)
got = store.read_by_id(series_id)
assert got.name == "load"
assert np.array_equal(np.asarray(got.data), np.asarray(ts.data))
Diagnostics and tracing
The store emits structured tracing spans for every significant operation. To see them, initialize a subscriber before your first store call.
Via environment variable — set RUST_LOG before starting Python. The module auto-initializes a
subscriber on import when this variable is set:
RUST_LOG=debug python myscript.py
# or, to limit output to the store core only:
RUST_LOG=infrastore_core=debug python myscript.py
Programmatically — call init_tracing with a filter directive string:
from infrastore import init_tracing
init_tracing("infrastore_core=debug")
store = Store.create(in_memory=True)
store.add_time_series(...) # spans appear on stderr
init_tracing is a no-op if a subscriber is already registered (including the automatic one from
RUST_LOG). The filter syntax is the same as RUST_LOG: comma-separated target=level pairs, or a
bare level such as "debug" to match everything. Useful targets:
| Target | What it covers |
|---|---|
infrastore_core | All store operations — add, get, remove and HDF5 I/O |
Julia Developer Guide
This guide covers building on InfraStore.jl, the Julia package that wraps the
C ABI, from installing it to the calls a consumer package makes. For exact
signatures see the Julia API reference.
For complete programs rather than isolated snippets, use the repository's
runnable Julia examples.
They cover static, non-sequential, persistent, deterministic, probabilistic, and scenario data;
fixed tuples; every function-valued element type; feature-based selection; and conversion to
DataFrames.
Install
Julia 1.11 or newer. InfraStore.jl is registered in General, and the native library comes with it:
using Pkg
Pkg.add("InfraStore")
Pkg downloads the libinfrastore_ffi artifact for your platform from the matching GitHub Release
(Artifacts.toml in the package pins its URL and hash). The library is linked statically against
HDF5 and zlib, so there is no HDF5_jll and no system HDF5 involved, and the HDF5 version behind
the on-disk format is the one infrastore pinned. Artifacts exist for Linux x86_64 and aarch64
(glibc), macOS x86_64 and Apple Silicon, and Windows x86_64; on any other platform, build the
library yourself and use the override below.
That is the whole recipe for a consumer package.
From a checkout
InfraStore.jl resolves the cdylib at first use, in this order:
- The
INFRASTORE_LIBenvironment variable — the development override. - The
libinfrastore_ffiartifactPkgdownloaded at install time.
So developing against a working tree means building the library and exporting the variable. This
needs the build tools (cmake, a C
compiler, protobuf), but no system HDF5:
cargo build -p infrastore-ffi --release
export INFRASTORE_LIB=$PWD/target/release/libinfrastore_ffi.dylib # .so on Linux
julia --project=julia/InfraStore.jl -e 'using Pkg; Pkg.instantiate()'
julia --project=julia/InfraStore.jl julia/InfraStore.jl/test/runtests.jl
using InfraStore always works; the resolution happens on the first call that reaches the native
library, and the path is cached for the rest of the session — so set the variable before that first
call, in the same shell that launched Julia. To use the checkout from your own project,
Pkg.develop(path="/path/to/infrastore/julia/InfraStore.jl") with the variable set.
If it does not load
Could not locate libinfrastore_ffi at <path>.— The resolved path is not a file. If<path>is under.julia/artifacts, the artifact download did not complete:Pkg.instantiate()(orPkg.add("InfraStore")again) fetches it. If it is your own path, exportINFRASTORE_LIBbefore the first store call.could not load library— Check the path exists and has the right extension for your OS (.dylibon macOS,.soon Linux,.dllon Windows), and that you built with--releaseif your variable points attarget/release.InvalidParameterErroron add —owner_idmust be an integer (e.g.42, anInt64), andfeaturesvalues must be JSON scalars.
Load the Package
using Dates, InfraStore
Exported names include Store, SingleTimeSeries, NonSequentialTimeSeries,
PersistentTimeSeries, the forecast structs (Deterministic, Probabilistic, Scenarios),
OwnerCategory (Component, SupplementalAttribute), the add_time_series! / read_by_id /
list_metadata family, and transform_single_time_series!. The store type is named Store.
Open or Create a Store
# In-memory.
store = Store(in_memory=true)
# On disk: writes system.h5 and system.h5.sqlite.
store = Store(in_memory=false, path="system.h5")
# Reopen read-only.
store = open_store("system.h5"; read_only=true)
A Store registers a finalizer, so nothing leaks if you never close one — but the GC decides
when, and on disk that means an open file handle and a held SQLite write lock for an unbounded
time. Prefer the do-block forms, which release the store on the way out including on a throw:
Store(in_memory=true) do store
add_time_series!(store, 42, "Generator", Component, ts)
end
open_store("system.h5"; read_only=true) do store
only(list_metadata(store; owner_id=42, owner_category=Component, name="load"))
end
open_copy has one too. close!(store) is the explicit form when a do-block does not fit — a
long-lived store held by a consumer package, say — and is idempotent, so closing a store the
finalizer later reaps is fine.
Add a Series
# `name` ("load") is a required field on the struct.
ts = SingleTimeSeries(DateTime(2024, 1, 1), Hour(1), collect(100.0:123.0), "load")
A bare DateTime carries no zone, so this package reads one as a wall clock — the store holds
its fields unchanged and records the spelling as ZonelessReference(). If your timestamps are
genuinely zoned, using TimeZones and pass a ZonedDateTime instead — it names an instant on its
own, and is accepted anywhere a DateTime is:
using TimeZones
ts = SingleTimeSeries(
ZonedDateTime(DateTime(2024, 1, 1), tz"America/Denver"), # = 2024-01-01T07:00Z
Hour(1), collect(100.0:123.0), "load",
)
Reads return a bare DateTime holding the instant either way, with the recorded spelling beside it
(zoned_timestamp fuses the two back together); see
Time and resolution conversions.
id = add_time_series!(
store,
42,
"Generator",
Component,
ts; # name and descriptors come from ts
features = Dict("model_year" => 2030),
)
# `id` is the catalog row's id: how every read and removal addresses
# the series, and one integer to keep in your own model.
Notes:
owner_idis an integer (Int64) — the component identifier, e.g.42.resolutionis aPeriodsuch asHour(1)orMinute(5).featuresis aDictserialized to JSON, so values must be JSON scalars (Int,Float64,Bool,String). String features are supported and round-trip unchanged.- Adding a duplicate identity throws
DuplicateTimeSeriesError. - For a sparse step function — a monthly fuel price, say — use
PersistentTimeSeries, worked through in Step Functions.
add_time_series! returns the catalog row's id as an Int64 (see
Association ids). Every read, removal and copy takes
that id; list_metadata is how you recover one for a series you did not just write.
Two more rules worth knowing up front. Stored instants and periods are millisecond-precision: a
Microsecond(1) resolution, a Millisecond(0) one, or a negative period is refused with
InvalidParameterError (query bounds are unconstrained). And a Store is not thread-safe:
confine it, and any reader built from it, to one task, or guard every call with your own lock —
concurrent calls are undefined behavior, not just a race on results.
Descriptors
Beyond units, an association can carry quantity_kind (what the values measure — "ActivePower";
the one record of what per-unit values mean), unit_system (NaturalUnits or ComponentBase;
nothing means unspecified, not natural units — the store rescales nothing), component_field
(the field on the owning component these values vary — "max_active_power"; also a filter), and
application_data (an opaque string returned verbatim — the package-owned slot). They can be set on
the struct, where they become the add_time_series! defaults, or passed as keywords:
ts = SingleTimeSeries(DateTime(2024, 1, 1), Hour(1), collect(100.0:123.0), "load";
units = "MW", quantity_kind = "ActivePower",
unit_system = NaturalUnits, component_field = "max_active_power",
application_data = "{\"source\": \"weather_year_2012\"}")
id = add_time_series!(store, 42, "Generator", Component, ts) # keeps all five
A series also records a time_reference — how its timestamps were spelled — inferred from the
timestamp it was built with. A bare DateTime is a wall clock (ZonelessReference()); a
ZonedDateTime keeps its zone, so a Denver series renders correctly on both sides of every DST
transition. Reads still return a DateTime holding the instant, with the reference beside it;
using TimeZones adds zoned_timestamp to fuse them back together losslessly.
None of them is part of a series' identity or of either content hash, so two adds that differ only in a descriptor are a duplicate. See Optional Descriptors and Time references.
Add many series at once
AddBatch accepts the same add_time_series! calls as a Store but only accumulates them;
add_time_series_bulk! commits the whole batch in one catalog transaction and takes the block-sized
HDF5 write path, so same-shaped series land in the same packed dataset.
batch = AddBatch()
for (id, ts) in series
add_time_series!(batch, id, "Generator", Component, ts)
end
ids = add_time_series_bulk!(store, batch) # Vector{Int64}, in input order; all-or-nothing
This is an order of magnitude faster than a bare loop of single adds, which pays one catalog
transaction and one HDF5 flush per series. It is not faster than that same loop inside a
transaction, which buffers and writes the identical datasets. What separates the
two is one thing: the batch is written as a single block whatever its size, because you are already
holding it, where the transaction holds its buffered adds to
write_buffer_bytes and spills past it. Reach for the batch
when the whole cohort is in hand; reach for the loop when you would rather add each series as you
build it than hold them all first, and raise the budget if you want the single dataset back.
Transactions
Several operations that must take effect together — replacing a series is an add plus a remove — go inside a transaction. Removals are reversible only there; outside one the array bytes are reclaimed immediately.
transaction(store) do
new_id = add_time_series!(store, 42, "Generator", Component, updated)
remove_by_ids!(store, [old_id])
end # committed if the block returns, rolled back if it throws
Blocks nest (each level is a savepoint), and the store holds the SQLite write lock until the
outermost one ends. begin_transaction! / commit_transaction! / rollback_transaction! are the
explicit form.
A transaction is also where a run of single adds belongs. Nothing it writes is durable until the
outermost commit, so packed adds inside one are buffered per shape group and written as one block at
the commit — the datasets add_time_series_bulk! of the same series would produce, without having
to hold the batch yourself. Two things qualify it. The buffer spills a block early — an extra
dataset, nothing else, the same split a batch that wide gets — once a group reaches the narrower of
two widths, or once the unwritten arrays across every group cross 128 MiB:
- the columns one chunk row holds: 131,072 for a scalar
Float64; - 128 MiB divided by one column's bytes, which for anything but a short series is the ceiling that
actually binds — a 30,500-step
Float64series is 244 KB a column, so its group spills at 550.
And a span holding a single array fills a shared-pool slot rather than claiming a dataset one column wide.
How wide a dataset a span writes
The 128 MiB is a default, not a law. set_write_buffer_bytes! moves it, and with it how wide a
dataset a run of single adds can produce:
set_write_buffer_bytes!(store, 1 << 30) # 1 GiB
transaction(store) do
for (id, ts) in series
add_time_series!(store, id, "Generator", Component, ts)
end
end
# one dataset, however many series that was
Raised far enough, the loop writes exactly what add_time_series_bulk! of the same series writes —
and the memory it costs is the memory the batch was holding anyway. Measured on 400 hourly year-long
Float64 series, the two are already the same file in comparable time (~0.09 s either way, and
~0.12–0.14 s for a NonSequentialTimeSeries cohort on one axis); at 2,000 they part only over that
extra dataset, ~0.30 s as one batch against ~0.38 s as a loop, and raising the budget closes it.
write_buffer_bytes(store) reads the figure back. It belongs to the handle, not to the artifact —
nothing is persisted, and a store reopened elsewhere is back to 128 MiB. It is a budget for the
writing process, so a machine that cannot afford another machine's choice does not inherit it.
Lowering it mid-transaction writes out whatever the buffer already holds beyond the new figure; zero
throws, since a pool's width floors at one column and a zero budget would mean a dataset per array
rather than no buffering at all. The chunk-row ceiling above it does not move.
Values that are not numbers
A timestep's value can be a function rather than a number — a cost curve re-offered every hour, a
pair of coefficients, a fixed-width tuple. Hand the constructor those values and it does the rest:
it packs them into the array the store holds and names the element_type they imply.
curves = [
PiecewiseLinear([(x = 30.0, y = 1155.0), (x = 100.0, y = 4120.0)]),
PiecewiseLinear([(x = 30.0, y = 1353.0), (x = 65.0, y = 2730.0), (x = 100.0, y = 4223.0)]),
]
ts = SingleTimeSeries(t0, Hour(1), curves, "variable_cost")
ts.element_type # "piecewise_linear" -- derived, not declared
id = add_time_series!(store, 42, "Generator", Component, ts)
read_by_id(store, id).data == curves # true
There is no separate constructor for this and nothing to declare. A Vector{PiecewiseLinear} says
what it is, so element_type= is only for the numeric case, where the numbers alone cannot say what
they mean — and one that contradicts the values is an error rather than an override. The struct goes
on holding the values you gave it; encoding happens at the ABI boundary, which is why a read hands
back the same thing a write was given.
A case covered end to end in the runnable
single_custom_elements.jl
example. InfraStore.jl ships four value types plus NTuple{N,Float64}:
| Value type | element_type | Constructor |
|---|---|---|
LinearFunction | linear_function | (proportional, constant) |
QuadraticFunction | quadratic_function | (quadratic, proportional, constant) |
PiecewiseLinear | piecewise_linear | (points), a vector of XYCoords |
PiecewiseStep | piecewise_step | (x_coords, y_values) |
NTuple{N,Float64} | tuple(N,f64) | — |
Every series type takes them, including the irregular ones and the forecasts. A forecast's values keep their window shape rather than arriving flat, since a Julia array carries its own:
# [H = 2, count = 2]: a curve for every (horizon step, window) pair.
det = Deterministic(t0, Hour(1), Hour(2), Hour(1), 2, reshape(curves4, 2, 2), "offer")
Two things follow from Julia arrays carrying their element type that are worth knowing. An empty
series still names itself — NTuple{3,Float64}[] is a tuple(3,f64) series with no rows, which a
language whose empty list is untyped cannot express. And a metadata row's time_series_type is the
full parameterized type, SingleTimeSeries{PiecewiseLinear, 1}, so it describes the values rather
than their packing.
raw = true on a read hands back the packing instead, and the per-timestamp
readers are deliberately never decoded — they are the
simulation path, and their numbers are physical. encode_element_values / decode_element_values
are the same two directions as free functions, for an array with no series around it, and the door a
consumer extends to encode and decode its own domain types with no conversion step. See
Element values for both.
Read a Series
got = read_by_id(store, id)
@assert got.data == ts.data
println(got.initial_timestamp, " ", got.resolution) # resolution comes back as Millisecond
read_by_id also takes a window — start_time plus a len of timesteps or a count of windows —
which is checked: an over-long request throws rather than quietly returning less.
To read many whole series at once — e.g. loading everything for a plot — read_by_ids takes a
vector of ids and returns one struct per id in the same order (each of its stored type, so the
result is a Vector{Any}), reading each packed dataset's column span once instead of re-reading
every chunk per series. Its time_range keyword is the other kind of slice, which clips:
series = read_by_ids(store, ids)
window = read_by_ids(store, ids; time_range = (t0, t1)) # the same clip on every series
Attribute-Based Lookups
Beyond key handles, InfraStore.jl can resolve a series directly from its attributes — convenient
when a caller keeps its own identifiers (as an InfrastructureSystems.jl-side store does):
meta = get_metadata_by_id(
store,
42,
Component, # owner_category; the owner is the (owner_id, owner_category) pair
"load";
resolution = Hour(1),
features = Dict("model_year" => 2030),
)
# meta :: TimeSeriesMetadata — the whole record: owner_id/owner_type/owner_category,
# name, time_series_type, data_hash, initial_timestamp, resolution, length,
# horizon/interval/count, percentiles, element_type, element_shape, features,
# units, quantity_kind, unit_system, component_field, application_data
values = get_array_by_hash(store, meta.data_hash) # Vector{Float64}; pass ::Type{T} for other dtypes
# Identify, then act. `list_metadata` answers which series exist and hands back
# the id; every read and removal takes that id.
row = only(list_metadata(store, owner_id = 42, name = "load",
resolution = Hour(1),
exact_features = Dict("model_year" => 2030)))
got = read_by_id(store, row.id)
remove_by_ids!(store, [row.id])
# `has_time_series` stays attribute-addressed: it is an index probe that reads no
# row, so routing it through a resolution would cost more than it answers.
present = has_time_series(store, 42, Component, "load";
resolution = Hour(1), features = Dict("model_year" => 2030))
exact_features matches the feature map exactly — the series above was added with
model_year = 2030, so features (a subset match) would also select a sibling carrying more. The
plain features keyword is the right one for "every series tagged scenario = high"; a package
that resolves partial user queries lists, then decides what more than one match means.
Forecasts
InfraStore.jl exposes Deterministic, Probabilistic, and Scenarios structs that wrap a native
AbstractArray in the type's logical shape (the wrapper derives the dtype and dims and serializes
the buffer row-major). Construct one and add it through the generic add_time_series!:
data = zeros(Float64, 24, 7) # (horizon_count, count)
fc = Deterministic(
DateTime(2024, 1, 1), Hour(1), Hour(24), Hour(24), 7, data, "load_fc"; units = "MW"
)
id = add_time_series!(
store,
42,
"Generator",
Component,
fc, # name and units come from fc
)
got = read_by_id(store, id) # the id add_time_series! returned
values = got.data # Float64 matrix, shape (24, 7)
Probabilistic(initial_timestamp, resolution, horizon, interval, count, percentiles, data, name)
carries the percentile vector, and
Scenarios(initial_timestamp, resolution, horizon, interval, count, data, name) takes
scenario_count from data's leading axis. Every forecast constructor also accepts a
application_data= keyword. A read names only an id, so the row's own type decides what comes back
— there is no requested type to disagree with it.
If two forecasts of one owner/name/type differ only by interval (say day-ahead and intra-day),
pass interval= to the listing to pin the one you want; without it it returns both:
row = only(list_metadata(store; owner_id = 42, name = "load_fc",
resolution = Hour(1), interval = Hour(6)))
A DeterministicSingleTimeSeries is not added directly — derive one from the stored
SingleTimeSeries with transform_single_time_series!, which returns a TransformOutcome whose
transformed field is the count. It optionally restricts the transform to one owner_category
and/or one resolution, and dry_run = true runs every check without writing:
n = transform_single_time_series!(store, Hour(24), Hour(24)).transformed
outcome = transform_single_time_series!(store, Hour(24), Hour(24);
owner_category = Component, resolution = Hour(1),
normalize_single_window = true,
require_uniform_forecast_grid = true)
The two policy flags reproduce InfrastructureSystems.jl's rules (it passes both as true); see the
reference for what each enforces.
Filtering for Deterministic also matches a transformed DeterministicSingleTimeSeries, so you
find a forecast the same way whether it was added densely or derived — and either reads back as a
Deterministic, since a DST has no materialized struct. Each row still reports the concrete form it
is, so transform_single_time_series! needs no separate enumeration path:
for row in list_metadata(store; owner_id = 42, time_series_type = Deterministic)
row.time_series_type <: DeterministicSingleTimeSeries # derived, or densely stored?
series = read_by_id(store, row.id) # a Deterministic either way
end
transform_single_time_series! also reports the ids it wrote on its TransformOutcome.written, so
a caller can reference a view it just derived without listing the store to find it again.
has_time_series takes the time series type as its first argument to address anything other than a
SingleTimeSeries (and takes the same resolution / interval / features keywords).
copy_time_series! takes the source id and re-points that series at another owner without
duplicating data — it writes one association row against the same content-addressed array,
preserving the stored type (a DST stays a DST) — and returns the copy's own id:
has_time_series(Scenarios, store, 42, Component, "wind"; resolution = Hour(1))
src = only(list_metadata(store; owner_id = 42, name = "load")).id
copy_time_series!(store, src, 43, "Generator")
Every time_series_type filter keyword takes the Julia type as well:
list_metadata(store; time_series_type = Deterministic)
get_resolutions(store; time_series_type = SingleTimeSeries)
A metadata row's time_series_type is the full type — SingleTimeSeries{Float64,1},
Deterministic{Float32,3} — so a row names what a read of it hands back rather than only which of
the six kinds it is, and a consumer holding InfrastructureSystems.jl-style parameterized types gets
them back intact:
md = get_metadata_by_id(store, id)
md.time_series_type == typeof(read_by_id(store, id)) # every stored type but DST
md.time_series_type <: SingleTimeSeries # ask for the kind with <:, not ==
A derived DeterministicSingleTimeSeries is the exception: its row keeps the DST tag while a read
of it hands back a Deterministic with the same {T,N}. Dispatch on the read's type when the two
have to agree, and on the row's when you mean "was this derived?".
That type passes straight back into any filter, has_time_series, or reader. The parameters are
ignored there — a series is addressed by identity, which carries no element type — so they never
narrow a match; they are accepted so a row you just read round-trips without being taken apart
first.
The low-level get_metadata_by_id + get_array_by_hash path is still available for raw access. See
the Julia API reference.
Per-Timestamp Reads (Simulation Loop)
read_by_id hands back a whole series or forecast. Simulations instead walk the timeline and, at
each timestamp, want the value of every series at that instant. For that, build a reader once
and drive it in a loop — it pins one resolution and reuses its output buffers, so the loop allocates
almost nothing. StaticReader serves SingleTimeSeries; ForecastReader serves forecasts. (Full
signatures: Julia API reference.)
Static series
reader = build_static_reader(store; resolution = Hour(1))
grid = static_grid(reader) # StaticGrid: initial_timestamp, resolution, length
for k in 0:(grid.length - 1)
static_read!(reader, grid.initial_timestamp + grid.resolution * k)
for (gi, g) in enumerate(static_groups(reader))
vals = static_values(reader, gi) # (num_columns, element_dims...); column j ↔ g.ids[j]
end
end
Series are grouped by (dtype, element_shape); each group's static_values is one dense array
whose columns line up with the group's ids. All matched series must share one grid
(initial_timestamp + length), validated at build.
Most real systems do not meet that — a year of load beside a week of an outage schedule — and the build then errors, naming the series that diverges. Give the reader a span instead of letting it inherit one:
reader = build_static_reader(store; resolution = Hour(1),
window_start = DateTime(2024, 1, 1, 7),
window_length = 8760) # optional: without it, as far as all reach
Each column then reads at an offset of its own, so ragged series sweep together. The span is checked rather than clamped: a series that does not cover it errors, naming that series, and the anchor must land on each series' own step boundaries. See reader windows.
When the odd series out should not take part at all — a stray day of data beside a year of it is a different component, not a shorter view of the same sweep — filter to one grid instead:
reader = build_static_reader(store; resolution = Hour(1),
initial_timestamp = DateTime(2024, 1, 1, 7), length = 8784)
The window sweeps a span across whatever matched; the filter matches only the series already on that
grid, and reaches list_metadata and remove_by_filter! the same way. See
selecting one grid.
Forecasts
reader = build_forecast_reader(store, Deterministic; resolution = Hour(1))
tl = forecast_timeline(reader) # ForecastTimeline: initial_timestamp, resolution, interval, count
for k in 0:(tl.count - 1)
forecast_read!(reader, tl.initial_timestamp + tl.interval * k)
for (i, e) in enumerate(forecast_entries(reader))
window = forecast_values(reader, i) # shape e.window_shape, for e.key
end
end
A Deterministic reader is abstract — it also includes any DeterministicSingleTimeSeries (read
into identical windows).
Shared forecasts are read once
Forecasts that share a backing array (deduplicated identical data, or several
DeterministicSingleTimeSeries over one SingleTimeSeries) collapse to a single window slot.
forecast_read! reads each slot from the .h5 file once per timestamp, so a forecast shared by 10
components costs one read, not ten. forecast_num_slots(reader) is the physical read count, and
each ForecastEntry.slot says which slot an entry uses — group by slot to materialize each unique
window only once:
forecast_read!(reader, t)
windows = Dict{Int, Any}()
for (i, e) in enumerate(forecast_entries(reader))
w = get!(() -> forecast_values(reader, i), windows, e.slot)
# apply w to e.key's owner
end
Step Functions (PersistentTimeSeries)
A PersistentTimeSeries is a sparse step function: a strictly increasing vector of
breakpoints plus one value each, where the value at an arbitrary instant is the one belonging to
the greatest breakpoint at or before it. The motivating data is a monthly fuel or gas price curve —
a dozen breakpoints a simulation reads at timestamps that almost never land on one. The same curve
stored as a NonSequentialTimeSeries would throw at nearly every step, because an irregular series
has no value between its timestamps; that difference in read semantics is the whole reason this
is a separate type. See
Time series types for the model.
Build and add
The struct takes the same arguments as NonSequentialTimeSeries — and, like every series struct
here, its descriptors as keywords. A bare DateTime is read as a wall clock and a ZonedDateTime
as the instant it names, exactly as for the other types:
breakpoints = [DateTime(2024, m, 1) for m in (1, 4, 7, 10)]
prices = PersistentTimeSeries(
breakpoints,
[3.5, 4.25, 5.0, 4.75],
"gas_price";
units = "USD/MMBtu",
component_field = "fuel_cost",
# Whether a curve is expanded to a full series or collapsed to one scalar is
# your application's policy, and rides here where the store never reads it.
application_data = """{"as_time_series":false,"force_scalar_mode":"midpoint"}""",
)
id = add_time_series!(store, 7, "ThermalStandard", Component, prices)
got = read_by_id(store, id) # a PersistentTimeSeries; `timestamps` are the breakpoints
get_metadata_by_id(store, id).time_series_type <: PersistentTimeSeries # `<:`, not `==`
A metadata row's time_series_type is the full parameterized type
(PersistentTimeSeries{Float64, 1}), so ask which kind a row is with <:. Every type-taking call —
a time_series_type filter, has_time_series, either reader — accepts the bare spelling and the
parameterized one alike.
Read a window
read_by_ids' time_range slices on the step function's own terms: the result begins at the
breakpoint in force at the start, so it always defines a value at the start of the window you
asked for.
sliced = only(
read_by_ids(store, [id]; time_range = (DateTime(2024, 4, 10), DateTime(2024, 9, 1))),
)
sliced.timestamps # [2024-04-01T00:00:00, 2024-07-01T00:00:00] — April, not the first one inside
sliced.data # [4.25, 5.0]
Past the last breakpoint the last value holds forever, so a window opening after the end comes back
with that one row. Before the first breakpoint a step function is undefined: a non-empty window
starting there throws InvalidParameterError rather than clamping. (A zero-width range selects
nothing, here as for every type.) read_by_id's start_time + len window is checked rather
than sliced, so it must name one of the breakpoints; reach for the time_range form when the
instant is arbitrary.
Sweep step functions in the simulation loop
A StaticReader filtered to the type is the per-timestamp path, and it is the one place the
one-timeline-per-reader rule bends: a step
function has a value at every instant from its own first breakpoint on, so the columns need not
share a breakpoint vector. Per-fuel curves whose breakpoints do not line up still build one reader.
reader = build_static_reader(
store;
time_series_type = PersistentTimeSeries, # no resolution — passing one throws
component_field = "fuel_cost",
)
grid = static_grid(reader) # grid.resolution === nothing: no constant step
for at in static_timestamps(reader) # the sorted union of every column's breakpoints
static_read!(reader, at)
for (gi, g) in enumerate(static_groups(reader))
vals = static_values(reader, gi) # the value in force at `at`; column j ↔ g.ids[j]
end
end
static_timestamps is the union of the columns' breakpoints, so a position on it is not a
storage row for any one column — each column independently reports the value in force there. There
is still no presence mask: reading at an instant before some column's first breakpoint throws
InvalidParameterError naming that column's association id. Either filter the reader down to
columns that start early enough, or begin the sweep at the latest first breakpoint among them.
The sweep need not follow that union axis at all. static_read! accepts any instant every column
defines a value at, so driving this reader at your SingleTimeSeries grid's timestamps — the
simulation's own clock — works and is usually what an application wants:
for at in static_timestamps(load_reader) # the hourly grid the simulation runs on
static_read!(load_reader, at)
static_read!(reader, at) # each fuel price, held forward to this hour
end
These rows do not travel in an OpenAPI document
PersistentTimeSeries is an infrastore-local extension, and the vendored wire contract is a oneOf
over six canonical Sienna types with no schema for a seventh. So
export_time_series_associations_openapi omits persistent rows — a mixed store still exports
its six-type rows — a filter naming the type throws InvalidParameterError rather than answering
with an empty array, and an import refuses a document that carries one. The series themselves are
unaffected: they live in the artifact, which holds them in full. Ask the catalog what a document
leaves behind:
left_behind = list_metadata(store; time_series_type = PersistentTimeSeries)
Store-Wide Operations
counts = get_counts(store) # TimeSeriesCounts: components_with_time_series, static_time_series, forecasts
nerr = verify_integrity(store) # 0 == every referenced array and time axis matches its hash
report = compact!(store) # CompactionReport; rewrites the .h5 from the live set, so a
# delete actually shrinks the file. Nothing else may have the
# store open while it runs.
Associations
Two catalog tables record relationships between entities the store does not otherwise model, wholly independently of time series: which supplemental attributes are attached to which components, and directed parent/child edges between components. Removing a time series never touches either, and vice versa — see Associations Between Entities.
Filter keywords are all optional and ANDed; passing none matches everything.
add_supplemental_attribute_association!(
store, SupplementalAttributeAssociation(42, "Generator", 100, "GeographicInfo"))
# Bulk add is one all-or-nothing transaction.
add_supplemental_attribute_associations!(store, [
SupplementalAttributeAssociation(43, "Generator", 100, "GeographicInfo"),
SupplementalAttributeAssociation(43, "Generator", 101, "Outage"),
])
# Queries run in both directions, returning distinct ids in ascending order.
list_supplemental_attribute_ids(store; component_id=43) # [100, 101]
list_components_with_attributes(store; attribute_id=100) # [42, 43]
has_supplemental_attribute_association(store; component_id=42, attribute_id=100) # true
# `*_types` filters take CONCRETE type names, so expand an abstract type yourself —
# `get_all_subtype_names` in InfrastructureSystems.jl is the usual source. An empty
# vector is a deliberate "none of these" and matches nothing.
list_supplemental_attribute_ids(store; component_id=43, attribute_types=["Outage"]) # [101]
count_supplemental_attributes(store) # 2, distinct attributes
count_components_with_attributes(store) # 2, distinct components
supplemental_attribute_counts_by_type(store)
# [SupplementalAttributeTypeCount("GeographicInfo", 2), SupplementalAttributeTypeCount("Outage", 1)]
supplemental_attribute_summary(store)
# [SupplementalAttributeSummaryRow("Generator", "GeographicInfo", 2), ...]
Identity is the (component_id, attribute_id) pair. The type names ride along for filtering and are
not part of it, so re-attaching the same pair under different type names is still a duplicate:
try
add_supplemental_attribute_association!(
store, SupplementalAttributeAssociation(42, "Load", 100, "Outage"))
catch e
e isa InfraStore.DuplicateAssociationError || rethrow()
@info e.msg # attribute 100 is already attached to component 42
end
# Removal returns a count. Matching nothing returns 0 rather than throwing, so assert on
# the count yourself if you expected a hit.
remove_supplemental_attribute_associations!(store; component_id=43) # 2
Parent/child edges work the same way, except that identity is the ordered pair — the reverse of an edge is a different edge — and both endpoints are always components:
add_parent_child_association!(store, ParentChildAssociation(42, "Generator", 7, "Bus"))
add_parent_child_associations!(store, [ParentChildAssociation(43, "Generator", 7, "Bus")])
list_children(store; parent_id=42) # [7]
list_parents(store; child_id=7) # [42, 43]
count_parent_child_associations(store) # 2
# Renumbering a component rewrites both ends of every edge.
replace_parent_child_component_id!(store, 42, 99) # 1
list_parents(store; child_id=7) # [43, 99]
Neither table is reachable over gRPC or the infrastore CLI.
Persist to Disk
flush!(store) # sync HDF5 + SQLite; afterwards system.h5 + system.h5.sqlite can be copied
Keep the .h5 and .h5.sqlite files together.
To change a store you did not build in this process, open a copy: open_store defaults to
read-write, and HDF5 has no journal, so an interrupted in-place write is unrecoverable.
store = open_copy(src, joinpath(scratch, "time_series.h5")) # src is never opened for writing
...
persist!(store, src) # one atomic rename replaces it
open_store(path; read_only=true) is the right call when nothing will be written.
Where the Catalog Lives
By default the catalog is system.h5.sqlite, and every commit is durable. Passing
catalog=:memory keeps it in RAM instead, so it reaches disk only via persist!:
# Build in a scratch directory; nothing is durable until the explicit save.
store = Store(; in_memory=false, path=joinpath(scratch, "time_series.h5"), catalog=:memory)
add_time_series!(store, 42, "Generator", Component, ts)
persist!(store, destination) # writes both halves as a matched pair
persist_catalog!(store) # or: land only the .sqlite half beside the arrays already at path
Arrays still stream to the HDF5 file, so this does not require the data to fit in memory. It suits
building a store beside volatile in-process state — a crash loses that state anyway, so journaling
the scratch catalog buys nothing. catalog_mode(store) reports which mode a store is in.
open_store(path; catalog=:memory) loads an existing catalog into RAM the same way. Note that the
HDF5 half is still opened in place, so mutations land in the original file; open a copy if you
mean to leave the source untouched until an explicit save.
persist! stages both halves and renames them into place, and stamps the pair so that a save
interrupted between the two renames is caught on the next open rather than read as a valid store. It
does replace the destination, though, so a failed save may have destroyed what was there — recover
by calling persist! again on the still-live store rather than assuming the target survived.
Error Handling
Errors subtype InfraStore.TimeSeriesException. The exception types are not exported, so reference
them module-qualified. Catch broadly or narrowly:
try
add_time_series!(store, 42, "Generator", Component, ts)
catch e
if e isa InfraStore.DuplicateTimeSeriesError
@warn "already present"
else
rethrow()
end
end
The available types are InfraStore.NotFoundError, InfraStore.DuplicateTimeSeriesError,
InfraStore.DuplicateAssociationError, InfraStore.InvalidParameterError,
InfraStore.IntegrityError, InfraStore.ReadOnlyStoreError, InfraStore.IOError,
InfraStore.StoreExistsError (creating over an existing artifact),
InfraStore.MismatchedArtifactError (the .h5 and .sqlite halves came from two saves),
InfraStore.IncompatibleFormatError (the on-disk store was written by an incompatible data format
version), and InfraStore.GenericError (which carries the raw FFI status code). See the
reference for the full table.
InfrastructureSystems.jl Integration Notes
Embedding in a Parent Package is the language-neutral version of this section — the store lifecycle, id mapping, and lookup semantics a package like InfrastructureSystems.jl has to honor. The Julia-specific points:
- Owners are integer component identifiers (
Int64), matching InfrastructureSystems.jl component/attribute IDs. OwnerCategorydistinguishesComponentfromSupplementalAttributeand is part of the owner identity: the owner is the(owner_id, owner_category)pair, so a component and a supplemental attribute may share a numeric id and stay distinct. Owner-scoped calls take the category alongside the id.- The attribute-based existence probes (
has_time_series,has_any_time_series) plusget_metadata_by_idandget_array_by_hashlet an InfrastructureSystems.jl-side store keep its own object model — holding only the catalog id — and reach the array layer directly. - For the simulation read pattern — iterate every component's value at each timestamp, reading a
forecast shared across components only once — use the readers
(Per-Timestamp Reads). The
ForecastEntry.slot/forecast_num_slotssurface lets the wrapping store dedup its own per-component work, mirroring the store's one-read-per-shared-array behavior.
See Language Bindings for how this maps onto the FFI.
Diagnostics and tracing
The store emits structured tracing spans for every significant operation. To see them, initialize a subscriber before your first store call.
Via environment variable — set RUST_LOG before loading the package. The module's __init__
hook calls init_logging("") automatically, which reads RUST_LOG if set:
# shell
export RUST_LOG=infrastore_core=debug
julia --project=. myscript.jl
Programmatically — call init_logging with a filter directive string:
using InfraStore
init_logging("infrastore_core=debug")
store = Store(in_memory=true)
add_time_series!(store, ...) # spans appear on stderr
init_logging is a no-op if a subscriber is already registered (including the automatic one from
RUST_LOG). The filter syntax is the same as RUST_LOG: comma-separated target=level pairs, or a
bare level such as "debug" to match everything. Useful targets:
| Target | What it covers |
|---|---|
infrastore_core | All store operations — add, get, remove and HDF5 I/O |
CLI Developer Guide
infrastore loads time series from CSV files and inspects a store, talking directly to the on-disk
.h5 + .h5.sqlite pair — no gRPC server, and unlike the server it is not read-only. This
guide walks the whole workflow; for every flag, the descriptor schema, and the CSV layouts, see the
CLI Reference. For a 60-second round trip, see the
CLI Quick Start.
It is also the fastest way to see what a consumer package actually wrote: infrastore list and
infrastore info read any artifact your package produces, and infrastore diff compares two by
hash without reading arrays.
Install
Grab a prebuilt archive from the Releases page — the executables inside are statically linked against HDF5, so there is nothing else to install:
VERSION=v0.14.0 # pick a release from the Releases page
curl -fsSLO https://github.com/NatLabRockies/infrastore/releases/download/$VERSION/infrastore-aarch64-apple-darwin.tar.gz
tar xzf infrastore-aarch64-apple-darwin.tar.gz
Or install from crates.io, which builds HDF5 from source and so needs cmake and a C compiler:
cargo install infrastore-cli # installs the `infrastore` binary
See Installation for the per-platform archive list and checksum verification.
Working in a checkout instead:
cargo build -p infrastore-cli # debug build at target/debug/infrastore
cargo build -p infrastore-cli --release
The examples below assume infrastore is on your PATH (or use ./target/debug/infrastore).
Describe the Data
Numeric values live in a CSV; everything that does not fit a flat grid (owner, name, type,
element_type, resolution, initial timestamp, units, features) lives in a descriptor JSON. Print
a starting point for any type with template:
infrastore template SingleTimeSeries > load.json # print an example descriptor to edit
Edit it to point at your data and metadata:
{
"owner_id": 42,
"owner_type": "Generator",
"owner_category": "Component",
"name": "load",
"type": "SingleTimeSeries",
"element_type": "f64",
"units": "MW",
"csv": "load.csv",
"initial_timestamp": "2024-01-01T00:00:00Z",
"resolution": "PT1H",
"features": {
"model_year": 2030
}
}
# load.csv
value
100.0
101.5
103.0
104.2
102.8
101.0
Every data CSV needs a header row. It is not decoration: add reads it to tell a hand-written
value-only file from one export wrote (see
Reading back, and re-adding). A file whose first
row is data is rejected rather than quietly losing that row to the header.
The descriptor rejects unknown keys, so a typo (resolutionn) is a hard error rather than a
silently ignored setting.
Timestamps must name an instant. A timestamp column (or initial_timestamp) written as
2024-01-01 00:00:00 — no offset, the way most spreadsheets and databases export — is rejected,
because it names no instant. Rather than rewrite the file, say what zone it was written in with the
global --assume-timezone (UTC, or a fixed offset like -07:00; named zones such as
America/Denver are deliberately not accepted, because a DST fold would make some rows ambiguous):
infrastore --store demo.h5 --assume-timezone UTC add --descriptor load.json
A timestamp that carries its own offset is never overridden. See Zoneless timestamps.
Add It to a Store
infrastore --store demo.h5 add --descriptor load.json --dry-run # check first
infrastore --store demo.h5 add --descriptor load.json
The store (demo.h5 and its demo.h5.sqlite catalog) is created on first add, or explicitly with
init when you want to pin a compression policy up front:
infrastore --store demo.h5 init --compression deflate:6 # default is deflate:3
infrastore --store demo.h5 init --compression none --catalog in-memory # see below
A descriptor may also be a JSON array of objects to add many series in one transaction. --csv
overrides the descriptor's csv path, but only when the descriptor is a single object: with an
array of two or more it errors (--csv cannot be used with an array descriptor).
--dry-run is worth running first on anything large. It resolves every descriptor and reads every
CSV in full, then prints the resolved (owner, type, name, element type, shape) table without
opening the store — which catches the "I got the shape wrong" class of mistake before a multi-GB
load starts. --replace makes a re-run after fixing the data idempotent, and --descriptor - reads
the JSON from stdin so a generator script can pipe descriptors straight in:
infrastore --store demo.h5 add --descriptor batch.json --replace
generate.py | infrastore --store demo.h5 add --descriptor - --quiet
For a load too large to hold in one transaction, --batch-size N commits every N series (at the
cost of the load's atomicity), and --catalog in-memory skips the per-commit journaling of the
SQLite catalog while the command runs — the CLI writes it out at the end of the command either way:
infrastore --store demo.h5 add --descriptor batch.json --batch-size 500 --catalog in-memory
Every command carries worked examples in its own help — infrastore add --help — and
infrastore --help is the grouped index.
For a one-off, the descriptor fields are also add flags:
infrastore --store demo.h5 add --csv load.csv --owner-id 42 --owner-type Generator \
--name load --type SingleTimeSeries --element-type f64 --units MW \
--resolution PT1H --initial-timestamp 2024-01-01T00:00:00Z
One file, many components
The canonical power-systems CSV is one column per component, which is the opposite shape from the descriptor's one-object-one-series default:
# gen_profiles.csv
timestamp,gen_001,gen_002,gen_003
2024-01-01T00:00:00Z,101.5,88.2,44.0
2024-01-01T01:00:00Z,102.1,87.4,44.6
"layout": "wide" loads that as one scalar series per column. The store keys on an integer
owner_id while the headers are component names, so the mapping is an input — a
column,owner_id[,owner_type] sidecar CSV, an inline object, or "owner_id_from": "header" when
the headers already are ids:
{
"csv": "gen_profiles.csv",
"layout": "wide",
"type": "SingleTimeSeries",
"name": "max_active_power",
"owner_type": "ThermalStandard",
"element_type": "f64",
"units": "MW",
"initial_timestamp": "2024-01-01T00:00:00Z",
"resolution": "PT1H",
"owner_map": "components.csv"
}
infrastore grid writes that same shape back out, so the two are an inverse pair — see below.
Read It Back
infrastore follows an output convention: a global -f/--format with table (default), json,
jsonl, and csv. The read commands render their results in it; the write commands (add,
remove, transform, …) report their outcome in it — prose under table, a one-object status
document such as {"added": 3, "store": "demo.h5"} under json/jsonl — so a scripted mutation
pipes into jq the same way a query does. Only template ignores it. jsonl is json
line-delimited — one compact object per line, which streams into jq where a single pretty array
cannot. Under -f json an error is a {"status": "error", "message": …} document on stderr.
Before you can write a selector you need to know what values exist, which is what the discovery commands are for:
infrastore --store demo.h5 names # distinct series names
infrastore --store demo.h5 owner-types # distinct owner types
infrastore --store demo.h5 owners --type SingleTimeSeries # distinct owner ids
infrastore --store demo.h5 exists --name load # exit 0 = yes, 1 = no
infrastore --store demo.h5 list # what's in the store
infrastore --store demo.h5 list --name-glob 'load_*' # name pattern (SQLite GLOB)
infrastore --store demo.h5 list --limit 20 --wide # bounded, all columns
infrastore --store demo.h5 get --owner-id 42 --name load # pretty table
infrastore --store demo.h5 get --name load --tail --limit 24 # the last day
infrastore --store demo.h5 get --name load --plot # a terminal sparkline
infrastore --store demo.h5 -f csv get --owner-id 42 --name load # timestamped CSV
infrastore --store demo.h5 -f jsonl list # one JSON object per line
infrastore --store demo.h5 -f json info --owner-id 42 --name load # metadata + hash + stats
infrastore --store demo.h5 -f csv export --dir out/ # one file per series
To see many series side by side against one time axis — the read-direction inverse of the wide
ingest above — use grid:
infrastore --store demo.h5 -f csv grid --name max_active_power --resolution PT1H
Every column in a grid shares one timeline, which is what makes the rows line up without a presence
mask; that is why SingleTimeSeries needs --resolution. When every column shares one series name
the headers are bare owner ids, which is exactly the wide form add reads back.
When the matched series do not share one — a year of load beside a shorter schedule — the build
fails, naming the series that diverges. --window-start sweeps a span you name instead, with each
column reading at an offset of its own:
infrastore --store demo.h5 -f csv grid --resolution PT1H --window-start 2024-01-01T02:00:00Z
Add --window-length N to pin the extent; without it the sweep runs as far as every matched series
reaches. A series that does not cover the span is an error naming it, never a column quietly left
out of the table.
Sometimes the odd series out should not be a column at all. Then select one grid instead — with
--resolution, the --initial-timestamp and --length selectors name a whole grid, and every
series not on it drops out:
infrastore --store demo.h5 -f csv grid --resolution PT1H \
--initial-timestamp 2024-01-01T02:00:00Z --length 6
They are ordinary selectors, so list, remove, and the rest take them too — which is how you find
the grids a store holds (list --length 24) and retire a stray cohort without naming its owners.
export is the bulk read-direction inverse of add: every series the selector matches is written
to its own CSV or JSON file under --dir (or to stdout when exactly one matches), optionally sliced
with --time-range; -f parquet instead writes
one file pair per partition. Setting INFRASTORE_STORE in the
environment stands in for --store, every destructive command except compact accepts --dry-run
to preview its effect, and the global -y/--yes answers every confirmation prompt so a script
does not have to know which commands ask:
export INFRASTORE_STORE=demo.h5
infrastore remove --name-glob 'scratch_*' --dry-run # what would go
infrastore -y remove --name-glob 'scratch_*' # no prompt
infrastore -f csv export --name load --time-range 2024-01-01T00:00:00Z..2024-01-08T00:00:00Z
info reports metadata, the array's content hash and where it lives in the HDF5 file, and stats
over the values: min/max/mean/stddev, the p5–p95 percentiles, first/last, and a
separate non_finite count — or true_count/false_count when the dtype is bool. The stats are
the only part that reads the array — --no-stats skips it for a purely catalog-side query.
list shows every field that is part of a series' identity, features included, so two series that
differ only by a feature never render as the same row. Its Hash column is the first 12 characters
of the array's content hash: rows with equal hashes share one array on disk.
get/info/remove select a single series with --owner-id, --owner-category, --name,
--name-glob, --component-field, --type, --resolution, and repeated --feature key=value
(--feature is the only repeatable one); if more than one series matches, infrastore lists the
candidates so you can narrow the query. The owner is the (owner_id, owner_category) pair, so a
component and a supplemental attribute may share a numeric id — add --owner-category (Component
/ SupplementalAttribute) to disambiguate. Large series truncate in table output — pass
--limit N, --full, or --tail to read from the end. --stride N keeps every Nth row and,
unlike the display bounds, applies to a -f csv pipe too: it selects data rather than shortening a
view, and a silently short pipe is a bug in whatever consumes it.
--time-range START..END on get takes two timestamps (RFC3339 or epoch-ms), not a duration:
infrastore --store demo.h5 get --owner-id 42 --name load \
--time-range 2024-01-01T01:00:00Z..2024-01-01T03:00:00Z
Selectors accept either spelling: --type single and --type SingleTimeSeries mean the same thing,
as do --owner-category component and --owner-category Component. What the CLI prints — in
list/get/info output and in what template writes — is always the canonical CamelCase name,
so descriptors, rendered rows, and -f json output all string-match each other. The lowercase forms
are a typing shortcut on the command line, not a second vocabulary.
Chart It
infrastore --store demo.h5 get --name load --plot # sparkline, no file
infrastore --store demo.h5 plot --name load --out load.svg # the profile
infrastore --store demo.h5 plot --name load --kind duration --out ldc.svg
infrastore --store demo.h5 plot --name load --kind heatmap --out heat.html
infrastore --store demo.h5 plot --name load_prob --type Probabilistic --kind fan --window 0 --out fan.svg
infrastore --store demo.h5 plot --name load --type Deterministic --kind overlay --out forecast.svg
plot writes one self-contained file — no external fonts, scripts, or images, and both light and
dark themes inside it — so it opens in a browser and drops into a report. The five --kind values
are line, duration (the load duration curve), heatmap (time-of-day against day, which is how
you catch a timezone or DST shift), fan (percentile bands or scenario traces for one forecast
window), and overlay (a Deterministic's windows over the actuals it came from).
Find the Bytes on Disk
Arrays are content-addressed, so identical values are stored once and shared. arrays shows what
collapsed onto what, and where each array actually lives:
infrastore --store demo.h5 store-info # both file paths, format version, compression
infrastore --store demo.h5 arrays # one row per distinct array + the series sharing it
infrastore --store demo.h5 arrays --data-hash 2018057b # narrow to one (any prefix, any case)
info resolves a single series the same way, reporting data_hash, hdf5_dataset, and
hdf5_column. You need all three to open the data with an outside tool: a packed array is one
column of a dataset shared with other same-shaped arrays, and a packed dataset that fills up
spills into suffixed siblings, so neither the column nor the dataset name can be worked out from
metadata alone.
Opening the catalog directly, use the time_series_readable view — sqlite3 prints the raw BLOB
hashes as garbage bytes, and in .mode box it mangles the table borders:
sqlite3 demo.h5.sqlite 'SELECT name, data_hash FROM time_series_readable;'
Hand It to Something Else: Parquet
CSV is the interchange format add and export default to, and it has one real cost: a float goes
out as decimal text and comes back as whatever parsing that text gives. Parquet does not have that
problem, and every analysis tool worth the name reads it:
infrastore --store demo.h5 -f parquet export --name-glob 'load_*' --dir parquet/
infrastore --store other.h5 add --parquet parquet/
What comes out is a handful of partitions, not a file per series -- a store with thousands of series would otherwise become thousands of files. Each partition is a pair:
parquet/
SingleTimeSeries.f64.utc.values.parquet every distinct array once, one row per value
SingleTimeSeries.f64.utc.series.parquet one catalog row per series
SingleTimeSeries.f64.America_Denver.values.parquet
SingleTimeSeries.f64.America_Denver.series.parquet
Deterministic.f64.utc.values.parquet
Deterministic.f64.utc.series.parquet
One pair per (type, value type, time reference) triple, because those three cannot vary inside one
table without nullable or ill-typed columns -- a forecast has an issue_time and a static series
does not, and a table has one Arrow type per column. The payoff is that every column is
required, which is worth more to whoever queries the files than the files it costs.
The split is the other half of it. The store is content-addressed, so a thousand components sharing
one profile hold one array; writing the catalog row beside every value would write that profile
a thousand times, and Parquet's compression does not find repeats across pages. So the values file
holds each array once, keyed by the pair (data_hash, time_axis), and the series file carries that
same pair beside each catalog row. They join on it.
add --parquet takes a file, a whole directory, or a partition stem, and commits one transaction
per partition, so a partition that fails leaves the ones already committed alone. A pair export
wrote re-adds with no other flag; a foreign file -- one from a dataframe, or a values file whose
partner was not copied -- carries less, and is told what it is missing:
infrastore --store demo.h5 add --parquet from_pandas.parquet \
--owner-id 42 --owner-type Generator --name load
Three things to know. -f parquet requires --dir, because Parquet's footer sits at the end of the
file and a writer has to seek back to it -- a pipe cannot. The data_hash half of the key is a
checksum on the way back in: if you edited values in DuckDB, recompute it or pass --no-checksum.
And a series with no values fails the export, naming every one -- an empty series would be a
catalog row whose key matches no values rows, which is also what a truncated export looks like.
The full layout -- both column sets, the array key, the partition rules, the filenames, the footer, the merge join, and what a foreign file has to supply -- is in Parquet Layout.
Parquet is on by default, in the released binaries and in cargo install infrastore-cli alike. It
is a cargo feature, so --no-default-features --features vendored builds a binary without the
Arrow dependency tree; that binary still accepts the flags and tells you which feature to rebuild
with, rather than reporting parquet as an unknown format.
Querying it with DuckDB
The reason to write Parquet rather than to embed a query engine here. Every question starts with the same join, on the pair that keys both halves:
SELECT s.name, s.owner_id, s.units, max(v.value) AS peak
FROM 'parquet/SingleTimeSeries.f64.utc.values.parquet' v
JOIN 'parquet/SingleTimeSeries.f64.utc.series.parquet' s USING (data_hash, time_axis)
GROUP BY s.name, s.owner_id, s.units;
Write it once as a view and nothing after has to think about it:
CREATE VIEW load AS
SELECT s.*, v.timestamp, v.value
FROM 'parquet/SingleTimeSeries.f64.America_Denver.values.parquet' v
JOIN 'parquet/SingleTimeSeries.f64.America_Denver.series.parquet' s USING (data_hash, time_axis);
-- One component's day, in its own spelling -- the timestamp column is zoned.
SELECT timestamp, value FROM load
WHERE owner_id = 42 AND name = 'load'
ORDER BY timestamp;
That view is the denormalized table the format deliberately does not write. Materializing it costs exactly what the split saves; keeping it as a view costs nothing.
A forecast joins the same way, and its window is a column of the values half:
SELECT v.issue_time, count(*) AS steps, max(v.value) AS peak
FROM 'parquet/Deterministic.f64.utc.values.parquet' v
JOIN 'parquet/Deterministic.f64.utc.series.parquet' s USING (data_hash, time_axis)
WHERE s.name = 'load_det'
GROUP BY v.issue_time
ORDER BY v.issue_time;
Note what a glob does not mix: 'parquet/*.values.parquet' only works across partitions whose
columns agree, which is to say within one time_series_type. Reading a static partition and a
forecast one together needs the columns named, since only the latter has issue_time.
The catalog is still there when you want what the files leave out -- the data_hash in its binary
form, the feature sets, the association tables:
INSTALL sqlite; LOAD sqlite;
ATTACH 'demo.h5.sqlite' AS catalog (TYPE sqlite);
-- Two joins: the array key pairs the halves, then `id` reaches the catalog.
SELECT s.name, s.owner_id, max(v.value) AS peak, c.timestamps_hash
FROM 'parquet/SingleTimeSeries.f64.utc.values.parquet' v
JOIN 'parquet/SingleTimeSeries.f64.utc.series.parquet' s USING (data_hash, time_axis)
JOIN catalog.time_series_readable AS c ON c.id = s.id
GROUP BY s.name, s.owner_id, c.timestamps_hash;
time_series_readable is the catalog's hand-inspection view -- it hex-encodes the two content
hashes and decodes the integer type codes, so the rows read as text (see
Reading the SQLite catalog by hand). The
id column on the series half is what reaches it; it is provenance only, and add assigns
fresh ids rather than reusing it.
Stamp Provenance on the Artifact
A store built by a model run should say so. store-attr records free-form key/value provenance
about the whole artifact -- who built it, from what source system, under which of your own schema
versions:
infrastore --store demo.h5 store-attr set creator sienna-build
infrastore --store demo.h5 store-attr set source_system WECC-2032-ADS
infrastore --store demo.h5 store-attr list
infrastore --store demo.h5 store-attr get creator # bare value on stdout
infrastore --store demo.h5 store-attr remove creator
The store never interprets a value, so structure rides in the text -- store JSON if you need it. The
attributes live in the catalog, which means they travel with persist, survive compact, and show
up under store_attributes in infrastore -f json store-info.
Three things to know. set replaces rather than appending: an artifact records one creator, not a
history of them. get exits 1 when the key is unset, so a script can branch on it, while remove
reports removed: false and exits 0. And keys beginning with infrastore. are reserved.
merge brings a source's attributes across without overwriting: a key the destination lacks is
copied, a key both sides agree on is a no-op, and a disagreement is reported and left as the
destination has it. diff gives them a section of their own and gates on them -- an artifact whose
recorded source system changed is not the artifact you expected, even when every series is
identical.
Note the name. attributes (below) is a different command about a different thing.
Associations
Two association catalogs live alongside the time series, readable and writable here:
infrastore --store demo.h5 attributes # component <-> supplemental attribute
infrastore --store demo.h5 attributes --summary # counts by (component type, attribute type)
infrastore --store demo.h5 links --parent-id 42 # directed parent -> child edges
infrastore --store demo.h5 attach --from attachments.csv
infrastore --store demo.h5 link --parent-id 42 --parent-type Generator \
--child-id 7 --child-type Bus
infrastore --store demo.h5 reassign --old 42 --new 43 # both catalogs follow a renumbered component
The store holds only the relationship — the components and attributes themselves live in the
consumer's object graph — so the flags are bare ids and type names. attach --from and
link --from import a whole table in one all-or-nothing transaction; their header is checked,
because the four columns are two interchangeable-looking (id, type) pairs and a swapped file would
silently invert every relationship. detach and unlink are the inverses, and take --dry-run.
Forecasts
All six writable types work (SingleTimeSeries, NonSequentialTimeSeries, PersistentTimeSeries,
Deterministic, Probabilistic, Scenarios). infrastore template Deterministic prints a
descriptor to edit, but it is plain JSON and says nothing about the data layout, so here is the
rule:
Forecast CSVs are a flat, row-major stream of values with no structure of their own. The count must equal the product of the type's shape:
| Type | Shape |
|---|---|
Deterministic | [H, count, *element_shape] |
Probabilistic | [num_percentiles, H, count, *E] |
Scenarios | [scenario_count, H, count, *E] |
H = horizon / resolution — with the template's "horizon": "PT24H", "resolution": "PT1H", and
"count": 7, a scalar Deterministic needs exactly 24 * 7 = 168 values, plus the header row. Use
-f json to read the flat values back at full fidelity. get -f csv and export -f csv on a
forecast emit timestamped analysis rows instead — one row per (window, step) with
issue_time/target_time columns and one value column per percentile or scenario. add recognizes
that header too and transposes the rows back, so an exported forecast re-adds exactly (see
Reading back, and re-adding).
DeterministicSingleTimeSeries is not added from CSV — store a SingleTimeSeries, then derive it.
transform takes no selector: it rewrites every SingleTimeSeries in the store. --horizon
must fit inside each one (horizon / resolution steps must not exceed its length), so with the
6-row hourly load above, a 24-hour horizon fails and a 3-hour one works:
infrastore --store demo.h5 transform --horizon PT3H --interval PT1H
The derived series keeps the source's owner, name, and resolution, so load now matches two entries
and a bare selector becomes ambiguous. Disambiguate with --type:
infrastore --store demo.h5 get --owner-id 42 --name load --type single
infrastore --store demo.h5 get --owner-id 42 --name load --type deterministic_single
A forecast's table view is the same structured one -f csv writes — issue_time, target_time,
and one column per percentile or scenario — and --window N (or --issue-time <TS>) narrows it to
a single window instead of dumping all of them:
infrastore --store demo.h5 get --name load --type deterministic_single --window 0
Compare and Move Stores
infrastore --store run.h5 diff --against baseline.h5 # exits 1 when they differ
infrastore --store demo.h5 merge --from other.h5 --name-glob 'load_*'
infrastore --store demo.h5 persist --dest backup.h5 --force
diff compares catalog identities and content hashes without reading either store's arrays, which
makes it cheap enough for a CI gate: two series hold the same numbers exactly when they carry the
same hash. merge moves arrays as bytes, so nothing is lost to a CSV round trip. persist is the
one write that refuses an existing destination without --force: a save that fails partway may
already have destroyed what was there.
Maintain It
infrastore --store demo.h5 verify # re-hash every array; exit 1 on a mismatch
infrastore --store demo.h5 check-consistency # every SingleTimeSeries of a resolution on one grid
infrastore --store demo.h5 stats # association, owner, and distinct-array counts
infrastore --store demo.h5 remove --owner-id 42 --name load --dry-run
infrastore --store demo.h5 compact --force # rewrite the .h5 so deletions actually shrink it
Deleting frees a column or unlinks a dataset, but HDF5 cannot give the space back in place, so the
file only shrinks when compact rewrites it from the live set — nothing else may have the store
open while it runs, and it is the one destructive command with no --dry-run. verify and diff
use exit status 1 as an answer, not a failure; 2 is a usage error
(Exit Status).
Shell completion for bash, zsh, fish, elvish, and PowerShell comes from the binary itself:
infrastore completions zsh > "${fpath[1]}/_infrastore"
Notes
- The CLI writes locally; there is no remote/gRPC mode yet (store access is isolated so one can be added later).
- Output is colored (green table headers) only when stdout is a terminal; it is plain when
piped/redirected or when
NO_COLORis set, so-f json/-f csvstay clean for other tools. --log-level(orRUST_LOG) controls logging; the default is quiet (warn).- The
.h5and.h5.sqlitefiles are one artifact — move, copy, and delete them together.
gRPC Server & Client Guide
The gRPC server exposes a store for remote, read-only access. Writes always require local filesystem access, so the service offers only list/get/keys/resolutions/counts/exists/verify. This guide covers running the server and talking to it from Rust. For the wire contract see the gRPC API reference; for the config file see Server Configuration.
When to Use It
Use the server to fan out reads of an existing store to many clients or across the network — for
example, serving a published dataset to analysis jobs. A single writer produces the .h5 +
.h5.sqlite pair locally; the server then reads it and answers queries. It never modifies the
files.
Run the Server
-
Produce a store with any binding and
flush()it to disk. -
Write a config (start from
examples/server.toml):[server] host = "0.0.0.0" port = 50051 [data] file = "./system.h5" # the .h5.sqlite catalog must sit beside it [authentication] method = "none" -
Launch:
cargo run -p infrastore-server -- --config my_server.toml # or, from a release build: ./target/release/infrastore-server --config my_server.toml
On startup the server validates the auth section, opens [data].file read-only, and serves the
CatalogStore service on host:port. Set RUST_LOG=debug for verbose logs.
Check that it is up
With grpcurl and the proto file:
grpcurl -plaintext -proto proto/infrastore/v1/store.proto \
127.0.0.1:50051 infrastore.v1.CatalogStore/GetCounts
Add -H 'x-api-key: replace-me-with-a-secret' when authentication is enabled. The equivalent from
Rust is RemoteClient::connect(...).get_counts() — see The Rust Client below.
The Rust Client
RemoteClient mirrors the read methods of Store and returns the same core types. gRPC status
codes are mapped back onto TimeSeriesError, so remote and local calls surface the same error
taxonomy.
use infrastore_core::OwnerCategory; use infrastore_server::client::RemoteClient; #[tokio::main] async fn main() -> Result<(), Box<dyn std::error::Error>> { let client = RemoteClient::connect("http://127.0.0.1:50051".into()).await?; let counts = client.get_counts().await?; println!("{} static series", counts.static_time_series); // Identify, then act. The owner is the (owner_id, owner_category) pair, and // every row carries the catalog id that addresses it. let rows = client .list_metadata( Some(42), Some(OwnerCategory::Component), None, None, None, None, None, None, None, None, ) .await?; if let Some(id) = rows.first().and_then(|m| m.id) { let data = client.read_by_id(id, None).await?; println!("read {} values", data.as_single().unwrap().length); } Ok(()) }
A series is addressed by its catalog association id, never by a key: list_metadata (or
list_metadata_by_ids, for ids a caller already recorded) hands back rows carrying id, and
read_by_id / read_by_ids / get_metadata_by_id take it. association_exists answers whether a
stored reference still resolves without fetching its row — the cheap way to validate a whole model
on load.
Available methods: connect, from_channel, list_metadata, list_metadata_by_ids,
get_metadata_by_id, association_exists, has_any_time_series, read_by_id, read_by_ids,
get_resolutions, get_intervals, get_counts, counts_by_type, time_series_counts_detailed,
get_forecast_parameters, list_owner_ids, static_summary, forecast_summary,
check_static_consistency, verify_integrity.
Authentication
To require an API key, configure the server:
[authentication]
method = "api_key"
keys = ["replace-me-with-a-secret-1", "replace-me-with-a-secret-2"]
method = "api_key" with an empty keys list is rejected at startup. Clients must then send the
key in the x-api-key metadata header; a missing or wrong key is rejected with
Unauthenticated before the RPC runs. The comparison against the configured keys does not
early-exit — every key of the same length as the supplied one is checked — so which key matched is
not leaked by timing. The supplied key's length is not blinded; keys of a different length are
rejected without a byte-wise compare, on the assumption that length is not secret.
RemoteClient::connect does not attach auth metadata, so against an authenticated server use the
generated client with an interceptor that injects the header:
#![allow(unused)] fn main() { use infrastore_proto::pb::catalog_store_client::CatalogStoreClient; use infrastore_proto::pb::CountsReq; use tonic::metadata::MetadataValue; use tonic::transport::Channel; let channel = Channel::from_shared("http://127.0.0.1:50051")?.connect().await?; let key: MetadataValue<_> = "replace-me-with-a-secret-1".parse()?; let mut client = CatalogStoreClient::with_interceptor(channel, move |mut req: tonic::Request<()>| { req.metadata_mut().insert("x-api-key", key.clone()); Ok(req) }); let counts = client.get_counts(CountsReq {}).await?.into_inner(); }
Clients in Other Languages
The proto file at proto/infrastore/v1/store.proto is a standard proto3 definition. Generate a
client for any gRPC-supported language from it, sending the x-api-key metadata header when the
server requires authentication.
Benchmarks
infrastore-bench is a standalone binary (infrastore-bench) for measuring two critical
performance dimensions: bulk ingestion and simulation-loop reads.
Build
# Debug build (fast to compile, slower to run)
cargo build -p infrastore-bench
# Release build (recommended for any real measurement)
cargo build --release -p infrastore-bench
The binary is placed at target/release/infrastore-bench.
Subcommands
| Subcommand | What it measures |
|---|---|
add | add_time_series_bulk throughput — HDF5 packing and SQLite transaction cost |
read | Per-timestep simulation I/O — reading all N components at each step t |
all | Runs add then read back-to-back |
Common flags
| Flag | Default | Description |
|---|---|---|
--count N | 1 000 | Number of components (time series) |
--length L | 168 | Timesteps per SingleTimeSeries; window count for Deterministic |
--in-memory | off | Use an in-memory store — eliminates disk I/O to isolate CPU cost |
--path DIR | temp dir | Directory for on-disk store files |
The read and all subcommands also accept --steps T (default: --length) to control how many
simulation timesteps are benchmarked.
Add benchmark
# 10 000 components, 1 week hourly, in-memory
infrastore-bench add --count 10000 --length 168 --in-memory
# 100 000 components, same length, on-disk (tests real I/O + OS page cache)
infrastore-bench add --count 100000 --length 168
Reports for both SingleTimeSeries and Deterministic:
- Build requests — time to construct
AddRequestobjects in memory (array allocation only; excluded from the add throughput measurement). add_time_series_bulk— wall time, items/s, and data MB/s.
SingleTimeSeries arrays are column-packed into HDF5 datasets of up to 1 000 columns each and
wrapped in a single SQLite transaction; the add benchmark stresses both. Deterministic arrays are
standalone HDF5 variables; their add cost is dominated by per-variable write overhead and the same
single-transaction SQLite commit.
Read benchmark
# 1 000 components, 168 timesteps, in-memory (pure CPU / metadata cost)
infrastore-bench read --count 1000 --length 168 --in-memory
# Same, but on-disk — store is written, dropped, then reopened read-only
infrastore-bench read --count 1000 --length 168
# Benchmark only the first 24 simulation steps of a 168-step series
infrastore-bench read --count 10000 --length 168 --steps 24
The read benchmark simulates the access pattern of an energy-simulation step loop:
for t in 0..T:
for each component key:
store.read_by_ids_range(ids, (t₀ + t·Δt, t₀ + (t+1)·Δt))
For on-disk stores the binary flushes, drops, and reopens the store read-only before the timed loop. This rebuilds the HDF5 in-memory index and starts the HDF5 chunk cache cold, reflecting real simulation startup conditions. The OS page cache may still be warm.
Reports per-step min / median / p95 / max and total component-reads/s.
Deterministic note. The current
read_by_idimplementation reads the full[H × count]array from storage and then slices to the requested window in memory. For largecount(e.g. 168 windows) each single-step read transfersH × count × 8bytes, which is24 × 168 × 8 = 32 KBper component. The benchmark output flags this explicitly so the overhead is visible.
Interpreting results
The two metrics to watch:
-
addthroughput drops sharply when the number of items exceeds a SQLite transaction or HDF5 dataset threshold. If adding 100 000 items is notably slower per-item than adding 10 000, the bottleneck is likely the SQLite commit or HDF5 file growth, not array construction. -
Per-step time for the read benchmark scales linearly with
--count. If the cost per step is much higher for on-disk than in-memory, the bottleneck is HDF5 chunk reads or SQLite metadata queries rather than Rust overhead. The packedSingleTimeSerieslayout chunks(rows, cols)across the whole width — one timestamp per chunk, or a few for a narrow dataset (file format) — so reading one timestamp across many series is a chunk read per dataset, while reading one full series touches every chunk band (the slow direction, expected for exploration/plotting rather than hot loops).
Tracing spans for deeper diagnosis
When the benchmark numbers show a problem but don't tell you which layer is slow, enable the
built-in tracing spans with --log-level:
# Show all debug-level spans from the store core only (least noise)
infrastore-bench --log-level infrastore_core=debug add --count 100 --in-memory
# Show everything — useful when the bottleneck might be in infrastore-bench itself
infrastore-bench --log-level debug add --count 100
RUST_LOG is also accepted as a fallback when --log-level is not provided.
The key spans emitted by infrastore-core:
| Span | Layer | Key fields |
|---|---|---|
add_time_series_bulk | Store | count — number of items in the bulk request |
read_by_id | Store | id, window |
copy_time_series | Store | src_id — the source association |
remove_by_ids | Store | count — number of ids in the request |
read_by_ids | Store | count — number of ids read in one pass |
list_metadata_by_ids | Store | count — number of ids requested |
put_array | HDF5 backend | bytes, packed |
put_packed | HDF5 backend | bytes |
put_packed_block | HDF5 backend | n — series written in one batch-sized block |
put_standalone | HDF5 backend | bytes |
get_array / get_slice | HDF5 backend | start, end (slice only) |
read_arrays | HDF5 backend | n — arrays fetched in one decompress-once pass |
read_index_into | HDF5 backend | n, index — one timestep across n series |
read_window_into | HDF5 backend | count_axis, window_index |
read_locked | HDF5 backend | — |
rebuild_index | HDF5 backend | — (runs once on Store::open) |
Spans nest: a single add_time_series_bulk call groups packed series by shape and emits one
put_packed_block span per group (filling whole chunks), plus a put_array → put_standalone span
per standalone item; a single add_time_series call instead emits one put_array → put_packed
span. This makes it straightforward to see whether time is spent in metadata insertion, HDF5 I/O, or
the debug_span overhead itself.
The infrastore CLI supports the same --log-level flag for diagnosing a live store.
Reference
Information-oriented listings: exact signatures, schemas, and on-disk layouts. Reach for these pages when you already know what you want and need the precise shape of it. For narrative guidance, see the Developer Guides; for concepts, the Explanation section.
- On-Disk File Format — The HDF5 layout and SQLite schema, byte for byte.
- Parquet Layout — The values/series file pairs
export -f parquetwrites andadd --parquetreads. - Element Types — What
element_typemeans, the row layouts it names, and the conformance corpus every binding's codec is tested against. - Rust API —
infrastore-corepublic types andStoremethods. - Python API — The
infrastoremodule. - Julia API — The
InfraStore.jlpackage. - C ABI — The
infrastore_fficdylib functions. - gRPC API — The
infrastore.v1service. - Server Configuration — The server TOML file.
On-Disk File Format
A persisted store is a pair of files that must be kept together:
<name>.h5 # HDF5 — numerical arrays
<name>.h5.sqlite # SQLite — metadata associations
The SQLite catalog path is the HDF5 path with .sqlite appended to the file name. This page is the
authoritative description of both. For the rationale behind the split, see the
Storage Model.
The array file is a plain HDF5 file, written directly against libhdf5. Store::open accepts
only files it wrote itself (see storage_backend below). The .h5 extension is a convention — the
store never inspects it.
Format Version
The HDF5 root carries four global attributes:
data_format_version = "0.19.0"
compression = "deflate:3:shuffle"
storage_backend = "hdf5"
catalog_generation = "9f2c1ab4e70d5836c41b9e2af0d7c358"
data_format_version is the semver of the on-disk format (DATA_FORMAT_VERSION). It is bumped when
the HDF5 layout, the SQLite schema, or the hashing domain
changes in a backward-incompatible way. A purely additive SQLite table does not qualify: it costs
old readers nothing and old stores pick it up from the idempotent DDL on their first writable open.
The associations table landed that way.
compression records the filter policy the store was created with, so that appends made after
reopening reuse the same filter. It is not part of the compatibility contract — see
Compression below.
storage_backend marks the file as one infrastore wrote. Store::open checks it before reading
anything else and rejects a file that lacks it with InvalidParameter — this is what distinguishes
an infrastore store from an arbitrary HDF5 file, so a foreign file is refused rather than misread.
catalog_generation pairs this file with exactly one catalog; the same value lives in the catalog's
catalog_identity table. Store::open compares the two and rejects a mismatch
with MismatchedArtifact. It is written at creation and re-minted by every persist_to, which is
what makes a save interrupted between its two renames detectable — see
Saving. It is additive, so it carries
no data_format_version change: a store written before it existed has neither the attribute nor the
table, which reads as "unstamped" and skips the check rather than failing. Compaction preserves the
existing value rather than minting a new one, since it rewrites only the HDF5 half.
Opening a store sorts its recorded version into one of three tiers, not two:
-
Current — the stamp equals this build's
DATA_FORMAT_VERSION. Opened as-is. -
Upgradable — the stamp is at least
MIN_UPGRADABLE_VERSIONand older. The array layout is compatible, so the file is read as it stands; a writable open runs the catalog migration ladder and then re-stamps the file. A read-only open changes nothing and leaves the stamp alone.Whether a read-only open succeeds is decided by the catalog half, not by this tier.
CatalogMigrationRequiredcomes from a staleCATALOG_SCHEMA_REVISION; an upgradable stamp sitting over an already-current catalog opens fine. That is not a hypothetical combination — it is what an interrupted writable open leaves behind, and the step ordering is chosen to make it the harmless outcome. -
Incompatible — older than
MIN_UPGRADABLE_VERSION, newer than this build, or unparseable (including an unstamped file). Fails withIncompatibleFormat, naming both versions. There is no upgrade path: regenerate the store with the matching build.
The catalog carries its own revision in schema_version (CATALOG_SCHEMA_REVISION), independent of
the artifact version above. Any catalog change the idempotent DDL cannot make to an existing table
— a new column, a changed CHECK, a rebuilt table, a backfill — needs a CATALOG_SCHEMA_REVISION
bump plus an append-only migration, not a re-created store. A catalog written by a newer build is
CatalogTooNew and is refused in both directions. Catalog revision 2, which widens the
time_series_type CHECK from BETWEEN 0 AND 5 to >= 0, is the first such change and takes no
DATA_FORMAT_VERSION bump at all — nothing in the HDF5 file moves, so a 0.19.0 store upgrades
in place on its first writable open. See
Upgrade a store in place.
PersistentTimeSeries (storage code 6) is the first type to arrive through that door, and it
likewise takes no version bump. The HDF5 layout is unchanged — a persistent series pools into the
same nsts_… datasets as a NonSequentialTimeSeries on the same breakpoints, and its breakpoints
are an ordinary timestamp vector — so the widened CHECK is the whole of what it needed from the
catalog, and every 0.19.0 store already has it.
Version history
Newest first. Each entry is the change that forced the bump; 0.19.0 is both the current
DATA_FORMAT_VERSION and the current MIN_UPGRADABLE_VERSION, so only the top entry describes
stores this build can open.
0.19.0 — moved a NonSequentialTimeSeries's timestamps out of the SQLite catalog and into the
HDF5 file: the timestamp_sets table is gone, and each distinct time axis is now an i64 dataset
of unix milliseconds under time_series/timestamps/, named by the same content hash the association
row already carried. The hash domain changed with it — timestamps_hash is now the SHA-256 of those
milliseconds rather than of the delta-varint blob the table held — so the nsts_… pool names change
too. The timestamps are data, and belong in the half of the artifact built for data: a store may
hold many distinct axes, each of them long, and a JSON document round trip needs them to travel with
the artifact rather than be locked inside the catalog.
0.18.0 — made the id column of all three catalog tables (time_series_associations,
supplemental_attribute_associations, and parent_child_associations) an
INTEGER PRIMARY KEY AUTOINCREMENT rather than a bare rowid alias, so an id is never reissued once
its row is deleted. Recycled ids matter because the id is an external reference — a consumer stores
it in its own model — and reuse makes a stale reference resolve to a different, valid row.
This is the one bump that adds no column and changes no value. It was taken because AUTOINCREMENT
is part of a table's declaration and there is no ALTER TABLE for it, so re-running the DDL over an
older catalog would have left all three tables on recycled ids while reporting success — and,
before the migration ladder existed, stranding that catalog was the only way to keep it out. The
version floor is what enforces the guarantee, and it holds: MIN_UPGRADABLE_VERSION is 0.19.0,
so every catalog predating this entry is Incompatible and refused on open. No store this build can
open lacks AUTOINCREMENT. The silent-recycling failure above is what the bump prevented; it is
not a state a store can be found in, and nothing needs to check for it at runtime
(every_association_table_declares_autoincrement in
crates/infrastore-core/tests/association_ids.rs holds a fresh catalog to the property, and
sqlite_sequence is what proves the keyword took effect rather than merely being present).
The ladder has since changed what a repeat would cost, so do not reason from this entry to the next
one. A table rebuild is now an ordinary migration — revision 2 is exactly that,
and carries the AUTOINCREMENT high-water mark across by hand — so the same change made today would
be a CATALOG_SCHEMA_REVISION bump that upgrades existing catalogs in place, not a
DATA_FORMAT_VERSION bump that strands them.
0.17.0 — added the metadata column time_reference, which records how a series' timestamps
were spelled: an instant in UTC, an instant at a fixed offset, an instant in a named IANA zone, or
a wall clock naming no instant. It also changes how stored timestamps are interpreted — a row
marked zoneless holds wall clocks the store keeps as if UTC, which an older reader would hand back
as instants. See Time References.
0.16.0 — added the metadata column component_field.
0.15.0 — renamed the metadata column ext to application_data and added the quantity_kind
and unit_system columns. Unlike a new table, new columns are not picked up by the idempotent
CREATE TABLE IF NOT EXISTS DDL, so a store one version behind is rejected on open.
0.14.0 — moved a NonSequentialTimeSeries's timestamps out of the association row: the
timestamps_json TEXT column became a timestamps_hash BLOB resolving into a then-new
content-addressed timestamp_sets catalog table (which 0.19.0 replaced with the HDF5 datasets
described above), and irregular arrays sharing a time axis became column-packed into nsts_…
datasets keyed by that hash instead of one standalone arr_… dataset each.
0.13.0 — replaced the dtype column with element_type, which names the logical element
type and derives the physical dtype from it. See Element types.
0.12.0 — changed owner_category and time_series_type from TEXT names to small INTEGER
codes. See Discriminant encoding below.
0.11.0 — renamed the metadata column logical_type to ext, an opaque, package-owned
extension payload (typically JSON) the store stores verbatim and never interprets.
0.10.0 — replaced the per-association features table with the content-addressed
feature_sets table below, so a feature map is stored once and shared by every association that
uses it, dropping the association_id foreign key and its ON DELETE CASCADE.
0.9.0 — changed the packed-dataset chunking to timestamp-major (1, cols, *element_shape) and
made the column count cols per-dataset (sized to the writing batch) instead of a fixed 1,000,
optimizing reads across series by timestamp and bulk writes.
0.8.0 — added the forecast interval to the association uniqueness key, so two forecasts of
one variable differing only by interval became distinct series. This widened both unique indexes
(the NULL-folding index now COALESCEs interval as well as resolution).
0.7.0 — made resolution/horizon/interval calendar-aware
periods, encoded as ISO-8601 duration strings (e.g.
PT1H, P1M, P1Y) rather than integer milliseconds, in both the packed dataset names and the
SQLite columns, so irregular periods (Month/Quarter/Year) can be represented distinctly from
fixed spans.
0.6.0 — added owner_category to the association uniqueness key, making the owner identity
the pair (owner_id, owner_category) and widening the unique indexes and idx_owner.
0.5.0 — changed the owner identifier to a signed 64-bit integer (owner_id).
0.4.0 — the baseline the Rust port of InfrastructureSystems.jl shipped with, and the version
that introduced DATA_FORMAT_VERSION itself.
0.3.0 — switched the time unit from nanoseconds to milliseconds, renaming the SQLite *_ns
columns to *_ms and encoding the packed dataset name's {res} field in milliseconds instead of
whole seconds.
0.2.0 — introduced typed, multi-dimensional arrays and the two-mode array layout below.
0.1.0 — stored only 1-D f64.
Arrays Are Typed and N-Dimensional
Every stored array is a TypedArray: an element dtype, a shape [length, k1, k2, …] whose
first axis is time and whose trailing axes are a fixed per-step element shape, and the row-major
element values.
Byte order differs between the file and everything outside it. In the HDF5 file each dataset's
datatype records its byte order, which is the native order of the host that wrote it, and libhdf5
converts on every read. Outside the file, a TypedArray's buffer is always little-endian: that is
what the content hash covers and what every binding and the C ABI exchange. A reader using its own
HDF5 tools gets correct values from any host's file without knowing either fact.
The supported dtypes and their stable integer codes (shared with the bindings and the C ABI). Codes 0–5 are the original set and never move; new widths are appended:
| Code | dtype | Width | Code | dtype | Width |
|---|---|---|---|---|---|
| 0 | f64 | 8 | 6 | i16 | 2 |
| 1 | f32 | 4 | 7 | i8 | 1 |
| 2 | i64 | 8 | 8 | u32 | 4 |
| 3 | i32 | 4 | 9 | u16 | 2 |
| 4 | u64 | 8 | 10 | u8 | 1 |
| 5 | bool | 1 |
A scalar-per-step series has an empty element shape; a per-step tuple (e.g. the 3 coefficients of a
quadratic cost curve) has element shape [3].
The dtype says how wide an element is. What those elements mean — and how a ragged piecewise
curve is packed into a fixed-width row — is the association's element_type; see
Element types. The dtype above is derived from it.
The HDF5 file does not describe its own element typing, and is not meant to: bool and u8 are
the same byte on disk, and nothing in a dataspace says whether three f64s are a quadratic curve or
three independent samples. Every read takes the dtype from the catalog's element_type instead. The
two artifacts are one logical store — an .h5 without its .sqlite is not readable in any case —
so this removes a second, weaker source of truth rather than adding a dependency. Where a backend
does know a dtype independently (a packed dataset's name encodes it), the catalog's value is checked
against it and a mismatch is an integrity error.
HDF5 Layout
Arrays live under a two-level group hierarchy, in one of two storage modes, with the explicit time axes in a sibling group of their own:
<name>.h5
├── attribute data_format_version = "0.19.0"
├── attribute compression = "deflate:3:shuffle"
├── attribute storage_backend = "hdf5"
└── group time_series/
├── group single/
│ ├── dset sts_{dtype}_{shape}_{length}_{res} packed (length, cols, *element_shape)
│ ├── dset sts_{dtype}_{shape}_{length}_{res}_h u8 (cols, 64) # per-column hex hashes
│ ├── dset sts_{dtype}_{shape}_{length}_{res}__1 packed spill dataset
│ ├── dset nsts_{dtype}_{shape}_{length}_{tshash} packed irregular cohort
│ ├── dset nsts_{dtype}_{shape}_{length}_{tshash}_h u8 (cols, 64)
│ ├── dset arr_{hex_hash} standalone [length, *element_shape]
│ └── ...
└── group timestamps/
├── dset tsv_{hex_hash} i64 [n] # unix milliseconds
└── ...
Datasets carry no dimension scales and no dimension names — shape is read straight off the HDF5 dataspace. On open the backend recovers each packed dataset's column count from the second extent of its dataspace, which is how per-dataset widths round-trip.
The hash companion {dataset}_h is a (cols, 64) array of u8 holding each column's 64 lowercase
hex characters as raw bytes — not an HDF5 string dataset. An all-zero row marks a free slot.
Packed mode
Used for every static series: SingleTimeSeries, the underlying array of a
DeterministicSingleTimeSeries, NonSequentialTimeSeries, and PersistentTimeSeries. Many arrays
that share a (dtype, element_shape, length) and a time axis are column-packed into one
dataset:
| Element | Meaning |
|---|---|
sts_ | Prefix for a packed SingleTimeSeries / DST pool |
nsts_ | Prefix for a packed explicit-time-axis cohort (irregular + persistent) |
{dtype} | Element dtype string (f64, i64, …) |
{shape} | Element shape: s = scalar, 3 = [3], 3x2 = [3, 2] |
{length} | Number of timesteps (size of the time axis) |
{res} | sts_ only: resolution as an ISO-8601 duration (PT1H, P1M; no _) |
{tshash} | nsts_ only: 64 hex chars, the timestamp vector's content hash |
__{n} | Spill suffix; absent for the first dataset, __1, __2, … after |
The trailing element is what pins the time axis, which is what makes packing meaningful at all:
the chunking is timestamp-major, so row t of a dataset is "every column at the same instant". A
regular series takes its axis from the resolution; an irregular one carries the axis explicitly, and
two of them share one exactly when their timestamp vectors are equal — which the store already
answers by content-addressing those vectors (see Timestamp vectors). The pool
key is the time axis and never the series type, so a PersistentTimeSeries and a
NonSequentialTimeSeries on the same instants share one nsts_… dataset, and identical values on
them share one content-addressed array. The full 64-char hash is used, not a prefix: the name is
parsed back into the pool key when the index is rebuilt at open, so a truncated form could let two
distinct time axes collide into one pool.
The dataset shape is (length, cols, *element_shape) and chunking is (rows, cols, *element_shape)
with rows = 1 in the ordinary case, so one HDF5 chunk holds a single timestamp across every column
— making a read across series by timestamp one chunk, and a buffered bulk write fill whole chunks.
cols is chosen per dataset: a managed write sizes it to the batch it has in hand, while an
incremental one-at-a-time write path uses a default width (DEFAULT_COLS_PER_DATASET = 1000) and
fills one of its slots per call. In both cases cols is capped so one chunk stays within a byte
budget (MAX_CHUNK_BYTES = 1 MiB); a batch wider than the cap spills across datasets.
"The batch it has in hand" is the whole of an add_time_series_bulk call — or the whole span of
an open transaction, for irregular cohorts as well as regular pools: whether an irregular series
packs with others on its axis or stands alone is decided from the block's final membership at the
commit, not from the one request an add can see. Nothing a transaction writes is durable until its
outermost commit, so a single add inside one is buffered per pool rather than dropped into a
growth-pool slot, and the buffered arrays are written with the same block writer at the commit, or
when a read needs one of them to have a physical position. A loop of single adds inside one
transaction therefore produces exactly the datasets one bulk add of the same items produces — so
long as the span reaches its commit without materializing early; only an un-transactioned single
add takes the default width.
The buffer is the store's, not the file's: nothing on disk records it, and a reopen never sees one.
The byte ceiling below and an early materialization are what can break that equivalence: crossing
the ceiling, or asking a buffered array for its physical location (Store::locate_array, which
takes &mut self for that reason), writes the span out as it stands. Each split costs an extra
dataset and nothing else — the datasets are still block-written and chunk-aligned.
Two limits keep that buffer from being unbounded, and both simply write a block out early — the same spill a too-wide batch already performs, costing an extra dataset and nothing else:
A pool's block width is the lower of two ceilings, and the buffer as a whole has a third:
- Per pool, one chunk row of columns —
MAX_CHUNK_BYTESover the element block, exactly the width a bulk add's block spills at, so the span's datasets are the bulk add's datasets. It is deliberately notDEFAULT_COLS_PER_DATASET: a bulk add issued inside a transaction is buffered too, and a thousand-column cap cut a 100,000-series batch into a hundred datasets where the same batch outside a transaction writes ten, which every per-timestep read then paid for chunk by chunk. - Per pool,
MAX_PENDING_BYTESover one column's bytes —length × element_block. A chunk row is a count of columns and says nothing about how long they are, so for anything but a short series this is the ceiling that actually binds: scalarf64is 131,072 columns by the rule above, but 30,500 steps is 244,000 bytes a column, so the pool spills at 550. The two together aremin(MAX_CHUNK_BYTES / element_block, MAX_PENDING_BYTES / (length × element_block)). - Across every pool,
MAX_PENDING_BYTES = 128 MiBof unwritten arrays. Per-pool shares do not add up to a global one, so a span touching several shapes is held to the total as well; crossing it writes out the widest block.
MAX_PENDING_BYTES is the only one of the three a caller can move: Store::set_write_buffer_bytes
in the Rust core, store.write_buffer_bytes = n in Python, set_write_buffer_bytes!(store, n) in
Julia, infrastore_store_set_write_buffer_bytes across the C ABI. Raising it is how a run of single
adds gets the dataset the bulk add of the same series writes, since a batch handed over as a list is
written as one block with no budget applied at all — the caller is already holding it. The chunk-row
ceiling above it does not move, and neither does the figure travel: it belongs to the handle that
set it, not to the artifact, because it is a budget for the writing process rather than a property
of the file.
A block of one is not a block. If a span ends up holding a single array for a pool, it fills a
growth-pool slot rather than claiming a dataset sized to one column — chunked (1, 1), that would
give a scalar f64 series an eight-byte chunk per timestep, whose per-chunk overhead dwarfs the
data. It is the same rule add_time_series_bulk applies to a pool its batch gives one array. For an
irregular pool the fallback is a standalone arr_ dataset instead of a slot, because an nsts_
pool is shared only by the series on that exact axis: a cohort of one is a dataset spread over
length chunks for no reason, where a regular pool is shared by every series on the resolution.
From two columns up the block is what the bulk add of those items writes.
This is a write-time policy like every other choice on this page: the layouts it produces are ones
the format already had, so it does not affect data_format_version and stores written either way
stay mutually readable.
rows rises above one when a single timestamp row would leave the chunk under
MIN_CHUNK_BYTES = 32 KiB. A narrow dataset is where that bites hardest, and a dataset is narrow
when the width had to be small — which above is MAX_PENDING_BYTES / length for a span, so a long
series got a 512-byte chunk. That is small enough to cost on both sides: deflate has too little to
work with, and HDF5's fixed per-chunk cost stops being rounding error.
Measured on 512 series of 262,144 f64 steps written one at a time in one span — a 64-column block,
so a 512-byte timestamp row — carrying a daily-plus-annual profile with AR(1) noise rather than
anything conveniently compressible:
| chunk | write | file (1.074 GB raw) | 8,760 consecutive timestamp reads |
|---|---|---|---|
(1, 64) = 512 B | 59.5 s | 1.098 GB | 0.97 s |
(8, 64) = 4 KiB | 19.1 s | 0.909 GB | 1.26 s |
(32, 64) = 16 KiB | 13.8 s | 0.843 GB | 1.26 s |
(64, 64) = 32 KiB | 14.8 s | 0.813 GB | 1.24 s |
(128, 64) = 64 KiB | 16.5 s | 0.787 GB | 1.21 s |
The 512-byte chunking wrote a file larger than the raw data: at that size deflate's per-chunk overhead exceeds what it saves. Write time bottoms out around 16–32 KiB and file size keeps falling past it. Sweeps are flat at every chunk size — a sweep visits every chunk regardless, and the rows a chunk brings along are the next ones it wants, so the reader's main pattern is untouched.
Scattered single-timestamp reads are the one thing taller chunks cost in principle, since they decompress rows nobody asked for. In practice the measurement varied by ±0.4 s run to run and could not separate 4 KiB from 32 KiB; a consumer whose access pattern is genuinely scattered rather than swept should benchmark it rather than trust this note.
The floor reaches past the narrow blocks it is aimed at. A 1,000-column f64 growth pool is an
8,000-byte row and takes five rows per chunk; a year of hourly f64 in a 1,915-column span block is
15 KiB and takes three. On that wide shape the trade measured as about 15% more write time for about
3% less on disk.
Chunking is a write-time policy: it is not recorded in data_format_version, HDF5 readers do not
care, and files written under either rule stay readable.
- Rows are timesteps, columns are series. Column
iholds one complete series. - Hash companion dataset. Each packed dataset has a sibling
{dataset}_hdataset ofu8, shaped(cols, 64). Rowiholds the lowercase hex SHA-256 (64 characters) of columnias raw bytes, or 64 zero bytes if the column is free. This is the on-disk index: on open, the backend scans every…_h, decodes the non-zero rows, and rebuilds itshash → (dataset, column)map. (The backend also recovers each dataset'scolsfrom its column dimension length, so per-dataset widths round-trip.) - Spill. When a family's current dataset is full — a batch exceeds the column cap, or
incremental writes fill a default-width dataset — the next write creates a spill dataset
…__1, then…__2, and so on.
Standalone mode
Used for the dense forecast arrays (Deterministic, Probabilistic, Scenarios), and
for a NonSequentialTimeSeries or PersistentTimeSeries whose time axis nothing else
shares. Each array is its own typed, multi-dimensional variable named arr_{hex_hash} in the
time_series/single group. There is no column packing and no companion hash variable — the variable
name carries the hash.
- A lone explicit-time-axis series (
NonSequentialTimeSeriesorPersistentTimeSeries) is shaped[length, *element_shape]and chunked as a single whole-array chunk. Its explicit, strictly-increasing timestamps are not in the array: they are a shared time axis, stored once in thetimestampsgroup below. Packing is only a win once a cohort is several columns wide — a packed dataset spreads one array overlengthchunks — so a series alone on its time axis stays standalone. Whether a given array is packed or standalone is a write-time choice with no effect on reads (they resolve by content hash and handle either), and one cohort can hold columns of both. - Dense forecasts are shaped
[H, count, *element_shape](Deterministic),[num_percentiles, H, count, *element_shape](Probabilistic), or[num_scenarios, H, count, *element_shape](Scenarios), wherecountis the number of forecast windows. They are chunked in bounded blocks along thecount(window) axis — full on every other axis,colswindows wide, wherecolsis the largest count keeping one chunk within the same 1 MiB budget the packed datasets use. Reading a single window therefore decompresses one block rather than the whole array, and theForecastReaderaligns its in-memory cache to the same block width so sweeping the window timeline decompresses each block exactly once. The chunk width is not recorded anywhere: it is a write-time storage choice that reads transparently regardless of the width a store was written with, so it does not affect the data-format version.
Timestamp vectors
The explicit time axis of a NonSequentialTimeSeries lives in time_series/timestamps/, one
dataset per distinct vector, named tsv_{hex_hash} after the vector's content hash — the same
hash the association row carries in timestamps_hash and the nsts_… pool name carries as
{tshash}. Each is a 1-D i64 dataset of unix milliseconds, one element per timestamp, and
carries the file's compression policy once it is big enough to be chunked (short vectors go in the
object header, unfiltered, exactly as small standalone arrays do).
Milliseconds are the store's precision floor for every instant it records, so this is exact for
anything a write accepts; a finer timestamp is refused rather than truncated. Ordinary numbers in an
ordinary dataset: h5py reads a time axis with no help from this library.
Storing it here rather than in the catalog is what makes the timestamps data. Irregular series
in a power-systems model overwhelmingly share one axis — event times, an outage schedule, a market
timeline — so a thousand components sampled at the same instants hold one copy between them; but a
store may hold many distinct axes, each of them long, and a .sqlite file is the wrong place for
bulk numeric arrays. It also means the vectors travel with the artifact: a document round trip that
carries locators rather than values still finds the timestamps where it finds the arrays. (Before
0.19.0 they were a delta-varint blob in a timestamp_sets catalog table.)
Vectors are shared, so removing one series never deletes one; see
Deletion and compaction. verify_integrity reads each referenced vector
back and rehashes it, exactly as it does an array — the name records the hash, so a dataset
perturbed behind it is caught rather than silently served.
Compression
Compression is chosen at store creation and applies to every data variable, packed and standalone
alike (the …_h hash variables are strings and are not compressed). The default is DEFLATE
(zlib) level 3 with the byte-shuffle filter; the level (0–9) and shuffle can be changed, or
compression turned off entirely. The choice is persisted in the compression global attribute and
restored when the store is reopened, so later appends reuse the same filter:
| Attribute value | Meaning |
|---|---|
none | No compression filter |
deflate:{level}:shuffle | DEFLATE at level (0–9), byte-shuffle on |
deflate:{level}:noshuffle | DEFLATE at level (0–9), byte-shuffle off |
An absent or unparseable attribute falls back to the default (deflate:3:shuffle), which is what
such a file was written with. Compression is a storage detail only: arrays decode transparently
regardless of the filter, so stores written with different settings stay mutually readable and
data_format_version is unaffected by the choice.
Deletion and compaction
- Packed: deletion zero-fills both the column's hash row and the column's data, so no stale values are readable through a reused slot. The slot becomes reusable by the next compatible write. The dataset does not shrink.
- Standalone: deletion unlinks the HDF5 dataset. The object is unreachable immediately and stays gone across a reopen, but the space it occupied is not returned to the filesystem until a compaction (HDF5 cannot reclaim space in place). Re-adding the same content therefore rewrites the dataset rather than re-indexing it.
- Feature sets and timestamp vectors: because both are shared, deleting an association never
deletes either; removing the last association that referenced one leaves it unreachable.
compact()deletes them — the feature set as a catalog row, the timestamp vector as an unlinked HDF5 dataset — and reports the counts asfeature_sets_reclaimedandtimestamp_sets_reclaimed. Clearing a store is the exception: it orphans every one of both by construction, so it drops them outright rather than leaving a cleared store's worth of them for a compaction.
compact() on an on-disk store rewrites the .h5 file, because that is the only way HDF5 gives
the freed space back:
- The catalog is swept of unreachable feature sets, and the file of unreachable timestamp vectors; the catalog is then read for the live set — the catalog, not the file, is what makes an array or a time axis live.
- Every live array is written into a fresh file at
<store>.h5.repack, sibling to the original so the two share a filesystem. Layouts are planned from scratch: packed pools are created at exactly their cohort width rather than the growth-sized width an incremental write reserves. - The store's HDF5 handle is closed, the temp file is renamed over the original, and the store
reopens on the result. A crash before the rename leaves the original untouched plus a stray
.repackfile, which the next compaction deletes.
What the new file therefore lacks: freed packed slots, unreferenced datasets (an interrupted bulk
add's leftovers, unreferenced timestamp vectors, or tombstones left by a store written before
deletion unlinked), and the slack in over-wide packed pools. The .sqlite half is not touched —
arrays are content-addressed, so a different physical layout is invisible to it — and
data_format_version is unchanged, because the rewrite emits the same format.
Compaction assumes the process running it is the file's only user. On Unix another process holding the file open keeps reading the pre-compaction inode; on Windows its lock makes the rename fail, and the error surfaces with the compacting store still open on the original file.
SQLite Schema
The catalog database is created with PRAGMA foreign_keys = ON and the following DDL (idempotent —
CREATE TABLE IF NOT EXISTS).
time_series_associations
One row per association between an owner and a stored array.
| Column | Type | Notes |
|---|---|---|
id | INTEGER | AUTOINCREMENT primary key; never reissued — see below |
owner_id | INTEGER | Owner identity; signed 64-bit integer identifier (part of key) |
owner_type | TEXT | Owner's concrete type, descriptive |
owner_category | INTEGER | Code, CHECK in (0, 1); part of key — see below |
time_series_type | INTEGER | Code, CHECK >= 0; part of key — see below |
name | TEXT | Series name |
initial_timestamp | TEXT | RFC 3339 string; NULL for the explicit-time-axis types |
resolution | TEXT | ISO-8601 duration (PT1H, P1M, …); NULL for those types too |
length | INTEGER | Number of timesteps |
horizon | TEXT | ISO-8601 forecast horizon; NULL for non-forecasts |
interval | TEXT | ISO-8601 forecast interval; NULL for non-forecasts |
count | INTEGER | Forecast window count; NULL for non-forecasts |
timestamps_hash | BLOB | 32-byte hash of the timestamp/breakpoint vector; see below |
units | TEXT | Free-form units label |
quantity_kind | TEXT | What the values measure (QUDT QuantityKind name); NULL if unset |
unit_system | TEXT | natural_units or component_base; NULL means unspecified |
time_reference | TEXT | How the timestamps were spelled (below); NULL means unspecified |
component_field | TEXT | Owning component's field these values vary; NULL if unset |
percentiles_json | TEXT | JSON array of percentiles for Probabilistic; NULL else |
element_type | TEXT | Canonical element-type string (NOT NULL DEFAULT 'f64') |
element_shape | TEXT | JSON array of per-step dims ([] = scalar) |
application_data | TEXT | Opaque package-owned payload (JSON), verbatim; NULL if unset |
data_hash | BLOB | 32-byte SHA-256 of the array; links to an HDF5 column/variable |
features_hash | BLOB | 32-byte SHA-256 of the feature map |
The two content-address hashes are the last two columns. Column order is not load-bearing — every statement names its columns — so the layout is chosen for readability.
timestamps_hash is set only on NonSequentialTimeSeries rows and is NULL on every other type.
It is a locator, not a value: the vector itself lives in the HDF5 file (see
Timestamp vectors), and the same hash is the cohort key the nsts_… pools are
named by.
time_reference
One TEXT column holds all four spellings: utc, zoneless, a fixed offset (-07:00), or an IANA
zone name (America/Denver). That is unambiguous rather than merely hoped for, because the core
refuses a zone name that reads as an offset or as either literal — the utc literal is lowercase
precisely so the IANA zone UTC stays a distinct value.
NULL means unspecified, never utc. For query bounds it groups with the three zoned spellings
(an instant bound is accepted, a wall-clock bound refused), but it is not a claim the timestamps
were written as UTC.
Deliberately not indexed, and that does not change now that ListFilter::zoneless exists. The
idx_component_field partial-index pattern is wrong here twice over:
WHERE time_reference IS NOT NULL would exclude exactly the NULL rows that filter has to return,
and the column is low-cardinality — a handful of distinct values across a whole store — so it is not
selective enough to earn an index. In practice it is combined with owner_id or name, which are
indexed.
The column does not reach storage. array_hash takes no timestamp input at all, the packed dataset
names carry no timestamp, and initial_timestamp stays RFC 3339 UTC whatever the reference says
— a -07:00 series stores 2024-01-01T07:00:00Z and the label -07:00, and the offset is applied
on the way out. Two series with equal values therefore pool into the same dataset and share a
data_hash regardless of their references, which is correct: they are the same numbers.
feature_sets
The expanded feature map, one row per key. The typed columns are populated according to
value_kind.
Feature sets are content-addressed, exactly as arrays are: the table is keyed by the SHA-256 of
the feature map, and one set is stored once and shared by every association whose features_hash
matches. The association row already carries that hash, so no join column is needed. Two
associations with the same features therefore reference the same rows here — including a
DeterministicSingleTimeSeries and the SingleTimeSeries it was derived from, which is why
transform_single_time_series writes no feature rows at all.
| Column | Type | Notes |
|---|---|---|
key | TEXT | Feature name |
value_kind | TEXT | CHECK in (int, float, bool, str) |
value_int | INTEGER | Set when value_kind = 'int' |
value_float | REAL | Set when value_kind = 'float' |
value_bool | INTEGER | 0/1, set when value_kind = 'bool' |
value_str | TEXT | Set when value_kind = 'str' |
features_hash | BLOB | 32-byte SHA-256 of the feature map |
PRIMARY KEY (features_hash, key) |
An empty feature map stores no rows.
There is deliberately no foreign key to time_series_associations and no cascade: rows here
are shared, so deleting one association must not delete a set another association still uses.
Removing the last association that referenced a set instead leaves it unreachable — the same
deletion semantics as the HDF5 side's unreachable standalone variables. Store::compact sweeps
unreachable sets and reports the count as feature_sets_reclaimed; clearing a store drops them all
outright.
supplemental_attribute_associations
Which supplemental attributes are attached to which components. Columns match infrasys' table of the same name, whose logic this replaces. See Associations Between Entities for the data model.
CREATE TABLE supplemental_attribute_associations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
component_id INTEGER NOT NULL,
component_type TEXT NOT NULL,
attribute_id INTEGER NOT NULL,
attribute_type TEXT NOT NULL
);
CREATE UNIQUE INDEX uq_sa_assoc
ON supplemental_attribute_associations(component_id, attribute_id);
CREATE INDEX idx_sa_assoc_attribute
ON supplemental_attribute_associations(attribute_id, component_id, component_type);
uq_sa_assoc makes the (component_id, attribute_id) pair the row's identity — the type columns
are denormalized labels for filtering, so the same pair under different type names is a duplicate
and surfaces as DuplicateAssociation. That index also serves lookups keyed on the component;
idx_sa_assoc_attribute serves the reverse direction ("which components carry this attribute").
One attribute may be attached to many components; one component may carry many attributes. Only the exact pair is constrained.
parent_child_associations
Directed edges between components — a generator (parent) connected to a bus (child), say. Both endpoints are always components, which is why there is no category column: a supplemental attribute cannot appear here by construction.
CREATE TABLE parent_child_associations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
parent_id INTEGER NOT NULL,
parent_type TEXT NOT NULL,
child_id INTEGER NOT NULL,
child_type TEXT NOT NULL
);
CREATE UNIQUE INDEX uq_parent_child
ON parent_child_associations(parent_id, child_id);
CREATE INDEX idx_parent_child_child
ON parent_child_associations(child_id, parent_id, parent_type);
Identity is the ordered (parent_id, child_id) pair, so the reversed pair is a different edge.
There is no relationship-kind column: two components may be related at most once. Recording a second
kind of edge between the same pair would need such a column added and uq_parent_child widened —
which is a format change, unlike the tables themselves.
Properties shared by both association tables
Neither table has a foreign key — to time_series_associations or anywhere else — and neither
cascades. The endpoints live in the consumer's object graph, not in this store, so the store never
observes a component or attribute being deleted and a cascade could never fire. Removing a time
series therefore leaves both tables untouched, and vice versa; consumers issue both calls when they
want both effects.
Both are also independent of each other: the same integer may name a row in each without collision.
Compatibility
Both tables were originally added without bumping data_format_version, since the gate is exact
equality with no upgrade path and bumping for a purely additive table would have made every existing
store unreadable in exchange for nothing.
That is no longer how they land. 0.18.0 gave both an AUTOINCREMENT id, which is part of a
table's declaration and so cannot reach an existing table through CREATE TABLE IF NOT EXISTS; the
version check rejects an older catalog before this DDL runs. Only a store at the current version can
contain them, and it always does.
One consequence of the additive era survives, and is still required: opening read-only cannot run
DDL at all, so reads of a missing table return empty results (0, false, no rows) rather than
failing. That tolerance is not vestigial — it is what lets a read-only open work on a store whose
catalog was never opened for writing.
store_attributes
Free-form key/value provenance about the artifact as a whole — who built it, from what source
system, under which of the consumer's own schema versions. The store never interprets a value, in
the same spirit as a row's application_data, and nothing here participates in any identity, hash,
or query plan. See Store attributes for the model.
CREATE TABLE store_attributes (
key TEXT PRIMARY KEY NOT NULL,
value TEXT NOT NULL
);
key is the primary key, so a set is an upsert rather than an append: an artifact records one
creator, not a history of them. Values are TEXT; a caller wanting structure stores JSON, which is
what application_data already asks of them. Keys beginning with infrastore. are reserved and
refused on the write path — by the store, not by a CHECK, so a future build can stamp its own
facts through a deliberate internal write without a format change.
The table is additive, like the two association tables: an existing store gains it on its first
writable open, so it needed no CATALOG_SCHEMA_REVISION bump. A read-only open of a catalog written
before it existed cannot run that DDL, so reads degrade to the empty answer rather than erroring.
The table is not carried by the OpenAPI export or import, which have no place for it.
schema_version
CREATE TABLE schema_version (version INTEGER NOT NULL);
A single-column table holding the catalog's own schema revision — CATALOG_SCHEMA_REVISION, which
is 2 as of this build. It holds at most one row: the DDL creates the table, and the migration
ladder writes the revision, replacing whatever was there. A fresh catalog is stamped with the
current revision directly rather than seeded at 1 and walked up the ladder.
An absent table, or a present one holding no row, reads as revision 1 — the pre-ladder shape,
which is where the ladder starts. This tracks the SQLite schema alone and is distinct from the HDF5
data_format_version attribute, which governs the artifact as a whole and is the value open
validates; the two move independently.
catalog_identity
CREATE TABLE catalog_identity (generation TEXT NOT NULL);
Holds zero or one row pairing this catalog with one HDF5 file, whose catalog_generation root
attribute carries the same value. Store::open compares them and rejects a mismatch with
MismatchedArtifact, which is what turns an interrupted persist_to — two renames that cannot be
made atomic together — into a loud error instead of a store that quietly disagrees with itself. It
also catches one half being copied without the other.
Added additively, so it lands on an existing store's first writable open without a
data_format_version change. A store predating it has no row here and no root attribute, and reads
as unstamped rather than as a mismatch. Because a read-only open cannot run the DDL, every read of
this table tolerates the table being absent.
Indexes
CREATE UNIQUE INDEX uq_ts_assoc ON time_series_associations
(owner_id, owner_category, time_series_type, name, resolution, interval, features_hash);
CREATE UNIQUE INDEX uq_ts_assoc_coalesced ON time_series_associations
(owner_id, owner_category, time_series_type, name,
COALESCE(resolution, ''), COALESCE(interval, ''), features_hash);
CREATE INDEX idx_hash ON time_series_associations(data_hash);
CREATE INDEX idx_owner ON time_series_associations(owner_id, owner_category);
CREATE INDEX idx_resolution ON time_series_associations(resolution);
-- Secondary indexes for the filter / discovery surface.
CREATE INDEX idx_ts_type ON time_series_associations(time_series_type);
CREATE INDEX idx_name ON time_series_associations(name);
CREATE INDEX idx_owner_type ON time_series_associations(owner_type);
CREATE INDEX idx_category_owner ON time_series_associations(owner_category, owner_id);
CREATE INDEX idx_interval ON time_series_associations(interval);
CREATE INDEX idx_component_field ON time_series_associations(component_field)
WHERE component_field IS NOT NULL;
idx_component_field is the only partial index, because component_field is the only optional
column anything filters on: a store that never sets it would otherwise pay index maintenance on
every insert to record one NULL per row and buy nothing, so the WHERE clause makes that case
cost zero entries. SQLite still uses it for the predicate the filter issues — component_field = ?
cannot be true of a NULL whatever the parameter binds to — but it can never serve an IS NULL
query, which is the same reason ListFilter::component_field cannot select the rows that left the
field unset.
An index over a column that already exists is additive: a store gains any it lacks on its first
writable open, at a one-time build cost proportional to catalog size, with no data_format_version
change. idx_component_field is the exception, because it names a column introduced by a bump — it
applies only to a catalog already carrying component_field, and an older store never reaches the
DDL, being rejected by the version check first.
Together the two unique indexes enforce
identity uniqueness; a violation surfaces as
DuplicateTimeSeries. Both owner_id and owner_category are part of the key, so a component and
a supplemental attribute that share an owner_id are independent owners. interval is part of the
key, so two forecasts of one variable at the same resolution but different intervals are distinct
series. SQLite treats NULL values as distinct in a UNIQUE index, so uq_ts_assoc does not
constrain rows with a NULL resolution or interval (e.g. NonSequentialTimeSeries, or any
static series, which carry no interval). uq_ts_assoc_coalesced covers that case by folding NULL
to the empty-string sentinel via COALESCE before enforcing uniqueness (the empty string is never a
valid ISO-8601 period).
These two were named uq_assoc and uq_assoc_coalesced before the association tables above existed
and made a bare "assoc" ambiguous. The DDL drops the old names before creating the new ones, so a
store written by an earlier build is renamed in place on its first writable open rather than
accumulating two equivalent index pairs. Index names are not part of the on-disk contract, so this
carries no data_format_version change.
time_series_readable (view)
CREATE VIEW time_series_readable AS
SELECT id, owner_id, owner_type,
CASE owner_category WHEN 0 THEN 'Component'
WHEN 1 THEN 'SupplementalAttribute'
ELSE 'unknown(' || owner_category || ')' END AS owner_category,
CASE time_series_type WHEN 0 THEN 'SingleTimeSeries'
WHEN 1 THEN 'NonSequentialTimeSeries'
WHEN 2 THEN 'Deterministic'
WHEN 3 THEN 'DeterministicSingleTimeSeries'
WHEN 4 THEN 'Probabilistic'
WHEN 5 THEN 'Scenarios'
WHEN 6 THEN 'PersistentTimeSeries'
ELSE 'unknown(' || time_series_type || ')' END AS time_series_type,
name,
initial_timestamp, resolution, length, horizon, interval, count,
units, quantity_kind, unit_system, time_reference, component_field,
element_type, element_shape, application_data,
lower(hex(data_hash)) AS data_hash,
lower(hex(features_hash)) AS features_hash,
lower(hex(timestamps_hash)) AS timestamps_hash
FROM time_series_associations;
This view decodes both discriminants and every content hash, so it is the convenient thing to query by hand. Nothing in the library reads it.
A projection of the association table with both hashes hex-encoded, for humans opening the catalog
in sqlite3 — see Reading the catalog by hand. Nothing in the
library reads it. It costs no storage, is created by the same idempotent DDL as the tables above,
and so lands on an existing store's first writable open without a data_format_version change.
Field Encoding Notes
- Timestamps are RFC 3339 strings in UTC, holding a whole number of milliseconds. The format could carry finer digits and a store written before the precision rule may, so a reader must parse the full string; but no current write path produces one — see timestamp precision.
- Periods (
resolution,horizon,interval) are canonical ISO-8601 duration strings in SQLite (PT1H,P1M,P1Y), and the packed dataset name's{res}field uses the same encoding. Calendar periods (Month/Quarter/Year) are stored distinctly from fixed spans. - Hashes are raw 32-byte
BLOBs in SQLite, lowercase hex in HDF5 (the…_hdataset for packed arrays, thearr_dataset name for standalone arrays).BLOBrather than hexTEXTin SQLite because the two hash columns sit in the association table, inidx_hash, and in both unique indexes — hex would cost roughly 32% more catalog space — and because aBLOBliteral (X'…') compares case-insensitively while a hexTEXTcolumn would not. Thetime_series_readableview supplies the readable form. element_shapeis the per-step shape only (the trailing axes); the timelengthis a separate column.
Discriminant encoding
owner_category and time_series_type are stored as small INTEGER codes, not names:
owner_category | code | time_series_type | code | |
|---|---|---|---|---|
Component | 0 | SingleTimeSeries | 0 | |
SupplementalAttribute | 1 | NonSequentialTimeSeries | 1 | |
Deterministic | 2 | |||
DeterministicSingleTimeSeries | 3 | |||
Probabilistic | 4 | |||
Scenarios | 5 | |||
PersistentTimeSeries | 6 |
Both columns sit in the two wide unique indexes, and time_series_type additionally in
idx_ts_type while owner_category sits in idx_owner and idx_category_owner. A 1-byte code
instead of a 9–29 byte string shrinks those indexes by roughly 35% and the whole catalog by roughly
29% on a 400k-row store, and makes type-scoped scans about 1.2x faster warm (1.4–1.5x under page
cache pressure). Point lookups by key are unaffected — those are dominated by the 32-byte
features_hash.
Two code assignments are load-bearing and cannot be reordered without a version bump:
Deterministic(2) andDeterministicSingleTimeSeries(3) are adjacent. A request forDeterministicmatches both (see the data model), so adjacency lets that widen totime_series_type BETWEEN 2 AND 3— one index seek instead of a two-valueIN.- The static types (0–1) and forecast types (2–5) are contiguous blocks, so the static and
forecast summary queries each scope with a single
BETWEEN.
The C ABI uses the same integers for its ts_type arguments, so there is one mapping rather than
two. The time_series_readable view decodes both columns for hand
inspection.
Inspecting a Store by Hand
The quickest route is the CLI, which resolves the hash-to-location step for you:
infrastore --store system.h5 store-info # both file paths, format version, compression
infrastore --store system.h5 arrays # every distinct array, its location and sharers
infrastore --store system.h5 info --name load # one series: data_hash, hdf5_dataset, hdf5_column
To go straight at the files:
h5ls -r system.h5 # groups, datasets, dtypes, shapes
h5dump -A -n system.h5 # root attributes and the object list
sqlite3 system.h5.sqlite '.schema'
# Query the view, not the table, to see decoded discriminants.
sqlite3 system.h5.sqlite \
'SELECT name, time_series_type, element_type, element_shape, length FROM time_series_readable;'
# Both association tables are present in every current-format store.
sqlite3 system.h5.sqlite 'SELECT * FROM supplemental_attribute_associations;'
sqlite3 system.h5.sqlite 'SELECT * FROM parent_child_associations;'
Reading the catalog by hand
sqlite3 renders a BLOB as raw bytes in its default list mode and in .mode box / .mode json,
which corrupts the terminal (and, in box mode, the table borders). Use the
time_series_readable view, which is the association table with both hashes hex-encoded:
sqlite3 system.h5.sqlite 'SELECT name, data_hash FROM time_series_readable;'
The view spells hashes in lowercase, matching hash_hex in the core, every binding, and the CLI, so
a value copied from it compares equal to one printed anywhere else. Against the base table, use
hex(data_hash) or .mode quote; hex() returns uppercase.
The view is created by the same idempotent DDL as the tables, on a store's first writable open, so an older store gains it without a format bump. Nothing in the library reads it.
Mapping an association to its bytes
Read the association's data_hash. For a standalone array, read the dataset named
arr_<hex_hash> directly. For a packed array, hex-encode the hash and find the matching row in
the relevant sts_…_h dataset; that row index is the column index into the sts_… dataset. Note
that a packed pool which fills up spills into {name}__1, {name}__2, so the dataset holding a
given array is not derivable from its metadata — scan the _h datasets, or let infrastore info /
infrastore arrays report the resolved dataset and column.
A NonSequentialTimeSeries row's time axis needs no scan: hex-encode its timestamps_hash and read
time_series/timestamps/tsv_<hex_hash>, an i64 vector of unix milliseconds in the row's own
order.
Parquet Layout
What infrastore export -f parquet writes and infrastore add --parquet reads: a normalized,
partitioned layout. Per partition, two files sharing a stem — one holding every distinct array
once, one holding the catalog rows that name them.
Two things it is not. Not one file per series: a store with thousands of series would become thousands of files, which defeats every reader worth exporting for. And not one table with the catalog row beside every value: the store is content-addressed, so a thousand components sharing one profile hold one array, and a denormalized table would write that profile a thousand times. Parquet's compression does not find repeats across pages, so the file really would be a thousand times larger.
This is a file interchange format, not the store's own. The on-disk store is HDF5 plus SQLite, and nothing here is load-bearing for it. Python's
to_arrow()andfrom_arrow()are a different thing again: per-series, in-memory conveniences. The relationship is one sentence — a series file's columns areto_arrow()'s schema-metadata keys turned into columns, and the values file is its two columns keyed by the array — and neither side depends on the other.
The two files
parquet/
SingleTimeSeries.f64.utc.values.parquet one row per value, each array once
SingleTimeSeries.f64.utc.series.parquet one row per series
| File | Rows | Carries |
|---|---|---|
<stem>.values | one per value | the array key, the time coordinates, the value |
<stem>.series | one per series | the array key and the whole catalog row |
They join on the array key, and both are sorted by it. That is the entire relationship: a values row belongs to whichever series rows carry its key, and a series row reads whichever values group carries its key.
The array key
(data_hash, time_axis)
data_hash alone will not do. It covers the array's bytes and not the axis those bytes sit on, and
the store pools irregular series by time axis precisely because two series with identical values on
different timelines are one stored array. So the key is the pair.
time_axis is a string spelling whatever decides where a value sits in time, per type:
| Type | time_axis |
|---|---|
SingleTimeSeries | R<length>/<initial>/<resolution>, an ISO 8601 repeat |
NonSequentialTimeSeries, PersistentTimeSeries | the axis's timestamps_hash, hex — the catalog's own key for it |
| dense forecasts | R<count>/<initial>/<interval>/<horizon>/<resolution> |
R24/2024-01-01T00:00:00Z/PT1H
9f0c4e... (an irregular axis)
R24/2024-01-01T00:00:00Z/PT1H/PT2H/PT1H (a forecast)
The instant is spelled in UTC whatever the partition's time_reference, because the reference
is a partition key and repeating it would say nothing. A forecast needs its horizon as well as its
interval: the horizon's own step decides how many target times a window has, so two forecasts
sharing an array, an anchor and an interval but not a horizon are different tables.
The axis is read off the values being exported, not off the catalog row. The two agree for a
whole-series export; where they disagree — export --time-range writes a slice whose anchor and
length are its own — the values are what the file holds, and data_hash beside them is computed
from the same slice.
Partitioning
Three things cannot vary within one Parquet file without nullable or ill-typed columns: the set of
key columns (a forecast has an issue_time, a static series does not), the Arrow type of
value, and the zone of timestamp. So an export partitions its selection by the triple
(time_series_type, value type, time_reference)
and writes one file pair per distinct triple. The payoff is that every column in both files is required: there are no nullable columns anywhere in this format, and a reader never has to ask whether a null means "absent" or "unknown".
The value type is the element type with its per-step shape, except that the four composite kinds
(linear_function, quadratic_function, piecewise_linear, piecewise_step) partition by kind
alone. Their stored width varies per series — a piecewise_linear row is [n, x₁, y₁, …]
zero-padded to the widest timestep in that series — so keying by width would scatter one kind across
a file per width. Instead every composite row in a partition is re-padded to the widest series in
it, which the layout allows: the leading count n keeps each row self-describing whatever the
padding. The cost is a data_hash caveat, below.
An unspecified time_reference is a partition of its own. A series that declared no spelling
must not pool with one that declared UTC, even though both write a UTC-zoned column.
File names
<type>.<value-slug>.<reference-slug> plus .values.parquet or .series.parquet:
SingleTimeSeries.f64.utc.values.parquet
SingleTimeSeries.f64_2x3.America_Denver.series.parquet
Deterministic.tuple3_f64.offset_minus07_00.values.parquet
NonSequentialTimeSeries.piecewise_linear.zoneless.series.parquet
PersistentTimeSeries.f64.unspecified.values.parquet
Slugs avoid every character Windows forbids (<>:"/\|?*), which matters because a zone name carries
/ and a fixed offset carries :; a leading - is avoided too, since it reads as a flag wherever
the name is passed to a command. Trailing dots go (Windows strips them, so a. and a would be one
file there), and a fragment that would be a device name (CON, NUL, LPT9, …) gets a prefix.
The name is a convenience, not the truth. The slug function is one-way — the zones a/b and
a_b both flatten to a_b — so nothing parses a partition back out of a filename, and two
partitions that would collide get a numeric suffix in the keys' own sort order, so a re-run of the
same export produces the same names. The footer carries the exact key.
Values file columns
| Column | Type | Files | Notes |
|---|---|---|---|
data_hash | utf8 (hex) | all | Half the array key; also a checksum on import. |
time_axis | utf8 | all | The other half. |
timestamp | timestamp(ms, zone) | all | The target time for a forecast, the breakpoint for a PersistentTimeSeries. |
issue_time | timestamp(ms, zone) | forecasts | Which window the row belongs to. Same zone as timestamp. |
percentile | float64 | Probabilistic | One row per (issue, target, percentile). |
scenario | int64 | Scenarios | Zero-based trajectory index. |
value | see below | all |
Nothing about who owns the array is here, which is exactly what stops a shared profile being written once per component.
Series file columns
| Column | Type | Files | Notes |
|---|---|---|---|
data_hash, time_axis | utf8 | all | The array this series reads. |
id | int64 | all | Provenance only; ignored on import. |
owner_id | int64 | all | |
owner_type | utf8 | all | |
owner_category | utf8 | all | Component or SupplementalAttribute. |
time_series_type | utf8 | all | Constant per partition. |
name | utf8 | all | |
initial_timestamp | timestamp(ms,zone) | SingleTimeSeries, forecasts | The anchor the values start at. |
resolution | utf8 (ISO-8601) | SingleTimeSeries, forecasts | Absent from the irregular types, which have no constant step. |
length | int64 | SingleTimeSeries | |
interval, horizon | utf8 (ISO-8601) | forecasts | |
count | int64 | forecasts | Windows. |
features | utf8 (JSON object) | all | {} when empty. Plain scalars: {"model_year":2030}. |
element_type, element_shape | utf8 | all | Constant per partition except a composite's width. |
time_reference | utf8 | all | Constant per partition; the literal unspecified when the series has none. |
units, quantity_kind, unit_system, component_field, application_data | utf8 | all | Empty string when absent, which is what keeps every column required. |
The grid columns come from the values being exported, for the same reason time_axis does. Every
string column is dictionary-encoded; compression is zstd on both files.
The value column
Its Arrow type follows the element type alone. Nesting is innermost-first, which is the order the flat row-major buffer is already in.
element_type and shape | Arrow type |
|---|---|
scalar dtype, shape [] | primitive (double, int32, bool, …) |
tuple(N,T) | FixedSizeList<T>[N] |
scalar dtype with dense shape [N] | FixedSizeList<T>[N] |
scalar dtype with shape [M,N] | FixedSizeList<FixedSizeList<T>[N]>[M] |
| composite kinds | FixedSizeList<double>[w], stored packing, w the partition's widest series |
Composites keep their stored packing rather than being decoded into Struct/List. That
packing is the documented cross-language wire form, held to conformance/element_type_vectors.json,
and every binding has a decoder for it; a --decode option is a possible follow-up. Casting to a
common dtype is ruled out — the dtype round trip is a project promise.
Row order and row groups
Both files are sorted by data_hash, time_axis; the series file then sorts by id. Within one
array the values rows are sorted by issue_time, then timestamp, then percentile or scenario,
so a forecast comes out window-major and GROUP BY issue_time scans contiguously.
Row groups in the values file target roughly one million rows and are cut at an array boundary
whenever the coming array would carry the group past the target, so row-group statistics on
data_hash mean something and a reader can skip whole groups. An array larger than the target on
its own spans several. The series file is one row group: it has one row per series rather than one
per value, so even a store with a million series is a file a reader loads whole.
Footer
Both files carry it.
| Key | Value |
|---|---|
infrastore.format | normalized_v1. Read first, so a later format is refused by version rather than by a missing column. |
infrastore.role | values or series. |
time_series_type | The partition's type. |
element_type | The partition's element type. |
element_shape | JSON list; a composite's is the width the partition settled on. |
time_reference | The partition's spelling, unspecified included. |
rows_contiguous_by_key | true. |
The three partition keys are here exactly, because the filename is one-way.
Reading it back
add --parquet takes a file, a directory, or a partition stem — out/SingleTimeSeries.f64.utc
names the pair — and commits one transaction per partition, so a partition that fails leaves the
ones already committed alone.
The import is a merge join. Both files are sorted by the array key, so it walks them together: read the next values group into one array, file every series row carrying that key, move on. Peak memory is one values group plus one row group of each file, never a partition. The store's write path already recognizes an array it holds, so the second through thousandth adds of one array are catalog inserts only.
Both dangling sides are errors, each naming the key:
- a series row whose key names no values group has no array to read;
- a values group no series row claims is an array nothing would file.
Either means the two halves came from different exports, or one was truncated. A .series.parquet
with no .values.parquet beside it is refused for the same reason, before anything is read.
Keys must be contiguous. A key that reappears after another key's rows, in either file, is
refused with a message saying to sort by data_hash, time_axis. Stitching it back together would
mean holding the whole file, which is what the layout exists to avoid. A file you have re-sorted in
a query engine must meet the same rule.
add never accepts an id, so the id column is reported at --dry-run and then dropped. Identity
on import is the row's own KeyIdentity columns, as for any add.
Refused rather than coerced:
- Nulls, in any column. The store holds none, and NaN is a value rather than an absence.
- Timestamps finer than a millisecond. Seconds and milliseconds cross as they are; microseconds and nanoseconds only when every value is a whole millisecond, the rule the store's write path enforces on every instant it records.
- Rows that leave a declared grid. A
SingleTimeSerieswhoseresolutionsaysPT1Hmust walk one — checked against the grid that resolution generates, not against successive differences, sinceP1Mclamps to month end. StructandListvalue columns. Those are the decoded form this version does not write.- A
DeterministicSingleTimeSeriespartition. The type is derived from a storedSingleTimeSeriesrather than added; import that and runtransform.
A dense forecast is placed by its coordinates, not its row order, so a file a query engine
sorted or partitioned still reads correctly. Every slot must be filled exactly once: a cube has no
hole to leave, and two rows for one slot means they disagree. Its grid comes from the series file's
resolution, interval and horizon rather than being reverse-engineered — a merely
self-consistent set of rows would give a plausible wrong answer, since a one-window forecast is
indistinguishable from a static series and overlapping windows make the interval ambiguous.
data_hash is a checksum
The import re-encodes each group, hashes it, and refuses a mismatch naming the key. If you edited
values in a query engine, either recompute the hash or pass --no-checksum, which waives the
comparison and leaves the pair as nothing but a join key. Dropping the column is not the remedy: a
values file without it has no key, and no key means no series file to join to.
For composite kinds the hash is over the decoded points, not the packed bytes: a partition
re-pads them to its widest series, so hashing the padding would make an untouched export fail its
own checksum — and it is what lets two composite series whose curves are the same points at
different paddings share one values group. One consequence: for a composite series this column is
not the data_hash the catalog holds, and id is the way back to that.
Foreign files
A values file with no series file beside it is a foreign file — anything with a timestamp and
a value column. It carries no catalog rows, so the inline flags supply what a series row would
have. It is read as one series per distinct (data_hash, time_axis) if those columns exist, and as
exactly one series if they do not.
What the columns do not say is inferred, and each inference takes the reading that assumes least:
| Missing | Read as |
|---|---|
time_series_type | SingleTimeSeries when the timestamps walk a grid, NonSequentialTimeSeries otherwise. PersistentTimeSeries is never inferred — name it with --type. |
element_type | The leaf Arrow type. A FixedSizeList<double>[3] becomes f64 with element shape [3] — dense, not tuple(3,f64), because the bytes cannot say. |
time_reference | The timestamp column's Arrow zone; a column with no zone reads as zoneless, since a naive timestamp is a wall clock. |
name, owner_id, owner_type | Nothing. All three are refused rather than defaulted — owner 0 is a real owner, not a sentinel — so pass --name, --owner-id, --owner-type. |
Inline flags
They fall into three groups, and the split follows the project's usual rule.
Overrides replace a column for every series in the partition: --owner-id, --owner-type,
--owner-category, --name, --feature, --time-reference, and the five free-form descriptors
--units, --quantity-kind, --unit-system, --component-field, --application-data. Passing an
empty string clears a descriptor, since the empty string is how this format spells "absent" anyway.
Assertions state something a file cannot, so a file that contradicts one is an error rather than
being silently replaced: --element-type (tuple(3,f64) states the reading the bytes cannot),
--element-shape, --resolution, and --type. On a foreign file, which says nothing to
contradict, an assertion is simply the answer — --resolution PT1H names the grid, and the rows are
then checked against the grid it generates.
Refused with --parquet, by name rather than silently dropped: --initial-timestamp,
--interval, --horizon, --count, --percentile, --scenario-count, --layout, --owner-map,
--owner-id-from. The values imply the grid — a SingleTimeSeries' anchor is its first timestamp
and a forecast's windows are its issue_time column — so a flag naming one is either redundant or a
contradiction nothing should have to adjudicate, and --layout describes a CSV's columns. A foreign
forecast — a values file with an issue_time column and no series file — is consequently not
supported; export one and keep both halves.
Querying it
The join is the whole idiom:
SELECT s.name, s.owner_id, s.units, max(v.value) AS peak
FROM 'parquet/SingleTimeSeries.f64.utc.values.parquet' v
JOIN 'parquet/SingleTimeSeries.f64.utc.series.parquet' s USING (data_hash, time_axis)
GROUP BY s.name, s.owner_id, s.units;
A view flattens it once and hides the join from everything after:
CREATE VIEW load AS
SELECT s.*, v.timestamp, v.value
FROM 'parquet/SingleTimeSeries.f64.utc.values.parquet' v
JOIN 'parquet/SingleTimeSeries.f64.utc.series.parquet' s USING (data_hash, time_axis);
SELECT timestamp, value FROM load WHERE owner_id = 42 AND name = 'load' ORDER BY timestamp;
That view is the denormalized table this format deliberately does not write: materializing it costs exactly what the layout saves, and a query engine that keeps it as a view pays nothing.
What a round trip does not preserve
- The catalog id.
addnever accepts one — "never reissued" is a guarantee of the catalog'sAUTOINCREMENT, and a caller free to name an id could re-file a retired one — so theidcolumn is reported at--dry-runand then ignored. The destination assigns fresh ids. - An empty-string descriptor. The empty string is how the format writes "absent", so a stored
empty string reads back as absent.
unit_system's unset state means unspecified rather than natural units, and the empty string preserves that. - A composite series' padding. Values round-trip exactly — an improvement over CSV, where floats
pass through decimal text — but a composite is re-padded on the way out and shrunk to its own
width on the way back, so its stored
data_hashmay differ from the original's.
An empty series is not a round-trip caveat but a refusal: the export fails, naming every empty series it was asked for and writing nothing. A values file has one row per value, so an empty series would be a series row whose key matches no values group — which is exactly what a truncated export looks like, and there is no way to write one a reader could tell apart from damage. Narrow the selection past it.
Element Types
Every stored array carries an element_type: what its elements mean, and — for the composite
kinds — how one timestep's values are laid out across the array's trailing dimensions.
It is a first-class, store-owned concept, not a binding convention. A Julia PiecewiseLinearData
and a Python list of {"x": …, "y": …} dicts are both piecewise_linear here, so a consumer
written in either language can decode an array without knowing which language wrote it.
element_type replaces a separate physical dtype column: the dtype of the stored bytes is
derived from it. TypedArray still carries a dtype, because it describes bytes; the element type
lives on the association metadata and on the write API, where interpretation belongs.
Canonical string form
The element type travels as a string: the element_type column in the SQLite catalog, a UTF-8
string across the C ABI, and a string field over gRPC. A parameterized grammar does not fit an
integer code, so unlike dtype there is no numeric encoding.
f64 | f32 | i64 | i32 | i16 | i8 | u64 | u32 | u16 | u8 | bool
tuple(N,dtype) e.g. tuple(3,f64)
linear_function
quadratic_function
piecewise_linear
piecewise_step
The names are deliberately language-neutral: piecewise_linear, not PiecewiseLinearData;
tuple(3,f64), not NTuple{3, Float64}. Each binding maps them to its own domain types.
Encoding
The first array dimension is time (the per-window horizon for forecasts). The trailing dimensions are the per-step element shape, determined by the element type:
element_type | element shape | row layout (one timestep) |
|---|---|---|
| a dtype spelling | [] | the value |
tuple(N,T) | [N] | t1 … tN |
linear_function | [2] | proportional, constant |
quadratic_function | [3] | quadratic, proportional, constant |
piecewise_linear | [1 + 2*w] | n, x1, y1, …, xn, yn, zero-padded to width |
piecewise_step | [max(1, 2*w)] | n, x1 … xn, y1 … y(n-1), zero-padded to width |
w is the maximum point / x-coordinate count across the series (or across every window of a
forecast). Ragged rows are self-describing through the leading count n, so decoding one row needs
no global state.
A scalar element type still allows a dense per-step array — a Probabilistic forecast's percentile
columns, say. The dense-array case and the tuple(N,T) case differ in meaning, not bytes: the tuple
says the N values are one composite value, not N independent samples.
Forecasts stack windows in front of the per-step element shape: [H, count, *E] for a
Deterministic, [P, H, count, *E] for a Probabilistic or Scenarios. The per-row scheme is
unchanged; only how many leading axes precede it differs (TimeSeriesType::leading_dims).
Every function-data kind is stored as f64.
Validation
Because the store owns the element type, it can reject a write that contradicts it rather than storing the inconsistency blindly:
- the array's dtype must equal the element type's physical dtype;
- fixed-width kinds must have exactly their per-step dims (
[2],[3],[N]); - ragged kinds must have exactly one trailing dim, of a width their layout can produce (odd for
piecewise_linear; 1 or even forpiecewise_step); - every ragged row's leading count
nmust be a non-negative whole number that fits the row.
Scalars are unconstrained in their trailing dims, since a dense per-step array is legitimate.
Every series carries a concrete element type — a constructor resolves it to plain scalars of the array's dtype, and declaring one replaces it — so the checks above run on every write, not only on writes that declared something. There is no "undeclared" state to fall back from.
The catalog is the source of truth
Nothing about element typing is recoverable from the HDF5 file. Storage records how many bytes an
element occupies; element_type records what it means, and even the physical dtype is not fully
recoverable (bool and u8 are the same byte). Every read therefore resolves the element type from
the catalog first and tells the storage backend what to decode — the backend never infers it.
infrastore verify follows from this: it walks the arrays the catalog references, so a catalog
row pointing at an array the file does not hold, or a row too malformed to name one, is reported. An
array in the file that no association references is not checked — it is unreachable, and nothing
records what its bytes mean.
Codecs
Each binding ships a reference codec between the stored bytes and per-timestep values:
-
Rust —
infrastore_core::{decode, encode}overTypedArray+ElementType. Prefer the paired forms: every value type has afrom_valuesconstructor that encodes the values and declares the element type they imply, andTimeSeriesData::decoded_valuesreads them back. Anelement_typeand the array it describes are two things a caller can get out of step — the store rejects the mismatch on write, but deriving both from one set of values means there is none to reject.encode_asis the declared-type encoder for the one seriesfrom_valuescannot name: a tuple with no rows, whose arity lives in rows it does not have. See Element values. -
Python — the paired forms first, as in Rust: every series type has a
from_valuesclassmethod that encodes the values and declares the element type they imply, and.decoded_values()on a series reads them back. Underneath sitinfrastore.decode_element_values(array, element_type, leading_dims)andencode_element_values(values, element_type, leading_dims), for the cases the pair cannot name — an emptytuple(N,f64)series, whose arity lives in rows it does not have — and for decoding an array that arrived without a series around it.A Python payload carries no type tag of its own, unlike a Rust
DecodedValuesor a JuliaVector{PiecewiseLinear}, sofrom_valuesreads the element type off the shape of a row. The five shapes are disjoint, which makes that a decision rather than a guess:valuesentryelement type {"proportional": …, "constant": …}linear_function{"quadratic": …, "proportional": …, "constant": …}quadratic_functionlist[{"x": …, "y": …}]piecewise_linear{"x": list, "y": list}piecewise_steplist[float]of lengthNtuple(N,f64)element_type=is still accepted, as an assertion rather than an override: it raises if it disagrees with the values. Where the values name nothing it is the only thing to go on — an emptyvalues, or rows that are all empty and read equally as a pointless curve or a zero-arity tuple — and without it those are refused, naming the remedy. The one series no declaration reaches is an emptytuple(N,f64), whose arity lives in rows it does not have. -
Julia —
InfraStore.encode_element_values/decode_element_values, over the value typesLinearFunction,QuadraticFunction,PiecewiseLinearandPiecewiseStep. Decode takes atypeskeyword, so a consumer with its own domain types — InfrastructureSystems.jl'sFunctionData— decodes straight into them and pays no conversion; encode is three small generic functions it extends instead. The names follow the wire vocabulary (PiecewiseLinear, notPiecewiseLinearData) so thatusing InfraStore, InfrastructureSystemsis not an ambiguity error.
The CLI decodes composite rows for get -f json, under an element_values key alongside the raw
values. Its CSV output stays packed on purpose: that form is what add reads back, so it has
to stay the store's own layout rather than a rendering of it.
Extending the codec
A consumer with its own domain types does not have to convert at the boundary. In Julia the two directions extend differently, because they start from different things:
- Decoding starts from an
element_typestring, so the type to build is chosen by name:decode_element_values(...; types = ...), andread_by_id(store, id; types = ...). - Encoding starts from a value, so it is open dispatch: add
element_type_tag,element_row_widthandwrite_element_row!methods for your type and it packs directly.
is_element_values is the predicate the write path uses to tell "domain values to pack" from
"numbers to store as they are", and it answers by asking whether those three methods exist — so
opting a type in is exactly defining them, with nothing to register.
conformance/element_type_vectors.json at the repo root pins encoded bytes against expected decoded
values for every element type, static and forecast. It is generated by infrastore-core's
tests/element_type_conformance.rs and read by the Python and Julia codec tests, so every
implementation is held to one definition of the encodings rather than to each other. Regenerate it
with:
UPDATE_CONFORMANCE_VECTORS=1 cargo test -p infrastore-core --test element_type_conformance
A binding may reject what it cannot represent — the grammar allows tuple(4,i32), which the Julia
binding does not map — but the store accepts the full grammar. A binding's codec is the other way
round: it has to represent everything the store accepts, which is why the Julia value types take the
zero- and one-point piecewise curves that a domain type like InfrastructureSystems.jl's
PiecewiseLinearData rejects. A curve too short to interpolate is still a row a read has to hand
back.
Because consumers go through the codecs, a future storage optimization (a true ragged layout with an offsets array instead of zero padding) can land behind this boundary without touching them.
Rust API
The public surface of infrastore-core. Import paths below are relative to the crate root.
#![allow(unused)] fn main() { use infrastore_core::{ Store, BulkAdd, TimeSeriesId, KeyIdentity, SingleTimeSeries, NonSequentialTimeSeries, PersistentTimeSeries, Deterministic, Probabilistic, Scenarios, TimeSeriesData, TimeSeriesType, Period, TypedArray, Dtype, Compression, OwnerCategory, FeatureValue, Features, TimeSeriesMetadata, ListFilter, AddRequest, SupplementalAttributeAssociation, SupplementalAttributeFilter, SupplementalAttributeSummaryRow, ParentChildAssociation, ParentChildFilter, StaticReader, StaticGroup, ForecastReader, ForecastEntry, WindowSlot, TimeSeriesCounts, TimeSeriesCountsDetailed, StaticSummaryRow, ForecastSummaryRow, ForecastParameters, StaticConsistency, CompactionReport, IntegrityReport, TimeSeriesError, Result, DATA_FORMAT_VERSION, }; // `array_hash` and `hash_hex` are also re-exported at the crate root; `features_hash` is not: use infrastore_core::hash::{array_hash, features_hash, hash_hex}; use infrastore_core::storage::StorageBackend; }
All time spans in this API — resolutions, horizons, and intervals — are the crate's
Period, a calendar-aware span. Builders and constructors take impl Into<Period>, so
you can pass a fixed chrono::Duration (e.g. Duration::hours(1), via From<Duration>) or a
calendar span (Period::months(n), for the monthly/annual resolutions a fixed Duration cannot
represent). Values read back — struct fields, get_resolutions, and the reader accessors — are
always Period. Instants (DateTime<Utc>) remain chrono types.
Constructors
#![allow(unused)] fn main() { impl Store { pub fn create(path: Option<&Path>, in_memory: bool) -> Result<Store> pub fn create_with_catalog( path: Option<&Path>, in_memory: bool, compression: Compression, catalog: CatalogMode, ) -> Result<Store> pub fn create_replacing( path: &Path, compression: Compression, catalog: CatalogMode, ) -> Result<Store> pub fn open(path: &Path, read_only: bool) -> Result<Store> pub fn open_with_catalog(path: &Path, read_only: bool, catalog: CatalogMode) -> Result<Store> pub fn open_copy(src: &Path, dest: &Path, catalog: CatalogMode) -> Result<Store> pub fn open_without_catalog(path: &Path, catalog: CatalogMode) -> Result<Store> } }
Store::create(None, true)— in-memory store, no filesystem I/O.Store::create(Some(path), false)— createspath(HDF5) andpath.sqlite(metadata). Fails withStoreExistsif either half is already there; see protecting a saved artifact for why creating over an existing store is refused rather than allowed to truncate it.Store::create_with_catalog(...)— as above but with an explicit HDF5 compression policy andCatalogMode.Store::create_replacing(...)— discards any artifact already atpath, both halves plus the catalog's-wal/-shmsidecars, then creates. Destructive and not atomic: an interrupted call can leave neither the old store nor the new one.Store::open(path, read_only)— opens an existing pair.read_only = truerejects all writes.Store::open_copy(src, dest, catalog)— copies both halves todestand opens the copy read-write, leavingsrcuntouched. The safe way to load a store you intend to change: mutating an artifact in place is unrecoverable if interrupted, since HDF5 has no journal.Store::open_without_catalog(path, catalog)— opens the array half of an artifact whose catalog is absent and mints an empty one, returning a writable store that holds every array and no rows. The way in to a store shipped as arrays plus an OpenAPI document; see restoring a catalog from a document.
#![allow(unused)] fn main() { pub enum Compression { None, Deflate { level: u8, shuffle: bool }, // level 0–9 } }
Store::create uses Compression::default() (DEFLATE level 3 + shuffle). The policy is persisted
and restored when the store is reopened for appends, applies only to on-disk stores, and never
changes how data is read back — see the storage model.
Store
#![allow(unused)] fn main() { impl Store { pub fn read_only(&self) -> bool; // The compression policy applied to writes (restored from the file on open; // `Compression::None` for in-memory stores). pub fn compression(&self) -> Compression; pub fn add_time_series( &mut self, owner_id: i64, owner_type: &str, owner_category: OwnerCategory, data: TimeSeriesData, features: Features, ) -> Result<TimeSeriesId>; // the catalog id its row was filed under // The same write from a prebuilt request (sets `application_data`, the unit // descriptors, …). pub fn add(&mut self, request: AddRequest) -> Result<TimeSeriesId>; // A managed batch: packed series are written into batch-sized datasets that // fill whole HDF5 chunks (the optimized bulk-write path). pub fn add_time_series_bulk(&mut self, items: Vec<AddRequest>) -> Result<Vec<TimeSeriesId>>; // Begin a buffered bulk add. Requests pushed onto the returned guard are // accumulated in memory and written together by `BulkAdd::commit` (same // block-write path as `add_time_series_bulk`); dropping without committing // discards the buffer. pub fn bulk_add(&mut self) -> BulkAdd<'_>; // Copy one association onto another owner (metadata only; the array is shared). pub fn copy_time_series( &mut self, src: TimeSeriesId, dst_owner_id: i64, dst_owner_type: &str, new_name: Option<&str>, // None keeps the source name ) -> Result<TimeSeriesId>; // the copy's own id // Read many full series at once. Packed `SingleTimeSeries` are read in one // decompress-once pass per dataset. Results follow the order the ids are // given, repeats included; `NotFound` if any id names no row. pub fn read_by_ids( &self, ids: &[TimeSeriesId], window: ReadWindow, ) -> Result<Vec<TimeSeriesData>>; // The bounds read beside the window read. A window says "these exact steps" // and is checked; a range says "whatever falls between these instants" and // clips -- which is what an export wants, since it knows the bounds and not // the step count. pub fn read_by_ids_range( &self, ids: &[TimeSeriesId], time_range: TimeRange, ) -> Result<Vec<TimeSeriesData>>; // One series by id, whole or windowed, in a single call: the id is a // primary-key lookup and its row carries the grid the window resolves // against. `len` counts timesteps (static types), `count` counts windows // (forecasts); supplying the other is `InvalidParameter`, as is a start off // the grid or an extent past the end -- a window is checked where a // `TimeRange` is clamped. `ReadWindow::full()` reads everything. pub fn read_by_id(&self, id: TimeSeriesId, window: ReadWindow) -> Result<TimeSeriesData>; pub fn transform_single_time_series( &mut self, horizon: impl Into<Period>, interval: impl Into<Period>, owner_category: Option<OwnerCategory>, resolution: Option<Period>, ) -> Result<usize>; // One all-or-nothing transaction: `NotFound` if any id names no row, and // nothing removed. A repeated id is removed, and counted, once. pub fn remove_by_ids(&mut self, ids: &[TimeSeriesId]) -> Result<usize>; pub fn clear_time_series( &mut self, owner: Option<(i64, OwnerCategory)>, ) -> Result<usize>; pub fn replace_owner( &mut self, old_owner: i64, new_owner: i64, owner_category: OwnerCategory, ) -> Result<usize>; // The identify half: which series exist, what each is, which array each // resolves to (`data_hash`), and the `id` to address it by. It replaced five // key-shaped listings, each of which was this one query projected // differently. Rows carry no time axis -- read the series for that. pub fn list_metadata(&self, filter: ListFilter) -> Result<Vec<TimeSeriesMetadata>>; // The same listing addressed by id: one catalog query for a whole model's // worth of recorded references. `NotFound` if any id names no row. pub fn list_metadata_by_ids( &self, ids: &[TimeSeriesId], ) -> Result<Vec<TimeSeriesMetadata>>; // Existence over a filter without listing: "does this owner have any time // series (of type T)?". Both probes answer from a covering index and are // safe for hot loops. pub fn has_any_time_series(&self, filter: ListFilter) -> Result<bool>; // Whether the store holds no content of any kind — no time series, no // associations in any catalog. One short-circuited existence probe per // catalog table, so it is O(1) in store size, and it covers tables a // client-side conjunction over the count APIs would miss. pub fn is_empty(&self) -> Result<bool>; // The row filed under `id`, or `None` if the catalog holds no such row -- // a consumer validating references it persisted earlier is asking whether // one still resolves, and a stale reference is an answer. pub fn get_metadata_by_id(&self, id: TimeSeriesId) -> Result<Option<TimeSeriesMetadata>>; // The same question without fetching the row: a primary-key probe, cheap // enough to check every reference in a model on load. pub fn association_exists(&self, id: TimeSeriesId) -> Result<bool>; pub fn get_array_by_hash(&self, hash: &[u8; 32]) -> Result<TypedArray>; // (SingleTimeSeries, DeterministicSingleTimeSeries) associations on one array. pub fn count_array_references(&self, data_hash: &[u8; 32]) -> Result<(usize, usize)>; pub fn get_resolutions( &self, time_series_type: Option<TimeSeriesType>, ) -> Result<Vec<Period>>; pub fn get_time_series_counts(&self) -> Result<TimeSeriesCounts>; pub fn get_forecast_parameters( &self, resolution: Option<Period>, interval: Option<Period>, ) -> Result<ForecastParameters>; // Catalog introspection (each one catalog query; see "Introspection" below). pub fn check_static_consistency( &self, resolution: Option<Period>, ) -> Result<Vec<StaticConsistency>>; pub fn counts_by_type(&self) -> Result<Vec<(TimeSeriesType, i64)>>; pub fn num_distinct_arrays(&self) -> Result<i64>; pub fn time_series_counts_detailed(&self) -> Result<TimeSeriesCountsDetailed>; pub fn list_owner_ids( &self, category: OwnerCategory, time_series_type: Option<TimeSeriesType>, resolution: Option<Period>, ) -> Result<Vec<i64>>; pub fn static_summary(&self) -> Result<Vec<StaticSummaryRow>>; pub fn forecast_summary(&self) -> Result<Vec<ForecastSummaryRow>>; // Per-timestamp readers (see "Readers" below). pub fn build_static_reader(&self, filter: ListFilter) -> Result<StaticReader>; // The same, over a caller-named span rather than the grid the series share. pub fn build_static_reader_over( &self, filter: ListFilter, window: ReadWindow, ) -> Result<StaticReader>; pub fn static_read(&self, reader: &mut StaticReader, at: DateTime<Utc>) -> Result<()>; pub fn build_forecast_reader(&self, filter: ListFilter) -> Result<ForecastReader>; pub fn forecast_read(&self, reader: &mut ForecastReader, at: DateTime<Utc>) -> Result<()>; // The supplemental-attribute catalog (see "Associations" below). Independent // of time series: none of these touch, or are touched by, a time-series call. pub fn add_supplemental_attribute_association( &mut self, assoc: SupplementalAttributeAssociation, ) -> Result<()>; pub fn add_supplemental_attribute_associations( &mut self, assocs: Vec<SupplementalAttributeAssociation>, ) -> Result<usize>; pub fn has_supplemental_attribute_association( &self, filter: &SupplementalAttributeFilter, ) -> Result<bool>; pub fn list_supplemental_attribute_associations( &self, filter: &SupplementalAttributeFilter, ) -> Result<Vec<SupplementalAttributeAssociation>>; pub fn list_supplemental_attribute_ids( &self, filter: &SupplementalAttributeFilter, ) -> Result<Vec<i64>>; pub fn list_components_with_attributes( &self, filter: &SupplementalAttributeFilter, ) -> Result<Vec<i64>>; pub fn remove_supplemental_attribute_associations( &mut self, filter: &SupplementalAttributeFilter, ) -> Result<usize>; pub fn replace_supplemental_attribute_component_id( &mut self, old_id: i64, new_id: i64, ) -> Result<usize>; pub fn count_supplemental_attribute_associations( &self, filter: &SupplementalAttributeFilter, ) -> Result<i64>; pub fn count_supplemental_attributes( &self, filter: &SupplementalAttributeFilter, ) -> Result<i64>; pub fn count_components_with_attributes( &self, filter: &SupplementalAttributeFilter, ) -> Result<i64>; pub fn supplemental_attribute_counts_by_type(&self) -> Result<Vec<(String, i64)>>; pub fn supplemental_attribute_summary( &self, ) -> Result<Vec<SupplementalAttributeSummaryRow>>; // The parent/child catalog (see "Associations" below). Same independence // from time series. pub fn add_parent_child_association(&mut self, assoc: ParentChildAssociation) -> Result<()>; pub fn add_parent_child_associations( &mut self, assocs: Vec<ParentChildAssociation>, ) -> Result<usize>; pub fn has_parent_child_association(&self, filter: &ParentChildFilter) -> Result<bool>; pub fn list_parent_child_associations( &self, filter: &ParentChildFilter, ) -> Result<Vec<ParentChildAssociation>>; pub fn list_children(&self, filter: &ParentChildFilter) -> Result<Vec<i64>>; pub fn list_parents(&self, filter: &ParentChildFilter) -> Result<Vec<i64>>; pub fn remove_parent_child_associations( &mut self, filter: &ParentChildFilter, ) -> Result<usize>; pub fn replace_parent_child_component_id( &mut self, old_id: i64, new_id: i64, ) -> Result<usize>; pub fn count_parent_child_associations(&self, filter: &ParentChildFilter) -> Result<i64>; // Reclaims both halves. On an on-disk store this rewrites the .h5 file from // the catalog's live set and replaces it, so a delete actually shrinks the // store; assumes this process is the file's only user. pub fn compact(&mut self) -> Result<CompactionReport>; pub fn verify_integrity(&self) -> Result<IntegrityReport>; pub fn flush(&mut self) -> Result<()>; // Cross-operation transactions: the operations between a begin and its // matching commit either all take effect or none do. Removals are reversible // only inside one -- outside, a freed array is gone. Calls nest (SQLite // savepoints); only the outermost commit is durable. Composes with // `bulk_add` rather than replacing it: batch each operation, and use a // transaction when several must be atomic together. Holds the SQLite write // lock until the outermost commit/rollback. pub fn begin_transaction(&mut self) -> Result<()>; pub fn commit_transaction(&mut self) -> Result<()>; pub fn rollback_transaction(&mut self) -> Result<()>; pub fn in_transaction(&self) -> bool; // The byte budget an open transaction's buffered adds are held to, and so // how wide a dataset a run of single adds writes. Raised, the run produces // what `add_time_series_bulk` of the same items produces -- a batch handed // over as a list applies no budget, the caller already holding it. Belongs // to this handle, not to the artifact: nothing is persisted. Lowering it // under an open transaction writes out what the buffer already holds beyond // the new figure; zero is `InvalidParameter`. pub fn write_buffer_bytes(&self) -> usize; pub fn set_write_buffer_bytes(&mut self, bytes: usize) -> Result<()>; // Write the whole store (arrays + catalog) to `path` + `<path>.sqlite`, // overwriting them. Works for on-disk *and* in-memory stores. pub fn persist_to(&mut self, path: &Path) -> Result<()>; } }
Store is Send but not Sync (the SQLite catalog holds a rusqlite::Connection): a store
can be moved between threads, but sharing one requires external synchronization —
Arc<Mutex<Store>>, serializing reads as well as writes, which is what the gRPC server does.
Method notes
add_time_series— Accepts anyTimeSeriesDatavariant —SingleTimeSeries,NonSequentialTimeSeries,PersistentTimeSeries, or a dense forecast (Deterministic,Probabilistic,Scenarios). Hashes the array, stores it (deduplicating on the hash), inserts a metadata association, and returns its key. Errors withDuplicateTimeSeriesif the key already exists orReadOnlyStoreon a read-only store. It is a convenience wrapper overadd_time_series_bulk.transform_single_time_series— Derives aDeterministicSingleTimeSeriesfrom every storedSingleTimeSeries, sharing the underlying array (withcountderived from the series length), and returns the number of series transformed. This is the only way to create aDeterministicSingleTimeSeries; it is never added directly. The optionalowner_categoryandresolutionfilters restrict the transform to a single owner category and/or resolution, leaving other series untouched.add_time_series_bulk— All-or-nothing: every array put and association insert in the call commits together or rolls back together.read_by_id/read_by_ids— Reconstruct the stored type as aTimeSeriesDatavariant (static series and all forecast types). A read names only an id, so the row's owntime_series_typedecides what comes back — there is no requested type to disagree with it. AReadWindowis checked: a start off the series' grid, or an extent past its end, isInvalidParameterrather than the smaller answer a range would clip to.read_by_ids_range— The bounds read.startis inclusive andendis exclusive, and it clips to what is there — see Reading a time range for what each type applies that to. Both bounds must be spelled the way the series are, and a selection spanning both coherence groups is refused rather than resolved per series.clear_time_series—Some((id, category))removes one owner's series (the owner is the(owner_id, owner_category)pair);Noneremoves all. Returns the count removed. Underlying arrays are freed only when their last reference is gone.replace_owner— Reassigns every series owned by(old_owner_id, owner_category)to(new_owner_id, owner_category), returning the number of associations updated. The category is unchanged by the move and scopes which owner's series are reassigned.copy_time_series— Copies one association onto(dst_owner_id, dst_owner_type), keeping the source'sowner_categoryand every descriptive column — cruciallytime_series_type, so aDeterministicSingleTimeSeriesstays one rather than being materialized into a denseDeterministic(what a read-then-write copy through the bindings would produce). Only a metadata row is written: the array is content-addressed and shared.new_name = Nonekeeps the source name. Errors withDuplicateTimeSeriesif the destination identity already exists.list_metadata— The identify half of the whole surface: which series exist, what type and grid each is, which array each resolves to (data_hash), and theidthat addresses it. ItsListFilterreadstime_series_typethroughTimeSeriesType::accepts, so asking forDeterministicalso selects a storedDeterministicSingleTimeSeries, and each row still reports the concrete type that matched. A caller wanting exactly one row poses the filter and checks that it got one — there is deliberately no separate attribute-to-id resolver.list_metadata_by_ids— The same listing addressed by id, for a consumer hydrating a model full of recorded references: one catalog query for the whole set rather than one call each.get_metadata_by_id/association_exists— One row by id, and the same question without fetching it. Both answerNone/falsefor a stale reference, because a consumer validating what it persisted is asking a question; the reads and removals treat the same reference as a failure, because they are already committed to acting on it.get_array_by_hash— Read an array directly by content hash, given adata_hashoff any catalog row.count_array_references—(sts, dst)association counts referencing onedata_hash, so a caller can tell whether removing aSingleTimeSerieswould orphan aDeterministicSingleTimeSeriesderived from (and sharing) its array.verify_integrity— Reads back every array and timestamp vector the catalog references, recomputes its hash, and reports mismatches and dangling references. It checks the HDF5 half against the catalog, never the catalog against itself, so an empty report does not mean the store as a whole is sound. See content addressing.flush— IssuesH5Fflushso the files can be copied for persistence without closing.persist_to— Writes both halves of the artifact topathand<path>.sqlite, overwriting existing targets. An on-disk store is flushed and copied; an in-memory store is materialized (every distinct array by hash, plus the whole catalog). Because arrays are content-addressed, this reproduces every series — static, forecast, non-sequential — without per-type reconstruction.persist_catalog— Writes an in-memory catalog to this store's own<path>.sqlite, stamped to match the HDF5 file already beside it. Unlikepersist_to, writes no arrays: they are already in place. A checkpoint, not a mode switch — the catalog stays in RAM afterwards. For aCatalogMode::Attachedstore this isflush.
Reading a time range
read_by_ids_range(ids, TimeRange::new(start, end)) selects on the time axis. The rule is the same
for all seven types — start is inclusive, end is exclusive — but what it is applied to
differs, because the types disagree about what a stored value is:
| Type | Selected |
|---|---|
NonSequentialTimeSeries | every timestamp t with start <= t < end |
PersistentTimeSeries | the breakpoint in force at start, then every one with start < b < end |
SingleTimeSeries | every step whose covered interval [t, t + resolution) overlaps the range |
Deterministic / Probabilistic / Scenarios / DeterministicSingleTimeSeries | every window whose start w has start <= w < end, and start must be a window boundary |
The two static rows differ only at the start bound, and only when start falls strictly inside a
step. An irregular series pairs a value with an instant, so a value at t < start is outside the
range. A regular series pairs a value with the step it covers, so the step containing start does
overlap the range and is returned — which means the sliced series' initial_timestamp can be
earlier than the start that was asked for. A PersistentTimeSeries goes one step further for the
same reason: a step function defines a value at start itself — the one carried by the breakpoint
in force there — so the slice begins at that breakpoint even though it precedes the window. A
start before the very first breakpoint is an InvalidParameter error, not a clamp: a step
function is undefined there. The bounds need not be grid-aligned; start is floored and end is
ceiled onto the grid (calendar-aware for a monthly resolution). The end bound behaves identically
under either reading: a step at or after end cannot overlap [start, end).
A zero-width range (end == start) selects nothing, for every one of the seven types — [t, t)
contains no instant, so there is none for a value to be attached to. That is an answer, not a fault.
It holds for PersistentTimeSeries too, and takes precedence over the row above: an empty window
has no start to be in force at, so an empty window before the first breakpoint is empty rather
than an error.
Forecasts are stricter on purpose. A window is a whole array, not a point, so there is no partial
window to return: an off-grid start is rejected with InvalidParameter rather than snapped, at
any magnitude — including one finer than a millisecond, which Period::steps_between
checks the exact landing for. A start that is aligned but at or past the last window is rejected
too, rather than returning an empty selection. A start before the first window is the exception
and clips to it: nothing partial lies there, only nothing at all, and rejecting it would fail every
range wider than the data — which is the range a bulk export asks for.
end < start is InvalidParameter for every type. Query bounds themselves are unconstrained: they
may be finer than the millisecond every stored instant is held to (see
timestamp precision).
One slice is refused whatever the bounds say. A SingleTimeSeries or forecast whose period is a
calendar month is stored as an anchor plus a count, and the end-of-month clamp is not associative —
a monthly grid from Jan-31 is Jan-31, Feb-29, Mar-31, but re-anchored at its own Feb-29 it reads
Feb-29, Mar-29, Apr-29. A slice that would have to describe itself that way is an
InvalidParameter, because the shape cannot express the instants the store holds and the wrong
answer would be silent. See
A calendar period is not closed under slicing.
A reader is exact rather than range-based: index_at maps a timestamp to its index and
errors if that instant is not on the timeline — for both the regular and the irregular case. It
never floors, ceils, or clamps.
Introspection
Grouped catalog queries the bindings use instead of listing every association and aggregating in the caller. All are read-only and hit SQLite once.
check_static_consistency— oneStaticConsistency{ resolution, initial_timestamp, length }per resolution present (emptyVecwhen there are noSingleTimeSeries), ordered by resolution; each row is the grid shared by everySingleTimeSeriesat that resolution. Consistency is only required within a resolution — series at different resolutions legitimately have different grids — so passSome(resolution)to scope the check to one grid. ReturnsIntegrityErrorwhen the series at a single resolution disagree.counts_by_type— Association count perTimeSeriesType.num_distinct_arrays— Distinct stored content hashes; series sharing an array count once.time_series_counts_detailed—TimeSeriesCountsDetailed: distinct owners split by category, and distinct arrays (not associations) split into static vs forecast.list_owner_ids— Distinct owner ids in one category that have a time series, optionally narrowed by type and/or resolution.static_summary/forecast_summary— OneStaticSummaryRow/ForecastSummaryRowper distinct owner/name/shape (or window) combination, with the association count. The core groups; the binding formats the table.
Forecasts
Dense forecasts (Deterministic, Probabilistic, Scenarios) are written through the generic
add_time_series by wrapping the corresponding object in a
TimeSeriesData variant. Build the object with its new constructor — each
holds a TypedArray in its native shape, and the constructor validates the
shape against the windowing parameters (horizon, interval, count, and for Probabilistic the
percentiles):
#![allow(unused)] fn main() { use infrastore_core::{Deterministic, TimeSeriesData}; let forecast = Deterministic::new( initial_timestamp, resolution, horizon, interval, count, data, name, )?; let key = store.add_time_series( owner_id, owner_type, OwnerCategory::Component, TimeSeriesData::Deterministic(forecast), features, units, )?; }
Dense forecast arrays (Deterministic / Probabilistic / Scenarios) are stored as standalone
HDF5 variables. A DeterministicSingleTimeSeries is not added directly: call
transform_single_time_series(horizon, interval, owner_category, resolution) to derive one from
every stored SingleTimeSeries (it shares the backing column-packed array, derives count from the
series length, and dedups against that series).
Conventional array shapes:
| Type | data shape | extra metadata |
|---|---|---|
Deterministic | [H, count, *E] | — |
DeterministicSingleTimeSeries | the backing SingleTimeSeries array (dedups) | — |
Probabilistic | [percentile_count, H, count, *E] | percentiles |
Scenarios | [scenario_count, H, count, *E] | — |
Reading forecasts: read_by_id reconstructs all forecast types, returning the matching
TimeSeriesData variant — Deterministic, Probabilistic, or Scenarios. A
DeterministicSingleTimeSeries is synthesized into a Deterministic by gathering its windows from
the underlying packed array. The low-level pair still works for direct array access: fetch a
TimeSeriesMetadata with get_metadata_by_id (it carries horizon,
interval, count, and percentiles), then read the array with
get_array_by_hash(&meta.data_hash).
Readers
read_by_id returns a whole series or forecast. To read many whole series at once (e.g.
exploration or plotting), read_by_ids takes a slice of ids and reads packed SingleTimeSeries in
one decompress-once pass per dataset — far cheaper than a read_by_id per series under the
timestamp-major chunking, where a single full-series read touches every chunk. Results follow the
order the ids are given, repeats included, and an id naming no row fails the read with NotFound
rather than being skipped. For the timestamp-oriented access pattern — walk the timeline and read
every series' value at each instant — build a reader instead. A reader is built once over a
ListFilter, pins one resolution, and holds reusable buffers that each read
overwrites in place, so a tight loop allocates nothing. The reader is a passive plan: it does not
borrow the Store, so reads go through Store::static_read / Store::forecast_read, which fill
the buffers; the caller then walks the groups/entries. There are two:
StaticReader for the static types and
ForecastReader for forecasts.
#![allow(unused)] fn main() { // Static: value of every SingleTimeSeries at one timestamp, columnar. let mut reader = store.build_static_reader(ListFilter::new().resolution(res))?; // `timestamps()` walks the timeline whichever kind it is, so the loop below is // identical for an irregular reader. for at in reader.timestamps().collect::<Vec<_>>() { store.static_read(&mut reader, at)?; for group in reader.groups() { let bytes = group.values(); // [num_columns, *element_shape], row-major LE // group.keys()[j] identifies column j; group.dtype(), group.element_shape() } } // Forecast: the window at one timestamp for every matching forecast of one type. let mut reader = store.build_forecast_reader( ListFilter::new().time_series_type(TimeSeriesType::Deterministic).resolution(res), )?; for k in 0..reader.count() { // Window k: initial + k·interval. let at = reader.interval().add_to(reader.initial_timestamp(), k as i64).unwrap(); store.forecast_read(&mut reader, at)?; // `entry_slot` takes the *entry* index, not `entry.slot()` (which indexes `slots()`). for (i, entry) in reader.entries().iter().enumerate() { let slot = reader.entry_slot(i); let bytes = slot.window(); // window of slot.window_shape(), row-major LE // entry.id() names the forecast; get_metadata_by_id resolves its owner } } }
build_static_reader covers all three static types, and which one the filter names decides what
must hold. For SingleTimeSeries (the default) the filter must pin a resolution and all matched
series must share one grid (initial_timestamp + length). For NonSequentialTimeSeries it must
pin no resolution — an irregular series has none — and all matched series must instead lie on one
timestamp vector, the same cohort that pools their arrays on disk:
#![allow(unused)] fn main() { let mut reader = store.build_static_reader( ListFilter::new().time_series_type(TimeSeriesType::NonSequentialTimeSeries), )?; assert!(reader.resolution().is_none()); // no constant step to report }
For PersistentTimeSeries the filter must likewise pin no resolution, but this is the one case
whose columns need not share a timeline: a step function has a value at every instant from its
first breakpoint onward, so each column carries its values forward on breakpoints of its own. The
reader's timeline is then the sorted union of every column's breakpoints — every instant at
which some column changes value — and index_at reports a position on that union axis, never a
storage row index. Reading at an instant before some column's first breakpoint is an error naming
that column.
#![allow(unused)] fn main() { let mut reader = store.build_static_reader( ListFilter::new().time_series_type(TimeSeriesType::PersistentTimeSeries), )?; // Columns may sit on different breakpoint vectors; the timeline merges them. for t in reader.timestamps().collect::<Vec<_>>() { store.static_read(&mut reader, t)?; } }
When the matched SingleTimeSeries do not share a grid — the usual shape of a real system —
build_static_reader_over takes the span instead of deriving it. Each column then reads at an
offset of its own, so series that begin at different instants, or run for different lengths, sweep
together as long as they all cover the span:
#![allow(unused)] fn main() { let mut reader = store.build_static_reader_over( ListFilter::new().resolution(Duration::hours(1)), ReadWindow::from(anchor).with_len(8760), // len optional: without it, as far as all reach )?; }
The window is checked, never clamped, in the three ways that would otherwise return a full,
plausible, wrong row: a matched series that does not cover it is an error naming that series
rather than a column silently dropped; the anchor must fall at or after each series' start and on
one of its own step boundaries (unlike read_by_id, which floors a start inside a step, because
there a value covers its step); and a calendar resolution is refused where re-anchoring would move
the dates, by Period::sub_grid_is_anchorable — the same rule that governs a sliced read. The
window belongs to SingleTimeSeries alone, and ReadWindow::count (which counts forecast windows)
and a len with no start are both errors.
The window's counterpart is a filter: ListFilter::initial_timestamp and ListFilter::length match
only the series already on one grid, so the ones that are not on it never become columns.
#![allow(unused)] fn main() { // three series named "active_power": one stray day, two full leap years store.build_static_reader(ListFilter::new().resolution(hour))?; // Err: no shared grid store.build_static_reader_over(filter, ReadWindow::from(t7))?; // 3 columns, 17 steps store.build_static_reader(ListFilter::new().resolution(hour).initial_timestamp(t7))?; // 2 columns, 8784 }
Use the window when the ragged series should all take part in the sweep, the filter when they should
not; the two compose. With resolution they complete the grid triple, which is what lets a filter
name a grid rather than only be refused a divergent one — the role ListFilter::zoneless plays
for time-reference coherence. Being ordinary filter fields they reach every filter-taking call, and
like every filter they select rather than assert: a grid no row is on is an empty result, not an
error, and a row that stores no initial_timestamp (the two irregular types) matches no value at
all. They are not part of KeyIdentity, so an identity probe never narrows by them: two series
differing only in start or length are the same row to the catalog.
Uniformity — where it is required — is validated at build, so there is no presence mask in any of
the three cases. build_forecast_reader requires a forecast type and a resolution; a
Deterministic reader is abstract (also matches DeterministicSingleTimeSeries), and all matched
forecasts must share one window timeline (initial_timestamp + interval + count). static_read
/ forecast_read error (never clamp) if at is off the grid/timeline.
A read is all-or-nothing. It spans every group (or slot), so a failure anywhere leaves the
reader wholly empty rather than holding the new timestamp's values in the groups it reached and the
previous read's in the ones it did not — values() / window() go empty and stay empty until a
read succeeds. invalidate() is the same thing on demand, for a binding that refuses a read before
the core sees it (the Julia wrapper checks the bound's spelling against the reader's timeline, which
the C ABI's at_unix_ms cannot carry).
Window-read deduplication. A ForecastReader groups its entries into WindowSlots keyed by
(array hash, read plan): forecasts that reference the same array and slice it the same way —
deduplicated identical data, or several DeterministicSingleTimeSeries over one SingleTimeSeries
— share one slot. forecast_read performs one backend read per slot, not per entry, so a
forecast shared by N owners is read once per timestamp (the forecast analog of StaticReader
reading a packed column once and gathering it to many columns). reader.slots() /
reader.entry_slot(i) expose the slots; note that entry_slot takes the entry index i and
returns the slot backing that entry, while entry.slot() is that slot's index into slots() (equal
for entries that share data).
Associations
Two catalogs of relationships between entities the store does not otherwise model. They live here so consumers do not each carry their own SQLite database for them, and they are wholly independent of time series: there are no foreign keys and no cascade (both endpoints live in the caller's object graph, so a cascade could never fire), so removing a time series never removes an association and removing an association never removes a time series. A caller that wants both composes the two calls.
Both families share the same filter conventions: every field of the filter is optional, set fields
are ANDed, and the default filter matches every row — which is what makes a bulk export/import pair
a round trip. The *_types fields are lists of concrete type names rendered as SQL IN (…);
expanding an abstract type into its subtypes stays with the caller, where the type hierarchy lives,
and an empty list matches nothing. Every remove_* returns the number of rows removed, and removing
zero rows is Ok(0) rather than an error: the store has no view of whether the caller expected a
hit.
Supplemental-attribute associations
Which supplemental attributes are attached to which components. Identity is the
(component_id, attribute_id) pair — the type names are denormalized labels carried for filtering
and reporting, not part of identity — so re-attaching the same pair under different type names is
still a duplicate. One attribute may be attached to many components.
add_supplemental_attribute_association— Attaches oneSupplementalAttributeAssociation. Errors withDuplicateAssociationif that component already carries that attribute, whatever type names are supplied.add_supplemental_attribute_associations— All-or-nothing: a duplicate anywhere in the batch rolls the whole batch back. Returns the number inserted. It is the import half of the round trip whose export islist_supplemental_attribute_associationswith a default filter.list_supplemental_attribute_associations/has_supplemental_attribute_association/count_supplemental_attribute_associations— TheSupplementalAttributeFilterpredicate over the table. The list returns rows in insertion order, so a default-filter export/import pair round-trips.list_supplemental_attribute_ids/list_components_with_attributes— Distinct ids on one end of the matching rows, ascending: the attributes attached to a component whencomponent_idis set, and the components carrying an attribute whenattribute_idis set.count_supplemental_attributes/count_components_with_attributes— The same two queries counted rather than listed.remove_supplemental_attribute_associations— Removes every matching row and returns the count.replace_supplemental_attribute_component_id— Moves every attachment from componentold_idtonew_id, returning the rows updated. Errors withDuplicateAssociationifnew_idalready carries one of the attributes being moved.supplemental_attribute_counts_by_type/supplemental_attribute_summary— Grouped counts, by attribute type or by both type names (SupplementalAttributeSummaryRow, ordered by attribute type then component type). The core groups; the caller formats.
#![allow(unused)] fn main() { use infrastore_core::{SupplementalAttributeAssociation, SupplementalAttributeFilter}; store.add_supplemental_attribute_association(SupplementalAttributeAssociation { component_id: 1, component_type: "Generator".into(), attribute_id: 100, attribute_type: "GeographicInfo".into(), })?; // The attributes attached to component 1, then the components carrying attribute 100. let attributes = store.list_supplemental_attribute_ids(&SupplementalAttributeFilter::new().component_id(1))?; let components = store.list_components_with_attributes(&SupplementalAttributeFilter::new().attribute_id(100))?; // Detach them: removing the attachments leaves any time series untouched. let removed = store.remove_supplemental_attribute_associations( &SupplementalAttributeFilter::new().component_id(1), )?; // Bulk round trip — the default filter matches every row. let exported = store.list_supplemental_attribute_associations(&Default::default())?; target.add_supplemental_attribute_associations(exported)?; }
Parent/child associations
Directed edges between components — a generator (parent) connected to a bus (child), say. Both
endpoints are always components; an attribute cannot appear here. Identity is the ordered
(parent_id, child_id) pair, so the reversed pair is a different edge. There is no
relationship-kind column, so one ordered pair may be related at most once.
This family is deliberately narrower than the supplemental one: it has no counts-by-type and no grouped summary, because there is no consumer for them yet. Both are additive if one appears.
add_parent_child_association— Records oneParentChildAssociation. Errors withDuplicateAssociationif that ordered pair is already related.add_parent_child_associations— All-or-nothing bulk insert, returning the number inserted; the import half of the round trip whose export islist_parent_child_associationswith a default filter.list_parent_child_associations/has_parent_child_association/count_parent_child_associations— TheParentChildFilterpredicate over the table. The list returns rows in insertion order.list_children/list_parents— Distinct ids on one end of the matching edges, ascending: the children of a component whenparent_idis set, and its parents whenchild_idis set.remove_parent_child_associations— Removes every matching edge and returns the count.replace_parent_child_component_id— Rewrites componentold_idtonew_idon both ends of every edge, returning the rows updated. Errors withDuplicateAssociationif the rewrite would duplicate an edgenew_idalready has.
#![allow(unused)] fn main() { use infrastore_core::{ParentChildAssociation, ParentChildFilter}; store.add_parent_child_association(ParentChildAssociation { parent_id: 1, parent_type: "Generator".into(), child_id: 7, child_type: "Bus".into(), })?; // The reversed pair is a different edge, not a duplicate. store.add_parent_child_association(ParentChildAssociation { parent_id: 7, parent_type: "Bus".into(), child_id: 1, child_type: "Generator".into(), })?; let children = store.list_children(&ParentChildFilter::new().parent_id(1))?; // [7] let parents = store.list_parents(&ParentChildFilter::new().child_id(7))?; // [1] // Bulk round trip — the default filter matches every row. let exported = store.list_parent_child_associations(&ParentChildFilter::default())?; target.add_parent_child_associations(exported)?; }
Neither association catalog is exposed over the gRPC server or the
infrastore CLI.
Store attributes
Key/value provenance about the artifact as a whole, as opposed to a row. See Store attributes for the model.
#![allow(unused)] fn main() { fn set_store_attribute(&mut self, key: &str, value: &str) -> Result<()>; fn get_store_attribute(&self, key: &str) -> Result<Option<String>>; fn list_store_attributes(&self) -> Result<BTreeMap<String, String>>; fn remove_store_attribute(&mut self, key: &str) -> Result<bool>; }
store.set_store_attribute("creator", "sienna-build")?;
store.set_store_attribute("source_system", "WECC 2032 ADS")?;
assert_eq!(store.get_store_attribute("creator")?.as_deref(), Some("sienna-build"));
assert_eq!(store.get_store_attribute("absent")?, None); // a question, not an error
assert!(store.remove_store_attribute("creator")?); // true: it was there
assert!(!store.remove_store_attribute("creator")?); // false: it was not
A set replaces rather than appending. Both writers take part in the ambient transaction and return
TimeSeriesError::ReadOnlyStore on a read-only store; both refuse an empty key and any
key beginning with RESERVED_STORE_ATTRIBUTE_PREFIX ("infrastore.") with InvalidParameter.
list_store_attributes returns a BTreeMap, so the ordering does not depend on insertion history —
two stores' attribute sets compare and print the same way.
Restoring a catalog from a document
An artifact is two files, but a consumer that already carries the association rows in JSON of its
own — PowerSystems ships a system.json beside a time_series.h5 — has no reason to move the
.sqlite half around as well. The arrays are the half that cannot be reconstructed; the catalog can
be replayed.
#![allow(unused)] fn main() { use infrastore_core::{CatalogMode, ListFilter, Store}; // Writing side: the arrays are already in `bundle.h5`; take the rows as JSON. let store = Store::open(path, true)?; let ts_rows = store.export_time_series_associations_openapi(&ListFilter::new())?; let sa_rows = store.export_supplemental_attribute_associations_openapi()?; // Reading side: `bundle.h5` arrived with no `bundle.h5.sqlite` beside it. let mut restored = Store::open_without_catalog(path, CatalogMode::Attached)?; restored.import_time_series_associations_openapi(&ts_rows)?; restored.import_supplemental_attribute_associations_openapi(&sa_rows)?; }
Store::open cannot open that bundle: the array file carries a generation stamp and a catalog
created on the spot does not, so it reports MismatchedArtifact — the right answer everywhere
except here. open_without_catalog mints a catalog carrying the array file's own stamp, so the
rebuilt pair opens normally ever after. It refuses (StoreExists) when a catalog is already there;
delete it first to rebuild deliberately.
Every row keeps the association_id the document recorded, which is the point — an import that
assigned fresh ids would leave every reference in the document pointing at the wrong series.
What each row must name. The array it resolves to, under the geometry that array actually has —
a document carries locators, never values. And, for a NonSequentialTimeSeries, its time axis, as
timestamps_uri. That one is a locator for the same reason the array is: the axis is stored beside
the arrays and shared across a cohort. It cannot be left out and inferred, because arrays are
content-addressed — two irregular series with byte-identical values on different axes share one
stored array, and only the catalog's timestamps_hash tells them apart, so an import that guessed
would hand back another series' timestamps.
Incoming rows are validated against the vendored SiennaSchemas specs before anything is decoded, so a document that drifted is refused in the schema's own terms.
What does not travel. A PersistentTimeSeries is an infrastore-local extension, and the wire
contract is a oneOf over six canonical types with no schema for a seventh — so the export omits
those rows and the import refuses one a foreign document carries. A store holding them still exports
everything else; an export whose filter names the type is an error rather than an empty array,
since that request cannot be honored at all. Those series live in the artifact and are read from it
directly, so the gap is in the document round trip, not in the store: ask
list_metadata(ListFilter::new().time_series_type(TimeSeriesType::PersistentTimeSeries)) for what a
restore would leave behind.
The wire contract
The JSON spelling both directions use is SiennaSchemas' own, vendored at
crates/infrastore-core/sienna_schemas/ (its SOURCE.md records the upstream commit; refresh with
scripts/sync_sienna_schemas.sh). Those files are compiled into the crate and are what the import
path validates against — no filesystem access, no network. A time-series row is checked against the
per-type schema its own time_series_type selects, which is how the wrapper's discriminator says
to read it and what makes an error name the offending field rather than only reporting that a
oneOf matched nothing.
Types
TimeSeriesId and KeyIdentity
TimeSeriesId is the catalog id of one association — the only way to address a stored series. It is
a newtype over i64 rather than a bare integer because the store hands out several unrelated
integer id streams (this one, owner_id, and the two association catalogs' own ids), and every read
and removal takes one of them; passing an owner_id where a series id belongs is a type error here
rather than a lookup that silently finds the wrong row.
#![allow(unused)] fn main() { #[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] #[serde(transparent)] pub struct TimeSeriesId(pub i64); impl TimeSeriesId { pub const fn get(self) -> i64; // for a boundary that speaks in scalars } }
#[serde(transparent)], so the SQLite catalog, the gRPC wire and the OpenAPI document (which spells
it association_id) are unchanged by the wrapper, and every binding exchanges a plain integer.
KeyIdentity is the tuple the catalog files a row under, matching its uniqueness constraint. It is
not an address: it stays internal to the write path, and nothing takes one. interval is part
of the identity (Some for every forecast type, None for the static types); resolution is
Option because neither NonSequentialTimeSeries nor PersistentTimeSeries has one.
#![allow(unused)] fn main() { pub struct KeyIdentity { pub owner_id: i64, pub owner_category: OwnerCategory, pub time_series_type: TimeSeriesType, pub name: String, pub resolution: Option<Period>, pub interval: Option<Period>, pub features: Features, } }
A caller that knows a series by its attributes recovers its id from a list_metadata row. See
Data Model.
SingleTimeSeries
#![allow(unused)] fn main() { pub struct SingleTimeSeries { pub initial_timestamp: DateTime<Utc>, pub resolution: Period, pub length: usize, pub data: TypedArray, pub name: String, pub element_type: ElementType, // never optional; see below pub units: Option<String>, pub quantity_kind: Option<String>, pub unit_system: Option<UnitSystem>, pub time_reference: Option<TimeReference>, pub component_field: Option<String>, pub application_data: Option<String>, } impl SingleTimeSeries { pub fn new( initial_timestamp: DateTime<Utc>, resolution: impl Into<Period>, data: TypedArray, name: impl Into<String>, ) -> Self; pub fn with_element_type(self, element_type: ElementType) -> Self; pub fn with_units(self, units: impl Into<String>) -> Self; pub fn with_quantity_kind(self, quantity_kind: impl Into<String>) -> Self; pub fn with_unit_system(self, unit_system: UnitSystem) -> Self; pub fn with_time_reference(self, time_reference: TimeReference) -> Self; pub fn with_component_field(self, component_field: impl Into<String>) -> Self; pub fn with_application_data(self, application_data: impl Into<String>) -> Self; pub fn timestamp_at(&self, index: usize) -> Result<DateTime<Utc>>; pub fn timestamps(&self) -> impl Iterator<Item = DateTime<Utc>> + '_; pub fn from_timestamps( timestamps: &[DateTime<Utc>], data: TypedArray, name: impl Into<String>, ) -> Result<Self, String>; pub fn from_values( initial_timestamp: DateTime<Utc>, resolution: impl Into<Period>, values: &DecodedValues, name: impl Into<String>, ) -> Result<Self, String>; } }
from_timestamps builds from the timeline a caller holds, inferring the resolution with
[Period::infer] and proving the instants lie on it. new takes initial_timestamp +
resolution and cannot check the claim — the vector it describes is never supplied — so a caller
whose values sit on a drifting timeline gets a grid that silently disagrees with their data.
from_timestamps either fits a Period exactly or errors naming the index that broke the pattern
and pointing at NonSequentialTimeSeries. It is also how a local-clock timeline reaches the
store: the core has no time-zone database and never runs local → instant, so the caller
materializes their local grid in their own date library and hands over the instants.
length is derived from the array's first axis (data.length()) by new.
timestamps materializes the grid, [0, length) in order — the regular counterpart of the explicit
vector NonSequentialTimeSeries and PersistentTimeSeries carry as a field, and the only correct
way to rebuild the timeline: a Period::Months resolution steps on the calendar, so a series
starting January 31st lands on February 29th, and multiplying a fixed span by the index gets it
wrong. timestamp_at is the single-index form, erroring past length or on date overflow. Both
report UTC instants; how they were spelled is time_reference, which neither applies.
The descriptors travel on the series rather than on the write request, so a read returns what a
write declared. element_type is not an Option: new resolves it to Scalar(data.dtype) —
what an ordinary numeric series is — and with_element_type replaces it. There is deliberately no
"undeclared" spelling, because it would be a second way to say Scalar(dtype) and a series written
that way would not compare equal to the same series read back. The consequence to know: replacing
data on an already-built series without updating element_type is a mismatch the store rejects on
write (InvalidParameter) rather than silently re-deriving one — build the series again instead.
The other four series types follow the same pattern.
from_values is the constructor that makes that mismatch unrepresentable, and the one to prefer for
a composite series: it takes the per-timestep values, encodes them into the array, and declares the
element type they imply. An element_type and the array it describes are two independent things a
caller can get out of step; deriving both from one input means there is nothing left to keep in
step. See Element values, which every series type's from_values shares.
Element values
ElementType says what one timestep's stored bytes mean; DecodedValues is those meanings as
Rust values. The two travel together, which is the whole design: a from_values constructor takes
the values and produces both halves, and TimeSeriesData::decoded_values reverses it.
#![allow(unused)] fn main() { pub struct XyPoint { pub x: f64, pub y: f64 } pub struct LinearFunction { pub proportional: f64, pub constant: f64 } pub struct QuadraticFunction { pub quadratic: f64, pub proportional: f64, pub constant: f64 } pub struct StepFunction { pub x: Vec<f64>, pub y: Vec<f64> } // n x's, n-1 y's between them pub enum DecodedValues { Raw, // nothing to decode; the array is the answer Tuple(Vec<Vec<f64>>), // one arity-long row per timestep LinearFunction(Vec<LinearFunction>), QuadraticFunction(Vec<QuadraticFunction>), PiecewiseLinear(Vec<Vec<XyPoint>>), // the points of one curve per timestep PiecewiseStep(Vec<StepFunction>), } impl DecodedValues { pub fn len(&self) -> usize; // timesteps; 0 for `Raw` pub fn is_empty(&self) -> bool; } pub fn decode(array: &TypedArray, element_type: ElementType, leading_dims: usize) -> Result<DecodedValues>; pub fn encode(values: &DecodedValues, leading_dims: &[usize]) -> Result<TypedArray>; pub fn encode_as(values: &DecodedValues, leading_dims: &[usize], element_type: ElementType) -> Result<TypedArray>; pub fn element_type_of(values: &DecodedValues) -> Option<ElementType>; }
Every variant but Raw holds one entry per timestep, in row-major order over the array's
leading dims. Raw is what a scalar element type decodes to, and any array whose physical dtype is
not f64: there the stored elements already are the values, so the TypedArray is the answer and
there is nothing to hand back. It is the one variant encode refuses, because it carries no values
of its own.
Prefer the paired forms. encode/decode are the low-level pair; from_values and
decoded_values are encode/decode with the second half — the element type on the way in, the
element type and leading_dims on the way out — supplied by the series instead of the caller:
#![allow(unused)] fn main() { let curves = DecodedValues::PiecewiseLinear(vec![ vec![XyPoint { x: 0.0, y: 1.0 }, XyPoint { x: 1.0, y: 3.0 }], vec![XyPoint { x: 0.0, y: 2.0 }], ]); let series = SingleTimeSeries::from_values(t0, Period::Fixed(Duration::hours(1)), &curves, "cost")?; assert_eq!(series.element_type, ElementType::PiecewiseLinear); // nobody declared it let data = store.read_by_id(id, ReadWindow::full())?; assert_eq!(data.decoded_values()?, curves); }
element_type_of names the element type an encode of some values would produce, for a caller
assembling the two halves by hand.
encode_as is the declared-type encoder, and exists for the one storable series from_values
cannot name: a tuple with no rows. A tuple's arity lives in its rows, so an empty
DecodedValues::Tuple implies tuple(0,f64), which is not a legal element type; encode_as takes
the arity from the declaration instead. Pair it with new + with_element_type. It also checks the
packing it produces against the declaration, so tuple(3,f64) given two-wide rows is an error
rather than a two-wide array under a three-wide label.
The ragged kinds pad to the widest timestep across the whole input, so the same curve encodes
differently in a differently-shaped series. That is the storage layout, not a property of the value
— see Element types for the byte layouts and
conformance/element_type_vectors.json for the pinned vectors every binding is held to.
TypedArray and Dtype
The array type every read and write carries: an element dtype, an N-dimensional shape
[length, k1, k2, …] (first axis time, trailing axes the per-step element shape), and raw
row-major, little-endian bytes. Little-endian describes the buffer, not the HDF5 file, whose
datasets record their own byte order.
#![allow(unused)] fn main() { pub enum Dtype { F64, F32, I64, I32, U64, Bool, // codes 0..=5; size() = 8/4/8/4/8/1 I16, I8, U32, U16, U8, // codes 6..=10; size() = 2/1/4/2/1 } pub struct TypedArray { pub dtype: Dtype, pub shape: Vec<usize>, pub bytes: Vec<u8>, } impl TypedArray { pub fn new(dtype: Dtype, shape: Vec<usize>, bytes: Vec<u8>) -> Result<Self, String>; // validates len pub fn from_f64(shape: Vec<usize>, values: &[f64]) -> Self; pub fn to_f64_vec(&self) -> Result<Vec<f64>, String>; pub fn length(&self) -> usize; // shape[0] pub fn element_shape(&self) -> &[usize]; // shape[1..] } }
Dtype::code() / Dtype::from_code(i32) and Dtype::as_str() / Dtype::parse(&str) convert to
and from the stable integer codes and string names used by the bindings and the on-disk format.
Period
The calendar-aware time span used for every resolution, horizon, and interval. A Period is either
a fixed span (a chrono::Duration — hours, minutes, days, weeks) or a calendar span (a
count of months, so Quarter = 3, Year = 12), letting the store represent monthly/annual grids a
fixed Duration cannot.
#![allow(unused)] fn main() { pub enum Period { Fixed(Duration), // a fixed chrono::Duration Months(i32), // n calendar months } impl Period { pub fn fixed(d: Duration) -> Self; // also: From<Duration> for Period pub fn months(n: i32) -> Self; pub fn is_irregular(&self) -> bool; // true for Months pub fn is_positive(&self) -> bool; pub fn same_kind(&self, other: &Period) -> bool; // both Fixed, or both Months // Grid arithmetic (calendar-aware for `Months`). pub fn add_to(&self, dt: DateTime<Utc>, k: i64) -> Option<DateTime<Utc>>; // Whole steps from `start` to `at`; errors if `at` is before `start` or off-grid. pub fn steps_between(&self, start: DateTime<Utc>, at: DateTime<Utc>) -> Result<usize>; // Nearest grid step at or below / at or above `at`; clamps to 0, never errors // (used for time-range slicing, where the bounds are arbitrary). pub fn floor_steps(&self, start: DateTime<Utc>, at: DateTime<Utc>) -> usize; pub fn ceil_steps(&self, start: DateTime<Utc>, at: DateTime<Utc>) -> usize; // `other / self` as an exact positive integer (H = horizon / resolution). // Mixing a Fixed and a Months period is an error. pub fn divide_into(&self, other: &Period) -> Result<usize>; // The on-disk / on-the-wire encoding: an ISO-8601 duration ("PT1H", "P1M", "P1Y"). pub fn to_iso8601(&self) -> String; // also the `Display` impl pub fn from_iso8601(s: &str) -> Result<Period>; } }
to_iso8601 / from_iso8601 are the persistence contract: every resolution, horizon, and interval
is stored and transmitted as that string. The encoding is a pure function of the value, so equal
periods always encode identically (which is what the catalog's uniqueness key relies on), and it
round-trips. Calendar units (Y, M before the T) decode to Months; fixed units (W, D, and
H/M/S after the T) decode to Fixed; a string mixing the two is rejected.
Because Period: From<Duration>, anywhere the API takes impl Into<Period> you may pass a
chrono::Duration directly (e.g. Duration::hours(1)); use Period::months(n) for calendar spans.
Two periods of different kinds (one Fixed, one Months) are never equal, even if a particular
month happens to span the same wall-clock time. See the data model
for how resolution drives the storage grid.
NonSequentialTimeSeries
#![allow(unused)] fn main() { pub struct NonSequentialTimeSeries { pub timestamps: Vec<DateTime<Utc>>, pub length: usize, pub data: TypedArray, pub name: String, } impl NonSequentialTimeSeries { pub fn new( timestamps: Vec<DateTime<Utc>>, data: TypedArray, name: impl Into<String>, ) -> Result<Self, String>; } }
new validates that timestamps are strictly increasing and match the data length.
PersistentTimeSeries
#![allow(unused)] fn main() { pub struct PersistentTimeSeries { pub timestamps: Vec<DateTime<Utc>>, // breakpoints, strictly increasing pub length: usize, pub data: TypedArray, pub name: String, } impl PersistentTimeSeries { pub fn new( timestamps: Vec<DateTime<Utc>>, data: TypedArray, name: impl Into<String>, ) -> Result<Self, String>; /// The value in force at `at`, for a series of scalars. `T` must match the /// array's dtype; a shaped per-step element is an error (use `row_at`). pub fn value_at<T: Element>(&self, at: DateTime<Utc>) -> Result<T, String>; /// The whole per-step slice in force at `at`, shape-generic. `[]` for a /// scalar series. pub fn row_at(&self, at: DateTime<Utc>) -> Result<TypedArray, String>; /// The index of the breakpoint governing `at` — the greatest one `<= at`. /// `Err` if `at` precedes the first breakpoint. pub fn index_at(&self, at: DateTime<Utc>) -> Result<usize, String>; /// That breakpoint itself: the instant from which the value at `at` has /// been in force. Equal to `at` when `at` is itself a breakpoint. pub fn breakpoint_at(&self, at: DateTime<Utc>) -> Result<DateTime<Utc>, String>; } }
A sparse step function: the value at breakpoint i is in force until breakpoint i + 1, and
past the last one forever; before the first breakpoint it is undefined and asking for it is an
error. new validates exactly what NonSequentialTimeSeries::new does.
value_at is the everyday call, and it is not an approximation: the step function is total on
[first breakpoint, +∞), so it has a genuine value at every instant a caller can ask about. Only
the row that value came from sits earlier, which is why index_at and breakpoint_at are the
pair spelled as lookups. All four go through one definition of the boundary rule — nothing
re-derives it. See time-series types for
the full contract and the contrast with NonSequentialTimeSeries.
Deterministic
#![allow(unused)] fn main() { pub struct Deterministic { pub initial_timestamp: DateTime<Utc>, pub resolution: Period, pub horizon: Period, pub interval: Period, pub count: usize, pub data: TypedArray, // shape [H, count, *E] pub name: String, } impl Deterministic { pub fn new( initial_timestamp: DateTime<Utc>, resolution: impl Into<Period>, horizon: impl Into<Period>, interval: impl Into<Period>, count: usize, data: TypedArray, name: impl Into<String>, ) -> Result<Self, String>; pub fn from_values( initial_timestamp: DateTime<Utc>, resolution: impl Into<Period>, horizon: impl Into<Period>, interval: impl Into<Period>, count: usize, values: &DecodedValues, name: impl Into<String>, ) -> Result<Self, String>; pub fn horizon_count(&self) -> usize; pub fn window_start(&self, index: usize) -> Result<DateTime<Utc>>; pub fn window_timestamps(&self, index: usize) -> Result<Vec<DateTime<Utc>>>; } }
new validates data.shape against [H, count, *E] where H = horizon / resolution.
from_values is the composite-value constructor described under
SingleTimeSeries, and it carries more weight on a forecast: the leading axes
are [H, count], and H is derived here from horizon/resolution rather than asked for. Values
are one entry per timestep in row-major order over those axes, so entry i * count + j is window
j's step i, and there must be exactly H * count of them. Probabilistic::from_values takes
percentiles and fills [percentiles.len(), H, count]; Scenarios::from_values takes
scenario_count and fills [scenario_count, H, count].
A forecast has two grids and both are needed to place a value: windows step by interval
(window_start), and the steps inside one window step by resolution (window_timestamps, which
returns horizon_count() of them from that window's issue time). They coincide only where windows
abut without overlapping — a day-ahead forecast reissued hourly overlaps 23 of every 24 steps.
horizon_count is data.shape[0], which validate holds equal to horizon / resolution.
validate re-checks those same invariants against the values the struct currently holds, and
returns the same Err(String). Every field is pub and the type derives Deserialize, so a struct
literal, a field assignment, or serde_json::from_str all produce a value that never met new —
which is why add_time_series calls validate on the write path rather than trusting the
constructor. Probabilistic and Scenarios carry the same method.
Probabilistic
#![allow(unused)] fn main() { pub struct Probabilistic { pub initial_timestamp: DateTime<Utc>, pub resolution: Period, pub horizon: Period, pub interval: Period, pub count: usize, pub percentiles: Vec<f64>, pub data: TypedArray, // shape [num_percentiles, H, count, *E] pub name: String, } impl Probabilistic { pub fn new( initial_timestamp: DateTime<Utc>, resolution: impl Into<Period>, horizon: impl Into<Period>, interval: impl Into<Period>, count: usize, percentiles: Vec<f64>, data: TypedArray, name: impl Into<String>, ) -> Result<Self, String>; } }
new also requires percentiles to be non-empty and strictly increasing.
Scenarios
#![allow(unused)] fn main() { pub struct Scenarios { pub initial_timestamp: DateTime<Utc>, pub resolution: Period, pub horizon: Period, pub interval: Period, pub count: usize, pub scenario_count: usize, pub data: TypedArray, // shape [scenario_count, H, count, *E] pub name: String, } impl Scenarios { pub fn new( initial_timestamp: DateTime<Utc>, resolution: impl Into<Period>, horizon: impl Into<Period>, interval: impl Into<Period>, count: usize, scenario_count: usize, data: TypedArray, name: impl Into<String>, ) -> Result<Self, String>; } }
StaticReader and StaticGroup
The columnar static-series reader (see Readers). Period is the crate's resolution
type. values() is empty until the first Store::static_read, and empty again after one fails.
#![allow(unused)] fn main() { impl StaticReader { pub fn time_series_type(&self) -> TimeSeriesType; // which static type, hence which timeline pub fn initial_timestamp(&self) -> DateTime<Utc>; pub fn resolution(&self) -> Option<Period>; // None for the explicit-axis types pub fn length(&self) -> usize; // timeline points pub fn groups(&self) -> &[StaticGroup]; pub fn index_at(&self, at: DateTime<Utc>) -> Result<usize>; pub fn timestamp_at(&self, index: usize) -> Result<DateTime<Utc>>; pub fn timestamps(&self) -> impl Iterator<Item = DateTime<Utc>> + '_; } impl StaticGroup { pub fn dtype(&self) -> Dtype; pub fn element_shape(&self) -> &[usize]; // trailing per-step dims; empty == scalar pub fn ids(&self) -> &[TimeSeriesId]; // column j's catalog id pub fn num_columns(&self) -> usize; pub fn values(&self) -> &[u8]; // [num_columns, *element_shape], row-major LE } }
ForecastReader, WindowSlot, and ForecastEntry
The forecast-window reader (see Readers). Entries are the per-series forecasts; slots
are the deduplicated physical reads. WindowSlot::window() is empty until the first
Store::forecast_read.
#![allow(unused)] fn main() { impl ForecastReader { pub fn time_series_type(&self) -> TimeSeriesType; pub fn initial_timestamp(&self) -> DateTime<Utc>; pub fn resolution(&self) -> Period; pub fn interval(&self) -> Period; pub fn count(&self) -> usize; // windows; valid timestamps initial + k·interval pub fn entries(&self) -> &[ForecastEntry]; pub fn slots(&self) -> &[WindowSlot]; // one backend read each per forecast_read pub fn entry_slot(&self, i: usize) -> &WindowSlot; // slot backing entry i pub fn window_index(&self, at: DateTime<Utc>) -> Result<usize>; } impl ForecastEntry { pub fn id(&self) -> TimeSeriesId; pub fn slot(&self) -> usize; // index into slots(); equal for entries sharing data } impl WindowSlot { pub fn dtype(&self) -> Dtype; pub fn window_shape(&self) -> &[usize]; // [H,*E] / [P,H,*E] / [scenarios,H,*E] pub fn window(&self) -> &[u8]; // most recent window, row-major LE } }
TimeSeriesData
#![allow(unused)] fn main() { pub enum TimeSeriesData { SingleTimeSeries(SingleTimeSeries), NonSequentialTimeSeries(NonSequentialTimeSeries), PersistentTimeSeries(PersistentTimeSeries), Deterministic(Deterministic), Probabilistic(Probabilistic), Scenarios(Scenarios), } impl TimeSeriesData { pub fn time_series_type(&self) -> TimeSeriesType; pub fn as_single(&self) -> Option<&SingleTimeSeries>; pub fn as_non_sequential(&self) -> Option<&NonSequentialTimeSeries>; pub fn as_persistent(&self) -> Option<&PersistentTimeSeries>; pub fn as_deterministic(&self) -> Option<&Deterministic>; pub fn as_probabilistic(&self) -> Option<&Probabilistic>; pub fn as_scenarios(&self) -> Option<&Scenarios>; pub fn element_type(&self) -> ElementType; pub fn decoded_values(&self) -> Result<DecodedValues>; } }
decoded_values is the read-side counterpart of the from_values constructors — see
Element values. It takes the element type and the leading-axis count off the
value itself, so a caller decoding a read never restates either.
There is no DeterministicSingleTimeSeries variant: a stored DeterministicSingleTimeSeries is
read back as a Deterministic (so as_deterministic returns Some for it).
TimeSeriesType
#![allow(unused)] fn main() { pub enum TimeSeriesType { SingleTimeSeries, NonSequentialTimeSeries, Deterministic, DeterministicSingleTimeSeries, Probabilistic, Scenarios, PersistentTimeSeries, } }
as_str() / parse(&str) convert to and from the canonical string names used on disk.
PersistentTimeSeries is appended rather than inserted: the storage codes are an on-disk
contract, and the Deterministic/DeterministicSingleTimeSeries adjacency that code_span relies
on must not be disturbed. That makes the static group non-contiguous in the code space, which is why
static_codes() / forecast_codes() return lists rather than ranges.
OwnerCategory
#![allow(unused)] fn main() { pub enum OwnerCategory { Component, SupplementalAttribute } }
FeatureValue and Features
#![allow(unused)] fn main() { pub enum FeatureValue { Int(i64), Float(f64), Bool(bool), Str(String) } pub type Features = BTreeMap<String, FeatureValue>; }
Features is sorted by key, which fixes hash order and the uniqueness constraint. FeatureValue
canonicalizes NaN for hashing and equality.
Feature names that would shadow a time-series or key field are rejected on the write path with
InvalidParameter — see
reserved feature names. The list and the
check are public:
#![allow(unused)] fn main() { pub const RESERVED_FEATURE_NAMES: &[&str]; // sorted, exact, case-sensitive pub fn is_reserved_feature_name(name: &str) -> bool; pub fn validate_features(features: &Features) -> Result<()>; }
TimeSeriesMetadata
The full record returned by list_metadata and get_metadata_by_id: owner fields,
time_series_type, name, data_hash: [u8; 32], the optional temporal fields
(initial_timestamp, resolution, length, horizon, interval, count, timestamps),
features, the descriptors (units, quantity_kind: Option<String>,
unit_system: Option<UnitSystem>, time_reference: Option<TimeReference>,
component_field: Option<String>, application_data: Option<String>),
percentiles: Option<Vec<f64>> (set for Probabilistic), and the array typing: dtype: Dtype,
element_shape: Vec<usize>. The span fields (resolution, horizon, interval) are
Option<Period>.
UnitSystem
#![allow(unused)] fn main() { pub enum UnitSystem { NaturalUnits, ComponentBase } impl UnitSystem { pub fn as_str(&self) -> &'static str; // "natural_units" / "component_base" pub fn parse(s: &str) -> Option<Self>; } }
None on a metadata row means unspecified, not NaturalUnits. See
Optional descriptors.
TimeReference and TimeRange
#![allow(unused)] fn main() { pub enum TimeReference { Utc, // an instant, written as UTC FixedOffset(i32), // an instant, written at a fixed offset — minutes east Zone(String), // an instant, written in a named IANA zone; held opaquely Zoneless, // a wall clock; names no instant } impl TimeReference { pub fn is_zoneless(&self) -> bool; pub fn accepts_zoned_bound(reference: Option<&TimeReference>) -> bool; pub fn as_storage_string(&self) -> String; // "utc" / "-07:00" / "America/Denver" / "zoneless" pub fn parse(s: &str) -> Result<Self>; pub fn validate(&self) -> Result<()>; // shape only; no tz database } }
How a series' timestamps were spelled. None on a metadata row means unspecified, and groups
with the three zoned variants for query bounds — it is not a claim the timestamps were written as
UTC. Rust has no naive datetime type, so a native caller declares the spelling; the bindings
infer it from theirs.
validate checks shape only: a zone name must be non-empty, bounded, IANA-shaped, and unreadable as
an offset or as either literal — which is what lets one catalog column hold all four spellings.
Existence is deliberately not checked; see Time references.
#![allow(unused)] fn main() { pub struct TimeRange { pub start: DateTime<Utc>, pub end: DateTime<Utc>, pub zoneless: bool, } impl TimeRange { pub fn new(start: DateTime<Utc>, end: DateTime<Utc>) -> Self; // zoned pub fn zoneless(start: DateTime<Utc>, end: DateTime<Utc>) -> Self; pub fn spelled(start: DateTime<Utc>, end: DateTime<Utc>, zoneless: bool) -> Self; pub fn bounds(&self) -> (DateTime<Utc>, DateTime<Utc>); } impl From<(DateTime<Utc>, DateTime<Utc>)> for TimeRange; // zoned }
The time_range argument of read_by_ids_range. The zoneless flag is what lets the core refuse a
bound whose spelling the series cannot answer rather than coercing it; a DateTime<Utc> is zoned by
construction, so (start, end).into() is the native spelling.
Descriptors
The descriptive attributes a series carries alongside its array, applied to a reconstructed series
by TimeSeriesData::set_descriptors:
#![allow(unused)] fn main() { pub struct Descriptors { pub element_type: ElementType, pub units: Option<String>, pub quantity_kind: Option<String>, pub unit_system: Option<UnitSystem>, pub time_reference: Option<TimeReference>, pub component_field: Option<String>, pub application_data: Option<String>, } }
It is a struct rather than a positional argument list because four of the seven fields are
Option<String>: as bare parameters, units, quantity_kind, component_field, and
application_data would be silently interchangeable at every call site.
ListFilter
A builder; every field is an optional filter, combined with AND. ListFilter::new() and
ListFilter::default() are the same empty filter (matches everything).
#![allow(unused)] fn main() { ListFilter::new() .owner_id(42) .owner_type("Generator") .owner_category(OwnerCategory::Component) .time_series_type(TimeSeriesType::SingleTimeSeries) .name("load") .name_glob("load_*") // SQLite GLOB (case-sensitive, `*`/`?`); ANDed with .name .component_field("max_active_power") // exact, case-sensitive; see below .zoneless(false) // coherence predicate on the timestamp spelling; see below .resolution(Duration::hours(1)) // impl Into<Period> .interval(Duration::hours(24)) // impl Into<Period>; forecasts only .features(features) // subset match: rows must contain at least these pairs }
component_field answers "every series that varies this field", alone or scoped to one owner. It is
a descriptor, not part of a series' identity, so it narrows a listing but never addresses a single
row on its own — one component may carry several series for one field, distinguished by name or
features. A row that declares no component_field matches no value (SQL equality is never true
against NULL), so the filter cannot select the rows that left it unset. It is served by the partial
index idx_component_field, which costs a store that never sets the field nothing.
zoneless is a binary predicate, not a match on a specific TimeReference: Some(true) keeps
the wall-clock series, Some(false) keeps everything that accepts an instant bound — the three
zoned spellings and the rows that left the reference unset. An exact match could not name that
second group at all (the trap component_field documents), and here those rows are a coherence
group rather than an oversight. It is the constructive half of the rules that make
read_by_ids_range and build_static_reader refuse a selection spanning both groups; see
Time references.
AddRequest
The element type of add_time_series_bulk (and of BulkAdd::push), mirroring the add_time_series
arguments plus an optional application_data — an opaque, package-owned payload (typically JSON)
stored verbatim. The series name lives on the TimeSeriesData object, not here.
#![allow(unused)] fn main() { pub struct AddRequest { pub owner_id: i64, pub owner_type: String, pub owner_category: OwnerCategory, pub data: TimeSeriesData, pub features: Features, pub units: Option<String>, pub application_data: Option<String>, // …plus the other descriptors (`quantity_kind`, `unit_system`, // `time_reference`, `component_field`), all `Option` and defaulting to unset } impl AddRequest { pub fn new(owner_id: i64, owner_type: &str, owner_category: OwnerCategory, data: TimeSeriesData) -> Self; // everything else unset pub fn with_features(self, features: Features) -> Self; } }
A request names no catalog id. Every add — this one, add_time_series, and both association
catalogs' — lets the catalog assign, and returns the TimeSeriesId
it chose. The one writer that files rows under ids a caller supplies is import_association_rows,
replaying a document that already recorded them; see
Association ids.
BulkAdd
The buffered bulk-add session returned by Store::bulk_add. Requests accumulate in memory
— no validation and no I/O until commit, which writes every array as a batch-sized block and
inserts every association in one transaction, all-or-nothing. Dropping the session without
committing discards the buffer and writes nothing.
#![allow(unused)] fn main() { impl BulkAdd<'_> { pub fn push(&mut self, request: AddRequest) -> &mut Self; // prebuilt request pub fn add( // …or from its parts &mut self, owner_id: i64, owner_type: &str, owner_category: OwnerCategory, data: TimeSeriesData, features: Features, ) -> &mut Self; pub fn len(&self) -> usize; // requests buffered so far pub fn is_empty(&self) -> bool; pub fn commit(self) -> Result<Vec<TimeSeriesId>>; // in push order } }
Requested types
What a query — a ListFilter, whether on list_metadata, an existence probe, or a reader build —
is asked to match. Every type matches only itself, with one exception: Deterministic also
matches a stored DeterministicSingleTimeSeries, since a DST is a synthetic view that reads back
as a Deterministic and callers should not have to know which form a store holds. (The two never
coexist for one identity, so this never creates ambiguity.) Requesting
DeterministicSingleTimeSeries narrows to the derived form.
#![allow(unused)] fn main() { impl TimeSeriesType { /// Does a stored series of type `stored` satisfy a request for `self`? pub fn accepts(self, stored: TimeSeriesType) -> bool; /// The same rule as catalog type names, for the SQL predicates. pub fn stored_names(self) -> &'static [&'static str]; } }
Association types
The row, predicate, and grouped-row types of the two association catalogs. All
derive Serialize/Deserialize, so a binding can hand a whole filter or a whole batch across a
language boundary as one JSON value. The rows also derive PartialEq/Eq/Hash, so they work in
sets and as map keys.
#![allow(unused)] fn main() { // One attachment: a supplemental attribute carried by a component. Identity is // the (component_id, attribute_id) pair; the type names are denormalized labels. pub struct SupplementalAttributeAssociation { pub component_id: i64, pub component_type: String, pub attribute_id: i64, pub attribute_type: String, } // One directed edge between two components. Identity is the *ordered* // (parent_id, child_id) pair, so the reversed pair is a different edge. pub struct ParentChildAssociation { pub parent_id: i64, pub parent_type: String, pub child_id: i64, pub child_type: String, } // One grouped row of `supplemental_attribute_summary`; `count` is how many // attachments share the (component_type, attribute_type) pair. pub struct SupplementalAttributeSummaryRow { pub component_type: String, pub attribute_type: String, pub count: i64, } }
The two filters are builders, like ListFilter: every field is optional and the set
ones are combined with AND, so ::new() / ::default() matches every row.
#![allow(unused)] fn main() { pub struct SupplementalAttributeFilter { pub component_id: Option<i64>, pub component_types: Option<Vec<String>>, pub attribute_id: Option<i64>, pub attribute_types: Option<Vec<String>>, } pub struct ParentChildFilter { pub parent_id: Option<i64>, pub parent_types: Option<Vec<String>>, pub child_id: Option<i64>, pub child_types: Option<Vec<String>>, } }
#![allow(unused)] fn main() { SupplementalAttributeFilter::new() .component_id(1) .component_types(["Generator", "Load"]) // concrete type names, rendered as SQL `IN (…)` .attribute_id(100) .attribute_types(["GeographicInfo"]) ParentChildFilter::new() .parent_id(1) .parent_types(["Generator"]) .child_id(7) .child_types(["Bus"]) }
The *_types lists take concrete type names only; expanding an abstract type into its subtypes
stays with the caller, where the type hierarchy lives. An empty list is an empty allow-list and
matches nothing (as opposed to leaving the field unset, which matches everything).
Report and count types
#![allow(unused)] fn main() { pub struct TimeSeriesCounts { pub components_with_time_series: i64, pub static_time_series: i64, pub forecasts: i64, } // Owner- and array-oriented counts (`time_series_counts_detailed`). Unlike // `TimeSeriesCounts`, the series counts here are deduplicated by array content // and owners are split by category. pub struct TimeSeriesCountsDetailed { pub components_with_time_series: i64, pub supplemental_attributes_with_time_series: i64, pub static_time_series_count: i64, pub forecast_count: i64, } // One grouped row of `static_summary` / `forecast_summary`; `count` is the // number of associations in the group. pub struct StaticSummaryRow { pub owner_type: String, pub owner_category: OwnerCategory, pub time_series_type: TimeSeriesType, pub name: String, pub initial_timestamp: Option<DateTime<Utc>>, pub resolution: Option<Period>, pub time_step_count: Option<i64>, pub count: i64, } pub struct ForecastSummaryRow { pub owner_type: String, pub owner_category: OwnerCategory, pub time_series_type: TimeSeriesType, pub name: String, pub initial_timestamp: Option<DateTime<Utc>>, pub resolution: Option<Period>, pub horizon: Option<Period>, pub interval: Option<Period>, pub window_count: Option<i64>, pub count: i64, } pub struct CompactionReport { // on-disk compaction rewrites the .h5; see the file-format reference pub slots_reclaimed: usize, pub datasets_dropped: usize, pub feature_sets_reclaimed: usize, pub timestamp_sets_reclaimed: usize, pub bytes_reclaimed: u64, // how much smaller the file got; 0 for an in-memory store } pub struct IntegrityReport { pub errors: Vec<String> } // .ok() == errors.is_empty() pub struct ForecastParameters { pub horizon: Option<Period>, pub interval: Option<Period>, pub count: Option<usize>, pub resolution: Option<Period>, pub initial_timestamp: Option<DateTime<Utc>>, } pub struct StaticConsistency { // one row per resolution from check_static_consistency pub resolution: Period, pub initial_timestamp: DateTime<Utc>, pub length: usize, } }
Errors
#![allow(unused)] fn main() { pub type Result<T> = std::result::Result<T, TimeSeriesError>; #[non_exhaustive] // match with a wildcard arm; new variants are not semver breaks pub enum TimeSeriesError { NotFound, DuplicateTimeSeries, /// An association with the same identity already exists — the /// `(component_id, attribute_id)` pair of an attachment, or the ordered /// `(parent_id, child_id)` pair of an edge. The payload names the offending /// pair; it is a human-readable message, not a parseable encoding. DuplicateAssociation(String), InvalidParameter(String), IntegrityError(String), ReadOnlyStore, ConnectionError(String), /// The store on disk was written in a different, incompatible on-disk /// format. There is no in-place upgrade; see the file-format reference. IncompatibleFormat { found: String, expected: &'static str }, /// The two halves do not carry the same generation stamp, so they came from /// different saves. Both unstamped (an artifact predating the stamp) is /// legal; exactly one stamped is not. `"none"` renders a missing stamp. MismatchedArtifact { h5: String, sqlite: String }, /// A store already exists where one was about to be created. See /// [`Store::create`](#constructors). StoreExists { path: String }, /// The artifact is already open in this process, in any mode. One handle /// per artifact per process; drop it before opening another. Surfaces as /// the base `TimeSeriesError` in Python and `GenericError` in Julia. StoreInUse { path: String }, Io(std::io::Error), Sqlite(rusqlite::Error), Serde(serde_json::Error), } }
StorageBackend Trait
The seam between Store and array storage. Implemented by MemoryBackend and Hdf5Backend. You
rarely call it directly, but it documents the backend contract. It is not re-exported at the
crate root — import it (and the backends) from the storage module:
#![allow(unused)] fn main() { use infrastore_core::storage::{MemoryBackend, Hdf5Backend, StorageBackend}; }
Every method below with a default is a performance override: the default is correct but naive, and
Hdf5Backend implements a faster path (single hyperslab reads, whole-chunk block writes).
#![allow(unused)] fn main() { pub trait StorageBackend: Send + Sync { // --- required --- // `packed = true` column-packs same-shaped arrays (SingleTimeSeries / DST); // `packed = false` stores a standalone multi-dim variable (NonSequential, dense forecasts). // Idempotent on hash: returns `true` only if this call physically wrote new content. fn put_array( &mut self, hash: &[u8; 32], data: &TypedArray, resolution: Period, packed: bool, ) -> Result<bool>; fn get_array(&self, hash: &[u8; 32]) -> Result<TypedArray>; // Slice along axis 0 (the time axis); `range` end is exclusive. fn get_slice(&self, hash: &[u8; 32], range: Range<usize>) -> Result<TypedArray>; fn remove_array(&mut self, hash: &[u8; 32]) -> Result<()>; // no-op if absent fn contains(&self, hash: &[u8; 32]) -> Result<bool>; fn flush(&mut self) -> Result<()>; // --- provided (overridden by Hdf5Backend) --- // In-memory path only: the default refuses, and `Store::compact` rewrites the file // for an on-disk store. fn compact(&mut self) -> Result<CompactionReport>; // Re-read and rehash every array, via `get_array`. fn verify(&self) -> Result<IntegrityReport>; // Write a block of same-shaped packed arrays at once (the bulk-add write path). // The returned Vec is aligned to `hashes`: `true` where this call wrote new content. fn put_packed_block( &mut self, hashes: &[[u8; 32]], arrays: &[&TypedArray], resolution: Period, ) -> Result<Vec<bool>>; // Read many whole arrays at once (`Store::read_by_ids`): one decompress pass per dataset. fn read_arrays(&self, hashes: &[[u8; 32]]) -> Result<Vec<TypedArray>>; // One time step across co-located arrays (`StaticReader`); `out` is cleared, then // filled row-major as [column, *element_shape]. Reusing the buffer keeps the loop // allocation-free. fn read_index_into(&self, hashes: &[[u8; 32]], index: usize, out: &mut Vec<u8>) -> Result<()>; // Stored (dtype, shape), ideally without reading the data. fn array_shape(&self, hash: &[u8; 32]) -> Result<(Dtype, Vec<usize>)>; // One forecast window: the `window_index` slice along `count_axis`, that axis dropped. fn read_window_into( &self, hash: &[u8; 32], count_axis: usize, window_index: usize, out: &mut Vec<u8>, ) -> Result<()>; // The compression policy applied to writes; defaults to `Compression::None` // (in-memory backends never compress). fn compression(&self) -> Compression; } }
Hashing
In the hash module (infrastore_core::hash). array_hash, hash_hex, and hash_from_hex are
also re-exported at the crate root; features_hash is only reachable through the module.
#![allow(unused)] fn main() { pub fn array_hash(data: &TypedArray) -> [u8; 32]; // domain: dtype tag + shape + typed bytes pub fn features_hash(features: &Features) -> [u8; 32]; pub fn hash_hex(hash: &[u8; 32]) -> String; pub fn hash_from_hex(s: &str) -> Option<[u8; 32]>; // inverse; None unless exactly 64 hex digits }
These define the cross-language content-addressing contract; see Content Addressing.
Constants
#![allow(unused)] fn main() { pub const DATA_FORMAT_VERSION: &str = "0.11.0"; // The key prefix `set_store_attribute` / `remove_store_attribute` refuse. pub const RESERVED_STORE_ATTRIBUTE_PREFIX: &str = "infrastore."; }
Python API
The PyO3 binding is importable as the infrastore module (package infrastore). It is built as an
abi3-py311 wheel, so one build runs on CPython 3.11 and newer.
from infrastore import (
Store, SingleTimeSeries, NonSequentialTimeSeries, PersistentTimeSeries,
Deterministic, Probabilistic, Scenarios,
TimeSeriesType, OwnerCategory,
SupplementalAttributeAssociation, ParentChildAssociation,
TimeSeriesError, NotFoundError, OwnerMismatchError, DuplicateTimeSeriesError,
DuplicateAssociationError, InvalidParameterError, IntegrityError, ReadOnlyStoreError,
)
infrastore.__version__ reports the wheel version.
Array dtypes. The binding accepts and returns NumPy arrays of
float64,float32, the signed and unsigned integer widths (int64/int32/int16/int8/uint64/uint32/uint16/uint8), orbool; whatever dtype is given round-trips unchanged. What those elements mean is the association'selement_type(see Element types). A composite series is built with thefrom_valuesclassmethod, which encodes the per-timestep values and declares the element type they imply, and read back with.decoded_values();element_type=on the plain constructor declares it for an array you already hold, andencode_element_values/decode_element_valuesare the standalone pair. Multi-dimensional arrays (a per-step element shape) are supported via the NumPy array's shape.
Datetimes
Every datetime argument — an initial timestamp, a NonSequentialTimeSeries timestamp vector or a
PersistentTimeSeries breakpoint vector, a time_range bound, a reader's when — may be aware or
naive, and the store records which.
An aware datetime names an instant, and any zone will do: datetime.timezone.utc, a ZoneInfo,
or a fixed offset. It is converted to UTC on the way in, so two aware datetimes naming the same
instant are the same instant to the store — and the spelling it arrived in is recorded, so it is the
spelling that comes back.
A naive datetime names a wall clock and no instant. It is accepted and recorded as
time_reference = "zoneless"; its fields are read as they stand (never through astimezone, which
would apply the machine's local zone), and a read hands back a naive datetime again. That round-trip
is the whole reason accepting one is safe:
datetime(2024, 1, 1) == datetime(2024, 1, 1, tzinfo=timezone.utc) # False
datetime(2024, 1, 1) < datetime(2024, 1, 1, tzinfo=timezone.utc) # TypeError
A store that took a naive datetime and returned an aware one would be worse than one that refused.
Time references
Every series carries a time_reference recording how its timestamps were spelled, inferred from the
datetime it was built with:
| Input | time_reference |
|---|---|
tzinfo=timezone.utc | "utc" |
a fixed-offset tzinfo | "-07:00" |
ZoneInfo("America/Denver") | "America/Denver" |
| naive | "zoneless" |
ZoneInfo("UTC") records the zone "UTC", not the literal "utc": the two render identically
forever, and the difference is only in what the catalog reports back.
Reads spell the timestamp back the same way — a ZoneInfo series returns datetimes carrying that
ZoneInfo, including the correct side of a fall-back hour. A query bound must match: a naive
bound against a series that records instants, or an aware bound against a zoneless one, raises
InvalidParameterError rather than being coerced, and so does a time_range whose two ends
disagree. list_metadata(zoneless=...), build_static_reader(..., zoneless=...), and the other
filter-taking methods take a zoneless predicate for building a coherent selection. See
Time references for the full rules.
A datetime that is stored — an initial timestamp, or an entry of a NonSequentialTimeSeries
or PersistentTimeSeries timestamp vector — must also be a whole number of milliseconds;
microsecond must be a multiple of 1000. A finer instant raises InvalidParameterError rather than
being silently truncated, because it cannot survive every binding intact (see
timestamp precision). Note that
datetime.now(timezone.utc) carries microseconds: quantize it, e.g.
now.replace(microsecond=now.microsecond // 1000 * 1000). A datetime used only as a query bound
— a time_range end, a reader's when — is unconstrained.
Store
Constructors
@classmethod
def create(
cls,
path: str | os.PathLike | None = None,
*,
in_memory: bool = False,
compression: str = "deflate", # "deflate" or "none"
compression_level: int = 3, # 0–9, DEFLATE only
shuffle: bool = True, # byte-shuffle filter, DEFLATE only
catalog: str | None = None, # "attached" or "memory"; None matches the backend
overwrite: bool = False, # discard an artifact already at `path`
) -> Store: ...
@classmethod
def open(
cls, path: str | os.PathLike, *, read_only: bool = False, catalog: str = "attached"
) -> Store: ...
@classmethod
def open_copy(
cls, src: str | os.PathLike, dest: str | os.PathLike, *, catalog: str = "attached"
) -> Store: ...
Every argument after the path(s) is keyword-only; Store.create("s.h5", True) raises TypeError.
Paths accept anything os.fspath does, pathlib.Path included (the shipped stub spells them
str).
create(in_memory=True)— in-memory store;pathand compression arguments are ignored.create(path=...)— writespath(HDF5) andpath + ".sqlite"(metadata).create(path=..., compression="none")— store arrays uncompressed;compression="deflate"with acompression_level/shuffleof your choice tunes the filter. The policy persists with the store and is reused on later appends. An unknowncompressionor out-of-range level raisesInvalidParameterError.catalog="attached"makes the catalog the.sqlitefile, where every commit is durable;catalog="memory"holds it in RAM so it reaches disk only throughpersist_to(). Arrays stream to the HDF5 file either way. The default (None) matches the backend —"memory"whenin_memory=True, else"attached"— so existing call sites are unchanged. An unknowncatalograisesInvalidParameterError. See Where the Catalog Lives.create(path=...)raisesStoreExistsErrorifpathorpath + ".sqlite"already holds a store. Creating there would discard the arrays while keeping the catalog, leaving a store that reopens cleanly with every array missing — see protecting a saved artifact.overwrite=Truediscards both halves on purpose; it is rejected forin_memory=True, which has no artifact to replace.open(path, read_only=True)— read-only open; writes raiseReadOnlyStoreError.open(path, catalog="memory")— loads the catalog into RAM; the HDF5 half is still opened in place.store.catalogreports the mode.open_copy(src, dest)— copies both halves todestand opens the copy read-write, leavingsrcuntouched. This is the safe way to load a store you intend to change.open()defaults to read-write, and mutations then land in that file directly; HDF5 has no journal and no repair tool, so an interrupted write is unrecoverable. Change the copy andpersist_to(src)— one atomic rename replaces the original. RaisesStoreExistsErrorifdestalready holds a store.
The store is also a context manager: with Store.create(...) as store: closes it on exit.
store.close() drops the underlying handle and releases its files; subsequent operations raise
TimeSeriesError (it is idempotent). repr(store) shows the path (or in-memory), the read-only
flag, and closed once closed.
Properties
store.read_only -> bool
store.catalog -> str # "attached" or "memory"
store.in_transaction -> bool
store.write_buffer_bytes -> int # settable; default 128 MiB
write_buffer_bytes is the byte budget an open transaction's buffered adds are held to, and through
it how wide a dataset a run of single adds writes: raise it and the loop produces what
add_time_series_bulk of the same series produces, which applies no budget at all. It belongs to
this Store object rather than the artifact (nothing is persisted), lowering it mid-transaction
writes out what the buffer already holds beyond the new figure, and 0 raises
InvalidParameterError. See
how wide a dataset a span writes.
Methods
def add_time_series(
self,
owner_id: int,
owner_type: str,
owner_category: OwnerCategory,
time_series: SingleTimeSeries | NonSequentialTimeSeries | PersistentTimeSeries
| Deterministic | Probabilistic | Scenarios,
*,
features: dict[str, int | float | bool | str] | None = None,
) -> int: ... # the catalog id its row was filed under
# `features` is the only thing this call adds. `name` and every descriptive
# attribute -- `units`, `quantity_kind`, `unit_system`, `component_field`,
# `application_data`, `element_type`, `time_reference` -- come off the
# time_series object, where they were set at construction. That is what makes a
# read-then-add lossless: a series read from one store can be added to another
# unchanged, with nothing to re-supply.
# A `features` key that shadows a time-series or identity field (`name`,
# `resolution`, `owner_id`, ...) raises InvalidParameterError.
def add_time_series_bulk(self, items: list[dict]) -> list[int]: ...
# Each item dict mirrors add_time_series's parameters: required `owner_id`,
# `owner_type`, `owner_category`, `time_series`; optional `features`. Any other
# key raises, as the misspelled keyword it almost always is.
# All items commit in ONE metadata transaction (all-or-nothing), which is much
# faster than looping over add_time_series *outside* a transaction; inside one,
# the loop buffers and writes the same datasets. Results are in input order.
# Every write returns the catalog `id` its row was filed under -- the handle to
# record in your own object model, and what every read and removal
# takes. It is never reissued once its row is deleted. No add takes an id: the
# catalog assigns, and the write reports what it chose. The one writer that
# files rows under supplied ids is import_time_series_associations_openapi.
def get_metadata_by_id(self, id: int) -> dict | None: ... # None when no row has the id
def list_metadata_by_ids(self, ids: list[int]) -> list[dict]: ...
# The listing addressed by id, in the order given; NotFoundError if any is stale.
def association_exists(self, id: int) -> bool: ... # no row fetched
def transform_single_time_series(
self,
horizon: timedelta | str,
interval: timedelta | str,
*,
owner_category: OwnerCategory | None = None,
resolution: timedelta | str | None = None,
) -> int: ...
# Derives a DeterministicSingleTimeSeries from every stored SingleTimeSeries —
# or, with `owner_category` / `resolution`, only from the ones matching — and
# returns the count. `horizon / resolution` steps must fit inside each source.
def copy_time_series(
self,
src: int,
dst_owner_id: int,
dst_owner_type: str,
*,
new_name: str | None = None,
) -> int: ...
# Attach the same array to another owner (no data is duplicated); returns the
# copy's own id. The source id is untouched and still resolves.
def get_array_by_hash(self, data_hash: str) -> numpy.ndarray: ...
# The raw array behind a 64-char hex content hash, bypassing the catalog.
def count_array_references(self, data_hash: str) -> dict: ...
# {"sts": int, "dst": int}: SingleTimeSeries and DeterministicSingleTimeSeries
# associations sharing that array.
def read_by_ids_range(
self, ids: list[int], time_range: tuple[datetime, datetime]
) -> list[SingleTimeSeries | NonSequentialTimeSeries | PersistentTimeSeries
| Deterministic | Probabilistic | Scenarios]: ...
# The bounds read: it CLIPS to what falls between the two instants, where
# read_by_id's window is CHECKED. Both bounds must be spelled the way the series
# are; a selection spanning both coherence groups is refused. A PersistentTimeSeries
# clips on its own terms: the result begins at the breakpoint in force at `start`.
def read_by_ids(
self, ids: list[int]
) -> list[SingleTimeSeries | NonSequentialTimeSeries | Deterministic | Probabilistic | Scenarios]: ...
# The same read addressed by catalog association id. Results follow the order the
# ids are given, repeats included; NotFoundError if any id names no row.
def read_by_id(
self,
id: int,
*,
start_time: datetime | None = None,
len: int | None = None,
count: int | None = None,
owner_id: int | None = None,
owner_category: OwnerCategory | None = None,
) -> SingleTimeSeries | NonSequentialTimeSeries | Deterministic | Probabilistic | Scenarios: ...
# The single-id read, which also takes the slice -- in one call, because the
# primary-key lookup already returns the row the window resolves against. `len`
# counts timesteps (static types) and `count` counts windows (forecasts);
# passing the one that does not apply raises InvalidParameterError, as does a
# `start_time` off the series' grid or an extent past its end. A window is
# checked where read_by_ids_range clips. No keywords reads the whole series.
# `owner_id` + `owner_category` is the owner guard -- see below.
def remove_by_ids(
self,
ids: list[int],
*,
owner_id: int | None = None,
owner_category: OwnerCategory | None = None,
) -> int: ...
# One all-or-nothing transaction: NotFoundError if any id names no row, and
# nothing removed. A repeated id is removed, and counted, once. `owner_id` +
# `owner_category` is the owner guard -- see below.
def remove_by_filter(self, *, ...) -> int: ...
# Same keyword-only filter arguments as list_metadata; one all-or-nothing
# transaction; returns the count removed (0 when nothing matched).
def clear_time_series(
self,
*,
owner_id: int | None = None,
owner_category: OwnerCategory | None = None,
) -> int: ...
# Pass both owner_id and owner_category to clear one owner's series (the owner is
# the (owner_id, owner_category) pair); pass neither to clear the whole store.
def replace_owner(
self,
old_owner: int,
new_owner: int,
owner_category: OwnerCategory,
) -> int: ...
# Reassign every series owned by (old_owner, owner_category) to
# (new_owner, owner_category). Returns the number of associations moved.
def list_metadata(
self,
*,
owner_id: int | None = None,
owner_category: OwnerCategory | None = None,
owner_type: str | None = None,
time_series_type: TimeSeriesType | str | None = None,
name: str | None = None,
name_glob: str | None = None, # SQLite GLOB pattern; ANDed with `name`
component_field: str | None = None, # exact, case-sensitive
resolution: timedelta | str | None = None,
interval: timedelta | str | None = None,
features: dict[str, int | float | bool | str] | None = None,
features_exact: bool = False, # `features` as the whole set, not a subset
) -> list[dict]: ...
# `component_field` selects every series that varies that field on its owner. A
# series that declares none matches no value, so it cannot select those rows.
def list_names(self, *, ...) -> list[str]: ... # distinct names, sorted
def list_owner_types(self, *, ...) -> list[str]: ... # distinct owner types, sorted
# Every `...` above is the same keyword-only filter as list_metadata, and so
# is remove_by_filter's.
# `time_series_type` is a TimeSeriesType (or its member name as a str).
# TimeSeriesType.Deterministic matches both Deterministic and
# DeterministicSingleTimeSeries rows. Every filter surface takes it, including
# has_any_time_series, get_resolutions, get_intervals, list_owner_ids, and
# build_forecast_reader.
def list_owner_ids(
self,
owner_category: OwnerCategory,
*,
time_series_type: TimeSeriesType | None = None,
resolution: timedelta | str | None = None,
) -> list[int]: ...
# Distinct owner ids of that category holding time series, ascending.
def has_any_time_series(self, *, ...) -> bool: ...
# Existence without listing ("does this owner have any time series?"); same
# keyword-only filter arguments as list_metadata. Index-probe fast.
def is_empty(self) -> bool: ...
# Whether the store holds nothing at all — no time series, no associations in
# any catalog. One index probe per catalog table, so its cost does not grow with
# the store, and it stays correct as the catalog gains tables; a conjunction over
# the count_* methods does neither.
def get_resolutions(self, time_series_type: TimeSeriesType | None = None) -> list[str]: ...
def get_intervals(self, time_series_type: TimeSeriesType | None = None) -> list[str]: ...
# Distinct resolutions / forecast intervals as ISO 8601 duration strings, e.g. "PT1H".
def get_time_series_counts(self) -> dict: ...
def time_series_counts_detailed(self) -> dict: ...
def counts_by_type(self) -> dict[str, int]: ... # {time_series_type name: count}
def num_distinct_arrays(self) -> int: ...
def show(self, *, file=None) -> None: ...
# Prints the above as a summary — see below. `file` is any writable object,
# defaulting to sys.stdout; it is handed straight to print.
def static_summary(self) -> list[dict]: ...
def forecast_summary(self) -> list[dict]: ...
def check_static_consistency(self, resolution: timedelta | str | None = None) -> list[dict]: ...
# One {"resolution", "initial_timestamp", "length"} per resolution present (or
# the one given); raises if the SingleTimeSeries of one resolution disagree on
# their grid — the precondition build_static_reader relies on.
def get_forecast_parameters(self, *, resolution: timedelta | str | None = None,
interval: timedelta | str | None = None) -> dict: ...
def get_compression(self) -> dict: ...
def compact(self) -> dict: ...
def verify_integrity(self) -> dict: ...
# {"ok": bool, "errors": list[str]}
def flush(self) -> None: ...
def persist_to(self, path: str) -> None: ...
def persist_arrays_to(self, path: str) -> None: ...
# Writes only the array half, leaving no catalog beside it — the write-side
# counterpart of Store.open_without_catalog, for shipping an artifact as arrays
# plus a document of your own. Atomic: one file, one rename. StoreExistsError if
# a <path>.sqlite is already there, since its rows would be left dangling.
def persist_catalog(self) -> None: ...
# Writes an in-memory catalog to this store's own <path>.sqlite, stamped to
# match the HDF5 file already beside it. Unlike persist_to, writes no arrays:
# they are already in place. A checkpoint, not a mode switch — the catalog
# stays in RAM. For catalog="attached" this is flush().
# -- transactions --
# Span several operations so they all take effect or none do. Removals are
# reversible only inside a transaction. Blocks nest; the write lock is held
# until the outermost one ends.
def transaction(self) -> Transaction: ... # context manager: commit on exit, roll back on raise
# `with store.transaction() as s:` binds the Store
def begin_transaction(self) -> None: ...
def commit_transaction(self) -> None: ... # InvalidParameterError if none is open
def rollback_transaction(self) -> None: ... # InvalidParameterError if none is open
in_transaction: bool # property
with store.transaction():
store.add_time_series(...)
store.remove_by_ids([old_id])
# both applied, or neither -- including the removal
Keyword-only arguments. Every optional argument in the binding is keyword-only (the
*marker): filter kwargs,features=on the add paths,units=/application_data=and the rest of the descriptors on the value constructors,time_range=on the read paths, and so on. Positional use raisesTypeError. The wheel ships ainfrastore.pyistub, so IDEs and type checkers see the full signatures.
Return shapes
-
add_time_seriesaccepts aSingleTimeSeries, aNonSequentialTimeSeries, aPersistentTimeSeries, or a dense forecast object (Deterministic/Probabilistic/Scenarios) — see Forecasts.transform_single_time_seriesderives aDeterministicSingleTimeSeriesfrom every storedSingleTimeSeries(or the subset itsowner_category/resolutionarguments select) and returns the count transformed.read_by_idreturns whichever matches the stored type — a read names only an id, so the row's owntime_series_typedecides, with no requested type to disagree with it. -
read_by_idsreturns one typed object per id, in the order the ids are given, repeats included (an empty id list returns an empty list). It is the bulk counterpart toread_by_id: packedSingleTimeSeriesare read in one decompress-once pass per dataset instead of one read each. An id naming no row raisesNotFoundErrorand fails the whole call, unlikeassociation_exists, which asks the question rather than committing to a read. -
read_by_ids_rangeis the bounds read: it clips every series to what falls between the two instants, whereread_by_id's window is checked. An export names bounds and does not know how many steps each series has inside them. -
remove_by_idsis the removal direction of the same reference: one all-or-nothing transaction, the count removed, andNotFoundErrorif any id names no row — in which case nothing is removed. A repeated id is removed, and counted, once. -
The owner guard. Both id-addressed calls take an optional keyword-only
owner_id+owner_category— both together, or neither, since a component and a supplemental attribute can carry the same integer id and half an owner would check less than the caller asked for:store.read_by_id(id, owner_id=7, owner_category=OwnerCategory.Component) store.remove_by_ids(ids, owner_id=7, owner_category=OwnerCategory.Component)The addressed row is held to that owner and one belonging to anyone else raises
OwnerMismatchError— distinct fromNotFoundError, because the row is there and it is the caller's belief about who owns it that is stale. For the removal the check and the delete are one transaction, so a refused batch removes nothing.A caller whose model says "this component's series" must pass the owner rather than confirm it in a call of its own. An id is the whole address and it survives
replace_owner, so aget_metadata_by_idthat confirms the owner and aremove_by_idsthat then deletes are two calls with a window between them — and a reassignment landing in that window makes the removal retire the new owner's series, the very thing the check was for. On the read side there is no window either way, but the guard is still the cheaper spelling: the owner comes off the same row the values are materialized from, where a separate check is a second round trip. -
list_metadatareturns a list of dicts (the same shapeget_metadata_by_idreturns for one row), each with the keys:owner_id,owner_type,owner_category,time_series_type,name,data_hash(hex string),initial_timestamp(RFC 3339 string, orNonefor non-sequential series),length,resolution(ISO 8601 duration string, e.g.PT1H, orNone),timestamps,horizon,interval,count,percentiles,element_type,element_shape,features,units,quantity_kind,unit_system("natural_units"/"component_base"/None),time_reference,component_field,application_data.timestampsis a list of RFC 3339 strings for non-sequential series andNoneotherwise;horizon/interval/countare set for forecasts andpercentilesforProbabilisticonly.timestampsis alwaysNoneon a listing row — an irregular series' time axis is the one part of a row that costs a read per row, so a listing omits it andread_by_idreturns the series with its axis. Thefeaturesfilter is a subset match — rows must contain at least the given pairs. -
Every row also carries
data_hash, so grouping a listing by that field finds the series that share one stored array (a deduplicated array, or aSingleTimeSeriestogether with aDeterministicSingleTimeSeriesderived from it). That replaced a separate array-group listing, which was this same query projected differently. -
list_metadata_by_idsis the same listing addressed by id, for a caller hydrating a model full of recorded references: one catalog query for the whole set rather than one call each. -
get_time_series_countsreturns{"components_with_time_series": int, "static_time_series": int, "forecasts": int};time_series_counts_detailedaddssupplemental_attributes_with_time_seriesand spells the other twostatic_time_series_count/forecast_count. -
showprints those same counts as a block of text and returnsNone. It is the hand-inspection surface — what a store holds, at a glance, without composing four calls and formatting the result. Seeshow(). -
static_summaryreturns one dict per distinct(owner_type, owner_category, time_series_type, name, initial_timestamp, resolution, time_step_count)with itscount;forecast_summarydoes the same for forecasts, addinghorizon,interval, andwindow_count. -
get_forecast_parametersreturns{"horizon": str, "interval": str, "count": int, "resolution": str, "initial_timestamp": str}, wherehorizon,interval, andresolutionare ISO 8601 duration strings (e.g."PT1H") andinitial_timestampis an RFC 3339 string. Every value isNonewhen the store holds no forecasts. The keyword-onlyresolution/intervalarguments scope the query to forecasts matching that grid. -
get_compressionreturns{"compression": "deflate" | "none", "level": int, "shuffle": bool}— the policy the store was created with (restored from the file on open;"none"for in-memory). -
compactreturns{"slots_reclaimed": int, "datasets_dropped": int, "feature_sets_reclaimed": int, "timestamp_sets_reclaimed": int, "bytes_reclaimed": int}.feature_sets_reclaimedcounts content-addressed feature rows that no association referenced any more; see the file format. On an on-disk store the call rewrites the.h5file from the live set and replaces it, which is what makesbytes_reclaimednonzero — nothing else may have the store open while it runs. -
verify_integrityreturns{"ok": bool, "errors": list[str]};okisTruewhen the error list is empty. It checks stored arrays against their recorded hashes and does not inspect the SQLite catalog, sookis not a statement about the store as a whole — see content addressing. -
read_by_ids_rangewithtime_range=(start, end)slices on the time axis;endis exclusive.
show()
show() prints what the store holds. It composes nothing you cannot ask for individually — the
counts come from counts_by_type, time_series_counts_detailed, num_distinct_arrays, and the two
association count_* methods — but it is the one call to reach for at a REPL or in a log line:
store.show()
# Store: system.h5 (read-write)
# Time series: 128 associations over 128 distinct arrays
# SingleTimeSeries 100
# PersistentTimeSeries 8
# Deterministic 20
# Owners with time series: 108 components, 0 supplemental attributes
# Supplemental attribute attachments: 12
# Parent/child edges: 5
Every number is a catalog aggregate query, so the cost does not grow with how much data the store
holds; no array is read. The type breakdown lists only the types actually present, static types
before forecasts — not the numeric type-code order counts_by_type returns, which puts
PersistentTimeSeries after the forecasts. An empty store reports Time series: none rather than a
zero, and the header reads (read-only) for a store opened that way. Writing elsewhere is the
file argument, handed straight to print:
store.show(file=sys.stderr)
The output is meant for a person to read; parse the count_* methods instead if you need the
numbers.
SingleTimeSeries
SingleTimeSeries(
initial_timestamp: datetime,
resolution: timedelta,
data: numpy.ndarray, # shape (length,) or (length, k1, ...)
name: str,
*,
application_data: str | None = None,
element_type: str | None = None,
units: str | None = None,
quantity_kind: str | None = None,
unit_system: str | None = None, # "natural_units" | "component_base"
component_field: str | None = None, # e.g. "max_active_power"
time_reference: str | None = None, # "utc" | "zoneless" | "-07:00" | "America/Denver"
)
Read-only properties: initial_timestamp -> datetime, resolution -> str (ISO 8601 duration, e.g.
PT1H), length -> int, data -> numpy.ndarray, timestamps -> list[datetime], name -> str,
plus the seven descriptive attributes. initial_timestamp comes back
spelled the way it was written — see Time references. The constructor accepts
either a timedelta or an ISO 8601 duration string for resolution; the getter always returns the
ISO string. name is a required association attribute (the same array may be stored under different
names). It is read off the object by add_time_series and populated on read_by_id. The array's
element_type and per-step element shape are preserved through a round-trip.
SingleTimeSeries.from_timestamps
@classmethod
def from_timestamps(
cls, timestamps: Sequence[datetime], data: numpy.ndarray, name: str, **descriptors
) -> SingleTimeSeries: ...
Build from the timeline you actually hold, inferring resolution and proving the instants lie
on it. The constructor takes initial_timestamp + resolution and the store cannot check that
claim — the vector it describes is never supplied. This takes the vector: it either fits a period
exactly, or raises InvalidParameterError naming the entry that broke the pattern and pointing at
NonSequentialTimeSeries.
This is how a local-clock timeline reaches the store. The core has no time-zone database and
never runs local → instant; you materialize the grid with zoneinfo — where the policy for a
nonexistent or ambiguous wall clock belongs — and hand over the instants.
denver = ZoneInfo("America/Denver")
# An hourly local grid IS a uniform instant grid, so it compacts.
hours = local_hourly_walk(datetime(2024, 11, 3, tzinfo=denver), 6)
SingleTimeSeries.from_timestamps(hours, values, "load").resolution # "PT1H"
# A daily one is not, and says so.
days = [datetime(2024, 11, d, tzinfo=denver) for d in range(1, 6)]
SingleTimeSeries.from_timestamps(days, values, "peak") # InvalidParameterError
Step the timeline in instants, not wall clocks.
aware_datetime + timedelta(hours=1)is wall-clock arithmetic in Python: on a fall-back day it jumps 01:00 straight to 02:00 and silently drops a real hour. Go through UTC —(t.astimezone(timezone.utc) + step).astimezone(zone)— orfrom_timestampswill (correctly) refuse the result.
A fixed span wins when both a fixed and a calendar reading fit; two entries always fit some fixed
span, so pass an explicit "P1M" to the constructor if you mean calendar months on a short vector.
timestamps materializes the grid — initial_timestamp + k · resolution, spelled the way the
series was written. It is the only correct way to rebuild the timeline: a P1M resolution steps on
the calendar, so a January 31st series lands on February 29th, and multiplying a fixed span by
the index would get it wrong.
Descriptive attributes
Every value type — the three static ones and all three forecasts — takes the same seven keyword-only
arguments and exposes each as a read-only property. They describe the values without addressing
them, so none is part of a series' identity: two series differing only in these are a duplicate, and
none can be filtered on except component_field.
| Argument | Meaning |
|---|---|
units | Free-form label for the values, e.g. "MW". Never interpreted or validated. |
quantity_kind | What kind of physical quantity they measure, e.g. "ActivePower". QUDT QuantityKind local names are recommended. |
unit_system | "natural_units" or "component_base". Omitted leaves the basis unspecified, which is not the same as natural units. |
component_field | The owning component's field these values are the time-varying form of, e.g. "max_active_power". The one filterable one. |
application_data | Opaque, package-owned payload (typically JSON) stored verbatim. End users are not expected to set it. |
element_type | What the array's elements mean, e.g. "tuple(3,f64)". Omit for plain numbers; the property then reports the dtype. |
time_reference | Overrides the spelling otherwise inferred from the timestamps. See Time references. |
An unrecognized unit_system raises InvalidParameterError rather than degrading to unspecified.
element_type is the only property that never returns None: it is always concrete, reporting the
array's own dtype spelling for a plain numeric series.
These live on the object rather than on add_time_series so that a read-then-add is lossless — a
series read from one store can be added to another unchanged, with no descriptor to re-supply and
none that a write could silently replace.
to_arrow()
All three static types — SingleTimeSeries, NonSequentialTimeSeries, and PersistentTimeSeries —
convert to a two-column pyarrow.Table of timestamp and value:
table = series.to_arrow()
table.to_pandas() # if pandas is installed
polars.from_arrow(table) # if polars is
pyarrow.parquet.write_table(table, "series.parquet")
pyarrow is an optional extra. It is not installed with infrastore — it is several times the size
of the wheel that would pull it in, and the binding's own currency is numpy arrays. Install it with
pip install 'infrastore[arrow]'; calling to_arrow() without it raises ImportError naming the
extra. Nothing else in the package imports pyarrow.
The timestamp column carries the series' spelling. Arrow's timestamp(unit, tz) is the same
shape as the store's own model — an instant plus how it was spelled — so the mapping is total, and
millisecond unit throughout means nothing is widened or truncated:
time_reference | Arrow column type |
|---|---|
unset, or "utc" | timestamp[ms, tz=UTC] |
"zoneless" | timestamp[ms] (no zone) |
"-07:00" | timestamp[ms, tz=-07:00] |
"America/Denver" | timestamp[ms, tz=America/Denver] |
A zone this interpreter's tz database does not know warns and falls back to UTC, exactly as reading
initial_timestamp does: the instants are intact either way. For a SingleTimeSeries the column is
the materialized grid (calendar-aware for a monthly resolution); for the two irregular types it is
the stored vector.
The value column is the array. A scalar series gives an Arrow primitive (double, int64,
bool, …); a multidimensional per-timestep value gives nested fixed_size_list, one level per
element dimension. Composite element types stay in their stored packing — decode_element_values
unpacks them, and element_type in the metadata says which.
The descriptive attributes ride in table.schema.metadata, so the table is not lossy against
the object it came from and survives a Parquet round trip: name, time_series_type,
element_type, time_reference, resolution (a SingleTimeSeries only — an irregular timeline
has no constant step), and whichever of units, quantity_kind, unit_system, component_field,
and application_data were declared. An undeclared one is absent rather than empty, so
b"units" in table.schema.metadata answers "was a label declared?".
time_reference is the exception: it is always written, and a series that records no spelling gets
the literal b"unspecified". Arrow's timestamp type has a zone or it has none, and unspecified
has no third spelling — an unspecified reference produces a UTC-zoned column, the same as "utc" —
so omitting the key would leave that column as the only evidence and from_arrow would hand back a
series claiming utc, which it never did. unspecified is a metadata encoding only: it is not a
value time_reference= accepts on any constructor.
The value column is named value rather than after the series so that tables from different
components concatenate without renaming; the series' own name is in the metadata.
A PersistentTimeSeries table has one row per breakpoint, not per instant — it is the sparse
step function as stored. Resampling onto a dense grid is the caller's to do, and needs a grid the
series does not carry: there is no value before the first breakpoint, so a grid starting earlier has
no answer to give.
Also written, and not part of the descriptive set above: element_shape, as a JSON list ([] for a
scalar element). It is a fact about the data rather than a label, so it is written even when empty,
and the CLI's Parquet export writes the same key — the two producers write one schema.
from_arrow()
The inverse, on the same three types, and a reader of foreign tables too — anything with a
timestamp and a value column, whether or not it carries the metadata to_arrow() writes:
series = SingleTimeSeries.from_arrow(series.to_arrow()) # exact round trip
import pyarrow.parquet as pq
SingleTimeSeries.from_arrow(pq.read_table("load.parquet")) # written by anything
NonSequentialTimeSeries.from_arrow(table, name="irregular")
PersistentTimeSeries.from_arrow(table, name="steps")
The inference rules are the ones infrastore add --parquet applies to a foreign file, stated once
under Foreign files in the Parquet layout reference so the two
implementations cannot drift apart quietly. In short: what the metadata says is used; what it does
not say is inferred from the Arrow schema, taking the reading that assumes least.
| Missing | Read as |
|---|---|
resolution | Inferred from the timestamps, which must then walk a grid. |
element_type | The leaf Arrow type. A fixed_size_list<double>[3] becomes f64 with shape (rows, 3) — dense, not tuple(3,f64), because the bytes cannot say. |
time_reference | The timestamp column's zone; a column with no zone reads as zoneless, since a naive timestamp is a wall clock. A metadata unspecified beats the zone and gives back None. |
name | Nothing. A name is part of a series' identity, so pass name=. |
Every keyword overrides the metadata, with one exception: element_type is an assertion.
element_type="tuple(3,f64)" states the reading the bytes cannot, and a value that contradicts the
table's own raises InvalidParameterError rather than replacing it — the rule this project applies
to every assertion.
Refused rather than coerced: nulls in either column (the store holds none, and NaN is a value
rather than an absence); a microsecond or nanosecond timestamp that is not a whole millisecond
(the store's own precision, and rounding one would move it); rows that leave a declared grid,
checked against the grid the resolution generates rather than against successive differences, since
P1M clamps to month end; and struct/list value columns, which are the decoded form
to_arrow() does not produce.
from_arrow is not the whole catalog row: a table records no owner and no catalog id, because
to_arrow() is a method on a value object and a series built here is not filed anywhere. Pass the
result to Store.add_time_series with the owner you want, as you would any other series.
from_arrow covers the three static types. A dense forecast has a file shape of its own -- a
values file of issue_time, timestamp, value (plus percentile or scenario) keyed by the
array, which the CLI writes and reads with export -f parquet and add --parquet.
to_arrow_windows() is deliberately not that shape: it returns a dict of per-window tables, which
is an in-memory analysis form rather than anything that could be one Parquet file, so the two are
not competing spellings of the same thing.
NonSequentialTimeSeries
NonSequentialTimeSeries(
timestamps: list[datetime],
data: numpy.ndarray,
name: str,
*,
application_data: str | None = None,
element_type: str | None = None,
units: str | None = None,
quantity_kind: str | None = None,
unit_system: str | None = None, # "natural_units" | "component_base"
component_field: str | None = None, # e.g. "max_active_power"
time_reference: str | None = None, # "utc" | "zoneless" | "-07:00" | "America/Denver"
)
Read-only properties: timestamps, length, data, name, and the seven
descriptive attributes, plus to_arrow(). Timestamps must
be strictly increasing, match the first data dimension, and agree on one spelling — a vector mixing
naive and aware values raises InvalidParameterError, since one series records one reference.
read_by_id returns this class for a non-sequential row.
PersistentTimeSeries
PersistentTimeSeries(
timestamps: list[datetime],
data: numpy.ndarray,
name: str,
*,
application_data: str | None = None,
element_type: str | None = None,
units: str | None = None,
quantity_kind: str | None = None,
unit_system: str | None = None, # "natural_units" | "component_base"
component_field: str | None = None, # e.g. "max_active_power"
time_reference: str | None = None, # "utc" | "zoneless" | "-07:00" | "America/Denver"
)
A sparse step function. Constructed exactly like a NonSequentialTimeSeries — same arguments,
same validation, same spelling inference — with the same read-only properties: timestamps,
length, data, name, and the seven descriptive attributes, plus
to_arrow(). timestamps is the breakpoint vector, and so are the rows of the Arrow
table — one per breakpoint, not per instant. A step function's scalar-collapse policy belongs in
application_data; the store has no column for it.
What differs is the read: the value at breakpoint i is in force until breakpoint i + 1, and past
the last one forever, where a NonSequentialTimeSeries has no value between its timestamps at all.
There is no value before the first breakpoint, and asking for one raises InvalidParameterError
rather than clamping.
Three methods ask that question of a series in hand:
value_at(at: datetime) -> Any # the value in force at `at`
index_at(at: datetime) -> int # the row it came from
breakpoint_at(at: datetime) -> datetime # the instant it has been in force since
value_at is the everyday call, and it is not an approximation: a step function is defined at
every instant from its first breakpoint onward, so it has a genuine value at at. It returns
exactly what indexing data returns — a numpy scalar of the series' own dtype, or the per-step
subarray for a series with a shaped element. at must be spelled the way the breakpoints are (both
aware or both naive), the same rule a time_range bound follows. Only an at strictly before the
first breakpoint raises.
curve = PersistentTimeSeries(
[datetime(2024, 1, 1), datetime(2024, 4, 1), datetime(2024, 7, 1)],
np.array([10.0, 40.0, 70.0]),
"gas",
)
curve.value_at(datetime(2024, 5, 17)) # 40.0, carried forward from April
curve.breakpoint_at(datetime(2024, 5, 17)) # datetime(2024, 4, 1)
A range read slices on those terms — the returned series begins at the breakpoint in force at
start, so it always defines a value there:
sliced, = store.read_by_ids_range([id], (mid_april, september))
# sliced.timestamps[0] is the April breakpoint, not the July one.
A zero-width range (end == start) is the exception, and selects nothing — as it does for every
other type, since [t, t) holds no instant for a value to be in force at. That applies before the
first breakpoint too, where a non-empty window raises.
Policy about how a step function collapses for a downstream solver belongs to the application and
travels in application_data; the store never interprets it. See the
time-series types.
Enums
TimeSeriesType.SingleTimeSeries
TimeSeriesType.NonSequentialTimeSeries
TimeSeriesType.PersistentTimeSeries
TimeSeriesType.Deterministic
TimeSeriesType.DeterministicSingleTimeSeries
TimeSeriesType.Probabilistic
TimeSeriesType.Scenarios
OwnerCategory.Component
OwnerCategory.SupplementalAttribute
TimeSeriesType names a stored type, and is also what a query asks for. Every member matches only
itself with one exception: TimeSeriesType.Deterministic also matches a stored
DeterministicSingleTimeSeries, which is what a caller asking "does this owner have a
deterministic forecast?" wants — whether the forecast was added densely or derived by
transform_single_time_series is a storage detail. Returned rows and keys still carry the concrete
stored type, and TimeSeriesType.DeterministicSingleTimeSeries narrows to the derived form for
callers auditing which forecasts are synthetic.
A member's name is accepted anywhere a TimeSeriesType is — time_series_type="Deterministic"
selects exactly what TimeSeriesType.Deterministic does. It is the spelling a metadata row reports,
so a value read out of one can be handed straight back. The match is case-sensitive: an unrecognized
string raises InvalidParameterError naming the valid ones, and a value that is neither a
TimeSeriesType nor a string raises TypeError.
Forecasts
Dense forecasts are constructed as Deterministic, Probabilistic, or Scenarios objects and then
passed to add_time_series. They are read back through read_by_id, which returns the
matching object for the row's stored type (a DeterministicSingleTimeSeries is synthesized into a
Deterministic on read). A DeterministicSingleTimeSeries is not added directly — derive one from
stored SingleTimeSeries with transform_single_time_series.
get_time_series_counts reports the forecast total under forecasts.
ts = Deterministic(
initial_timestamp, resolution, horizon, interval, count, data, "load_fc", units="MW"
)
series_id = store.add_time_series(42, "Generator", OwnerCategory.Component, ts)
data is a NumPy array in the canonical shape for the forecast type, where H is
horizon / resolution. As with SingleTimeSeries, every period argument (resolution, horizon,
interval) accepts either a timedelta or an ISO 8601 duration string — the string form is
required for calendar periods such as "P1M" — and the getters always return the ISO string. Every
forecast also takes a required name (after data), exposed as a read-only property, and the same
seven keyword-only descriptive attributes as the static types.
| Type | data shape | extra constructor arg |
|---|---|---|
Deterministic | [H, count, *element_shape] | — |
Probabilistic | [len(percentiles), H, count, *E] | percentiles |
Scenarios | [scenario_count, H, count, *E] | scenario_count is taken from data |
Deterministic
Deterministic(
initial_timestamp: datetime,
resolution: timedelta | str,
horizon: timedelta | str,
interval: timedelta | str,
count: int,
data: numpy.ndarray,
name: str,
*,
application_data: str | None = None,
element_type: str | None = None,
units: str | None = None,
quantity_kind: str | None = None,
unit_system: str | None = None,
component_field: str | None = None,
time_reference: str | None = None,
)
Read-only properties (plus the seven descriptive attributes):
forecast.initial_timestamp -> datetime
forecast.resolution -> str # ISO 8601 duration, e.g. "PT1H"
forecast.horizon -> str # ISO 8601 duration
forecast.interval -> str # ISO 8601 duration
forecast.count -> int
forecast.data -> numpy.ndarray
forecast.name -> str
to_arrow_windows()
Deterministic.to_arrow_windows() -> dict[datetime, pyarrow.Table]
The forecast as one pyarrow.Table per window, keyed by issue time —
initial_timestamp + k · interval, spelled the way the series was written. Requires the same
arrow extra as the static types.
windows = forecast.to_arrow_windows()
windows[datetime(2024, 1, 2, tzinfo=timezone.utc)] # that window's forecast
for issue_time, table in windows.items(): ... # chronological
Each value is a two-column timestamp/value table shaped exactly like a
SingleTimeSeries.to_arrow() — horizon / resolution rows stepping by resolution from the
issue time — so one window drops into anything that already consumes a static table.
The dict is in window order, which Python's insertion-ordered dict makes an ordering you can rely
on: next(iter(windows)) is the earliest issue time and iteration is chronological. It is not a
sorted container, so there is no O(log n) range lookup; bisect over list(windows) selects a
span of issue times.
Two grids, both needed to place a value. Windows step by interval; the rows inside one window
step by resolution. They coincide only for a forecast whose windows abut without overlapping,
which is not the common case — a day-ahead forecast reissued hourly overlaps 23 of every 24 rows.
The tables repeat those instants rather than pretending one timeline covers them, which is why this
is a dict of tables rather than a single table.
Each table carries the forecast's descriptive attributes as schema metadata, plus resolution,
horizon, interval, count, and its own issue_time — so a window written to Parquet on its own
still knows which one it is.
This materializes every window. The stored array is [H, count, *E] — window index innermost — so
it is transposed once on the way out; for a per-timestamp sweep the cheap path is
build_forecast_reader, which reads along the axis the data is already laid out on.
DeterministicSingleTimeSeries rows read back as a Deterministic, so they convert the same way.
Probabilistic and Scenarios do not have this yet — their windows carry a third axis, and how to
spell it is an open question.
Probabilistic
Probabilistic(
initial_timestamp: datetime,
resolution: timedelta | str,
horizon: timedelta | str,
interval: timedelta | str,
count: int,
percentiles: list[float],
data: numpy.ndarray,
name: str,
*,
application_data: str | None = None,
element_type: str | None = None,
units: str | None = None,
quantity_kind: str | None = None,
unit_system: str | None = None,
component_field: str | None = None,
time_reference: str | None = None,
)
Same properties as Deterministic, plus:
forecast.percentiles -> list[float]
Scenarios
Scenarios(
initial_timestamp: datetime,
resolution: timedelta | str,
horizon: timedelta | str,
interval: timedelta | str,
count: int,
data: numpy.ndarray, # leading axis is scenario_count
name: str,
*,
application_data: str | None = None,
element_type: str | None = None,
units: str | None = None,
quantity_kind: str | None = None,
unit_system: str | None = None,
component_field: str | None = None,
time_reference: str | None = None,
)
Same properties as Deterministic, plus:
forecast.scenario_count -> int
Readers
read_by_id returns one whole series or forecast. For the simulation access pattern — walk every
timestamp and, at each, read the value of every matching series — use a reader instead. A
reader is built once over a filter, pins one timeline, and reuses its output buffers so a tight loop
allocates almost nothing. There are two: StaticReader for the static types, and ForecastReader
for forecasts. Both share the lifecycle: build → inspect the layout once → *_read(when) in a loop
→ pull values per group/entry.
The builders and drivers live on Store:
def build_static_reader(
self,
resolution: timedelta | str | None = None,
*,
window_start: datetime | None = None, # sweep a named span instead of
window_length: int | None = None, # inheriting the shared grid
time_series_type: TimeSeriesType | None = None, # default: SingleTimeSeries
owner_id: int | None = None,
owner_category: OwnerCategory | None = None,
owner_type: str | None = None,
name: str | None = None,
name_glob: str | None = None,
component_field: str | None = None,
initial_timestamp: datetime | None = None, # select one grid, dropping
length: int | None = None, # the series not on it
features: dict[str, int | float | bool | str] | None = None,
features_exact: bool = False, # `features` as the whole set, not a subset
) -> StaticReader: ...
def static_read(self, reader: StaticReader, when: datetime) -> None: ...
def build_forecast_reader(
self,
time_series_type: TimeSeriesType,
resolution: timedelta | str,
*,
owner_id: int | None = None,
owner_category: OwnerCategory | None = None,
owner_type: str | None = None,
name: str | None = None,
name_glob: str | None = None,
component_field: str | None = None,
features: dict[str, int | float | bool | str] | None = None,
features_exact: bool = False, # `features` as the whole set, not a subset
) -> ForecastReader: ...
def forecast_read(self, reader: ForecastReader, when: datetime) -> None: ...
resolution is required on build_forecast_reader, and on build_static_reader for
SingleTimeSeries (one resolution per reader). It must be omitted for
time_series_type=TimeSeriesType.NonSequentialTimeSeries: an irregular series has no resolution, so
its timeline is the timestamp vector its cohort shares instead. Likewise for
TimeSeriesType.PersistentTimeSeries, whose timeline is the union of its columns' breakpoints.
static_read / forecast_read fill the reader's buffers in place and return None; passing a
when that is off the reader's timeline raises InvalidParameterError.
StaticReader
Reads the value of every matching static series at one timestamp. Results are columnar: series
are partitioned into (dtype, element_shape) groups, and each group's values come back as one dense
(num_columns, *element_shape) numpy array.
class StaticReader:
def grid(self) -> dict: ... # {"time_series_type": str, "initial_timestamp": rfc3339 str, "resolution": ISO str | None, "length": int}
def groups(self) -> list[dict]: ... # each: {"dtype": str, "element_type": str, "element_shape": list[int], "ids": list[int]}
def timestamps(self) -> list[datetime]: ... # every timestamp on the timeline, in order
def group_values(self, index: int) -> numpy.ndarray: ... # last read of group `index`
All matched series must share one timeline — one grid (initial_timestamp + length) for
SingleTimeSeries, one timestamp vector for NonSequentialTimeSeries. The build validates this and
raises on divergence, so there is no presence mask — every column has a value at every valid
timestamp. When they do not share one there are two remedies, and they answer different questions:
a reader window sweeps a span across the ragged series, while the
initial_timestamp / length filter drops the ones that are not on the grid
you want.
PersistentTimeSeries is the exception: its columns may sit on different breakpoint vectors,
because a step function has a value at every instant from its first breakpoint on. timestamps() is
then the sorted union of every column's breakpoints, and each column reports the value in force
there. Reading before some column's first breakpoint raises InvalidParameterError naming that
column. There is still no presence mask.
grid()["resolution"] is None for an irregular or persistent reader; timestamps() is the
timeline in every case, so a read loop written against it works unchanged for all three.
group_values(i) returns a (num_columns, *element_shape) array whose column j corresponds to
groups()[i]["ids"][j]; it is empty until the first static_read.
# For irregular series: build_static_reader(time_series_type=TimeSeriesType.NonSequentialTimeSeries)
# For step functions: build_static_reader(time_series_type=TimeSeriesType.PersistentTimeSeries)
reader = store.build_static_reader(timedelta(hours=1))
grid = reader.grid()
groups = reader.groups()
start = datetime.fromisoformat(grid["initial_timestamp"])
for ts in reader.timestamps():
store.static_read(reader, ts)
for i, g in enumerate(groups):
vals = reader.group_values(i) # column j ↔ g["ids"][j]
Reader windows
A shared grid is a strong requirement, and a real system rarely meets it: a year of load sits beside
a week of an outage schedule, or one component's data begins an hour later than the rest. Passing
window_start (and optionally window_length) drops the requirement. The reader's axis becomes the
span you named, and each column reads at an offset of its own — how many of its own steps
precede the anchor — so series that begin at different instants, or run for different lengths, sweep
together as long as they all cover the span.
# 24 hours of one series, a leap year of another: no shared grid, so no reader.
store.build_static_reader("PT1H")
# InvalidParameterError: StaticReader requires a uniform grid; series 'load' (owner 7)
# has grid (2024-01-01T00:00:00Z, PT1H, 24) but the reader grid is
# (2024-01-01T07:00:00Z, PT1H, 8784). Build the reader over a window ...
reader = store.build_static_reader("PT1H", window_start=datetime(2024, 1, 1, 7, tzinfo=utc))
reader.grid()["length"] # 17 -- as far as *every* matched series reaches from 07:00
Without window_length the reader runs as far from the anchor as every matched series reaches,
which is the widest span on which no column has to be dropped. With one, the span is exactly what
you asked for and is checked, in the three ways that would otherwise return a full, plausible,
wrong row:
- a matched series that does not cover the span raises
InvalidParameterErrornaming that series, rather than quietly leaving its column out — an absent column is invisible at read time; - the anchor must fall at or after each series' start and on one of its own step boundaries. A
timestamp part-way through a step is an error, not a floor. (
read_by_idfloors, because there a value covers its step; a reader hands back a whole cohort at one instant, so flooring per column would shift columns against each other by up to a step.) - a monthly resolution is refused where re-anchoring would move the dates, by the same rule that
governs a sliced
read_by_id— a monthly grid from Jan-31 re-anchored at its own Feb-29 would read Feb-29, Mar-29, Apr-29.
window_start must be spelled the way the series are (aware for a zoned series, naive for a
zoneless one), like every other query bound, and belongs to SingleTimeSeries alone: the two
irregular types carry their timeline rather than deriving it, so there is nothing to re-anchor.
window_length without window_start is refused — a span with no anchor is the ambiguity this
argument exists to remove.
Everything downstream is unchanged: grid() reports the window, timestamps() walks it, and
groups() still lists every matched column.
Selecting one grid
The window's counterpart. initial_timestamp and length are filter arguments — they match
only the series already on that grid, so the ones that are not on it never become columns:
# 24 hours of stray data beside two full leap years, all named active_power
store.build_static_reader("PT1H") # InvalidParameterError
store.build_static_reader("PT1H", window_start=t7).grid() # 3 columns, 17 steps
store.build_static_reader("PT1H", initial_timestamp=t7).grid() # 2 columns, 8784 steps
Use the window when the ragged series should all take part in the sweep, and the filter when they should not. They compose: filter to a cohort, then window a span inside it.
With resolution these two complete the grid triple, which is what lets a filter name a whole grid
rather than only be refused a divergent one — the role zoneless plays for time-reference
coherence. They are ordinary filter arguments, so they reach every filter-taking call
(list_metadata, list_names, has_any_time_series, remove_by_filter, …), and like every filter
they select rather than assert: a grid no row is on is an empty result, not an error. A row that
stores no initial_timestamp — the two irregular types — matches no value at all, the same
SQL-equality trap component_field has.
store.list_metadata(initial_timestamp=t7, length=8784) # the cohort
store.remove_by_filter(initial_timestamp=t0, length=24) # retire the stray one
ForecastReader
Reads the forecast window at one timestamp for every matching forecast of one type. The build
filter must name a forecast type and pin a resolution; a Deterministic reader is abstract and also
includes DeterministicSingleTimeSeries (read into identical (horizon, *element_shape) windows).
All matched forecasts must share one window timeline (initial_timestamp + interval + count).
time_series_type must be one of the forecast types — Deterministic,
DeterministicSingleTimeSeries, Probabilistic, or Scenarios; any other raises
InvalidParameterError. A Deterministic reader also covers stored DeterministicSingleTimeSeries
forecasts, matching the read request rule.
class ForecastReader:
def timeline(self) -> dict: ... # {"initial_timestamp": rfc3339 str, "resolution": ISO str, "interval": ISO str, "count": int, "time_series_type": str}
def entries(self) -> list[int]: ... # per-entry catalog ids, in order (parallel to entry_values)
def timestamps(self) -> list[datetime]: ... # every window-start timestamp, in order
def entry_values(self, index: int) -> numpy.ndarray: ... # last read of entry `index`
def num_slots(self) -> int: ... # deduplicated window slots (physical reads per forecast_read)
def entry_slot(self, index: int) -> int: ... # 0-based slot backing entry `index`
Valid read timestamps are initial_timestamp + k·interval for k in range(count) (each names the
window forecast from that instant). entry_values(i) returns the window backing entries()[i],
shaped (horizon, *element_shape) for Deterministic / DeterministicSingleTimeSeries,
(num_percentiles, horizon, *element_shape) for Probabilistic, and
(scenario_count, horizon, *element_shape) for Scenarios; it is empty until the first
forecast_read.
reader = store.build_forecast_reader(TimeSeriesType.Deterministic, timedelta(hours=1))
tl = reader.timeline()
entries = reader.entries()
for ts in reader.timestamps():
store.forecast_read(reader, ts)
for i, key in enumerate(entries):
window = reader.entry_values(i) # window for key's owner
Window-read deduplication. Forecasts that share one backing array and read plan — deduplicated
identical data, or several DeterministicSingleTimeSeries over one SingleTimeSeries — collapse to
a single window slot. forecast_read performs one backend (.h5) read per slot, not per entry,
so a forecast shared by N owners is read once per timestamp. num_slots() is that physical read
count (<= len(entries())), and entry_slot(i) (0-based) identifies the slot backing entry i;
entries that share data report the same slot. Group by slot to also materialize each unique window
only once on the Python side:
store.forecast_read(reader, ts)
windows: dict[int, numpy.ndarray] = {}
for i, key in enumerate(entries):
window = windows.setdefault(reader.entry_slot(i), reader.entry_values(i))
Associations
Two catalogs of relationships between entities the store does not otherwise model. Both are independent of time series: removing a time series never removes an association, and vice versa (there are no foreign keys and no cascade — both endpoints live in the caller's object graph, so a cascade could never fire), so a caller that wants both makes both calls.
Every query in both families takes the same keyword-only filter arguments as its family's has_*
method. All are optional and ANDed; with none set they match every row, which is what makes a
no-filter export and an add_* import a round trip. The *_types arguments are lists of
concrete type names, matched as SQL IN (…): expanding an abstract type into its subtypes stays
in Python, where the type hierarchy lives, and an empty list matches nothing — unlike omitting the
argument, which matches everything. Every remove_* returns the number removed; removing nothing
returns 0 rather than raising.
Supplemental-attribute associations
Which supplemental attributes are attached to which components. One attribute may be attached to many components.
SupplementalAttributeAssociation(
component_id: int,
component_type: str,
attribute_id: int,
attribute_type: str,
)
Read-only properties: component_id, component_type, attribute_id, attribute_type, and id —
the catalog row's own number, None on a value that has not been through the catalog. The object is
hashable and compares structurally (the id stays out of both), so attachments work in sets and as
dict keys. In the catalog, though, identity is only the (component_id, attribute_id) pair —
the type names are denormalized labels carried for filtering — so re-attaching the same pair under
different type names raises DuplicateAssociationError.
The id is an output only. The constructor takes none, and an add ignores whatever a listed row
carries, so attaching a row read from one store to another files it under a fresh id there.
def add_supplemental_attribute_association(
self, association: SupplementalAttributeAssociation
) -> None: ...
def add_supplemental_attribute_associations(
self, associations: list[SupplementalAttributeAssociation]
) -> int: ...
# All-or-nothing: a duplicate anywhere in the batch rolls the whole batch back.
# Returns the number inserted; the import half of the round trip whose export is
# list_supplemental_attribute_associations() with no filter.
def has_supplemental_attribute_association(
self,
*,
component_id: int | None = None,
component_types: list[str] | None = None,
attribute_id: int | None = None,
attribute_types: list[str] | None = None,
) -> bool: ...
def list_supplemental_attribute_associations(
self, *, ...
) -> list[SupplementalAttributeAssociation]: ...
def list_supplemental_attribute_ids(self, *, ...) -> list[int]: ...
def list_components_with_attributes(self, *, ...) -> list[int]: ...
def remove_supplemental_attribute_associations(self, *, ...) -> int: ...
def count_supplemental_attribute_associations(self, *, ...) -> int: ...
def count_supplemental_attributes(self, *, ...) -> int: ...
def count_components_with_attributes(self, *, ...) -> int: ...
# Every `...` above is the same keyword-only filter as has_supplemental_attribute_association.
def replace_supplemental_attribute_component_id(self, old_id: int, new_id: int) -> int: ...
def supplemental_attribute_counts_by_type(self) -> list[tuple[str, int]]: ...
def supplemental_attribute_summary(self) -> list[dict]: ...
list_supplemental_attribute_associationsreturns rows in insertion order, so exporting with no filter and importing the result withadd_supplemental_attribute_associationsis a round trip.list_supplemental_attribute_idsreturns the distinct attribute ids of the matching rows, ascending — the attributes attached to componentcwithcomponent_id=c.list_components_with_attributesis the other end: the components carrying attributeawithattribute_id=a.count_supplemental_attributesandcount_components_with_attributesare those two queries counted, andcount_supplemental_attribute_associationscounts the matching rows themselves.replace_supplemental_attribute_component_idmoves every attachment from componentold_idtonew_id, returning the rows updated, and raisesDuplicateAssociationErrorifnew_idalready carries one of the attributes being moved.supplemental_attribute_counts_by_typereturns[(attribute_type, count), …]ordered by type;supplemental_attribute_summaryreturns one dict per distinct pair with keyscomponent_type,attribute_type,count, ordered by attribute type then component type.
from infrastore import SupplementalAttributeAssociation, Store
store = Store.create(in_memory=True)
store.add_supplemental_attribute_association(
SupplementalAttributeAssociation(1, "Generator", 100, "GeographicInfo")
)
store.add_supplemental_attribute_association(
SupplementalAttributeAssociation(2, "Load", 100, "GeographicInfo")
)
store.list_supplemental_attribute_ids(component_id=1) # -> [100]
store.list_components_with_attributes(attribute_id=100) # -> [1, 2]
store.remove_supplemental_attribute_associations(component_id=1)
# -> 1; any time series of component 1 are untouched
Parent/child associations
Directed edges between components — a generator (parent) wired to a bus (child), say. Both endpoints are always components; an attribute cannot appear here.
ParentChildAssociation(
parent_id: int,
parent_type: str,
child_id: int,
child_type: str,
)
Read-only properties: parent_id, parent_type, child_id, child_type, and id; hashable and
structurally comparable like the attachment object, with the same output-only id. In the
catalog, identity is the ordered (parent_id, child_id) pair, so the reversed pair is a
different edge, while repeating the same ordered pair under different type names raises
DuplicateAssociationError. There is no relationship-kind column, so one ordered pair may be
related at most once.
This family is deliberately narrower than the supplemental one — no counts-by-type and no grouped summary — because there is no consumer for them yet; both are additive if one appears.
def add_parent_child_association(self, association: ParentChildAssociation) -> None: ...
def add_parent_child_associations(self, associations: list[ParentChildAssociation]) -> int: ...
# All-or-nothing, like the supplemental bulk add; returns the number inserted.
def has_parent_child_association(
self,
*,
parent_id: int | None = None,
parent_types: list[str] | None = None,
child_id: int | None = None,
child_types: list[str] | None = None,
) -> bool: ...
def list_parent_child_associations(self, *, ...) -> list[ParentChildAssociation]: ...
def list_children(self, *, ...) -> list[int]: ...
def list_parents(self, *, ...) -> list[int]: ...
def remove_parent_child_associations(self, *, ...) -> int: ...
def count_parent_child_associations(self, *, ...) -> int: ...
# Every `...` above is the same keyword-only filter as has_parent_child_association.
def replace_parent_child_component_id(self, old_id: int, new_id: int) -> int: ...
list_parent_child_associationsreturns rows in insertion order, so a no-filter export and anadd_parent_child_associationsimport round-trip.list_childrenreturns the distinct child ids of the matching edges, ascending — the children of componentpwithparent_id=p;list_parentsis the other end, the parents of componentcwithchild_id=c.replace_parent_child_component_idrewritesold_idtonew_idon both ends of every edge, returning the rows updated, and raisesDuplicateAssociationErrorif the rewrite would duplicate an edgenew_idalready has.
from infrastore import ParentChildAssociation, Store
store = Store.create(in_memory=True)
store.add_parent_child_association(ParentChildAssociation(1, "Generator", 7, "Bus"))
# The reversed pair is a different edge, not a duplicate.
store.add_parent_child_association(ParentChildAssociation(7, "Bus", 1, "Generator"))
store.list_children(parent_id=1) # -> [7]
store.list_parents(child_id=7) # -> [1]
store.remove_parent_child_associations(parent_types=["Bus"]) # -> 1
Neither association catalog is exposed over the gRPC server or the
infrastore CLI.
Store attributes
Key/value provenance about the artifact as a whole, as opposed to a supplemental attribute
(which belongs to a component) or application_data (which belongs to one series). See
Store attributes for the model.
def set_store_attribute(self, key: str, value: str) -> None: ...
def get_store_attribute(self, key: str) -> str | None: ...
def list_store_attributes(self) -> dict[str, str]: ...
def remove_store_attribute(self, key: str) -> bool: ...
store.set_store_attribute("creator", "sienna-build")
store.set_store_attribute("source_system", "WECC 2032 ADS")
store.get_store_attribute("creator") # 'sienna-build'
store.get_store_attribute("absent") # None — a question, not an exception
store.list_store_attributes() # {'creator': ..., 'source_system': ...}
store.remove_store_attribute("creator") # True; a second call is False
A set replaces rather than appending. None and "" are different answers: a key set to the
empty string is present. An empty key or one beginning with infrastore. — reserved, on removal as
well as on write — raises InvalidParameterError; a write to a read-only store raises
ReadOnlyStoreError.
The store never interprets a value, so structure rides in the text:
import json
store.set_store_attribute("provenance", json.dumps({"pipeline": "nightly", "run": 412}))
json.loads(store.get_store_attribute("provenance")) # {'pipeline': 'nightly', 'run': 412}
OpenAPI-row association serde
Direct JSON serde of the two association catalogs, in the wire spelling
SiennaSchemas defines (TimeSeries/*.json,
Core/Associations/SupplementalAttributeAssociation.json). Unlike list_metadata /
list_supplemental_attribute_associations, which return Python objects, these four methods exchange
the wire JSON verbatim — the format a document author (e.g. PowerTableDataParser) reads and writes
directly.
def export_time_series_associations_openapi(
self, *, owner_id=None, owner_category=None, owner_type=None,
time_series_type=None, name=None, name_glob=None, component_field=None,
resolution=None, interval=None, features=None, features_exact=False,
) -> str: ...
def import_time_series_associations_openapi(self, json: str) -> int: ...
def export_supplemental_attribute_associations_openapi(self) -> str: ...
def import_supplemental_attribute_associations_openapi(self, json: str) -> int: ...
@classmethod
def open_without_catalog(cls, path: str, *, catalog: str = "attached") -> Store: ...
export_time_series_associations_openapi takes the same filter keywords as list_metadata. Every
row's uri and data_hash are the hex-encoded content hash the store already has for that row —
never a caller-supplied locator. With no filter this exports the whole catalog, sorted by identity —
except PersistentTimeSeries rows, which are omitted: the type is an infrastore-local extension the
wire contract has no schema for, so it cannot be spelled in a document. A filter naming that type is
an error rather than an empty array.
export_supplemental_attribute_associations_openapi exports the whole
supplemental_attribute_associations table, sorted by (component_id, attribute_id);
import_supplemental_attribute_associations_openapi is its import half — a bulk, all-or-nothing
insert (a duplicate anywhere in the batch raises DuplicateAssociationError and rolls the batch
back), returning the number of rows inserted.
import_time_series_associations_openapi is the time-series import half, and it writes rows
only: the document carries locators, never values, so every row must name an array this store
already holds — the arrays arrive with the artifact. Each row keeps the association_id it carries,
which is the point: an import that assigned fresh ids would leave every reference the document
records pointing at the wrong series. An irregular series locates its time axis with
timestamps_uri, filled from the axis's own content hash: the axis is stored beside the arrays and
shared across a cohort, and the values cannot imply it — two irregular series with byte-identical
values on different axes share one content-addressed array. A row missing the locator, or naming an
axis the store does not hold, is refused. A PersistentTimeSeries row is refused before any of
that: the type is an infrastore-local extension, outside the six the wire contract defines, so a
document naming one is rejected by the discriminator check. Any of those, or an absent array, raises
InvalidParameterError and rolls the whole batch back.
Infrastore never modifies the data to make an incoming document agree with what it already holds. A
geometry disagreement between an added series and its own association row is likewise rejected at
the add boundary (InvalidParameterError), loudly and without writing anything.
Incoming rows are validated against the vendored SiennaSchemas specs before anything is decoded, so a document that drifted from the contract is refused in the schema's own terms — naming the row and the field.
Reading a bundle back with no catalog
Store.open_without_catalog opens the array half of an artifact whose .sqlite is absent,
minting an empty catalog, so the document's rows can be replayed into it. This is what lets a
consumer ship arrays plus JSON and nothing else:
store = Store.open_without_catalog("bundle.h5")
store.import_time_series_associations_openapi(ts_rows_json)
store.import_supplemental_attribute_associations_openapi(sa_rows_json)
Store.open cannot open that bundle — the array file carries a generation stamp and a catalog
created on the spot does not, so it reports a mismatched artifact. The catalog minted here inherits
the array file's own stamp, so every later open behaves normally. It raises StoreExistsError
when a catalog is already there; a store that has one wants Store.open.
A bundle carrying NonSequentialTimeSeries still needs its .sqlite: those rows cannot be
replayed, for the reason above.
store = Store.create(in_memory=True)
store.add_time_series(
owner_id=1, owner_type="Generator", owner_category=OwnerCategory.Component,
time_series=SingleTimeSeries(t0, timedelta(hours=1), values, "load"),
)
json_str = store.export_time_series_associations_openapi()
Exceptions
All inherit from TimeSeriesError:
| Exception | Raised when |
|---|---|
NotFoundError | A key or array does not exist |
OwnerMismatchError | An id-addressed call named an owner the row is not |
DuplicateTimeSeriesError | Adding a series whose key already exists |
DuplicateAssociationError | Re-adding an attachment or edge that already exists |
InvalidParameterError | Bad arguments (bad feature type, malformed period, …) |
IntegrityError | On-disk inconsistency detected |
ReadOnlyStoreError | A write on a read-only store |
IoError | Filesystem I/O failure |
ConnectionError | Connection failure (module-scoped, not the builtin) |
IncompatibleFormatError | Store written in an incompatible on-disk format |
StorageError | SQLite catalog or serialization failure |
StoreExistsError | Creating a store where one already exists |
MismatchedArtifactError | The .h5 and .sqlite halves came from two saves |
CatalogMigrationRequiredError | Read-only open of a store whose catalog needs upgrading |
CatalogTooNewError | The catalog was written by a newer infrastore |
A malformed ISO 8601 period string raises InvalidParameterError (inside the hierarchy), as does a
naive datetime. Only a period argument that is neither a timedelta nor a str (or a
time_series_type that is neither a TimeSeriesType nor a str) raises a plain TypeError, which
except TimeSeriesError will not catch. init_tracing with an unparseable filter raises
ValueError.
Feature-value typing note: because bool is a subtype of int in Python, the binding checks bool
first, so True/False features are stored as booleans, not integers.
init_tracing
def init_tracing(filter: str) -> None: ...
Initialize the Rust tracing subscriber with the given
EnvFilter
directive string. Examples:
init_tracing("debug") # all targets at DEBUG
init_tracing("infrastore_core=debug") # store core only
init_tracing("warn,infrastore_core=trace") # warn globally, trace the core
Silently no-ops if a subscriber is already registered (including the one auto-initialized from
RUST_LOG at module import). See the
Python developer guide for usage examples.
Julia API
The Julia package is InfraStore.jl (module InfraStore); it wraps the C ABI
cdylib. The library is resolved from the INFRASTORE_LIB environment variable (development builds),
or else from the libinfrastore_ffi artifact that Pkg downloads at install time (see
Integrate with Julia).
using InfraStore
Exported names (types first, then functions):
AddBatch, ArrayReferenceCounts, CompactionReport, Component, CompressionSettings,
Deterministic, DeterministicSingleTimeSeries, FixedOffsetReference, ForecastEntry,
ForecastParameters, ForecastReader, ForecastSummaryRow, ForecastTimeline,
NonSequentialTimeSeries, OwnerCategory, ParentChildAssociation, PersistentTimeSeries,
Probabilistic, Scenarios, SingleTimeSeries, StaticGrid, StaticGroup, StaticReader,
StaticSummaryRow, Store, SupplementalAttribute, SupplementalAttributeAssociation,
SupplementalAttributeSummaryRow, SupplementalAttributeTypeCount, TimeReference,
TimeSeriesCounts, TimeSeriesCountsDetailed, TimeSeriesMetadata, TimeSeriesTypeCount,
TransformOutcome, UTCReference, UnitSystem (NaturalUnits, ComponentBase), ZoneReference,
ZonelessReference, add_parent_child_association!, add_parent_child_associations!,
add_supplemental_attribute_association!, add_supplemental_attribute_associations!,
add_time_series!, add_time_series_bulk!, association_exists, begin_transaction!,
breakpoint_at, build_forecast_reader, build_static_reader, catalog_mode,
check_static_consistency, clear!, close!, commit_transaction!, compact!,
copy_time_series!, count_array_references, count_components_with_attributes,
count_parent_child_associations, count_supplemental_attribute_associations,
count_supplemental_attributes, counts_by_type,
export_supplemental_attribute_associations_openapi, export_time_series_associations_openapi,
flush!, forecast_entries, forecast_num_slots, forecast_read!, forecast_summary,
forecast_timeline, forecast_values, get_array_by_hash, get_compression, get_counts,
get_forecast_parameters, get_intervals, get_metadata_by_id, get_path, get_resolutions,
has_any_time_series, has_for_owner, has_parent_child_association,
has_supplemental_attribute_association, has_time_series,
import_supplemental_attribute_associations_openapi!, import_time_series_associations_openapi!,
in_transaction, index_at, init_logging, is_empty, is_zoneless, list_children,
list_components_with_attributes, list_metadata, list_metadata_by_ids, list_names,
list_owner_ids, list_owner_types, list_parent_child_associations, list_parents,
list_supplemental_attribute_associations, list_supplemental_attribute_ids,
num_distinct_arrays, open_copy, open_store, persist!, persist_catalog!, read_by_id,
read_by_ids, read_only, remove_by_filter!, remove_by_ids!,
remove_parent_child_associations!, remove_supplemental_attribute_associations!,
replace_owner!, replace_parent_child_component_id!,
replace_supplemental_attribute_component_id!, rollback_transaction!, set_write_buffer_bytes!,
static_grid, static_groups, static_read!, static_summary, static_timestamps,
static_values, supplemental_attribute_counts_by_type, supplemental_attribute_summary,
time_series_counts, timestamps, transaction, transform_single_time_series!, value_at,
verify_integrity, write_buffer_bytes, zoned_timestamp, zoned_timestamps.
Constructors
Store(; in_memory::Union{Nothing,Bool}=nothing, path::Union{Nothing,AbstractString}=nothing,
compression::Union{Symbol,AbstractString}=:deflate,
compression_level::Integer=3, shuffle::Bool=true,
catalog::Union{Nothing,Symbol,AbstractString}=nothing,
overwrite::Bool=false) -> Store
open_store(path::AbstractString; read_only::Bool=false,
catalog::Union{Symbol,AbstractString}=:attached) -> Store
open_copy(src::AbstractString, dest::AbstractString;
catalog::Union{Symbol,AbstractString}=:attached) -> Store
catalog_mode(store::Store) -> Symbol
in_memory defaults to whatever path implies — in-memory without one, file-backed with one — and
rarely needs setting; path together with in_memory=true throws ArgumentError (it used to be
accepted and silently discarded everything written).
A Store (and any reader built from it) is not thread-safe: the Rust core mutates the handle
without synchronization, so concurrent calls from two tasks or threads are undefined behavior, not
merely a race on results. Confine a store to one task, or guard every call with your own lock.
Store()— in-memory store.Store(in_memory=false, path="system.h5")— persists tosystem.h5plussystem.h5.sqlite.compression=:nonestores arrays uncompressed;:deflate(default) applies DEFLATE atcompression_level(0–9) with optional byteshuffle. The policy is persisted with the store and reused on later appends; it is ignored for in-memory stores. An unknowncompressionthrowsArgumentError.catalog=:attachedmakes the catalog the.sqlitefile, where every commit is durable;catalog=:memoryholds it in RAM so it reaches disk only throughpersist!orpersist_catalog!. Arrays stream to the HDF5 file either way. The default (nothing) matches the backend —:memorywhenin_memoryis true, else:attached— so existing call sites are unchanged. An unknowncatalogthrowsArgumentError. See Where the Catalog Lives.Store(in_memory=false, path=...)throwsStoreExistsErrorifpathor$path.sqlitealready holds a store. Creating there would discard the arrays while keeping the catalog, leaving a store that reopens cleanly with every array missing — see protecting a saved artifact.overwrite=truediscards both halves on purpose; it throwsArgumentErrorfor an in-memory store, which has no artifact to replace.open_store(path; read_only=true)— opens an existing on-disk pair.open_copy(src, dest)— copies both halves todestand opens the copy read-write, leavingsrcuntouched. This is the safe way to load a store you intend to change.open_storedefaults to read-write, and mutations then land in that file directly; HDF5 has no journal and no repair tool, so an interrupted write is unrecoverable. Change the copy andpersist!(store, src)— one atomic rename replaces the original. ThrowsStoreExistsErrorifdestalready holds a store. Has a do-block form.catalog_mode(store)returns:attachedor:memory.
The store registers a finalizer; close it eagerly with close!(store).
Types
Each struct carries the association name (required) and an optional application_data. Every
constructor takes name as the positional after data and application_data= as a keyword — e.g.
SingleTimeSeries(initial, resolution, data, name; application_data=nothing).
Every data-carrying struct is parameterized {T,N} on the element type and dimensionality of its
value array; {T,N} is inferred from data by the constructor (an AbstractArray argument — a
view or a range — is normalized to a concrete Array{T,N}).
struct SingleTimeSeries{T,N}
initial_timestamp :: DateTime
resolution :: Period # e.g. Hour(1), Millisecond(500)
data :: Array{T,N} # any element type; dim 1 = time
name :: String # required association name
application_data :: Union{Nothing,String}
element_type :: Union{Nothing,String} # canonical element_type, or nothing for plain scalars
units :: Union{Nothing,String}
quantity_kind :: Union{Nothing,String}
unit_system :: Union{Nothing,UnitSystem} # nothing = unspecified, not NaturalUnits
component_field :: Union{Nothing,String}
time_reference :: Union{Nothing,TimeReference} # inferred from the timestamp; see below
end
SingleTimeSeries(initial_timestamp, resolution, data, name; application_data=nothing, element_type=nothing, units=nothing,
quantity_kind=nothing, unit_system=nothing, component_field=nothing, time_reference=<inferred>)
struct NonSequentialTimeSeries{T,N}
timestamps :: Vector{DateTime} # strictly increasing; one per row of dim 1
data :: Array{T,N}
name :: String
application_data :: Union{Nothing,String}
element_type :: Union{Nothing,String} # canonical element_type, or nothing for plain scalars
units :: Union{Nothing,String}
quantity_kind :: Union{Nothing,String}
unit_system :: Union{Nothing,UnitSystem} # nothing = unspecified, not NaturalUnits
component_field :: Union{Nothing,String}
time_reference :: Union{Nothing,TimeReference} # inferred from the timestamp; see below
end
NonSequentialTimeSeries(timestamps, data, name; application_data=nothing, element_type=nothing, units=nothing,
quantity_kind=nothing, unit_system=nothing, component_field=nothing, time_reference=<inferred>)
struct PersistentTimeSeries{T,N}
timestamps :: Vector{DateTime} # breakpoints, strictly increasing; one per row of dim 1
data :: Array{T,N}
name :: String
application_data :: Union{Nothing,String}
element_type :: Union{Nothing,String} # canonical element_type, or nothing for plain scalars
units :: Union{Nothing,String}
quantity_kind :: Union{Nothing,String}
unit_system :: Union{Nothing,UnitSystem} # nothing = unspecified, not NaturalUnits
component_field :: Union{Nothing,String}
time_reference :: Union{Nothing,TimeReference} # inferred from the timestamp; see below
end
PersistentTimeSeries(timestamps, data, name; application_data=nothing, element_type=nothing, units=nothing,
quantity_kind=nothing, unit_system=nothing, component_field=nothing, time_reference=<inferred>)
struct Deterministic{T,N}
initial_timestamp :: DateTime
resolution :: Period
horizon :: Period
interval :: Period
count :: Int
data :: Array{T,N} # (H, count, element_dims...)
name :: String
application_data :: Union{Nothing,String}
element_type :: Union{Nothing,String} # canonical element_type, or nothing for plain scalars
units :: Union{Nothing,String}
quantity_kind :: Union{Nothing,String}
unit_system :: Union{Nothing,UnitSystem} # nothing = unspecified, not NaturalUnits
component_field :: Union{Nothing,String}
time_reference :: Union{Nothing,TimeReference} # inferred from the timestamp; see below
end
Deterministic(initial_timestamp, resolution, horizon, interval, count, data, name; application_data=nothing, element_type=nothing, units=nothing,
quantity_kind=nothing, unit_system=nothing, component_field=nothing, time_reference=<inferred>)
struct Probabilistic{T,N}
initial_timestamp :: DateTime
resolution :: Period
horizon :: Period
interval :: Period
count :: Int
percentiles :: Vector{Float64}
data :: Array{T,N} # (num_percentiles, H, count, element_dims...)
name :: String
application_data :: Union{Nothing,String}
element_type :: Union{Nothing,String} # canonical element_type, or nothing for plain scalars
units :: Union{Nothing,String}
quantity_kind :: Union{Nothing,String}
unit_system :: Union{Nothing,UnitSystem} # nothing = unspecified, not NaturalUnits
component_field :: Union{Nothing,String}
time_reference :: Union{Nothing,TimeReference} # inferred from the timestamp; see below
end
Probabilistic(initial_timestamp, resolution, horizon, interval, count, percentiles, data, name; application_data=nothing, element_type=nothing, units=nothing,
quantity_kind=nothing, unit_system=nothing, component_field=nothing, time_reference=<inferred>)
struct Scenarios{T,N}
initial_timestamp :: DateTime
resolution :: Period
horizon :: Period
interval :: Period
count :: Int
scenario_count :: Int # set from size(data, 1) by the constructor
data :: Array{T,N} # (scenario_count, H, count, element_dims...)
name :: String
application_data :: Union{Nothing,String}
element_type :: Union{Nothing,String} # canonical element_type, or nothing for plain scalars
units :: Union{Nothing,String}
quantity_kind :: Union{Nothing,String}
unit_system :: Union{Nothing,UnitSystem} # nothing = unspecified, not NaturalUnits
component_field :: Union{Nothing,String}
time_reference :: Union{Nothing,TimeReference} # inferred from the timestamp; see below
end
Scenarios(initial_timestamp, resolution, horizon, interval, count, data, name; application_data=nothing, element_type=nothing, units=nothing,
quantity_kind=nothing, unit_system=nothing, component_field=nothing, time_reference=<inferred>)
# note: scenario_count is NOT a constructor argument
# The seven descriptors after `name` are carried on the struct and are the only
# place they can be set: add_time_series! takes none of them, so a series built
# with units="MW" reaches the store with them and comes back with them.
# `unit_system` is a `UnitSystem`: `NaturalUnits` (the units named by `units`)
# or `ComponentBase` (per-unit against the owning component's own base). The
# store records the declaration only — it holds no base and rescales nothing —
# and `nothing` means unspecified, which is deliberately not `NaturalUnits`.
# `time_reference` is normally left to the constructor, which infers it from the
# timestamp it was handed — see "Time references" below.
# Marker type; never constructed and with no materialized struct. Derived via
# transform_single_time_series! and read back as a Deterministic. You normally
# do not request it: a Deterministic request matches it too. It surfaces as a
# key's / row's time_series_type, so which forecasts are synthetic stays
# inspectable, and passing it narrows a query to the derived ones. {T,N} exists
# only so a row's time_series_type is parameterized for every stored type; it
# describes the Deterministic the row reads back as. Write it bare as a request.
abstract type DeterministicSingleTimeSeries{T,N} end
mutable struct Store
handle :: Ptr{Cvoid}
end
@enum OwnerCategory begin
Component = 0
SupplementalAttribute = 1
end
application_data is an opaque, package-owned payload (typically JSON) the binding can use to
reconstruct a domain object on read; the store stores it verbatim and never interprets it.
add_time_series! reads name off the object (it is not a call argument), so the same array can be
stored under different names; the same is true of application_data and every other descriptor.
data keeps its Julia element type: the binding maps T to a stored dtype (Float64, Float32,
the signed and unsigned integer widths, Bool) and converts to row-major bytes on the way down. The
constructor's element_type= keyword declares what the elements mean when they are not plain
numbers ("tuple(3,f64)", "piecewise_linear", … — see Element types); it is
nothing for plain scalars.
Element values
A composite element_type describes a layout, not a number: "piecewise_linear" is a curve per
timestep, packed across the array's trailing axis. The write and read paths do that packing for
you — hand a series its values and get the same values back:
curves = [PiecewiseLinear([(x = 0.0, y = 1.0), (x = 1.0, y = 3.0)]),
PiecewiseLinear([(x = 0.0, y = 2.0)])]
ts = SingleTimeSeries(t0, Hour(1), curves, "cost") # element_type: "piecewise_linear"
id = add_time_series!(store, 1, "Generator", Component, ts)
read_by_id(store, id).data == curves # true
get_metadata_by_id(store, id).time_series_type # SingleTimeSeries{PiecewiseLinear, 1}
The constructor names the element_type from the values, so element_type= is only for the numeric
case where the numbers alone cannot say what they mean; declaring one that contradicts the values is
an error rather than an override. The constructor is the only door: add_time_series! takes no
element_type=, so a write can neither restate nor contradict what the struct settled.
raw = true on a read hands back the packing instead — one axis more, held as the physical dtype —
for a caller that wants the bytes as stored:
read_by_id(store, id; raw = true).data # 2×5 Matrix{Float64}
The readers are deliberately not decoded. StaticReader and ForecastReader are the
per-timestamp simulation path, and StaticGroup.dtype is physical by definition; they hand back the
packed numbers, which decode_element_values turns into values if you want them.
The two codec functions are public in their own right, and neither takes a Store — they work on
any array you already have:
array, element_type = encode_element_values(curves) # (2, 5), "piecewise_linear"
values = decode_element_values(array, element_type)
decode_element_values returns the array's shape without its trailing element axis — a vector
for a static series, an (H, count) matrix for a Deterministic, an (P, H, count) array for a
Probabilistic or Scenarios — so a forecast comes back windowed rather than flattened. A scalar
element_type is returned unchanged, because there the stored numbers already are the values;
is_composite_element_type tells the cases apart, and an unrecognized spelling reads back as raw
numbers rather than throwing.
| Value type | element_type | Constructor |
|---|---|---|
LinearFunction | linear_function | (proportional, constant) |
QuadraticFunction | quadratic_function | (quadratic, proportional, constant) |
PiecewiseLinear | piecewise_linear | (points), a vector of XYCoords |
PiecewiseStep | piecewise_step | (x_coords, y_values) |
NTuple{N,Float64} | tuple(N,f64) | — |
These types are permissive on purpose: they accept everything the store accepts, including the
zero- and one-point piecewise curves that a domain type such as InfrastructureSystems.jl's
PiecewiseLinearData rejects. A codec that could not represent a stored row could not read a store
back. They are named for the wire vocabulary for a second reason: so that
using InfraStore, InfrastructureSystems is not an ambiguity error.
A consumer with its own domain types never materializes them. Decode takes a types keyword whose
entries have exactly the constructor signatures in the table above:
decode_element_values(array, "piecewise_linear";
types = merge(DEFAULT_ELEMENT_TYPES, (piecewise_linear = MyCurve,)))
and encode is open dispatch — add methods to element_type_tag, element_row_width and
write_element_row! for your own type and it encodes without a conversion step.
The encodings themselves are the store's, specified in Element types and
pinned across every binding by conformance/element_type_vectors.json, which this package's tests
read. One consequence worth knowing: the ragged kinds are padded to the widest entry in the series
being written, so equal curves in differently-shaped series encode to different bytes and do not
share a stored array.
Result Types
The catalog, metadata, and summary queries return structs, not NamedTuples or Dicts. Each is
immutable, compares and hashes by value (so results can go straight into a Set or Dict), and
shows with its field names. Read a field with x.field; fields that do not apply to a row's time
series type are nothing.
Two conventions hold across every one of them: a time_series_type field holds the Julia type
(SingleTimeSeries, Deterministic, …), ready to pass to a time_series_type filter, and a reader
group's dtype field holds the Julia element type (Float64, Bool, …). Metadata instead
carries the store's canonical element_type string, which names both the meaning and (through
it) the dtype. An owner_category field is an OwnerCategory, never a string.
TimeSeriesMetadata.time_series_type is the full type, parameterized {T,N} like the value
structs — SingleTimeSeries{Float64,1}, Deterministic{Float32,3} — so a row names what a read of
it hands back, not merely which of the six kinds it is:
md = get_metadata_by_id(store, id)
md.time_series_type == typeof(read_by_id(store, id)) # every stored type but DST
The one exception is a derived DeterministicSingleTimeSeries: its row keeps the DST tag, since
that is where the derivation stays visible, while a read of it hands back the Deterministic it
becomes. The {T,N} agree; the outer types do not, and they are unrelated, so neither == nor <:
holds between them. Dispatch on the read's type when the two have to agree, and on the row's when
you mean "was this derived?".
Both parameters come off the row itself. For a plain numeric series T is the dtype and N is one
more than the rank of element_shape. For a composite element_type — one a read decodes — T
is the domain type and N is one lower, because the axis the values were packed across is the one
decoding consumes:
element_type | element_shape | time_series_type | with raw = true |
|---|---|---|---|
f64 | () | SingleTimeSeries{Float64,1} | same |
piecewise_linear | (7,) | SingleTimeSeries{PiecewiseLinear,1} | SingleTimeSeries{Float64,2} |
tuple(3,f64) | (3,) | SingleTimeSeries{NTuple{3,Float64},1} | SingleTimeSeries{Float64,2} |
A DeterministicSingleTimeSeries is parameterized by the Deterministic it reads back as, not by
the SingleTimeSeries whose array it shares — the parameters follow the read, the outer type does
not. An element_type written by a newer core than the wrapper knows leaves the row describing the
stored numbers, which is what a read of it hands back.
Test it with <:, not ==, when you mean "which kind is this row":
md.time_series_type <: SingleTimeSeries # kind
md.time_series_type == SingleTimeSeries # false — it is SingleTimeSeries{Float64,1}
A request — a time_series_type= filter, has_time_series, a reader — takes either spelling.
Parameters on a request are ignored, never matched: a series is addressed by its identity
(owner, category, type, name, resolution, interval, features), which carries no element type, so
{T,N} has nothing to select on. They are accepted so that a row's time_series_type round-trips
straight back into any of those calls;
list_metadata(store; time_series_type = SingleTimeSeries{Int32,1}) still matches every stored
SingleTimeSeries. A type that is no kind of time series raises InvalidParameterError.
TimeSeriesTypeCount, StaticSummaryRow, and ForecastSummaryRow group by stored type alone — the
grouping carries no dtype — so their time_series_type is always the bare one.
Every write returns the catalog row's id as a plain Int64 — assigned, never reissued, and what
every read, removal and copy takes.
struct TimeSeriesMetadata # get_metadata_by_id / list_metadata
owner_id :: Int64
owner_type :: String
owner_category :: OwnerCategory
time_series_type :: Type # parameterized, e.g. SingleTimeSeries{Float64,1}
name :: String
data_hash :: Vector{UInt8} # 32-byte content hash
initial_timestamp :: Union{Nothing,DateTime}
resolution :: Union{Nothing,Period}
horizon :: Union{Nothing,Period} # forecasts
interval :: Union{Nothing,Period} # forecasts
count :: Union{Nothing,Int} # forecasts
length :: Union{Nothing,Int} # static series
percentiles :: Union{Nothing,Vector{Float64}} # Probabilistic
element_type :: String # "f64", "tuple(3,f64)", "piecewise_linear", …
element_shape :: Tuple{Vararg{Int}} # per-timestep shape; () for scalars
features :: Dict{String,Any}
units :: Union{Nothing,String}
quantity_kind :: Union{Nothing,String}
unit_system :: Union{Nothing,UnitSystem} # NaturalUnits | ComponentBase
time_reference :: Union{Nothing,TimeReference} # how the timestamps were spelled
component_field :: Union{Nothing,String} # e.g. "max_active_power"
application_data :: Union{Nothing,String}
id :: Union{Nothing,Int64} # the catalog row's id; nothing off-catalog
end
TimeSeriesMetadata is the Julia mirror of the Rust core's type of the same name, and the package's
only metadata type: one struct for every time series type, reached either one at a time by
get_metadata_by_id by id, or in bulk by
list_metadata. The fields a type does not use are nothing rather than
absent, so no field is silently dropped by the addressing path taken.
| Struct | Returned by | Fields |
|---|---|---|
TimeSeriesCounts | get_counts | components_with_time_series, static_time_series, forecasts |
TimeSeriesCountsDetailed | time_series_counts | components_with_time_series, supplemental_attributes_with_time_series, static_time_series_count, forecast_count |
TimeSeriesTypeCount | counts_by_type | time_series_type, count |
ArrayReferenceCounts | count_array_references | sts, dst |
StaticSummaryRow | static_summary | owner_type, owner_category, time_series_type, name, initial_timestamp, resolution, time_step_count, count |
ForecastSummaryRow | forecast_summary | owner_type, owner_category, time_series_type, name, initial_timestamp, resolution, horizon, interval, window_count, count |
SupplementalAttributeTypeCount | supplemental_attribute_counts_by_type | attribute_type, count |
SupplementalAttributeSummaryRow | supplemental_attribute_summary | component_type, attribute_type, count |
ForecastParameters | get_forecast_parameters | horizon, interval, count, resolution, initial_timestamp (all nothing when nothing matches) |
StaticGrid | static_grid, check_static_consistency | initial_timestamp, resolution (nothing for an irregular reader), length, time_reference |
ForecastTimeline | forecast_timeline | initial_timestamp, resolution, interval, count, time_reference |
CompressionSettings | get_compression | compression (:deflate / :none), level, shuffle |
CompactionReport | compact! | slots_reclaimed, datasets_dropped, feature_sets_reclaimed, timestamp_sets_reclaimed, bytes_reclaimed |
Every struct in the table compares and hashes by value over all of its fields, id included: a
TimeSeriesMetadata describing the same series in two different stores is not equal to its
counterpart, because the id is the catalog's record of that row rather than a property of the data.
The association row types are the exception — SupplementalAttributeAssociation and
ParentChildAssociation compare on their endpoints alone, since a caller constructs those as plain
values and a row read back has to equal the one that wrote it.
StaticGrid is shared by static_grid (a reader's timeline) and check_static_consistency (one
per resolution present) — the same concept, so the same type. Its resolution is nothing only for
a NonSequentialTimeSeries or PersistentTimeSeries reader, whose timeline is an explicit list of
instants rather than a grid; enumerate it with static_timestamps.
time_reference is the one spelling the axis carries — a reader spans one timeline, so a cohort
whose columns agree reports their reference, one whose columns merely agree on naming instants
reports UTCReference(), and a cohort mixing zoneless with the rest never builds at all. It is
nothing when the cohort records no spelling, and from check_static_consistency, which reports
grids rather than readers. nothing is not ZonelessReference(): the second is the positive claim
that the timestamps are wall clocks. Three- and four-argument constructors (StaticGrid and
ForecastTimeline respectively) leave it unset.
Static Series
add_time_series!(
store::Store, owner_id, owner_type, owner_category::OwnerCategory,
ts; # SingleTimeSeries, NonSequentialTimeSeries, PersistentTimeSeries, or a forecast struct
features::AbstractDict = Dict(),
) -> Int64 # the catalog row's id -- what every read and removal takes
# `features` is the only thing the call adds. `name` and all seven descriptors
# (`element_type`, `units`, `quantity_kind`, `unit_system`, `component_field`,
# `application_data`, `time_reference`) come off `ts`, set where it was built.
# The call does NOT take an `id`: the catalog assigns and the write reports what
# it chose, because "never reissued" is a guarantee of AUTOINCREMENT that a
# caller free to name an id could break. Replaying the ids a document recorded
# is import_time_series_associations_openapi, a different door.
read_by_id(store::Store, id::Integer;
start_time=nothing, len=nothing, count=nothing,
owner=nothing) -> SingleTimeSeries | ...
read_by_ids(store::Store, ids::AbstractVector{<:Integer};
time_range=nothing) -> Vector
A read names only an id, so the row's own stored type decides what comes back — including a
PersistentTimeSeries, whose time_range slices on the step function's own terms: the result
begins at the breakpoint in force at start, so it always defines a value there, and a start
before the first breakpoint is an error rather than a clamp. A zero-width range (end == start) is
the exception and selects nothing, as it does for every other type — including before the first
breakpoint, where a non-empty window errors. Every read populates the returned struct's
application_data field from the stored association, so a binding's reconstruction tag comes back
with the data — no separate get_metadata_by_id call is needed.
owner_id is an integer identifier (Int64) and owner_category (Component /
SupplementalAttribute) completes the owner identity — the owner is the pair
(owner_id, owner_category). features is serialized to JSON and must contain only JSON-scalar
values (Int, Float64, Bool, String); a feature name that shadows a time-series or identity
field (name, resolution, owner_id, …) is rejected on add — see
reserved feature names.
A series known by its attributes is found with list_metadata, whose rows
carry the id every read takes. That split — identify, then act — is deliberate: a caller that
records ids in its own model does the first half once.
To read every series' value at one timestamp in a loop (the simulation pattern), use a
StaticReader rather than calling read_by_id per series.
owner = (owner_id, category) holds the row to that owner, throwing OwnerMismatchError when it
belongs to another — see the owner guard.
Bulk reads
read_by_ids(store::Store, ids::AbstractVector{<:Integer};
time_range::Union{Nothing,Tuple{Any,Any}}=nothing) -> Vector
# time_range clips every series to that window (default: each series in full);
# the bounds are DateTime or, with TimeZones loaded, ZonedDateTime
Reads many whole series in one call, returning one per id in the order the ids are given,
repeats included, each as the struct matching its stored type (SingleTimeSeries,
NonSequentialTimeSeries, PersistentTimeSeries, Deterministic, Probabilistic, or Scenarios)
— the result is a Vector{Any}, so narrow it yourself when every id is one type. Packed
SingleTimeSeries are read and decompressed once per dataset instead of per series, so this is the
efficient way to load many complete series (exploration, plotting). An empty id vector returns an
empty vector without touching the store.
An id naming no row throws NotFoundError (the whole call fails; the error does not say which id
dangled — sift them with association_exists when that matters).
time_range clips to whatever falls between the two instants, where read_by_id's window is
checked. Both bounds must be spelled the way the series are, and a selection spanning both
coherence groups (zoneless and instant-bearing) is refused rather than resolved per series; narrow
it with list_metadata's zoneless filter.
series = read_by_ids(store, ids)
window = read_by_ids(store, ids; time_range = (t0, t1))
read_by_id(store::Store, id::Integer; start_time=nothing, len=nothing, count=nothing)
The single-id read, which also takes the slice. Both halves happen in one call: the id is a
primary-key lookup and the row it lands on carries the grid the window resolves against, so a caller
holding an id spends nothing to learn a series' resolution or count before asking for the second
day of it. With no keywords this is read_by_ids for one id.
start_time is the first timestamp to read — a window boundary (initial_timestamp + k·interval)
for a forecast — and may be a DateTime or, with TimeZones loaded, a ZonedDateTime. len counts
timesteps and applies to SingleTimeSeries / NonSequentialTimeSeries; count counts windows and
applies to the forecasts; passing the one that does not apply throws InvalidParameterError. So
does a start_time off the series' own grid, or a len/count running past its end — a window is
checked where the time_range on read_by_ids is clamped. NotFoundError if the id names no row.
day_two = read_by_id(store, id; start_time = t0 + Day(1), len = 24)
Bulk Adds
batch = AddBatch()
add_time_series!(batch, owner_id, owner_type, owner_category, ts; ...) # any series type
add_time_series_bulk!(store::Store, batch::AddBatch) -> Vector{Int64} # ids, in input order
AddBatch accepts the same add_time_series! methods as Store (every series and forecast type)
but only accumulates the requests; add_time_series_bulk! commits the whole batch in one
metadata transaction, which is much faster than per-item adds outside a transaction; inside one,
the same per-item calls buffer and write the same datasets. The submit is all-or-nothing: on error
nothing is committed. The batch is drained by the call in either case and may be reused.
length(batch) returns the number of pending requests.
Lookups
get_metadata_by_id(store, id::Integer) -> Union{TimeSeriesMetadata, Nothing}
list_metadata_by_ids(store, ids::AbstractVector{<:Integer}) -> Vector{TimeSeriesMetadata}
association_exists(store, id::Integer) -> Bool
has_time_series(store, owner_id, owner_category::OwnerCategory, name;
resolution=nothing, features=Dict()) -> Bool
has_time_series(T::Type, store, owner_id, owner_category::OwnerCategory, name;
resolution=nothing, interval=nothing, features=Dict()) -> Bool
get_metadata_by_id returns the whole TimeSeriesMetadata record — every stored
type through the one function — or nothing when the catalog holds no such row. nothing rather
than a throw because a consumer validating references it persisted earlier is asking whether one
still resolves, and a stale reference is an answer; association_exists asks the same question
without building the row, cheap enough to check every reference in a model on load.
list_metadata_by_ids is the bulk form and does throw NotFoundError on a stale id, since a
caller naming ids is asserting they exist.
has_time_series stays attribute-addressed: it is answered off the catalog indexes without
hydrating a row, so routing it through an id lookup would cost more than the question. It takes the
type as its first argument to address anything other than a SingleTimeSeries, and it matches the
feature map exactly. owner_category (Component / SupplementalAttribute) is required
throughout: the owner identity is the pair (owner_id, owner_category), so a component and a
supplemental attribute may share a numeric owner_id and remain distinct.
A series known by its attributes is found with list_metadata, whose rows
carry the id every read and removal takes. There is deliberately no separate attribute-to-id
resolver — a caller that wants exactly one row poses the filter and checks that it got one:
row = only(list_metadata(store; owner_id = 42, name = "wind",
time_series_type = Scenarios, resolution = Hour(1)))
series = read_by_id(store, row.id)
Filtering for Deterministic also selects a stored DeterministicSingleTimeSeries, and each row
reports the concrete form it is; interval disambiguates forecasts that differ solely by interval.
get_array_by_hash(store, data_hash::Vector{UInt8}, ::Type{T}=Float64) -> Vector{T}
Fetches the flattened array for a 32-byte content hash, decoded as element type T. Combine with
get_metadata_by_id (for the element type and shape) to read values without reconstructing a
series. Every data_hash in this API is these same 32 bytes, so any metadata record feeds it
directly; bytes2hex gives the display form.
Removal
remove_by_ids!(store, ids::AbstractVector{<:Integer}; owner=nothing) -> Int
remove_by_filter!(store; owner_id=nothing, name=nothing, ...) -> Int
remove_by_ids! removes every id in one all-or-nothing transaction and returns the count: an id
naming no row throws NotFoundError and nothing is removed (sift the set with association_exists
first when some references are expected to have gone), and a repeated id is removed, and counted,
once. An empty id vector returns 0 without touching the store.
It refuses to remove a SingleTimeSeries whose array still backs a DeterministicSingleTimeSeries
when it is the last backing series (the DST is a view of that array), raising
InvalidParameterError — remove the derived forecast first, or use an owner-scoped clear!, which
is exempt.
The owner guard
remove_by_ids!(store, ids; owner = (7, Component)) # only rows owned by component 7
read_by_id(store, id; owner = (7, Component)) # only if component 7 owns it
Both id-addressed calls take an optional owner = (owner_id, category). The addressed row is held
to that owner and one belonging to anyone else throws OwnerMismatchError; for the removal the
check and the delete are one transaction, so a refused batch removes nothing.
A caller whose model says "this component's series" must pass the owner rather than confirm it in a
call of its own. An id is the whole address and it survives replace_owner!, so a
get_metadata_by_id that confirms the owner and a remove_by_ids! that then deletes are two calls
with a window between them — and a reassignment landing in that window makes the removal retire the
new owner's series, the very thing the check was for. The category is half the owner: a component
and a supplemental attribute can carry the same integer id.
On the read side there is no window either way, but the guard is still the cheaper spelling: the owner comes off the same row the values are materialized from, so it costs nothing, where a separate check is a second round trip.
remove_by_filter! is the one removal that does not take ids, because enumerating them first is the
wrong shape for "remove everything matching": it takes the same filter as list_metadata, resolves
it to ids internally, and removes those in one transaction.
Reading a DeterministicSingleTimeSeries returns a Deterministic, since the type has no
materialized form. Its row still reports DeterministicSingleTimeSeries as its time_series_type,
parameterized by that Deterministic.
Forecasts
Dense forecasts are constructed as Deterministic, Probabilistic, or Scenarios structs (see
Types) and added through the generic add_time_series!. Each struct wraps a native
AbstractArray of any supported element type and dimensionality — the binding derives the stored
dtype and dims and converts to row-major bytes, just like the static add_time_series! (see the
data model for the conventional shapes).
The forecast name comes from the struct, e.g.
Deterministic(initial, resolution, horizon, interval, count, data, name).
add_time_series!(
store, owner_id, owner_type, owner_category::OwnerCategory,
ts::Union{Deterministic,Probabilistic,Scenarios};
features=Dict(),
) -> Int64
The descriptors come off the struct, exactly as for the static types: a label set at construction is what the row records, and the add has no say in it.
A DeterministicSingleTimeSeries is not added directly. Derive one from every stored
SingleTimeSeries (sharing the backing array) with:
transform_single_time_series!(store, horizon::Period, interval::Period;
owner_category::Union{Nothing,OwnerCategory}=nothing,
resolution::Union{Nothing,Period}=nothing,
normalize_single_window::Bool=false,
require_uniform_forecast_grid::Bool=false,
dry_run::Bool=false) -> TransformOutcome
struct TransformOutcome
transformed :: Int # DSTs derived (or that would be, under dry_run)
sources :: Int # SingleTimeSeries in scope
interval :: Period # the interval actually stored
interval_normalized :: Bool # true when a single-window request was stored as zero interval
end
count is derived from each series' length. owner_category restricts the transform to one owner
category (both are transformed when it is nothing); resolution restricts it to the
SingleTimeSeries at that resolution. The store performs the whole eligibility check — horizon fit
and divisibility, interval divisibility, per-resolution grid uniformity, conflicts with existing
forecasts — so callers need not pre-check per series.
The two policy flags encode a client's contract rather than a storage invariant, and both default
to permissive. normalize_single_window stores a single-window request (interval equal to a horizon
spanning the whole series) as the zero interval rather than verbatim — the interval is part of the
key, so this decides which form later lookups must use. require_uniform_forecast_grid demands that
every resolution in scope, and any forecast already stored at the same (resolution, interval),
agree on the derived count and initial_timestamp. InfrastructureSystems.jl passes both as
true. dry_run runs every check and reports the outcome without writing; it is legal against a
read-only store.
has_time_series takes the time series type as its first argument to ask about a type other than
SingleTimeSeries:
has_time_series(T::Type, store, owner_id, owner_category, name;
resolution=nothing, interval=nothing, features=nothing) -> Bool
interval (a Period) pins the forecast interval — the only way to distinguish two forecasts of
one owner/name/type that differ solely by interval (e.g. day-ahead vs intra-day). Without it such a
question is ambiguous and errors.
This is an existence question, not an address: it answers Bool and hands back nothing to act on.
To read or remove a forecast, identify it with list_metadata and use the row's id — see
Lookups.
Copying an association
copy_time_series!(store, src_id::Integer, dst_owner_id, dst_owner_type::AbstractString;
new_name=nothing) -> Int64
Copies the association filed under src_id onto dst_owner_id (of Julia/domain type
dst_owner_type), optionally renaming it to new_name, and returns the catalog id of the new
row. Arrays are content-addressed, so this writes only a new association row against the same
underlying array: no data is duplicated and the stored time series type is preserved — a
DeterministicSingleTimeSeries stays a DST, whereas a read-then-write copy through read_by_id /
add_time_series! would materialize it into a dense Deterministic. A copy is its own row with its
own id: the source's id is untouched and both resolve afterwards. The copy keeps the source's
owner_category. Throws if the destination already holds a matching series.
src = only(list_metadata(store; owner_id=42, name="load")).id
copy_time_series!(store, src, 43, "Generator") # → a new id, under owner 43
Reading forecast values
Forecasts are read the way everything else is: identify the row with list_metadata, then read it
by id. read_by_id dispatches on the row's stored type, so it returns the corresponding struct —
Deterministic, Probabilistic, or Scenarios — whose data field is a decoded N-dimensional
Julia array (reshaped to the type's logical shape, with native Julia indexing).
id = only(list_metadata(store; owner_id=400, owner_category=Component, name="load",
time_series_type=Deterministic)).id
read_by_id(store, id) -> Deterministic
# data shape: (H, count, element_dims...)
read_by_id(store, id; start_time=t, count=3) -> Deterministic # three windows from t
For a Probabilistic the data shape is (num_percentiles, H, count, element_dims...), and for
Scenarios it is (scenario_count, H, count, element_dims...).
read_by_id's window is checked: start_time must be a window boundary
(initial_timestamp + k·interval) and count must not run past the end, or it throws
InvalidParameterError. The time_range on read_by_ids clips instead — see
Bulk reads.
The interval filter on list_metadata (a Period) is what distinguishes two forecasts under the
same owner/name/type that differ solely by interval; without it such a listing returns both rows and
only throws.
Reading a transformed forecast
An id names the exact stored row, so how a forecast came to exist never changes how it is read: a
DeterministicSingleTimeSeries row reads back as a Deterministic, since the type has no
materialized struct.
transform_single_time_series!(store, Hour(4), Hour(2))
id = only(list_metadata(store; owner_id=400, owner_category=Component, name="dst")).id
fc = read_by_id(store, id) # a Deterministic
Where the distinction matters — auditing which forecasts are synthetic rather than reading values — it is in the catalog, not in the read:
get_metadata_by_id(store, id).time_series_type
# DeterministicSingleTimeSeries{Float64,2} -- test kinds with <:, not ==
Filtering with time_series_type=Deterministic spans both: it matches a directly-stored
Deterministic and a DST derived by transform_single_time_series!. Narrow to
time_series_type=DeterministicSingleTimeSeries to select only the derived rows.
Alternatively, use get_metadata_by_id to obtain the data_hash, then get_array_by_hash for the
raw flattened array.
For the per-timestamp simulation access pattern (walk the timeline, read every series at each instant) prefer a reader — see Readers below.
Readers (per-timestamp iteration)
read_by_id returns a whole series or forecast struct. For the simulation access pattern — walk
every timestamp and, at each, read the value of every series — use a reader instead. A reader
is built once over a filter, pins one timeline, and reuses output buffers that each read overwrites
in place, so a tight loop allocates almost nothing. There are two: StaticReader for the static
types, and ForecastReader for forecasts. Both follow the same lifecycle: build → inspect the
layout once → *_read!(t) in a loop → pull values per group/entry.
StaticReader
Reads the value of every matching static series at one timestamp. Results are columnar: series
are partitioned into (dtype, element_shape) groups, and each group's values come back as one dense
(num_columns, element_dims...) array.
build_static_reader(store; resolution::Union{Nothing,Period}=nothing,
window_start=nothing, window_length=nothing,
time_series_type::Type=SingleTimeSeries, owner_id=nothing,
owner_category=nothing, name=nothing, name_glob=nothing,
features=Dict(), features_exact=false, component_field=nothing,
initial_timestamp=nothing, length=nothing) -> StaticReader
static_grid(reader) -> StaticGrid # .initial_timestamp, .resolution (or nothing), .length
static_timestamps(reader) -> Vector{DateTime} # every instant on the timeline, in order
static_groups(reader) -> Vector{StaticGroup} # each: .dtype, .element_shape, .ids
static_read!(reader, t) -> reader # fills buffers; errors if t is off the timeline
# t is spelled like the axis: a bare DateTime (a wall clock) for a zoneless one,
# a ZonedDateTime (TimeZones loaded) for one recording instants or unspecified
static_values(reader, group_index::Integer) -> Array
# (num_columns, element_dims...); column j is static_groups(reader)[group_index].ids[j]
All matched series must share one timeline — one grid (initial_timestamp + length) for
SingleTimeSeries, one timestamp vector for NonSequentialTimeSeries. The build validates this and
errors on divergence, so there is no presence mask — every column has a value at every valid
timestamp. When they do not share one there are two remedies below, answering different questions: a
window sweeps a span across the ragged series, a grid filter drops the ones that are not on the grid
you want.
PersistentTimeSeries is the exception: its columns may sit on different breakpoint vectors,
because a step function has a value at every instant from its first breakpoint on. The reader's
timeline is then the union of every column's breakpoints, and each column reports the value in force
there. Reading before some column's first breakpoint errors, naming that column. There is still no
presence mask.
resolution is required for SingleTimeSeries (one resolution per reader) and must be omitted for
time_series_type=NonSequentialTimeSeries and time_series_type=PersistentTimeSeries, which have
none; static_grid(reader).resolution is then nothing. Iterating static_timestamps covers every
kind, so one loop serves all three:
reader = build_static_reader(store; resolution = Hour(1))
# ...or, for irregular series:
# reader = build_static_reader(store; time_series_type = NonSequentialTimeSeries)
# ...or, for step functions:
# reader = build_static_reader(store; time_series_type = PersistentTimeSeries)
for t in static_timestamps(reader)
static_read!(reader, t)
for (gi, g) in enumerate(static_groups(reader))
vals = static_values(reader, gi) # column j ↔ g.ids[j]
end
end
Reader windows
Passing window_start (a DateTime, or a ZonedDateTime with TimeZones loaded) drops the
shared-grid requirement. The reader's axis becomes the span you named, and each column reads at an
offset of its own, so SingleTimeSeries that begin at different instants, or run for different
lengths, sweep together as long as they all cover the span. window_length pins the extent in
timesteps; without one the reader runs as far from the anchor as every matched series reaches.
reader = build_static_reader(store; resolution = Hour(1),
window_start = DateTime(2024, 1, 1, 7))
static_grid(reader).length # as far as every matched series reaches from 07:00
The span is checked, never clamped: a matched series that does not cover it is an
InvalidParameterError naming that series rather than a column quietly left out; the anchor
must fall at or after each series' start and on one of its own step boundaries; and a monthly
resolution is refused where re-anchoring would move the dates, by the same rule that governs a
sliced read. The anchor's spelling must match the series' (a DateTime is a wall clock, a
ZonedDateTime an instant), and the window belongs to SingleTimeSeries alone — the two irregular
types carry their timeline rather than deriving it. window_length without window_start is
refused.
Selecting one grid
initial_timestamp and length are the window's counterpart: filter keywords that match only
the series already on that grid, so the ones that are not on it never become columns.
build_static_reader(store; resolution = Hour(1), window_start = t7) # 3 columns, 17 steps
build_static_reader(store; resolution = Hour(1), initial_timestamp = t7) # 2 columns, 8784 steps
Use the window when the ragged series should all take part in the sweep, the filter when they should
not; they compose. With resolution the two complete the grid triple, which is what lets a filter
name a whole grid rather than only be refused a divergent one. They are ordinary filter keywords, so
they reach list_metadata, remove_by_filter!, and the rest
— and like every filter they select rather than assert: a grid no row is on is an empty result, not
an error.
ForecastReader
Reads the forecast window at one timestamp for every matching forecast of one type. The build
filter must name a forecast type and pin a resolution; a Deterministic reader is abstract and also
includes DeterministicSingleTimeSeries (read into identical [H, *E] windows). All matched
forecasts must share one window timeline (initial_timestamp + interval + count).
time_series_type must be one of the four forecast types — Deterministic,
DeterministicSingleTimeSeries, Probabilistic, or Scenarios. Any other type raises
InvalidParameterError.
build_forecast_reader(store, time_series_type::Type; resolution::Period,
owner_id=nothing, owner_category=nothing, name=nothing,
name_glob=nothing, features=Dict(), features_exact=false,
component_field=nothing) -> ForecastReader
forecast_timeline(reader) -> ForecastTimeline
# (initial_timestamp::DateTime, resolution::Period, interval::Period, count::Int)
forecast_entries(reader) -> Vector{ForecastEntry} # each: .dtype, .window_shape, .key, .slot
forecast_num_slots(reader) -> Int # physical reads per timestamp (see below)
forecast_read!(reader, t) -> reader # fills buffers; errors if t is off the timeline
# t is spelled like the axis, as for static_read!: DateTime only for a zoneless one
forecast_values(reader, entry_index::Integer) -> Array # window of size .window_shape
Valid read timestamps are initial_timestamp + k·interval for k in 0:count-1 (each names the
window forecast from that instant). A window's shape is [H, *E] for Deterministic /
DeterministicSingleTimeSeries, [num_percentiles, H, *E] for Probabilistic, and
[scenario_count, H, *E] for Scenarios.
reader = build_forecast_reader(store, Deterministic; resolution = Hour(1))
tl = forecast_timeline(reader)
for k in 0:(tl.count - 1)
forecast_read!(reader, tl.initial_timestamp + tl.interval * k)
for (i, e) in enumerate(forecast_entries(reader))
window = forecast_values(reader, i) # shape e.window_shape, for e.key's owner
end
end
Window-read deduplication
Forecasts that reference the same backing array and read plan — deduplicated identical data, or
several DeterministicSingleTimeSeries over one SingleTimeSeries — collapse to a single window
slot. forecast_read! performs one backend (.h5) read per slot, not per entry, so a forecast
shared by N owners is read once per timestamp. forecast_num_slots(reader) is that physical read
count (≤ length(forecast_entries(reader))), and every ForecastEntry.slot (0-based) identifies
the slot backing that entry; entries that share data report the same slot. Group entries by slot
to also materialize each unique window only once on the Julia side:
forecast_read!(reader, t)
windows = Dict{Int, Any}()
for (i, e) in enumerate(forecast_entries(reader))
window = get!(() -> forecast_values(reader, i), windows, e.slot) # materialize once per slot
# apply `window` to e.key's owner
end
Store-Wide Operations
get_counts(store) -> TimeSeriesCounts # components_with_time_series, static_time_series, forecasts
counts_by_type(store) -> Vector{TimeSeriesTypeCount} # (time_series_type, count) per stored type
num_distinct_arrays(store) -> Int # distinct content hashes; shared arrays count once
time_series_counts(store) -> TimeSeriesCountsDetailed # distinct owners per category + distinct arrays per kind
list_owner_ids(store, owner_category; time_series_type=nothing, resolution=nothing) -> Vector{Int}
count_array_references(store, data_hash::Vector{UInt8}) -> ArrayReferenceCounts # (sts, dst) refs to a 32-byte hash
static_summary(store) -> Vector{StaticSummaryRow} # grouped static rows with a `count`; build your own table
forecast_summary(store) -> Vector{ForecastSummaryRow} # grouped forecast rows with a `count`
get_forecast_parameters(store; resolution=nothing, interval=nothing) -> ForecastParameters # horizon, interval, count, resolution, initial_timestamp; fields `nothing` when none match
check_static_consistency(store; resolution=nothing) -> Vector{StaticGrid} # one grid per resolution present (empty when none); throws if the series at one resolution disagree
get_resolutions(store; time_series_type=nothing) -> Vector{Period} # distinct resolutions, in the core's stored (lexical-by-ISO) order
get_intervals(store; time_series_type=nothing) -> Vector{Period} # distinct forecast intervals, same order; empty for static types
get_path(store) -> Union{Nothing,String} # the .h5 path, or nothing for an in-memory store
read_only(store) -> Bool
has_for_owner(store, owner_id, owner_category; time_series_type=nothing) -> Bool
# does this owner have any series (of that type)? One index probe.
list_names(store; <list_metadata filters>) -> Vector{String} # distinct names, sorted
list_owner_types(store; <list_metadata filters>) -> Vector{String} # distinct owner types, sorted
remove_by_filter!(store; <list_metadata filters>) -> Int
# remove every match in one all-or-nothing transaction; count removed
get_compression(store) -> CompressionSettings # compression=:deflate|:none, level, shuffle; restored from file on open
verify_integrity(store) -> Int # number of integrity errors; 0 == intact
compact!(store) -> CompactionReport # reclaims both halves; on an on-disk store this rewrites the
# .h5 file from the live set and replaces it (single writer)
flush!(store) -> Nothing # sync to disk; afterwards .h5 and .sqlite can be copied
persist!(store, path) -> Nothing # write both halves to `path` + `$path.sqlite`, replacing them
persist_catalog!(store) -> Nothing # write an in-memory catalog to this store's own $path.sqlite,
# stamped to match the .h5 already beside it. Copies no arrays:
# they are already in place. A checkpoint, not a mode switch;
# for catalog=:attached this is flush!.
transaction(f, store) # do-block: commit if `f` returns, roll back if it throws.
# Spans any number of operations; removals are reversible only
# inside one. Nests. Holds the SQLite write lock until it ends.
begin_transaction!(store) -> Nothing
commit_transaction!(store) -> Nothing # errors if no transaction is open
rollback_transaction!(store) -> Nothing # errors if no transaction is open
in_transaction(store) -> Bool
write_buffer_bytes(store) -> Int # the budget an open transaction's buffered adds are held
# to, and so how wide a dataset a run of single adds writes
set_write_buffer_bytes!(store, bytes) -> Nothing
# move it: raised, a run of single adds writes what
# add_time_series_bulk! of the same series writes, which applies no
# budget at all. Belongs to the handle, not the artifact (nothing
# is persisted). Lowering it mid-transaction writes out what the
# buffer holds beyond the new figure; 0 throws ArgumentError.
clear!(store; owner_id=nothing, owner_category=nothing) -> Nothing
# both `nothing`: remove every series in the store.
# Scope to one owner by passing BOTH keywords — they identify the
# (owner_id, owner_category) pair. `owner_id` without
# `owner_category` throws ArgumentError.
replace_owner!(store, old_owner_id, new_owner_id, owner_category::OwnerCategory) -> Int
# reassign one owner's series to a new id (same category); count moved
close!(store) -> Nothing
list_metadata(store; owner_id=nothing, owner_category=nothing, time_series_type=nothing,
name=nothing, name_glob=nothing, resolution=nothing, interval=nothing,
features=nothing, features_exact=false, component_field=nothing,
zoneless=nothing) -> Vector{TimeSeriesMetadata}
list_metadata is the package's one identify entry point: it returns a full
TimeSeriesMetadata per matching row — identity, the per-type descriptive
snapshot, the physical detail (data_hash, element_type, percentiles, application_data), and
the row's id, which is what every read, removal, and copy then takes. Fields that do not apply to
a row's type are nothing. All the filters are optional and independent, and combine as a
conjunction; with none set the whole store is listed:
owner_id,owner_category— scope to one owner.time_series_type— the Julia type (SingleTimeSeries,Deterministic, …), the same value thetime_series_typefield of a returned row carries.Deterministicadditionally matchesDeterministicSingleTimeSeriesrows; each row still reports its own stored type, and passingDeterministicSingleTimeSeriesselects only those.name— exact association name.name_glob— a SQLiteGLOBpattern over the name (*and?, case-sensitive), e.g."wind_*". ANDed withnamerather than replacing it: set both and a row must satisfy both.resolution— aPeriod.interval— aPeriod; forecasts only (static rows carry no interval and never match an interval filter).features— match keys whose features include all the given entries (subset match).component_field— exact, case-sensitive match on the owning component's field (e.g."max_active_power"): every series that varies that field, alone or scoped to one owner. A row that declares nocomponent_fieldmatches no value, so this cannot select the rows that left it unset.zoneless— the coherence group:trueselects the wall-clock series,falsethe ones that name instants. The constructive remedy when a bulk read or a reader refuses a selection spanning both.
has_any_time_series(store; owner_id=nothing, owner_category=nothing, time_series_type=nothing,
name=nothing, name_glob=nothing, resolution=nothing, interval=nothing,
features=Dict(), features_exact=false, component_field=nothing) -> Bool
has_any_time_series is the existence probe over the same filters: true iff list_metadata with
that filter would return at least one row, answered off the catalog indexes without hydrating or
marshaling any rows, so it is safe for hot per-component loops. features is a subset match by
default, unlike the exact-key has_time_series forms, which compare the whole feature set by
content hash — and which are this function with features_exact=true. A subset features filter
still stays on indexes: the requested set is probed as an exact set by hash first (one covering seek
when the caller passes the complete feature set), with an indexed per-feature fallback for genuinely
partial lists.
The two matching rules are the thing to keep straight when a parent package resolves user queries:
the exact-identity has_time_series forms must be given the complete feature map or they miss,
while the list/filter forms accept a partial one and may return several rows — deciding what more
than one match means is the caller's job.
is_empty(store) -> Bool
is_empty is the store-wide predicate: true iff the store holds nothing at all — no time series,
and no associations in either catalog. It is one short-circuited existence probe per catalog table,
so its cost does not grow with the store, and it is the store's own answer: as the catalog gains
tables it stays correct, where a caller-side conjunction over get_counts and the
count_*_associations functions both costs a full aggregation and silently goes stale.
Every row list_metadata returns carries data_hash — the 32-byte content hash of the array the
row resolves to (a Vector{UInt8} hashes and compares by content, so it groups directly as a Dict
key). Rows that share a stored array share their data_hash: both deduplicated identical arrays and
a SingleTimeSeries together with any DeterministicSingleTimeSeries derived from it. Group rows
by data_hash to discover which time series share their underlying data — the foundation for
reading a shared series once (see Window-read deduplication). It is
one catalog query; there are no per-row get_metadata_by_id round-trips.
count_array_references(store, data_hash) returns an ArrayReferenceCounts (sts, dst) — how
many SingleTimeSeries and DeterministicSingleTimeSeries associations reference the given 32-byte
hash, across all owners. Because a DST shares its backing SingleTimeSeries array, a caller uses
these counts to decide whether removing a SingleTimeSeries would orphan a derived DST.
Associations
Two catalogs of relationships between entities the store does not otherwise model, replacing the association tables IS3.jl used to keep itself. Both are independent of time series: there are no foreign keys and no cascade (both endpoints live in the caller's object graph, so a cascade could never fire), so removing a time series never removes an association and vice versa; a caller that wants both makes both calls.
Every query in a family takes that family's four optional keyword filters, ANDed; with none set they
match every row, which is what makes a bare list_* call a whole-catalog export that the matching
add_*! re-imports unchanged. The *_types keywords take a vector of concrete type names,
matched as SQL IN (…): expanding an abstract type into its subtypes stays on the Julia side, where
the type hierarchy lives, and an empty vector matches nothing, unlike omitting the keyword, which
matches everything. Every remove_*! returns the number of rows removed; removing nothing is 0,
not an error.
Supplemental-attribute associations
Which supplemental attributes are attached to which components. One attribute may be attached to many components.
struct SupplementalAttributeAssociation
component_id::Int64
component_type::String
attribute_id::Int64
attribute_type::String
end
SupplementalAttributeAssociation overloads ==, hash, and show (a compact
SupplementalAttributeAssociation(Generator 1 <- GeographicInfo 100)), so attachments work as
Dict/Set members. In the catalog, identity is only the (component_id, attribute_id) pair —
the type names are denormalized labels carried for filtering — so re-attaching the same pair under
different type names throws DuplicateAssociationError.
add_supplemental_attribute_association!(store, association::SupplementalAttributeAssociation) -> Int64
# the catalog id it was filed under
add_supplemental_attribute_associations!(store, associations::AbstractVector{SupplementalAttributeAssociation}) -> Vector{Int64}
# one all-or-nothing transaction; one id per
# input row, in order (count is `length`)
has_supplemental_attribute_association(store; filters...) -> Bool
list_supplemental_attribute_associations(store; filters...) -> Vector{SupplementalAttributeAssociation}
# insertion order
list_supplemental_attribute_ids(store; filters...) -> Vector{Int}
# distinct attribute ids, ascending
list_components_with_attributes(store; filters...) -> Vector{Int}
# distinct component ids, ascending
remove_supplemental_attribute_associations!(store; filters...) -> Int # count removed
replace_supplemental_attribute_component_id!(store, old_id, new_id) -> Int # rows updated
count_supplemental_attribute_associations(store; filters...) -> Int
count_supplemental_attributes(store; filters...) -> Int
count_components_with_attributes(store; filters...) -> Int
supplemental_attribute_counts_by_type(store) -> Vector{SupplementalAttributeTypeCount} # (attribute_type, count)
supplemental_attribute_summary(store) -> Vector{SupplementalAttributeSummaryRow}
# (component_type, attribute_type, count), by attribute then component type
The four keyword filters are component_id, component_types, attribute_id, and
attribute_types.
list_supplemental_attribute_ids is "the attributes attached to this component" when component_id
is set; list_components_with_attributes is the other end, "the components carrying this attribute"
when attribute_id is set. count_supplemental_attributes and count_components_with_attributes
are those two queries counted, and count_supplemental_attribute_associations counts the matching
rows themselves.
replace_supplemental_attribute_component_id! moves every attachment from component old_id to
new_id, and throws DuplicateAssociationError if new_id already carries one of the attributes
being moved.
store = Store(in_memory=true)
add_supplemental_attribute_association!(
store, SupplementalAttributeAssociation(1, "Generator", 100, "GeographicInfo"))
add_supplemental_attribute_association!(
store, SupplementalAttributeAssociation(2, "Load", 100, "GeographicInfo"))
list_supplemental_attribute_ids(store; component_id=1) # [100]
list_components_with_attributes(store; attribute_id=100) # [1, 2]
remove_supplemental_attribute_associations!(store; component_id=1)
# 1; component 1's time series are untouched
Parent/child associations
Directed edges between components — a generator (parent) wired to a bus (child), say. Both endpoints are always components; an attribute cannot appear here.
struct ParentChildAssociation
parent_id::Int64
parent_type::String
child_id::Int64
child_type::String
end
ParentChildAssociation overloads ==, hash, and show (a compact
ParentChildAssociation(Generator 1 -> Bus 7)) the same way. In the catalog, identity is the
ordered (parent_id, child_id) pair, so the reversed pair is a different edge, while repeating
the same ordered pair under different type names throws DuplicateAssociationError. There is no
relationship-kind column, so one ordered pair may be related at most once.
This family is deliberately narrower than the supplemental one — no counts-by-type and no grouped summary — because there is no consumer for them yet; both are additive if one appears.
add_parent_child_association!(store, association::ParentChildAssociation) -> Int64
# the catalog id it was filed under
add_parent_child_associations!(store, associations::AbstractVector{ParentChildAssociation}) -> Vector{Int64}
# one all-or-nothing transaction; one id per
# input row, in order (count is `length`)
has_parent_child_association(store; filters...) -> Bool
list_parent_child_associations(store; filters...) -> Vector{ParentChildAssociation}
# insertion order
list_children(store; filters...) -> Vector{Int} # distinct child ids, ascending
list_parents(store; filters...) -> Vector{Int} # distinct parent ids, ascending
remove_parent_child_associations!(store; filters...) -> Int # count removed
replace_parent_child_component_id!(store, old_id, new_id) -> Int # rows updated
count_parent_child_associations(store; filters...) -> Int
The four keyword filters are parent_id, parent_types, child_id, and child_types.
replace_parent_child_component_id! rewrites old_id to new_id on both ends of every edge,
and throws DuplicateAssociationError if the rewrite would duplicate an edge new_id already has.
store = Store(in_memory=true)
add_parent_child_association!(store, ParentChildAssociation(1, "Generator", 7, "Bus"))
# The reversed pair is a different edge, not a duplicate.
add_parent_child_association!(store, ParentChildAssociation(7, "Bus", 1, "Generator"))
list_children(store; parent_id=1) # [7]
list_parents(store; child_id=7) # [1]
remove_parent_child_associations!(store; parent_types=["Bus"]) # 1
Neither association catalog is exposed over the gRPC server or the
infrastore CLI.
Store attributes
Key/value provenance about the artifact as a whole, as opposed to a supplemental attribute
(which belongs to a component) or application_data (which belongs to one series). See
Store attributes for the model.
set_store_attribute!(store, key::AbstractString, value::AbstractString) -> Nothing
get_store_attribute(store, key::AbstractString) -> Union{Nothing,String}
list_store_attributes(store) -> Dict{String,String}
remove_store_attribute!(store, key::AbstractString) -> Bool
set_store_attribute!(store, "creator", "sienna-build")
set_store_attribute!(store, "source_system", "WECC 2032 ADS")
get_store_attribute(store, "creator") # "sienna-build"
get_store_attribute(store, "absent") # nothing — a question, not an error
list_store_attributes(store) # Dict("creator" => ..., "source_system" => ...)
remove_store_attribute!(store, "creator") # true; a second call is false
A set replaces rather than appending. nothing and "" are different answers: a key set to the
empty string is present. An empty key or one beginning with infrastore. — reserved, on removal as
well as on write — throws InvalidParameterError; a write to a read-only store throws
ReadOnlyStoreError.
OpenAPI-row association serde
Direct JSON serde of the two association catalogs, in the wire spelling
SiennaSchemas defines (TimeSeries/*.json,
Core/Associations/SupplementalAttributeAssociation.json). Unlike list_metadata /
list_supplemental_attribute_associations, which return Julia structs, these four functions
exchange the wire JSON verbatim — the format a document author (e.g. PowerTableDataParser) reads and
writes directly.
export_time_series_associations_openapi(store; filters...) -> String
import_time_series_associations_openapi!(store, json::AbstractString) -> Int
export_supplemental_attribute_associations_openapi(store) -> String
import_supplemental_attribute_associations_openapi!(store, json::AbstractString) -> Int
open_store_without_catalog(path; catalog=:attached) -> Store
export_time_series_associations_openapi takes the same filter keywords as list_metadata. Every
row's uri and data_hash are the hex-encoded content hash the store already has for that row —
never a caller-supplied locator. With no filter this exports the whole catalog, sorted by identity —
except PersistentTimeSeries rows, which are omitted: the type is an infrastore-local extension the
wire contract has no schema for, so it cannot be spelled in a document. A filter naming that type is
an error rather than an empty array.
export_supplemental_attribute_associations_openapi exports the whole
supplemental_attribute_associations table, sorted by (component_id, attribute_id);
import_supplemental_attribute_associations_openapi! is its import half — a bulk, all-or-nothing
insert (a duplicate anywhere in the batch throws DuplicateAssociationError and rolls the batch
back), returning the number of rows inserted.
import_time_series_associations_openapi! is the time-series import half, and it writes rows
only: the document carries locators, never values, so every row must name an array this store
already holds — the arrays arrive with the artifact. Each row keeps the association_id it carries,
which is the point: an import that assigned fresh ids would leave every reference the document
records pointing at the wrong series. An irregular series locates its time axis with
timestamps_uri, filled from the axis's own content hash: the axis is stored beside the arrays and
shared across a cohort, and the values cannot imply it — two irregular series with byte-identical
values on different axes share one content-addressed array. A row missing the locator, or naming an
axis the store does not hold, is refused. A PersistentTimeSeries row is refused before any of
that: the type is an infrastore-local extension, outside the six the wire contract defines, so a
document naming one is rejected by the discriminator check. Any of those, or an absent array, throws
InvalidParameterError and rolls the whole batch back.
Infrastore never modifies the data to make an incoming document agree with what it already holds. A
geometry disagreement between an added series and its own association row is likewise rejected at
the add boundary (InvalidParameterError), loudly and without writing anything.
Incoming rows are validated against the vendored SiennaSchemas specs before anything is decoded, so a document that drifted from the contract is refused in the schema's own terms — naming the row and the field.
Reading a bundle back with no catalog
open_store_without_catalog opens the array half of an artifact whose .sqlite is absent,
minting an empty catalog, so the document's rows can be replayed into it. This is what lets a
consumer ship arrays plus JSON and nothing else:
store = open_store_without_catalog("bundle.h5")
import_time_series_associations_openapi!(store, ts_rows_json)
import_supplemental_attribute_associations_openapi!(store, sa_rows_json)
open_store cannot open that bundle — the array file carries a generation stamp and a catalog
created on the spot does not, so it reports a mismatched artifact. The catalog minted here inherits
the array file's own stamp, so every later open_store behaves normally. It throws
StoreExistsError when a catalog is already there; a store that has one wants open_store.
A bundle carrying NonSequentialTimeSeries still needs its .sqlite: those rows cannot be
replayed, for the reason above.
Errors
All subtype TimeSeriesException:
| Type | Mapped from FFI code |
|---|---|
NotFoundError | INFRASTORE_ERR_NOT_FOUND |
DuplicateTimeSeriesError | INFRASTORE_ERR_DUPLICATE |
DuplicateAssociationError | INFRASTORE_ERR_DUPLICATE_ASSOCIATION |
InvalidParameterError | INFRASTORE_ERR_INVALID_PARAMETER / INFRASTORE_ERR_INVALID_UTF8 / INFRASTORE_ERR_NULL_POINTER |
IntegrityError | INFRASTORE_ERR_INTEGRITY |
ReadOnlyStoreError | INFRASTORE_ERR_READ_ONLY |
IncompatibleFormatError | INFRASTORE_ERR_INCOMPATIBLE_FORMAT |
IOError | INFRASTORE_ERR_IO |
StoreExistsError | INFRASTORE_ERR_STORE_EXISTS |
MismatchedArtifactError | INFRASTORE_ERR_MISMATCHED_ARTIFACT |
OwnerMismatchError | INFRASTORE_ERR_OWNER_MISMATCH |
CatalogMigrationRequiredError | INFRASTORE_ERR_CATALOG_MIGRATION_REQUIRED |
CatalogTooNewError | INFRASTORE_ERR_CATALOG_TOO_NEW |
GenericError | Any other non-zero code (carries the numeric code) |
The message text comes from the FFI layer's thread-local error buffer.
Base Interface
The package overloads Base so the wrapped types behave like native Julia values:
showrenders compact one-liners forStoreand the value types; every result struct (TimeSeriesMetadata,StaticSummaryRow, …) gets generated==/hash/show, so results work asDict/Setmembers, andAddBatchdefineslength.length,eltype,getindex, anditerateonSingleTimeSeries/NonSequentialTimeSeries/PersistentTimeSeriesdelegate to the wrappeddataarray (element count, not time steps, for multi-dimensional values). Forecast types definelength= window count.- Do-block forms guarantee
close!even on throw:
Store(in_memory=true) do store
add_time_series!(store, 1, "Generator", Component, ts)
end
open_store(path; read_only=true) do store
only(list_metadata(store; owner_id=1, owner_category=Component, name="load"))
end
Time and Resolution Conversions
DateTimeis converted to/from Unix milliseconds at the boundary. A bareDateTimecarries no zone, so it names a wall clock, not an instant: it is stored as its own fields and recorded asZonelessReference(). The stored instant is unchanged from the old UTC-by-convention reading — what is new is that the store now records that it was a convention.- A
TimeZones.ZonedDateTimeis accepted wherever aDateTimeis — an initial timestamp, a timestamp vector, atime_rangebound, a reader'st— and is converted to the instant it names, recording the spelling its zone names. TimeZones is a weak dependency: the conversion lives in theInfraStoreTimeZonesExtextension, which loads when youusing TimeZones, so nobody else pays for the tz database. Passing one without loading TimeZones raises anInvalidParameterErrorsaying so. - Reads always return a
DateTimeholding the instant, whichever kind went in, with the spelling beside it as atime_reference. Widening the return type was rejected: it would make the type depend on package load order, andzdt == dtraises in Julia, so it would turn working comparisons against aDateTimeliteral into runtime errors. - A vector of
ZonedDateTimes is ordered by the instants it names, not by its local wall clocks, so the strictly-increasing rule is checked after conversion. It must also agree on one spelling — one series records one reference. - Milliseconds are lossless in both directions: the store records every instant to the millisecond and refuses a finer one on write, so this boundary cannot truncate a series written under that rule. See timestamp precision. (An artifact written before the rule may hold finer instants; those still truncate here.)
resolutionis passed as aPeriodand converted to an ISO-8601 duration string; reads return resolution as aPeriod(Millisecondfor fixed durations).
Time references
abstract type TimeReference end
struct UTCReference <: TimeReference end # an instant, written as UTC
struct FixedOffsetReference <: TimeReference; minutes::Int end # minutes east
struct ZoneReference <: TimeReference; name::String end # an IANA zone name
struct ZonelessReference <: TimeReference end # a wall clock, naming no instant
is_zoneless(reference) -> Bool # false for `nothing`: unset groups with the zoned ones
An abstract type with subtypes rather than an @enum like UnitSystem, because two of the four
carry a payload. The constructors infer one for you:
| Input | time_reference |
|---|---|
ZonedDateTime(..., tz"UTC") | UTCReference() |
ZonedDateTime(..., tz"-07:00") | FixedOffsetReference(-420) |
ZonedDateTime(..., tz"America/Denver") | ZoneReference("America/Denver") |
a bare DateTime or Date | ZonelessReference() |
The constructor signatures above write this default as time_reference=<inferred> rather than
naming a value, because there is no Julia literal for it: omitting the keyword infers the spelling
from the timestamp handed in, per the table above. Copying a literal nothing out of a signature
would suppress that inference.
Passing time_reference=nothing explicitly is a different claim from omitting the keyword: it
records unspecified, which is also what a read hands back for a series that declared no spelling
(one written by a native Rust caller, say). The two are never collapsed — a read that invented
ZonelessReference() for an unspecified series would have add_time_series! write that invention
back, since its default is the series' own reference.
The two FixedTimeZone cases split on the zone's name, not its offset: tz"UTC" and tz"+00:00"
place every instant identically forever, and telling them apart is the point of recording a spelling
at all.
timestamps(series::SingleTimeSeries) -> Vector{DateTime}
timestamps(series::NonSequentialTimeSeries) -> Vector{DateTime}
timestamps(series::PersistentTimeSeries) -> Vector{DateTime}
infer_resolution(timestamps) -> Period
SingleTimeSeries(timestamps::AbstractVector, data, name; kwargs...)
Every timestamp of a static series, in order. For the two irregular types this is a copy of the
stored vector; for a SingleTimeSeries it walks the grid from initial_timestamp by resolution.
That one method is the reason the function exists: a Month or Year resolution steps on the
calendar, so a series starting January 31st lands on February 29th, and a caller multiplying a
fixed span by the index would get it wrong. One entry per time step, so a multidimensional
per-step value gives fewer timestamps than length(series) counts elements.
The instants are the ones stored; the spelling beside them is series.time_reference.
The SingleTimeSeries grid is computed in the core, through infrastore_grid_timestamps, not
with initial_timestamp + k * resolution here. Julia is the one binding whose date library has
calendar arithmetic of its own — and whose TimeZones overload steps a local clock the core
deliberately does not — so computing it here would be a second implementation of which instants a
series contains, agreeing with the core only by luck.
infer_resolution is the inverse: the period that reproduces a timeline exactly, or an
InvalidParameterError naming the entry that breaks the pattern. The three-argument
SingleTimeSeries(timestamps, data, name) constructor uses it to build from the timeline you hold
rather than a resolution you assert — which is how a local-clock grid reaches the store. An
hourly local grid in a DST zone is a uniform instant grid and compacts; a daily or monthly one is
not, and is refused so you store it as a NonSequentialTimeSeries instead. A calendar-scale period
(Day(1) or coarser, and any Month/Year) on a ZoneReference is refused at the write for the
same reason; sub-daily periods are unaffected.
zoned_timestamp(instant::DateTime, reference::TimeReference) -> ZonedDateTime
zoned_timestamp(series) -> ZonedDateTime # SingleTimeSeries / the three forecasts
zoned_timestamp(metadata::TimeSeriesMetadata) -> ZonedDateTime
zoned_timestamps(series::SingleTimeSeries) -> Vector{ZonedDateTime}
zoned_timestamps(series::NonSequentialTimeSeries) -> Vector{ZonedDateTime}
zoned_timestamps(series::PersistentTimeSeries) -> Vector{ZonedDateTime}
Fuses a read instant back together with the spelling it was written in. Requires using TimeZones
(the methods live in the extension), and it is lossless — the instant plus the zone name
reconstructs the exact value written, including which side of a fall-back hour it was on:
using TimeZones
series = read_by_id(store, id)
zoned_timestamp(series) # 2024-01-01T00:00:00-07:00
Throws for a ZonelessReference() series, whose timestamps name no instant, and for one that
recorded no reference at all.
Reading a step function at an instant
value_at(series::PersistentTimeSeries, at) -> value
index_at(series::PersistentTimeSeries, at) -> Int
breakpoint_at(series::PersistentTimeSeries, at) -> DateTime
The value in force at at, the 1-based row it came from, and the breakpoint it has been in force
since. at is a DateTime or — with using TimeZones — a ZonedDateTime, and must be spelled the
way the series' breakpoints are; a mismatch throws the same InvalidParameterError a read bound
earns.
value_at is the everyday call, and it is not an approximation: a step function is defined at
every instant from its first breakpoint onward, so it has a genuine value at at. Between
breakpoints the previous value is carried forward, and past the last breakpoint the last value holds
indefinitely. Only an at strictly before the first breakpoint throws — no value was ever
declared there, and inventing one would be a guess. A scalar series returns a scalar; one with a
shaped per-step element returns that step as an array (a copy, so mutating it leaves the series
alone).
curve = PersistentTimeSeries(
[DateTime(2024, 1), DateTime(2024, 4), DateTime(2024, 7)],
[10.0, 40.0, 70.0],
"gas",
)
value_at(curve, DateTime(2024, 5, 17)) # 40.0, carried forward from April
breakpoint_at(curve, DateTime(2024, 5, 17)) # 2024-04-01T00:00:00
index_at(curve, DateTime(2024, 5, 17)) # 2
These answer for one series in hand. A columnar sweep over many is StaticReader,
which resolves the same rule per column.
A query bound must be spelled the way the series is: a bare DateTime bound against a series
that records instants, or a ZonedDateTime bound against a zoneless one, raises
InvalidParameterError rather than being coerced, and so does a time_range whose two ends
disagree. list_metadata, build_static_reader, and the other filter-taking functions accept
zoneless=true|false for building a coherent selection. See
Time references for the full rules, including why a calendar
Month/Year resolution still steps on the UTC calendar.
Because a read always hands back a bare DateTime, the obvious round trip does not close on a
series that records instants — the returned timestamp holds the instant, but its Julia type says
wall clock:
t = series.initial_timestamp # a DateTime: a wall clock
read_by_ids(store, [id]; time_range=(t, t + Hour(3))) # InvalidParameterError
Fuse the instant back together with the spelling that came with it, and the bound matches the series:
using TimeZones
t = zoned_timestamp(series) # or zoned_timestamp(metadata)
read_by_ids(store, [id]; time_range=(t, t + Hour(3))) # reads
A Julia-only workflow never meets this, because a bare DateTime writes a zoneless series and a
DateTime bound then matches it. It is a store written by Python, the CLI, or a native Rust caller
— which record instants — that needs the zoned bound, and therefore using TimeZones.
Tracing
init_logging(level::AbstractString = "") -> Int32 # the FFI status code
Initialize the Rust tracing subscriber. level is an
EnvFilter
directive string such as "debug" or "infrastore_core=debug". Pass an empty string (the default)
to read RUST_LOG; if that variable is also unset, no output is produced.
The subscriber is initialized at most once per process — subsequent calls are no-ops. The module's
__init__ hook calls init_logging("") automatically when RUST_LOG is set, so the common case
requires no code change:
export RUST_LOG=infrastore_core=debug
julia --project=. myscript.jl
For programmatic control without environment variables:
using InfraStore
init_logging("infrastore_core=debug")
See Julia developer guide for usage examples and a table of available span targets.
C ABI
infrastore-ffi compiles a cdylib (libinfrastore_ffi.{dylib,so,dll}) exposing a C-compatible
API over Store. The header crates/infrastore-ffi/include/infrastore.h is generated by cbindgen —
do not hand-edit it. The Julia binding is the primary consumer.
Conventions
- Return codes. Every function returns
int32_t.INFRASTORE_OK(0) is success; any other value is an error. Retrieve the human-readable detail withinfrastore_last_error_message. - Opaque handles.
InfraStore,InfraStoreBulkRead, the readers and the batch are incomplete struct types; you only ever hold pointers. Free them withinfrastore_store_free. - Out-parameters. Results are written through caller-provided pointers (
**out,*out_len, …). - Caller-owned buffers. Returned arrays come back through an out-pointer + length and must be
freed:
f64percentile buffers (double **) withinfrastore_buffer_free_f64, raw element-byte buffers (uint8_t **) withinfrastore_buffer_free_u8, timestamp and shape buffers (int64_t **) withinfrastore_buffer_free_i64, and theu64dims buffer frominfrastore_bulk_result_get_forecast(uint64_t **) withinfrastore_buffer_free_u64. - Typed arrays. Add functions take an
element_typestring (a dtype spelling such as"f64", or a composite kind such as"tuple(3,f64)"/"piecewise_linear"— see Element types),ndimsplus adims_ptrshape array ([length, k1, …]), and the raw little-endiandata_ptrofdata_byte_lenbytes. The physical dtype the bytes are encoded in is derived from the element type. Reads return the dtype code (0=f64, 1=f32, 2=i64, 3=i32, 4=u64, 5=bool, 6=i16, 7=i8, 8=u32, 9=u16, 10=u8) and a raw byte buffer for the caller to decode, plus an optionalout_element_typestring saying what those bytes mean;infrastore_bulk_result_get_singlefollows the same dtype-generic convention. owner_categoryis anint32_t(0 = Component,1 = SupplementalAttribute) passed to the add functions. The owner identity is the pair(owner_id, owner_category)— a component and a supplemental attribute may share a numericowner_idand stay distinct — and the category is recorded with the association at add time.application_datais an optional opaque, package-owned payload (typically JSON) passed verbatim to the add functions and stored uninterpreted. Element typing does not go here — that iselement_type.- Strings are null-terminated UTF-8. Optional string arguments (
application_data,features_json,units,quantity_kind,unit_system,time_reference,component_field,name_glob) acceptNULL. time_referencerecords how a series' timestamps were spelled:"utc","zoneless", a fixed offset ("-07:00"), or an IANA zone name ("America/Denver"). An unparseable value fails withINFRASTORE_ERR_INVALID_PARAMETERrather than degrading to unset. Zone existence is not checked here — the caller's own tz database is the one that should warn. Timestamps stay Unix milliseconds either way, sotime_range_zonelessis what tells a wall-clock bound from an instant one, and the store refuses a bound whose spelling the series cannot answer rather than coercing it. Thezonelessfilter argument on the list/filter exports is a tri-stateint32_t: negative means no filter,0selects the instant-bearing rows (including those with no reference), and1selects the wall-clock rows. See Time references.- Features are passed as a JSON object string whose values are int / float / bool / string. An
add call whose feature names shadow a time-series or key field (
name,resolution,owner_id, …) fails withINFRASTORE_ERR_INVALID_PARAMETER; see reserved feature names. - Timestamps are
int64_tUnix milliseconds. Resolutions/horizons/intervals are ISO-8601 duration strings (e.g."PT1H","P1M","P1Y"); aNULL(or empty) string means unset. On output they are ownedchar *strings — free each withinfrastore_string_free.
Status Codes
| Macro | Value | Meaning |
|---|---|---|
INFRASTORE_OK | 0 | Success |
INFRASTORE_ERR_NULL_POINTER | 1 | A required pointer was NULL |
INFRASTORE_ERR_INVALID_UTF8 | 2 | A string argument was not UTF-8 |
INFRASTORE_ERR_INVALID_PARAMETER | 3 | A bad argument value |
INFRASTORE_ERR_NOT_FOUND | 4 | No matching series / array |
INFRASTORE_ERR_DUPLICATE | 5 | Key already exists |
INFRASTORE_ERR_INTEGRITY | 6 | On-disk inconsistency |
INFRASTORE_ERR_READ_ONLY | 7 | Write on a read-only store |
INFRASTORE_ERR_IO | 8 | I/O failure |
INFRASTORE_ERR_INCOMPATIBLE_FORMAT | 9 | The store on disk was written in a different, incompatible on-disk format than this build reads. There is no in-place upgrade. |
INFRASTORE_ERR_DUPLICATE_ASSOCIATION | 10 | An attachment or parent/child edge with the same identity already exists. Distinct from INFRASTORE_ERR_DUPLICATE, which is about time-series identity. |
INFRASTORE_ERR_STORE_EXISTS | 11 | A store already exists where one was about to be created. Creating there would discard its arrays while keeping its catalog. Use infrastore_store_create_replacing to discard it on purpose. |
INFRASTORE_ERR_MISMATCHED_ARTIFACT | 12 | The HDF5 file and its .sqlite catalog do not carry the same generation stamp: they are halves of two different saves. |
INFRASTORE_ERR_DUPLICATE_ASSOCIATION_ID | 13 | An import supplied an association_id a live row already holds. Distinct from INFRASTORE_ERR_DUPLICATE_ASSOCIATION (the endpoint pair collides) and INFRASTORE_ERR_DUPLICATE (a series' identity collides). |
INFRASTORE_ERR_OWNER_MISMATCH | 14 | An id-addressed call carrying an expected owner named a row that belongs to a different one. Distinct from INFRASTORE_ERR_NOT_FOUND: the row is there, and it is the caller's belief about who owns it that is stale. |
INFRASTORE_ERR_CATALOG_MIGRATION_REQUIRED | 15 | The .sqlite catalog is at an older schema revision than this build and the store was opened read-only, so nothing could migrate it. Open it once for writing (or run infrastore upgrade) and the ladder runs. |
INFRASTORE_ERR_CATALOG_TOO_NEW | 16 | The .sqlite catalog was written by a newer infrastore than this build understands. The mirror of INFRASTORE_ERR_CATALOG_MIGRATION_REQUIRED, with the other remedy: upgrade the software, not the store. |
INFRASTORE_ERR_INTERNAL | 99 | Unexpected internal error |
Lifecycle
/* compression_kind: 0 = none, 1 = DEFLATE (deflate_level 0-9 + shuffle); 1, 3, true is the
default policy. catalog_mode: 0 = attached (<path>.sqlite), 1 = in memory (written only by
infrastore_store_persist). in_memory=true admits only catalog_mode=1. */
int32_t infrastore_store_create_with_catalog(const char *path, bool in_memory, uint8_t compression_kind,
uint8_t deflate_level, bool shuffle, uint8_t catalog_mode,
struct InfraStore **out);
int32_t infrastore_store_open_with_catalog(const char *path, bool read_only, uint8_t catalog_mode,
struct InfraStore **out);
/* Open the array half of an artifact whose catalog is ABSENT, minting an empty one stamped to
match the arrays, and hand back a writable store holding every array and no rows — the way in
to a store shipped as arrays plus an OpenAPI document. Replay the rows with
infrastore_store_import_time_series_associations_openapi and its supplemental-attribute
counterpart. INFRASTORE_ERR_STORE_EXISTS when <path>.sqlite is already there; that store wants
infrastore_store_open_with_catalog. Never read-only. */
int32_t infrastore_store_open_without_catalog(const char *path, uint8_t catalog_mode,
struct InfraStore **out);
/* The create entry points above fail with INFRASTORE_ERR_STORE_EXISTS when either half of a
store is already at `path`. This one discards it first — both halves plus the catalog's
-wal/-shm sidecars — and is destructive and not atomic. */
int32_t infrastore_store_create_replacing(const char *path, uint8_t compression_kind,
uint8_t deflate_level, bool shuffle, uint8_t catalog_mode,
struct InfraStore **out);
/* Copy both halves of the store at `src` to `dest` and open the copy read-write, leaving `src`
untouched. The safe way to load a store you intend to change: mutating an artifact in place is
unrecoverable if interrupted, since HDF5 has no journal. INFRASTORE_ERR_STORE_EXISTS if `dest`
already holds a store. */
int32_t infrastore_store_open_copy(const char *src, const char *dest, uint8_t catalog_mode,
struct InfraStore **out);
int32_t infrastore_store_catalog_mode(const struct InfraStore *handle, uint8_t *out);
void infrastore_store_free(struct InfraStore *handle);
/* Frees any owned `char *` this library returns (resolutions, horizons, intervals, …). */
void infrastore_string_free(char *s);
void infrastore_buffer_free_f64(double *ptr, uint64_t len);
void infrastore_buffer_free_u8(uint8_t *ptr, uint64_t len);
void infrastore_buffer_free_i64(int64_t *ptr, uint64_t len);
void infrastore_buffer_free_u64(uint64_t *ptr, uint64_t len);
SingleTimeSeries
There is no key on this ABI. A series is addressed by its catalog association id, an int64_t
that every write reports through infrastore_store_add_batch's out_ids and that
infrastore_store_list_metadata carries on every row. Reads take that id and come back through the
shared bulk-result handle, so one decoding path serves the single and bulk reads alike; see
Bulk Reads.
Writes go through a batch. There is one write path on this ABI: build a batch with
infrastore_batch_new, append to it, then commit it with infrastore_store_add_batch, which hands
back one id per item in batch order. A single add is a one-item batch, which costs one extra call
and keeps a second, near-identical set of per-type entry points off the surface. See
Batch Writes.
int32_t infrastore_batch_add_single(struct InfraStoreBatch *batch,
int64_t owner_id, const char *owner_type,
int32_t owner_category, /* 0=Component, 1=SupplementalAttribute */
const char *name,
int64_t initial_ts_unix_ms, const char *resolution, /* ISO-8601 */
const char *element_type, /* e.g. "f64", "piecewise_linear" */
uint64_t ndims, const uint64_t *dims_ptr,
const uint8_t *data_ptr, uint64_t data_byte_len,
const char *application_data, /* optional */
const char *features_json, /* optional */
const char *units, /* optional */
const char *quantity_kind, /* optional */
const char *unit_system, /* optional: "natural_units" | "component_base" */
const char *time_reference, /* optional: "utc" | "zoneless" | "-07:00" | IANA name */
const char *component_field); /* optional: e.g. "max_active_power" */
/* All-or-nothing removal by catalog association id: an id naming no row returns
INFRASTORE_ERR_NOT_FOUND and removes nothing. A repeated id is removed, and
counted, once. `ids` may be null only when `n` is 0.
Set has_owner to hold every id to (owner_id, owner_category): the owner is read
and the row deleted by the same transaction, and a row belonging to anyone else
is INFRASTORE_ERR_OWNER_MISMATCH with the whole batch rolled back. */
int32_t infrastore_store_remove_by_ids(struct InfraStore *handle, const int64_t *ids,
uint64_t n,
bool has_owner, int64_t owner_id, int32_t owner_category,
uint64_t *out_removed);
/* Whether an association is filed under `id` -- a primary-key probe that fetches
no row, so a consumer can validate every reference in its own model on load
rather than discovering a dangling one mid-run. Never NOT_FOUND: a stale
reference is the answer, not an error. */
int32_t infrastore_store_association_exists(const struct InfraStore *handle,
int64_t association_id, bool *out_present);
NonSequentialTimeSeries
infrastore_batch_add_non_sequential takes an explicit int64_t Unix-millisecond timestamp array
alongside the typed data buffer. It is read like every other type — by id, through
infrastore_store_read_by_id and then infrastore_bulk_result_get_irregular, which returns owned
timestamp, shape, and raw-byte buffers (free with infrastore_buffer_free_i64,
infrastore_buffer_free_i64, and infrastore_buffer_free_u8) plus the dtype code and, in
out_element_type, the canonical element-type string. The shape is the full
[length, *element_shape] array shape (the first dim is time, so callers can recover an
N-dimensional per-step element shape). out_application_data is the optional opaque package-owned
payload, returned as an owned C string of its full length (NULL when unset; free with
infrastore_string_free). Earlier revisions copied it into a caller-sized buffer, which invited
silent truncation.
int32_t infrastore_batch_add_non_sequential(struct InfraStoreBatch *batch,
int64_t owner_id, const char *owner_type,
int32_t owner_category, const char *name,
const int64_t *timestamps_unix_ms, uint64_t timestamps_len,
const char *element_type,
uint64_t ndims, const uint64_t *dims_ptr,
const uint8_t *data_ptr, uint64_t data_byte_len,
const char *application_data, const char *features_json,
const char *units,
const char *quantity_kind, const char *unit_system,
const char *time_reference,
const char *component_field);
/* Reads a NonSequentialTimeSeries or PersistentTimeSeries slot; any other type is
INFRASTORE_ERR_INVALID_PARAMETER. */
int32_t infrastore_bulk_result_get_irregular(const struct InfraStoreBulkReadHandle *result, uint64_t index,
int64_t **out_timestamps, uint64_t *out_timestamps_len,
int32_t *out_dtype,
int64_t **out_shape, uint64_t *out_shape_len,
uint8_t **out_data, uint64_t *out_data_byte_len,
char **out_application_data, char **out_element_type,
char **out_units, char **out_quantity_kind,
char **out_unit_system, char **out_time_reference,
char **out_component_field);
PersistentTimeSeries
infrastore_batch_add_persistent takes exactly the argument list of
infrastore_batch_add_non_sequential above — the same int64_t Unix-millisecond vector, the same
owned buffers, the same ownership and free rules. The two types carry the same payload; timestamps
is the breakpoint vector, and what differs is what a read between those instants means.
int32_t infrastore_batch_add_persistent(struct InfraStoreBatch *batch, /* ...as add_non_sequential... */);
It is read like every other type — by id, through infrastore_store_read_by_id /
infrastore_store_read_by_ids, then infrastore_bulk_result_get_irregular on the slot whose
infrastore_bulk_result_item_type is 6.
The value at out_timestamps[i] is in force from that instant until the next breakpoint, and past
the last one forever. There is no value before the first breakpoint: a range read whose start
precedes it is refused with INFRASTORE_ERR_INVALID_PARAMETER rather than clamped, and a range that
starts mid-step begins at the breakpoint in force there, so the returned slice always defines a
value at the caller's start.
The ABI discriminant is 6, kept numerically equal to the storage code. See the
time-series types.
Attribute-Based Existence
An existence probe stays attribute-addressed: it is answered off the catalog indexes without hydrating a row, so routing it through an id lookup would cost more than the question. There is one of them, taking the filter record every other filter-taking export takes.
Set features_exact to compare features_json as the row's whole feature set — that is a single
content-hash comparison. The default subset match adds an indexed probe per requested feature.
Neither hydrates a row, but a hot loop testing a complete feature set wants the exact form.
/* True iff any association matches the filter. A NULL filter matches everything. */
int32_t infrastore_store_has_any_by_filter(const struct InfraStore *handle,
const struct InfraStoreFilter *filter,
bool *out_present);
int32_t infrastore_store_get_array_by_hash(const struct InfraStore *handle, const uint8_t *data_hash,
int32_t *out_dtype,
uint8_t **out_data, uint64_t *out_byte_len); /* infrastore_buffer_free_u8 */
/* Count SingleTimeSeries (*out_sts) and DeterministicSingleTimeSeries (*out_dst)
associations that reference the 32-byte content hash data_hash, across all
owners — one catalog query to decide whether removing a SingleTimeSeries would
orphan a DST that shares its backing array. */
int32_t infrastore_store_count_array_references(const struct InfraStore *handle, const uint8_t *data_hash,
uint64_t *out_sts, uint64_t *out_dst);
/* The whole metadata record for one association id, as a JSON object with the
shape of one infrastore_store_list_metadata element: owner_id, owner_type,
owner_category, time_series_type, name, data_hash (64-char hex),
initial_timestamp_ms, resolution, horizon, interval, count, length,
percentiles, element_type, element_shape, features, units, quantity_kind,
unit_system, time_reference, component_field, application_data, id -- fields
that do not apply to the row's type are null. One export covers every time
series type, static and forecast alike. Probe-then-fetch: call with
buf = NULL, cap = 0 to learn *out_len, then again with an out_len+1-byte
buffer. *out_present is false when the id names no row -- a stale reference is
an answer, not an error. */
int32_t infrastore_store_get_metadata_by_id(const struct InfraStore *handle,
int64_t association_id,
char *buf, uint64_t cap, uint64_t *out_len,
bool *out_present);
/* The same listing addressed by a set of ids, in the order given, as an owned
JSON array (free with infrastore_string_free). INFRASTORE_ERR_NOT_FOUND if any
id names no row: a caller naming ids is asserting they exist, and a silently
short array would let a stale reference pass as an absent match. */
int32_t infrastore_store_list_metadata_by_ids(const struct InfraStore *handle,
const int64_t *ids, uint64_t n,
char **out_json, uint64_t *out_len);
infrastore_store_list_metadata is the identify half: it answers which series exist and carries the
id that addresses each. infrastore_store_get_metadata_by_id +
infrastore_store_get_array_by_hash is the direct-array read path for a binding that maintains its
own object model, and infrastore_store_association_exists answers whether a reference it recorded
earlier still resolves without fetching the row. There is deliberately no attribute-to-id resolver:
a caller that wants exactly one row poses the filter and checks that it got one.
Forecasts
The forecast types are created and read through the C ABI. ts_type is the TimeSeriesType
discriminant — 0 = SingleTimeSeries, 1 = NonSequentialTimeSeries, 2 = Deterministic,
3 = DeterministicSingleTimeSeries, 4 = Probabilistic, 5 = Scenarios,
6 = PersistentTimeSeries. The ABI keeps its own mapping, deliberately identical to the storage
codes. As a filter it is read per Type filters below. Forecast values are
dtype-generic raw little-endian byte buffers with explicit dimensions — the same element_type,
ndims, dims_ptr, data_ptr, data_byte_len convention as the static batch adds (see the
data model for the conventional shapes); the store
records the windowing parameters in metadata and does not interpret the layout. A
DeterministicSingleTimeSeries (3) is read like any other forecast but cannot be written through
infrastore_batch_add_forecast — it is derived via infrastore_store_transform_single_time_series.
Forecast writes go through a batch like every other write. infrastore_batch_add_forecast accepts
only ts_type 2 = Deterministic or 5 = Scenarios; infrastore_batch_add_probabilistic adds the
percentile vector for Probabilistic. DeterministicSingleTimeSeries (3) is not addable
through infrastore_batch_add_forecast — it errors and directs you to
infrastore_store_transform_single_time_series, which derives a DeterministicSingleTimeSeries
from every stored SingleTimeSeries (sharing the backing array) and writes the number transformed
to *out_count:
int32_t infrastore_batch_add_forecast(struct InfraStoreBatch *batch,
int64_t owner_id, const char *owner_type, int32_t owner_category,
const char *name, int32_t ts_type,
int64_t initial_ts_unix_ms,
const char *resolution, const char *horizon, const char *interval, /* ISO-8601 */
uint64_t count,
const char *element_type,
uint64_t ndims, const uint64_t *dims_ptr,
const uint8_t *data_ptr, uint64_t data_byte_len,
const char *application_data, /* optional */
const char *features_json, const char *units,
const char *quantity_kind, /* optional */
const char *unit_system, /* optional: "natural_units" | "component_base" */
const char *time_reference, /* optional: "utc" | "zoneless" | "-07:00" | IANA name */
const char *component_field); /* optional: e.g. "max_active_power" */
int32_t infrastore_batch_add_probabilistic(struct InfraStoreBatch *batch,
int64_t owner_id, const char *owner_type,
int32_t owner_category, const char *name,
int64_t initial_ts_unix_ms,
const char *resolution, const char *horizon, const char *interval, /* ISO-8601 */
uint64_t count,
const double *percentiles_ptr, uint64_t percentiles_len,
const char *element_type,
uint64_t ndims, const uint64_t *dims_ptr,
const uint8_t *data_ptr, uint64_t data_byte_len,
const char *application_data, /* optional */
const char *features_json, const char *units,
const char *quantity_kind, /* optional */
const char *unit_system, /* optional: "natural_units" | "component_base" */
const char *time_reference, /* optional: "utc" | "zoneless" | "-07:00" | IANA name */
const char *component_field); /* optional: e.g. "max_active_power" */
int32_t infrastore_store_transform_single_time_series(struct InfraStore *handle,
const char *horizon, const char *interval, /* ISO-8601 */
int32_t owner_category, /* <0 = all categories; else 0=Component, 1=SupplementalAttribute */
const char *resolution, /* NULL = all resolutions */
bool normalize_single_window, /* store the zero interval for a single window */
bool require_uniform_forecast_grid, /* every resolution must agree on count + initial_timestamp */
bool dry_run, /* check only; legal on a read-only store */
uint64_t *out_count,
uint64_t *out_sources, /* optional (NULL skips): matched before idempotent skips */
char *out_interval, /* optional (NULL skips): ISO-8601, INTERVAL_BUF_LEN bytes */
bool *out_interval_normalized, /* optional (NULL skips) */
int64_t **out_ids); /* optional (NULL skips): infrastore_buffer_free_i64 */
Every writer takes an optional out_id, which receives the catalog id the row was filed under; pass
NULL to skip it. No writer takes an id as input: the catalog always assigns. The one entry point
that files rows under ids a caller supplies is
infrastore_store_import_time_series_associations_openapi, replaying a document that already
recorded them — see Association ids.
infrastore_store_transform_single_time_series reports the rest of its outcome through four
optional out-parameters, each skippable with NULL: out_sources is how many SingleTimeSeries
matched before idempotent skips, so zero distinguishes "nothing to transform" from "everything was
already derived"; out_interval receives the ISO-8601 interval actually stored, NUL-terminated (an
ISO period is bounded, so this takes a fixed INTERVAL_BUF_LEN-byte buffer rather than a probe
pass); out_interval_normalized is non-zero when the request described a single window; and
out_ids receives the id of each view written, *out_count of them in write order, to free with
infrastore_buffer_free_i64. When nothing was written — a dry run, or a re-run that finds every
view already derived — *out_ids is set to NULL. On a dry run *out_count still reports the
count a committing run would produce, so a caller must check the pointer rather than the count
before indexing it.
Its two policy flags are the TransformPolicy of the core API. Both false is the permissive
default; InfrastructureSystems.jl passes both true. normalize_single_window selects the
single-window encoding — non-zero stores the zero interval, which is what InfrastructureSystems.jl
looks up by, and zero stores the requested interval verbatim. require_uniform_forecast_grid
requires every resolution in scope, plus any forecast already stored at the same
(resolution, interval), to agree on the derived window count and initial_timestamp. dry_run
runs every check and reports what a committing run would produce without writing, and is legal
against a read-only store.
Forecasts are read like everything else: by id, through infrastore_store_read_by_id (or
infrastore_store_read_by_ids / ..._range) and then infrastore_bulk_result_get_forecast, which
returns the decoded data buffer, its out-dimensions, the window parameters, and — for
Probabilistic — the percentile vector. A stored DeterministicSingleTimeSeries is synthesized
into Deterministic values (its dense windows are materialized from the backing
SingleTimeSeries), but it remains a distinct stored type in the catalog.
Type filters
A read names only an id, so there is no requested type to disagree with what is stored — the
family-resolution rules that a ts_type argument used to carry now live entirely in the identify
half. infrastore_store_list_metadata's type filter reads one the same way:
2 = Deterministicmatches a storedDeterministicor a storedDeterministicSingleTimeSeries. A DST is a synthetic view that reads back as aDeterministic, so a caller selects a deterministic forecast without knowing which form the store holds. Each row still reports its own concretetime_series_type.3 = DeterministicSingleTimeSeriesnarrows to the derived form alone — for callers auditing which forecasts are synthetic rather than reading values.4 = Probabilisticand5 = Scenariosmatch only themselves.
The caller owns the returned buffers: free *out_data with infrastore_buffer_free_u8, *out_dims
with infrastore_buffer_free_u64, *out_percentiles (non-NULL only for Probabilistic) with
infrastore_buffer_free_f64, and each of the *out_resolution / *out_horizon / *out_interval
ISO-8601 strings with infrastore_string_free.
Forecast metadata is read with the same infrastore_store_get_metadata_by_id as everything else.
The returned row carries the windowing parameters (horizon, interval, count), the content
hash, and the percentiles of a Probabilistic, without decoding the array.
infrastore_store_copy_time_series copies one association onto another owner (optionally under a
new name). Arrays are content-addressed, so only a new association row is written — no array data is
duplicated, and the stored type is preserved (a DeterministicSingleTimeSeries stays one rather
than being materialized into a dense Deterministic). The copy keeps the source's owner category.
The leading owner_id / owner_category / name / ts_type / resolution / interval /
features_json arguments identify the source series; a NULL new_name keeps the source name.
int32_t infrastore_store_copy_time_series(struct InfraStore *handle,
/* source series: */
int64_t owner_id, int32_t owner_category, const char *name,
int32_t ts_type,
const char *resolution, const char *interval, /* ISO-8601; NULL = unset */
const char *features_json,
/* destination: */
int64_t dst_owner_id, const char *dst_owner_type,
const char *new_name); /* NULL = keep the source name */
Grid Arithmetic
Stateless entry points into the core's own period arithmetic, for a binding that holds a series as a native struct and would otherwise reimplement the grid.
int32_t infrastore_grid_timestamps(int64_t initial_unix_ms,
const char *resolution_iso,
uint64_t length,
int64_t *buf,
uint64_t cap,
uint64_t *out_len);
int32_t infrastore_infer_period(const int64_t *timestamps_unix_ms,
uint64_t len,
char **out_iso);
infrastore_grid_timestamps materializes initial + k · resolution for k in [0, length),
probe-then-fetch like infrastore_static_reader_timestamps (call with buf null and cap 0 to
learn the length). It is calendar-aware for a P1M/P1Y resolution, which steps the UTC
calendar — the reference a series records is a spelling, not a grid.
infrastore_infer_period is the inverse: the ISO-8601 period that reproduces a timeline exactly, or
INFRASTORE_ERR_INVALID_PARAMETER with a message naming the entry that breaks the pattern and
NonSequentialTimeSeries as the remedy. out_iso is an owned string the caller frees with
infrastore_string_free. A local-clock daily or monthly grid in a DST zone is refused here, which
is what lets a caller hand over the timeline they have instead of asserting a resolution the store
cannot check.
There is exactly one implementation of "which instants does this series contain" in the project, and
it is the core's Period::add_to. These two functions are how a binding reaches it.
Readers
The per-timestamp read path is exposed as two opaque reader handles — InfraStoreStaticReaderHandle
for SingleTimeSeries and InfraStoreForecastReaderHandle for forecasts. A reader is built once
over a filter (the same has_owner / owner_id / has_owner_category / owner_category / name
/ resolution / features_json convention as the attribute-based access, with a forecast reader
also taking a ts_type), then driven per timestamp. The lifecycle is: build → read the layout
once → *_read in a loop → fetch values per group/entry → free. Each reader pins one resolution
and owns reusable buffers that each read overwrites in place.
Ownership rules: the *_grid / *_timeline resolution/interval out-strings (char **) are owned —
free each with infrastore_string_free. *_group_id / *_entry_id write a plain int64_t catalog
id, so nothing is allocated and nothing needs freeing — resolve one with
infrastore_store_get_metadata_by_id to recover the series a column or entry came from. The
*_values buffers (const uint8_t **) are borrowed: they point into reader memory, stay valid
only until the next read or *_free, and must not be freed. Group/entry shapes follow the
probe-then-fetch convention — call *_info with shape_buf = NULL / shape_cap = 0 to learn
*out_shape_len, then again with a buffer of that length. *_read errors (never clamps) if
at_unix_ms is off the reader's grid/timeline.
StaticReader
Reads every matching static series at one timestamp, partitioned into (dtype, element_shape)
groups; each group's values are one dense [num_columns, *element_shape] little-endian buffer whose
column j is the key from infrastore_static_reader_group_key(reader, group_idx, j, …).
int32_t infrastore_store_build_static_reader(const struct InfraStore *handle,
int32_t time_series_type,
const struct InfraStoreFilter *filter,
bool has_window_start, int64_t window_start_ms,
bool window_start_zoneless,
bool has_window_length, uint64_t window_length,
struct InfraStoreStaticReaderHandle **out_reader);
int32_t infrastore_static_reader_grid(const struct InfraStoreStaticReaderHandle *reader,
int64_t *out_initial_ms, char **out_resolution, /* null, or free with infrastore_string_free */
uint64_t *out_length);
int32_t infrastore_static_reader_time_reference(const struct InfraStoreStaticReaderHandle *reader,
char **out_time_reference); /* null, or free with infrastore_string_free */
int32_t infrastore_static_reader_timestamps(const struct InfraStoreStaticReaderHandle *reader,
int64_t *buf, uint64_t cap, uint64_t *out_len); /* probe with buf=NULL, cap=0 */
int32_t infrastore_static_reader_num_groups(const struct InfraStoreStaticReaderHandle *reader, uint64_t *out_n);
int32_t infrastore_static_reader_group_info(const struct InfraStoreStaticReaderHandle *reader, uint64_t group_idx,
int32_t *out_dtype, uint64_t *out_num_columns,
int64_t *shape_buf, uint64_t shape_cap, uint64_t *out_shape_len);
int32_t infrastore_static_reader_group_id(const struct InfraStoreStaticReaderHandle *reader,
uint64_t group_idx, uint64_t col_idx,
int64_t *out_id); /* the column's catalog id */
int32_t infrastore_static_reader_read(struct InfraStoreStaticReaderHandle *reader,
const struct InfraStore *store, int64_t at_unix_ms);
int32_t infrastore_static_reader_invalidate(struct InfraStoreStaticReaderHandle *reader);
/* empty every group; for a caller refusing a read of its own */
int32_t infrastore_static_reader_group_values(const struct InfraStoreStaticReaderHandle *reader, uint64_t group_idx,
const uint8_t **out_ptr, /* borrowed; valid until next read/free */
uint64_t *out_byte_len);
void infrastore_static_reader_free(struct InfraStoreStaticReaderHandle *reader);
has_window_start lifts the shared-grid requirement for SingleTimeSeries: the reader then sweeps
the window window_start_ms (plus window_length steps, or as far as every matched series
reaches when has_window_length is false) rather than the grid the series happen to share, and each
column reads at an offset of its own. It is checked, never clamped — a matched series that does not
cover the window is INFRASTORE_ERR_INVALID_PARAMETER with a message naming it, the anchor must
fall on each series' own step boundaries, and a calendar resolution is refused where re-anchoring
would move the dates. window_start_zoneless carries how the caller spelled the anchor, the same
convention as the ranged reads' bounds: the wire form is Unix milliseconds either way, and the flag
is what tells a wall clock from an instant. The window belongs to SingleTimeSeries alone, and
has_window_length without has_window_start is an error.
The filter record's has_initial_timestamp / has_length are something else: the grid filter,
which every filter-taking export carries. They match only the series already on that grid, so the
ones that are not never become columns — where a window sweeps a named span across whatever matched.
Use the window when the ragged series should all take part, the filter when they should not; they
compose. The filter carries no spelling flag because it selects rather than reads: a grid no row is
on is an empty result, not an error.
time_series_type is the reader's own argument rather than the filter's — a reader is built for one
type, so a record that also sets has_time_series_type must name the same type or the build is
INFRASTORE_ERR_INVALID_PARAMETER — and picks the three shapes a reader can take. The rest of the
record is infrastore_store_list_metadata's in full; a static series stores no interval, so a
filter that sets one matches nothing on a static reader. For SingleTimeSeries (0), the filter's
resolution must be a non-empty ISO-8601 period — one resolution per reader — and all matched
series must share one grid (initial_timestamp + length). For NonSequentialTimeSeries (1),
resolution must be null (an irregular series has none) and all matched series must instead
share one timestamp vector; infrastore_static_reader_grid then reports *out_resolution as null,
and infrastore_static_reader_timestamps is how the timeline is read. For PersistentTimeSeries
(6), resolution must likewise be null — but this is the one case whose columns need not
share a timeline: a step function has a value at every instant from its first breakpoint on, so each
column carries its values forward on breakpoints of its own, and the reader's timeline is the union
of them all. Reading at an instant before some column's first breakpoint is an error naming that
column. Any other discriminant is rejected.
Uniformity — where it is required — is validated at build and errors on divergence, so in every case each column has a value at every valid timestamp (no presence mask).
infrastore_static_reader_time_reference reports the one spelling the axis carries — "utc",
"zoneless", a fixed offset such as "-07:00", or an IANA zone name — as an owned string the
caller frees with infrastore_string_free. It is null when the cohort records no spelling,
which is not the same claim as "zoneless": the second says the timestamps are wall clocks. A
cohort whose columns agree reports their reference; one whose columns merely agree on naming
instants reports "utc"; one mixing zoneless with the rest never builds at all. Reading the axis
without it leaves a C consumer unable to tell a wall-clock timeline from an unspecified or a UTC
one. infrastore_forecast_reader_time_reference is the window-timeline counterpart, on the same
terms.
ForecastReader
Reads the forecast window at one timestamp for every matching forecast of one type. The build
ts_type names the forecast type; a Deterministic reader (2) also includes
DeterministicSingleTimeSeries (3), read into identical [H, *E] windows — the same rule
infrastore_store_list_metadata's type filter applies. All matched forecasts must share one window
timeline (initial_timestamp + interval + count). Each entry's window is a little-endian buffer
of its *_entry_info shape.
int32_t infrastore_store_build_forecast_reader(const struct InfraStore *handle,
int32_t time_series_type,
const struct InfraStoreFilter *filter,
struct InfraStoreForecastReaderHandle **out_reader);
int32_t infrastore_forecast_reader_timeline(const struct InfraStoreForecastReaderHandle *reader,
int64_t *out_initial_ms,
char **out_resolution, char **out_interval, /* free each with infrastore_string_free */
uint64_t *out_count);
int32_t infrastore_forecast_reader_time_reference(const struct InfraStoreForecastReaderHandle *reader,
char **out_time_reference); /* null, or free with infrastore_string_free */
int32_t infrastore_forecast_reader_num_entries(const struct InfraStoreForecastReaderHandle *reader, uint64_t *out_n);
int32_t infrastore_forecast_reader_num_slots(const struct InfraStoreForecastReaderHandle *reader, uint64_t *out_n);
int32_t infrastore_forecast_reader_entry_slot(const struct InfraStoreForecastReaderHandle *reader,
uint64_t entry_idx, uint64_t *out_slot);
int32_t infrastore_forecast_reader_entry_info(const struct InfraStoreForecastReaderHandle *reader, uint64_t entry_idx,
int32_t *out_dtype,
int64_t *shape_buf, uint64_t shape_cap, uint64_t *out_shape_len);
int32_t infrastore_forecast_reader_entry_id(const struct InfraStoreForecastReaderHandle *reader,
uint64_t entry_idx, int64_t *out_id); /* the entry's catalog id */
int32_t infrastore_forecast_reader_read(struct InfraStoreForecastReaderHandle *reader,
const struct InfraStore *store, int64_t at_unix_ms);
int32_t infrastore_forecast_reader_invalidate(struct InfraStoreForecastReaderHandle *reader);
/* empty every entry's window */
int32_t infrastore_forecast_reader_entry_values(const struct InfraStoreForecastReaderHandle *reader, uint64_t entry_idx,
const uint8_t **out_ptr, /* borrowed; valid until next read/free */
uint64_t *out_byte_len);
void infrastore_forecast_reader_free(struct InfraStoreForecastReaderHandle *reader);
A read is all-or-nothing. A read spans every group (or entry), so a failing
infrastore_static_reader_read / infrastore_forecast_reader_read leaves the reader wholly empty —
..._group_values / ..._entry_values hand back a zero-length buffer — rather than some groups
holding the new timestamp's values and the rest the previous read's. A binding that refuses a read
before calling in (the Julia wrapper checks the bound's spelling against the reader's timeline,
which at_unix_ms cannot carry) calls ..._invalidate to leave the same empty reader behind.
Window-read deduplication. Forecasts that reference the same backing array and read plan
(deduplicated identical data, or several DeterministicSingleTimeSeries over one
SingleTimeSeries) collapse to a single window slot. infrastore_forecast_reader_read performs
one backend read per slot, not per entry, so a forecast shared by N owners is read once per
timestamp. infrastore_forecast_reader_num_slots is that physical read count, and
infrastore_forecast_reader_entry_slot gives the 0-based slot backing each entry (entries that
share data report the same slot) — group entries by slot to also decode each unique window only
once.
Batch Writes
A batch is the only write path on this ABI. There are no per-type store-level add functions: a single add is a one-item batch, which costs one extra call and keeps a second, near-identical set of entry points off the surface.
A batch accumulates add requests client-side (no store I/O); infrastore_store_add_batch commits
them all in one metadata transaction, which is much faster than per-item adds when ingesting
many series. It is also the fast HDF5 write path: same-shaped SingleTimeSeries are packed into
batch-sized datasets so the timestamp-major chunks are filled whole rather than a column at a time.
Data buffers are copied into the batch, so they only need to stay valid for the
infrastore_batch_add_* call. The submit is all-or-nothing and drains the batch in either case (on
error nothing was committed and the batch is left empty). On success *out_ids holds one id per
item, in batch order; the caller owns that array and frees it with
infrastore_buffer_free_i64(*out_ids, *out_len). The batch handle itself is reusable after submit
and must eventually be released with infrastore_batch_free.
struct InfraStoreBatch *infrastore_batch_new(void);
void infrastore_batch_free(struct InfraStoreBatch *batch);
int32_t infrastore_batch_add_single(struct InfraStoreBatch *batch, ...);
int32_t infrastore_batch_add_non_sequential(struct InfraStoreBatch *batch, ...);
int32_t infrastore_batch_add_persistent(struct InfraStoreBatch *batch, ...);
int32_t infrastore_batch_add_forecast(struct InfraStoreBatch *batch, ...); /* 2=Deterministic, 5=Scenarios */
int32_t infrastore_batch_add_probabilistic(struct InfraStoreBatch *batch, ...);
int32_t infrastore_store_add_batch(struct InfraStore *handle, struct InfraStoreBatch *batch,
uint64_t *out_len, int64_t **out_ids); /* infrastore_buffer_free_i64 */
Bulk Reads
infrastore_store_read_by_ids reads many series in one call, reading each packed dataset's column
span once instead of re-reading every chunk per series — the efficient way to load many whole series
(e.g. for exploration or plotting), where a single full-series read otherwise touches every chunk
under the timestamp-major layout. infrastore_store_read_by_id is the single-id form, which also
takes a window; infrastore_store_read_by_ids_range is the bounds form, which clips.
Every read returns its results in an InfraStoreBulkRead handle — a single read holds exactly one
item — in the order the ids were given, repeats included. Elements are read out with
infrastore_bulk_result_get_single / ..._get_irregular / ..._get_forecast, chosen by the type
infrastore_bulk_result_item_type reports. The caller owns the returned strings and buffers and
frees them with infrastore_string_free, infrastore_buffer_free_i64, and
infrastore_buffer_free_u8. The handle is not consumed by a read (elements may be read more than
once) and must be released with infrastore_bulk_result_free.
int32_t infrastore_store_read_by_ids(const struct InfraStore *handle,
const int64_t *ids, uint64_t n,
struct InfraStoreBulkRead **out_result);
int32_t infrastore_store_read_by_ids_range(const struct InfraStore *handle,
const int64_t *ids, uint64_t n,
bool zoneless, int64_t start_ms, int64_t end_ms,
struct InfraStoreBulkRead **out_result);
int32_t infrastore_bulk_result_item_type(const struct InfraStoreBulkRead *result, uint64_t index,
int32_t *out_type);
int32_t infrastore_bulk_result_get_single(const struct InfraStoreBulkRead *result, uint64_t index, ...);
void infrastore_bulk_result_free(struct InfraStoreBulkRead *result);
An id naming no row fails the whole call with INFRASTORE_ERR_NOT_FOUND rather than being skipped.
The handle carries each item's name, and infrastore_bulk_result_item_name hands item index's
back as an owned C string, freed with infrastore_string_free — the companion to
infrastore_bulk_result_item_type, and how a caller labels what it decodes.
infrastore_store_remove_by_ids is the removal direction of the same reference, listed with the
other removals above: one all-or-nothing transaction, the count through out_removed, and
INFRASTORE_ERR_NOT_FOUND if any id names no row.
Both id-addressed calls carry an optional owner guard — has_owner beside owner_id and
owner_category, ignored when the flag is false — that holds the addressed row to one owner and
returns INFRASTORE_ERR_OWNER_MISMATCH when it belongs to another. A consumer that addresses a
series by id but means "this owner's series" has to use it rather than confirming the owner in a
call of its own: an id survives a reassignment, so a separate check has a window after it in which
the row can move, and a removal issued after that window retires the new owner's series. The guard
puts the check and the act in one transaction, and costs the read nothing at all — the owner comes
off the same row the values are materialized from.
infrastore_store_read_by_id also takes a window. Each optional argument is a *_present flag
beside its value; with none present it reads the whole series. start_ms is Unix milliseconds,
spelled zoned or zoneless by start_zoneless like every other bound; len counts timesteps (the
static types) and count counts windows (the forecasts), and supplying the one that does not apply
is INFRASTORE_ERR_INVALID_PARAMETER rather than an argument the store drops. So is a start off the
series' own grid or an extent running past its end — a window is checked where
infrastore_store_read_by_ids_range clips.
int32_t infrastore_store_read_by_ids(const struct InfraStore *handle, const int64_t *ids,
uint64_t n, struct InfraStoreBulkRead **out_result);
int32_t infrastore_store_read_by_id(const struct InfraStore *handle, int64_t id,
bool start_present, bool start_zoneless, int64_t start_ms,
bool len_present, uint64_t len,
bool count_present, uint64_t count,
bool has_owner, int64_t owner_id, int32_t owner_category,
struct InfraStoreBulkRead **out_result);
int32_t infrastore_bulk_result_item_name(const struct InfraStoreBulkRead *result,
uint64_t index, char **out_name);
Store-Wide Operations
/* True iff the store holds nothing at all — no time series, and no associations
in any catalog. One short-circuited existence probe per catalog table, so it is
O(1) in store size and stays correct as the catalog gains tables; prefer it to
a caller-side conjunction over the counting entry points below, which is
neither. */
int32_t infrastore_store_is_empty(const struct InfraStore *handle, bool *out);
int32_t infrastore_store_counts(const struct InfraStore *handle, int64_t *out_components_with_time_series,
int64_t *out_static_time_series, int64_t *out_forecasts);
/* Association count per type as a JSON array of {time_series_type, count};
probe-then-fetch. */
int32_t infrastore_store_counts_by_type(const struct InfraStore *handle,
char *buf, uint64_t cap, uint64_t *out_len);
/* Distinct stored arrays (content hashes); shared arrays count once. */
int32_t infrastore_store_num_distinct_arrays(const struct InfraStore *handle, int64_t *out_count);
/* Grouped static / forecast summaries as JSON arrays (one object per group with a
`count` field); probe-then-fetch. */
int32_t infrastore_store_static_summary(const struct InfraStore *handle,
char *buf, uint64_t cap, uint64_t *out_len);
int32_t infrastore_store_forecast_summary(const struct InfraStore *handle,
char *buf, uint64_t cap, uint64_t *out_len);
/* Distinct owners per category + distinct arrays per kind (static/forecast). */
int32_t infrastore_store_counts_detailed(const struct InfraStore *handle, int64_t *out_components,
int64_t *out_supplemental_attributes,
int64_t *out_static_time_series, int64_t *out_forecasts);
/* Distinct owner ids of owner_category as a JSON array; optional type/resolution
filters (NULL resolution = none). Probe-then-fetch. */
int32_t infrastore_store_list_owner_ids(const struct InfraStore *handle, int32_t owner_category,
bool has_time_series_type, int32_t time_series_type,
const char *resolution, char *buf, uint64_t cap, uint64_t *out_len);
/* out_present = false when no matching forecast. The out_horizon/out_interval/
out_resolution ISO-8601 strings are NULL when absent (free with infrastore_string_free);
out_count and out_initial_ms are -1 when absent. filter_* NULL = no filter. */
int32_t infrastore_store_get_forecast_parameters(const struct InfraStore *handle,
const char *filter_resolution, const char *filter_interval,
bool *out_present, char **out_horizon,
char **out_interval, int64_t *out_count,
char **out_resolution, int64_t *out_initial_ms);
/* Per-resolution static grids as a JSON array of {"resolution","initial_timestamp_ms",
"length"} objects, ordered by resolution (empty array when no SingleTimeSeries);
error when the series at one resolution disagree. filter_resolution NULL = every
resolution, else scope to that ISO-8601 grid. Probe-then-fetch (buf=NULL, cap=0 to size). */
int32_t infrastore_store_check_static_consistency(const struct InfraStore *handle,
const char *filter_resolution, char *buf,
uint64_t cap, uint64_t *out_len);
/* Distinct resolutions as a JSON array of ISO-8601 duration strings, ascending;
optional type filter. Probe-then-fetch (buf=NULL, cap=0 to size). */
int32_t infrastore_store_get_resolutions(const struct InfraStore *handle,
bool has_time_series_type, int32_t time_series_type,
char *buf, uint64_t cap, uint64_t *out_len);
/* out_kind: 0 = none, 1 = DEFLATE (out_level 0-9 + out_shuffle). */
int32_t infrastore_store_get_compression(const struct InfraStore *handle, uint8_t *out_kind,
uint8_t *out_level, bool *out_shuffle);
int32_t infrastore_store_verify(const struct InfraStore *handle, uint64_t *out_error_count);
/* Compacts and returns the report as a JSON object {slots_reclaimed, datasets_dropped,
feature_sets_reclaimed, timestamp_sets_reclaimed, bytes_reclaimed}. Owned allocation:
free with infrastore_string_free. On an on-disk store this rewrites the .h5 file and
replaces it, so the call must run exactly once (no probe-then-fetch). */
int32_t infrastore_store_compact(struct InfraStore *handle, char **out_json, uint64_t *out_len);
int32_t infrastore_store_flush(struct InfraStore *handle);
/* Cross-operation transactions. Adds, removals, and transforms between a begin and
its matching commit either all take effect or none do; removals are reversible
only inside one. Calls nest -- only the outermost commit is durable. This is
store state, not a borrowed guard, so nothing has to survive the ABI boundary.
Holds the SQLite write lock until the outermost commit/rollback. */
int32_t infrastore_store_begin_transaction(struct InfraStore *handle);
int32_t infrastore_store_commit_transaction(struct InfraStore *handle);
int32_t infrastore_store_rollback_transaction(struct InfraStore *handle);
int32_t infrastore_store_in_transaction(struct InfraStore *handle, bool *out);
/* The byte budget an open transaction's buffered adds are held to, and through it how wide a
dataset a run of single adds writes. Raised, that run produces the dataset the equivalent
bulk add produces -- a batch handed over as a list applies no budget, the caller already
holding it. The figure belongs to the handle, not the artifact: nothing is persisted.
`bytes` must be non-zero; lowering it under an open transaction writes out whatever the
buffer already holds beyond the new figure, so that call can do file I/O and fail. */
int32_t infrastore_store_write_buffer_bytes(const struct InfraStore *handle, uint64_t *out);
int32_t infrastore_store_set_write_buffer_bytes(struct InfraStore *handle, uint64_t bytes);
/* Persist the store's data to `path` (HDF5) and `<path>.sqlite` (metadata),
materializing an in-memory store to disk. Existing target files are overwritten. */
int32_t infrastore_store_persist(struct InfraStore *handle, const char *path);
/* Write an in-memory catalog to this store's own <path>.sqlite, stamped to match the HDF5 file
already beside it. Unlike infrastore_store_persist, writes no arrays: they are already in
place. A checkpoint, not a mode switch. For catalog_mode=0 this is infrastore_store_flush. */
int32_t infrastore_store_persist_catalog(struct InfraStore *handle);
/* has_owner=false clears all; when true, the owner is the pair (owner_id, owner_category). */
int32_t infrastore_store_clear(struct InfraStore *handle, bool has_owner, int64_t owner_id,
int32_t owner_category); /* owner_category: 0=Component, 1=SupplementalAttribute */
/* Reassign every time series owned by old_owner_id (in owner_category) to
new_owner_id; *out_updated (when non-NULL) receives the number of associations
changed. */
int32_t infrastore_store_replace_owner(struct InfraStore *handle,
int64_t old_owner_id, int64_t new_owner_id,
int32_t owner_category, uint64_t *out_updated);
/* List catalog metadata rows as a JSON array (identity, the per-type descriptive
snapshot, the physical detail, and the association `id` the write handed back
— the address every read and removal takes). A row carries no
timestamp vector: an irregular series' time axis is the one part of a row that
costs a read per row, so a listing omits it and a caller that needs it reads
the series. See the filter record below; a NULL filter lists the whole store.
The JSON comes back as an *owned* allocation through out_json (free with
infrastore_string_free), with out_len its byte length — NOT probe-then-fetch,
because a listing's size scales with the catalog and probing would run the
query and serialize the rows twice. */
int32_t infrastore_store_list_metadata(const struct InfraStore *handle,
const struct InfraStoreFilter *filter,
char **out_json, uint64_t *out_len);
The filter record
Every filter-taking export — infrastore_store_list_metadata, list_names, list_owner_types,
has_any_by_filter, remove_by_filter, export_time_series_associations_openapi, and both reader
builders — takes the catalog filter as one const struct InfraStoreFilter * rather than as
positional arguments.
A NULL pointer, and equally an all-zero record, is the empty filter: it matches everything. So a
caller writes InfraStoreFilter f = {0}; and sets only the fields it cares about. The predicates
are independent and ANDed. The record borrows its strings — they must outlive the call, and nothing
in it is freed.
The layout is part of the ABI. The record buys one filter parser and no churn across the eight
call sites when a predicate is added; it does not make the ABI forward-compatible. A caller compiled
against an older header allocates the older sizeof(InfraStoreFilter), so appending a field is a
breaking change like any other — rebuild against the regenerated header.
typedef struct InfraStoreFilter {
bool has_owner_id; int64_t owner_id;
bool has_owner_category; int32_t owner_category; /* 0=Component, 1=SupplementalAttribute */
bool has_time_series_type; int32_t time_series_type;
const char *name; /* NULL = unset */
const char *name_glob; /* SQLite GLOB over the name (`*`/`?`, case-sensitive), ANDed
with `name` rather than replacing it */
const char *resolution; /* ISO-8601 */
const char *interval; /* ISO-8601; matches forecasts only — static rows carry none */
const char *features_json; /* JSON object; int, float, bool or string values */
bool features_exact; /* compare `features_json` as the row's whole feature set
(content hash) rather than a subset it must contain;
with `features_json` NULL, only feature-less rows match */
const char *component_field; /* exact, case-sensitive; a row declaring none matches no value */
bool has_zoneless; bool zoneless; /* which timestamp-spelling group */
bool has_initial_timestamp; int64_t initial_timestamp_ms;
bool has_length; uint64_t length;
} InfraStoreFilter;
Every row carries the hex data_hash of the array it resolves to, and rows that share a stored
array share it — so grouping a listing by data_hash discovers which time series share their
underlying data, in one query.
Associations
Two catalogs of relationships between entities the store does not otherwise model. Both are independent of time series: there are no foreign keys and no cascade (both endpoints live in the caller's object graph, so a cascade could never fire), so removing a time series never removes an association and vice versa; a caller that wants both makes both calls.
Each family's query predicate crosses the boundary as one JSON object string rather than a set of positional arguments, because half its fields are string lists. Every field is optional and the set ones are ANDed:
{ "component_id": 1, "component_types": ["Generator", "Load"],
"attribute_id": 100, "attribute_types": ["GeographicInfo"] }
{ "parent_id": 1, "parent_types": ["Generator"],
"child_id": 7, "child_types": ["Bus"] }
A NULL (or empty) filter_json is the empty filter and matches every row — which is what
makes a bulk export/import round trip one call each way. The *_types lists hold concrete type
names, rendered as SQL IN (…); expanding an abstract type into its subtypes stays in the calling
language, and an empty list matches nothing. An unknown field or malformed JSON is
INFRASTORE_ERR_INVALID_PARAMETER. Every remove function reports its count through out_removed
and treats removing nothing as success, not an error.
The list functions use the probe-then-fetch convention: call with buf = NULL, cap = 0 to learn the
length via out_len, then again with a len + 1-byte buffer. (infrastore_store_list_metadata,
infrastore_store_list_metadata_by_ids, and
infrastore_store_list_supplemental_attribute_associations are the exceptions in this header — they
return an owned string instead, because a no-filter call exports the whole catalog or table.)
Supplemental-attribute associations
Which supplemental attributes are attached to which components. Identity is the
(component_id, attribute_id) pair; the type names are denormalized labels, so re-attaching the
same pair under different type names is still a duplicate. One attribute may be attached to many
components.
/* Attach supplemental attribute (attribute_id, attribute_type) to component
(component_id, component_type). INFRASTORE_ERR_DUPLICATE_ASSOCIATION if that component
already carries that attribute, whatever type names are supplied. */
int32_t infrastore_store_add_supplemental_attribute_association(struct InfraStore *handle,
int64_t component_id,
const char *component_type,
int64_t attribute_id,
const char *attribute_type);
/* Attach many in one all-or-nothing transaction, from a JSON array of objects with
component_id, component_type, attribute_id, and attribute_type. The import half
of the round trip whose export is infrastore_store_list_supplemental_attribute_associations
with a NULL filter. *out_added (when non-NULL) receives the number inserted. */
int32_t infrastore_store_add_supplemental_attribute_associations(struct InfraStore *handle,
const char *associations_json,
uint64_t *out_added, /* optional */
int64_t **out_ids); /* optional; owned,
infrastore_buffer_free_i64 */
/* Whether any attachment matches filter_json (NULL = any). */
int32_t infrastore_store_has_supplemental_attribute_association(const struct InfraStore *handle,
const char *filter_json,
bool *out_found);
/* Matching attachments as a JSON array, in insertion order; each object carries
component_id, component_type, attribute_id, attribute_type. Returns the JSON
through out_json as an OWNED allocation, freed with infrastore_string_free
(a no-filter call exports the whole table, so this follows the owned-string
convention rather than probe-then-fetch). */
int32_t infrastore_store_list_supplemental_attribute_associations(const struct InfraStore *handle,
const char *filter_json,
char **out_json, uint64_t *out_len);
/* Distinct attribute ids of the matching rows, ascending, as a JSON array — the
attributes attached to a component when component_id is set. Probe-then-fetch. */
int32_t infrastore_store_list_supplemental_attribute_ids(const struct InfraStore *handle,
const char *filter_json,
char *buf, uint64_t cap,
uint64_t *out_len);
/* Distinct component ids of the matching rows, ascending, as a JSON array — the
components carrying an attribute when attribute_id is set. Probe-then-fetch. */
int32_t infrastore_store_list_components_with_attributes(const struct InfraStore *handle,
const char *filter_json,
char *buf, uint64_t cap,
uint64_t *out_len);
/* Remove every matching attachment; *out_removed (when non-NULL) receives the
count. Removing nothing is success, not an error. */
int32_t infrastore_store_remove_supplemental_attribute_associations(struct InfraStore *handle,
const char *filter_json,
uint64_t *out_removed);
/* Move every attachment from component old_id to new_id; *out_updated (when
non-NULL) receives the rows changed. INFRASTORE_ERR_DUPLICATE_ASSOCIATION if new_id
already carries one of the attributes being moved. */
int32_t infrastore_store_replace_supplemental_attribute_component_id(struct InfraStore *handle,
int64_t old_id, int64_t new_id,
uint64_t *out_updated);
/* Attachment counts through out_count. kind selects what is counted:
0 = rows matching the filter, 1 = distinct attributes among them,
2 = distinct components among them. */
int32_t infrastore_store_count_supplemental_attribute_associations(const struct InfraStore *handle,
const char *filter_json,
int32_t kind, int64_t *out_count);
/* Grouped counts as JSON arrays: by attribute type, as {"type": …, "count": …}
ordered by type; and by both type names, as
{"component_type": …, "attribute_type": …, "count": …} ordered by attribute type
then component type. Probe-then-fetch. */
int32_t infrastore_store_supplemental_attribute_counts_by_type(const struct InfraStore *handle,
char *buf, uint64_t cap,
uint64_t *out_len);
int32_t infrastore_store_supplemental_attribute_summary(const struct InfraStore *handle,
char *buf, uint64_t cap,
uint64_t *out_len);
/* The attribute ids attached to component 1. */
const char *filter = "{\"component_id\":1}";
uint64_t len = 0;
infrastore_store_list_supplemental_attribute_ids(store, filter, NULL, 0, &len);
char *json = malloc(len + 1);
infrastore_store_list_supplemental_attribute_ids(store, filter, json, len + 1, &len); /* e.g. "[100]" */
free(json);
Parent/child associations
Directed edges between components — a generator (parent) connected to a bus (child), say. Both
endpoints are always components. Identity is the ordered (parent_id, child_id) pair, so the
reversed pair is a different edge, and with no relationship-kind column one ordered pair may be
related at most once.
This family is deliberately narrower than the supplemental one — no counts-by-type and no grouped summary — because there is no consumer for them yet; both are additive if one appears.
/* Record a directed edge from component (parent_id, parent_type) to component
(child_id, child_type). INFRASTORE_ERR_DUPLICATE_ASSOCIATION if that ordered pair is
already related; the reversed pair is a different edge. */
int32_t infrastore_store_add_parent_child_association(struct InfraStore *handle,
int64_t parent_id, const char *parent_type,
int64_t child_id, const char *child_type);
/* Record many edges in one all-or-nothing transaction, from a JSON array of objects
with parent_id, parent_type, child_id, and child_type. *out_added (when non-NULL)
receives the number inserted. */
int32_t infrastore_store_add_parent_child_associations(struct InfraStore *handle,
const char *associations_json,
uint64_t *out_added, /* optional */
int64_t **out_ids); /* optional; owned,
infrastore_buffer_free_i64 */
/* Whether any edge matches filter_json (NULL = any). */
int32_t infrastore_store_has_parent_child_association(const struct InfraStore *handle,
const char *filter_json,
bool *out_found);
/* Matching edges as a JSON array, in insertion order; each object carries
parent_id, parent_type, child_id, child_type. Probe-then-fetch. */
int32_t infrastore_store_list_parent_child_associations(const struct InfraStore *handle,
const char *filter_json,
char *buf, uint64_t cap,
uint64_t *out_len);
/* Distinct ids on one end of the matching edges, ascending, as a JSON array.
endpoint is 0 for parents and 1 for children — so endpoint = 1 with parent_id
set is "the children of this component". Probe-then-fetch. */
int32_t infrastore_store_list_parent_child_ids(const struct InfraStore *handle,
const char *filter_json, int32_t endpoint,
char *buf, uint64_t cap, uint64_t *out_len);
/* Remove every matching edge; *out_removed (when non-NULL) receives the count.
Removing nothing is success, not an error. */
int32_t infrastore_store_remove_parent_child_associations(struct InfraStore *handle,
const char *filter_json,
uint64_t *out_removed);
/* Rewrite component old_id to new_id on BOTH ends of every edge; *out_updated
(when non-NULL) receives the rows changed. INFRASTORE_ERR_DUPLICATE_ASSOCIATION if the
rewrite would duplicate an edge new_id already has. */
int32_t infrastore_store_replace_parent_child_component_id(struct InfraStore *handle,
int64_t old_id, int64_t new_id,
uint64_t *out_updated);
/* Number of edges matching filter_json, through out_count. */
int32_t infrastore_store_count_parent_child_associations(const struct InfraStore *handle,
const char *filter_json,
int64_t *out_count);
/* The children of component 1. */
const char *filter = "{\"parent_id\":1}";
uint64_t len = 0;
infrastore_store_list_parent_child_ids(store, filter, 1 /* children */, NULL, 0, &len);
char *json = malloc(len + 1);
infrastore_store_list_parent_child_ids(store, filter, 1, json, len + 1, &len); /* e.g. "[7]" */
free(json);
Neither association catalog is exposed over the gRPC server or the
infrastore CLI.
Store Attributes
Key/value provenance about the artifact as a whole. See Store attributes for the model.
int32_t infrastore_store_set_store_attribute(InfraStore *store,
const char *key,
const char *value);
int32_t infrastore_store_get_store_attribute(const InfraStore *store,
const char *key,
char **out_value,
uint64_t *out_len);
int32_t infrastore_store_list_store_attributes(const InfraStore *store,
char **out_json,
uint64_t *out_len);
int32_t infrastore_store_remove_store_attribute(InfraStore *store,
const char *key,
bool *out_removed);
infrastore_store_set_store_attribute(store, "creator", "sienna-build");
char *value = NULL;
uint64_t len = 0;
infrastore_store_get_store_attribute(store, "creator", &value, &len);
if (value != NULL) { /* NULL means the key is unset */
puts(value);
infrastore_string_free(value);
}
char *json = NULL;
infrastore_store_list_store_attributes(store, &json, &len); /* {"creator":"sienna-build"} */
infrastore_string_free(json);
bool removed = false;
infrastore_store_remove_store_attribute(store, "creator", &removed);
Two conventions to note:
getspells "unset" as a null*out_value, not as a zero*out_len. A key set to the empty string is a legitimate value, and the two must stay distinguishable. An unset key isINFRASTORE_OK, notINFRASTORE_ERR_NOT_FOUND.listreturns a JSON object, following the owned-string convention, rather than an array-of-pairs buffer — the ABI has no pair type and this needs no new one.
Unlike the JSON payloads every other owned-string export produces, a value is the caller's own text
and can contain an interior NUL; get reports one as INFRASTORE_ERR_INTEGRITY rather than
returning a value truncated at it. An empty key or one beginning with infrastore. (reserved, on
removal as well as on write) is INFRASTORE_ERR_INVALID_PARAMETER; a write to a read-only store is
INFRASTORE_ERR_READ_ONLY.
The read half is available over the gRPC server; the writes are not, like every other write.
OpenAPI-row Association Serde
Direct JSON serde of the two association catalogs, in the wire spelling
SiennaSchemas defines (TimeSeries/*.json,
Core/Associations/SupplementalAttributeAssociation.json). The two exports use the owned-string
convention.
/* Export time_series_associations matching the filter as a sorted OpenAPI-row
JSON array. Each row's uri and data_hash are the hex-encoded content hash
the store already has for that row -- never a caller-supplied locator.
Takes the same filter record as infrastore_store_list_metadata. Returns the
JSON through out_json as an OWNED allocation, freed with infrastore_string_free.
PersistentTimeSeries rows are omitted -- the wire contract has no schema for
the type -- and a filter naming it is INFRASTORE_ERR_INVALID_PARAMETER. */
int32_t infrastore_store_export_time_series_associations_openapi(const struct InfraStore *handle,
const struct InfraStoreFilter *filter,
char **out_json, uint64_t *out_len);
/* Bulk-ingest a JSON array of time-series association OpenAPI rows in one
all-or-nothing transaction -- the import half of the round trip whose export
is infrastore_store_export_time_series_associations_openapi. Rows only: every
row must name an array this store already holds, an irregular row must name
its time axis with timestamps_uri, and each keeps the association_id it
carries. A PersistentTimeSeries row is refused: no export writes one, and the
wire contract has no schema to check one against. *out_added (when non-NULL)
receives the number inserted. */
int32_t infrastore_store_import_time_series_associations_openapi(
struct InfraStore *handle, const char *json, uint64_t *out_added);
/* Export the whole supplemental_attribute_associations table as an OpenAPI-row
JSON array, sorted by (component_id, attribute_id). Owned-string return. */
int32_t infrastore_store_export_supplemental_attribute_associations_openapi(
const struct InfraStore *handle, char **out_json, uint64_t *out_len);
/* Bulk-ingest a JSON array of supplemental-attribute association OpenAPI rows in
one all-or-nothing transaction -- the import half of the round trip whose
export is infrastore_store_export_supplemental_attribute_associations_openapi.
*out_added (when non-NULL) receives the number inserted. */
int32_t infrastore_store_import_supplemental_attribute_associations_openapi(
struct InfraStore *handle, const char *json, uint64_t *out_added);
A catalog row's data_hash is NOT NULL, and infrastore fills a row's uri/data_hash wire
fields with that same content hash, hex-encoded, on export. The time-series import writes rows
only: infrastore never modifies the data to make an incoming document agree with what it already
holds, so a row naming an array this store does not hold is refused
(INFRASTORE_ERR_INVALID_PARAMETER) rather than written as a dangling reference. A
NonSequentialTimeSeries row locates its time axis the same way, with timestamps_uri: the values
cannot imply it, since two irregular series with identical values on different axes share one
content-addressed array, so a row missing the locator or naming an axis this store does not hold is
refused alike; a PersistentTimeSeries row is refused earlier still, as a type outside the six the
wire contract defines. A geometry disagreement between an added series and its own association row
is likewise rejected at the add boundary, loudly and without writing anything.
Error Messages
int32_t infrastore_last_error_message(char *buf, uint64_t buf_len, uint64_t *needed);
Copies the thread-local error message (UTF-8, null-terminated) into buf. *needed receives the
length excluding the NUL. If buf_len is too small, the message is truncated but INFRASTORE_OK is
still returned — call once with buf = NULL, buf_len = 0 to learn the needed size, then again with
a buffer of *needed + 1. This is the pattern TimeSeries.jl uses.
Building
cargo build -p infrastore-ffi --release
# Library at: target/release/libinfrastore_ffi.{dylib,so,dll}
Point consumers at it via the INFRASTORE_LIB environment variable (see the
Julia guide).
Tracing
int32_t infrastore_store_init_logging(const char *filter);
Initialize the Rust tracing subscriber. filter is a null-terminated UTF-8
EnvFilter
directive string (e.g. "debug" or "infrastore_core=debug"). Pass NULL to read the RUST_LOG
environment variable; if that variable is also unset, no output is produced.
The subscriber is initialized at most once per process — subsequent calls are no-ops. Returns
INFRASTORE_OK on success or INFRASTORE_ERR_INVALID_UTF8 if filter is not valid UTF-8.
The Julia binding calls this automatically from its __init__ hook when
RUST_LOG is set, and exposes init_logging for explicit control.
gRPC API
The proto contract lives at proto/infrastore/v1/store.proto and is compiled into
infrastore-proto with tonic. The service is read-only — every write operation (add, remove,
clear, compact) requires local filesystem access and is intentionally absent.
The association catalogs are absent
too, reads included: no message or RPC covers supplemental_attribute_associations or
parent_child_associations. Consumers of those tables work against a local Store.
- Package:
infrastore.v1 - Service:
CatalogStore
Methods
| RPC | Request | Response | Purpose |
|---|---|---|---|
ListMetadata | ListMetadataReq | ListMetadataResp | Catalog rows matching a filter |
ListMetadataByIds | ListMetadataByIdsReq | ListMetadataByIdsResp | Catalog rows for a set of ids |
GetMetadataById | GetMetadataByIdReq | TimeSeriesMetadata | One catalog row by id |
AssociationExists | AssociationExistsReq | AssociationExistsResp | Is an id still filed? (fetches no row) |
HasAnyTimeSeries | HasAnyTimeSeriesReq | HasAnyTimeSeriesResp | Attribute-addressed existence probe |
ReadById | ReadByIdReq | ReadByIdResp | One series' values (opt. range) |
ReadByIds | ReadByIdsReq | ReadByIdsResp | Many series at once (opt. range) |
GetResolutions | GetResolutionsReq | GetResolutionsResp | Distinct resolutions present |
GetIntervals | GetIntervalsReq | GetIntervalsResp | Distinct forecast intervals |
GetCounts | GetCountsReq | GetCountsResp | Aggregate counts |
GetDetailedCounts | GetDetailedCountsReq | GetDetailedCountsResp | Distinct owners/arrays per kind |
GetCountsByType | GetCountsByTypeReq | GetCountsByTypeResp | Association count per type |
GetForecastParameters | GetForecastParametersReq | GetForecastParametersResp | Horizon, interval, count, resolution |
ListOwnerIds | ListOwnerIdsReq | ListOwnerIdsResp | Distinct owner ids in a category |
GetStaticSummary | GetStaticSummaryReq | GetStaticSummaryResp | Grouped static-series summary |
GetForecastSummary | GetForecastSummaryReq | GetForecastSummaryResp | Grouped forecast summary |
CheckStaticConsistency | CheckStaticConsistencyReq | CheckStaticConsistencyResp | Per-resolution static-grid check |
VerifyIntegrity | VerifyIntegrityReq | VerifyIntegrityResp | Recompute and compare stored hashes |
ListStoreAttributes | ListStoreAttributesReq | ListStoreAttributesResp | Every store attribute |
GetStoreAttribute | GetStoreAttributeReq | GetStoreAttributeResp | One store attribute's value |
Every RPC is named for the Store method it exposes, and its request and response are <Rpc>Req /
<Rpc>Resp — including the ones that carry no field today, so a later filter lands as an added
field rather than a new message and a second RPC.
There is no key on this wire. A series is addressed by its catalog association id, an
int64 that ListMetadata and ListMetadataByIds hand back on every row and that ReadById,
ReadByIds and GetMetadataById take. The split is identify then act: ListMetadata is the
flexible half (the filter names attributes), and everything that reads or resolves a single series
takes the id it returned. There is deliberately no attribute-to-id resolver RPC — a caller that
wants exactly one row poses the filter and checks that it got one.
GetMetadataById and ListMetadataByIds return NOT_FOUND for an id that names no row, because a
call already committed to fetching treats a stale reference as a failure. AssociationExists is the
call that treats it as an answer, and it is a primary-key probe that hydrates nothing — the right
one for validating a whole model's stored references on load.
Common Messages
enum TimeSeriesType {
SINGLE_TIME_SERIES = 0;
NON_SEQUENTIAL_TIME_SERIES = 1;
DETERMINISTIC = 2;
DETERMINISTIC_SINGLE_TIME_SERIES = 3;
PROBABILISTIC = 4;
SCENARIOS = 5;
PERSISTENT_TIME_SERIES = 6;
}
enum OwnerCategory { COMPONENT = 0; SUPPLEMENTAL_ATTRIBUTE = 1; }
message FeatureValue {
oneof value {
int64 int_value = 1;
double float_value = 2;
bool bool_value = 3;
string str_value = 4;
}
}
message Features { map<string, FeatureValue> entries = 1; }
message TimeSeriesMetadata {
int64 owner_id = 1;
string owner_type = 2;
OwnerCategory owner_category = 3;
TimeSeriesType time_series_type = 4;
string name = 5;
bytes data_hash = 6; // 32 bytes
// Temporal fields are `optional` so genuine values (e.g. length == 0) decode
// correctly rather than colliding with a zero/empty sentinel.
optional string initial_timestamp_rfc3339 = 7;
optional string resolution = 8; // ISO-8601 duration
optional uint64 length = 9;
optional string horizon = 10; // ISO-8601 duration
optional string interval = 11; // ISO-8601 duration
optional uint64 count = 12;
repeated string timestamps_rfc3339 = 13;
Features features = 14;
optional string units = 16;
string element_type = 21; // canonical element-type string
// (17 is reserved: the former int32 dtype code)
repeated uint64 element_shape = 18; // per-step trailing dims
optional string application_data = 19; // opaque package-owned payload
repeated double percentiles = 20; // Probabilistic only
optional string quantity_kind = 22; // QUDT QuantityKind local name
optional string unit_system = 23; // "natural_units" | "component_base"
optional string component_field = 24; // owning component's field, free-form
// `ListMetadataReq.component_field` filters on this. A row that declares none
// matches no value, so it cannot select the rows that left it unset.
optional string time_reference = 25; // "utc" | "zoneless" | "-07:00" | IANA name
// How this series' timestamps were spelled. Absent means unspecified,
// which is NOT a claim they were written as UTC.
optional int64 id = 26; // the catalog association id
// The handle a consumer stores in its own model to reference this series
// later, and what every read RPC takes. Transmitted rather than derived:
// nothing computes it from the attributes, so the serving store is the
// only thing that knows it. Every row a server returns carries one.
}
Request / Response Messages
message ListMetadataReq {
optional int64 owner_id = 1;
optional string owner_type = 2;
optional TimeSeriesType time_series_type = 3;
optional string name = 4;
optional string resolution = 5; // ISO-8601 duration
Features features = 6; // subset match
optional OwnerCategory owner_category = 7;
optional string interval = 8; // ISO-8601 duration
optional string component_field = 9; // exact, case-sensitive
optional bool zoneless = 10; // coherence predicate; see below
}
message ListMetadataResp { repeated TimeSeriesMetadata metadata = 1; }
message ListMetadataByIdsReq { repeated int64 ids = 1; } // NOT_FOUND if any is stale
message ListMetadataByIdsResp { repeated TimeSeriesMetadata metadata = 1; }
message GetMetadataByIdReq { int64 id = 1; } // NOT_FOUND if stale
message AssociationExistsReq { int64 id = 1; } // never NOT_FOUND
message AssociationExistsResp { bool present = 1; }
message ReadByIdReq {
int64 id = 1; // catalog association id, from a ListMetadata row
optional string start_rfc3339 = 2; // optional time-axis slice; all-or-nothing with end
optional string end_rfc3339 = 3;
optional bool bounds_zoneless = 4; // how the client spelled those bounds; see below
}
message ReadByIdResp {
string initial_timestamp_rfc3339 = 1;
string resolution = 2; // ISO-8601 duration
uint64 length = 3;
repeated uint64 shape = 4; // array dimensions (multi-dim supported)
reserved 5; // was: repeated double values
TimeSeriesType time_series_type = 6;
repeated string timestamps_rfc3339 = 7; // set for NonSequentialTimeSeries and PersistentTimeSeries
string element_type = 16; // canonical element-type string
// (8 is reserved: the former int32 dtype code)
bytes value_bytes = 9; // raw little-endian, row-major
string application_data = 10;
// Forecast-specific fields (populated for Deterministic / Probabilistic / Scenarios).
string horizon = 11; // ISO-8601 duration
string interval = 12; // ISO-8601 duration
uint64 count = 13;
repeated double percentiles = 14; // Probabilistic only
uint64 scenario_count = 15; // Scenarios only
optional string time_reference = 21; // how the timestamps were spelled
string name = 22; // the series' name
// A read names an id, and an id carries no name, so this is the only place
// a client can get one without a second call.
}
message ReadByIdsReq {
repeated int64 ids = 1; // results align with these, repeats in place
optional string start_rfc3339 = 2;
optional string end_rfc3339 = 3;
optional bool bounds_zoneless = 4;
}
message ReadByIdsResp { repeated ReadByIdResp items = 1; }
message GetResolutionsReq { optional TimeSeriesType time_series_type = 1; }
message GetResolutionsResp { repeated string resolution = 1; } // ISO-8601 durations
message GetIntervalsReq { optional TimeSeriesType time_series_type = 1; }
message GetIntervalsResp { repeated string interval = 1; } // ISO-8601 durations
message GetCountsReq {}
message GetCountsResp {
int64 components_with_time_series = 1;
int64 static_time_series = 2;
int64 forecasts = 3;
}
message GetForecastParametersReq {
optional string resolution = 1; // ISO-8601 duration filter
optional string interval = 2; // ISO-8601 duration filter
}
message GetForecastParametersResp {
optional string horizon = 1; // ISO-8601 duration
optional string interval = 2; // ISO-8601 duration
optional uint64 count = 3;
optional string resolution = 4; // ISO-8601 duration
optional string initial_timestamp_rfc3339 = 5;
}
// An existence probe stays attribute-addressed: it is answered off the catalog
// indexes without hydrating a row, so posing it through an id lookup would cost
// more than the question. `features` matches the whole set, not a subset.
message HasAnyTimeSeriesReq {
int64 owner_id = 1;
OwnerCategory owner_category = 2;
string name = 3;
optional TimeSeriesType time_series_type = 4;
optional string resolution = 5; // ISO-8601 duration
optional string interval = 6; // ISO-8601 duration
map<string, FeatureValue> features = 7;
}
message HasAnyTimeSeriesResp { bool present = 1; }
message VerifyIntegrityReq {}
message VerifyIntegrityResp { repeated string errors = 1; }
ReadByIdReq Time Slice
start_rfc3339 and end_rfc3339 are all-or-nothing: supply both to request a time-axis slice,
or neither to fetch the whole series. Setting exactly one is rejected with InvalidArgument
("start_rfc3339 and end_rfc3339 must be supplied together"). Each value must parse as RFC 3339; a
malformed timestamp is also InvalidArgument.
Time References
TimeSeriesMetadata carries an optional time_reference recording how a series' timestamps were
spelled: "utc", "zoneless", a fixed offset ("-07:00"), or an IANA zone name
("America/Denver"). Absent means unspecified, which is not a claim they were written as UTC. It
is descriptive, so it is outside the identity the catalog files a row under. An unparseable value is
a convert error rather than a silent absence: "unspecified" and "a spelling this build cannot read"
must not look alike.
Timestamps stay RFC 3339 UTC on the wire whatever the reference says — the reference is the label,
applied by the client. ReadByIdReq.bounds_zoneless and ReadByIdsReq.bounds_zoneless carry how
the client spelled its slice bounds, because the wire form is identical either way: a zoneless
client sends the wall clock read as if UTC, exactly as the store holds one. The server refuses a
bound whose spelling the series cannot answer (InvalidArgument) rather than coercing it, and
refuses a ranged bulk read whose selection mixes zoneless series with instant-bearing ones.
ListMetadataReq.zoneless is the constructive half — true selects the wall-clock series, false
selects everything that accepts an instant bound, including the rows that recorded no reference.
See Time references for the full rules.
Forecasts Over gRPC
The service is read-only, but its read surface covers dense forecasts. Forecast associations created
through the Rust core or C ABI appear in
ListMetadata — TimeSeriesMetadata carries horizon, interval (ISO-8601 durations), count,
and (for Probabilistic) percentiles — and GetCounts includes them in forecasts.
ReadById returns forecast values too. For a Deterministic, DeterministicSingleTimeSeries
(synthesized into Deterministic), Probabilistic, or Scenarios row it fills the ReadByIdResp
array fields (value_bytes + element_type), the window parameters (horizon, interval,
count), and the percentiles (Probabilistic) or scenario_count (Scenarios); the client
reconstructs the matching type. Arrays are dtype-generic on the wire — value_bytes is the raw
little-endian buffer and element_type says both what the elements mean and, through it, their
physical dtype (f64/f32/i64/…, or a composite kind like piecewise_linear), so non-f64
arrays survive the round trip without coercion. One caveat:
application_datais not carried inReadByIdResp. The opaque package-owned payload is returned byListMetadata(onTimeSeriesMetadata) but left empty byReadById, so values fetched directly by id come back without it. Every other descriptor —name,units,quantity_kind,unit_system,component_field,time_reference— is on the response, so a read by id returns the same described series a local read does.
Catalog Revision and Read-Only Opens
The server opens its store read-only, which means it cannot upgrade a catalog. A store whose
catalog is at an older CATALOG_SCHEMA_REVISION is refused with CatalogMigrationRequired.
That refusal is a startup failure, not an RPC status: the store is opened once, by
CatalogStoreService::from_path, before any service exists to answer a request. The process exits
with the error on stderr and no client ever connects. Look for it in the server's own output, not in
a response.
The store must be opened once for writing before the server can serve it. The CLI command for
exactly that is infrastore --store <path> upgrade, which does nothing but the writable open and is
a no-op on a store that is already current. Every read command, store-info included, opens the
store read-only and so cannot upgrade it.
infrastore store-info reports catalog_schema_revision beside data_format_version once the
store is readable, which is how to confirm the upgrade landed.
A catalog written by a newer build is CatalogTooNew and is refused outright; there is no
downgrade. See
Upgrade a store in place.
Store Attributes
The read half of the artifact's key/value provenance — see Store attributes. Setting one is a write, and writes need local filesystem access, so they stay off this service.
message ListStoreAttributesReq {}
message ListStoreAttributesResp {
map<string, string> attributes = 1;
}
message GetStoreAttributeReq { string key = 1; }
message GetStoreAttributeResp { optional string value = 1; }
value is absent when the artifact carries no such key — not NOT_FOUND, mirroring
Store::get_store_attribute: a caller asking whether a key is there is asking a question, where
GetMetadataById deals with a caller holding an id it believes in. optional rather than an empty
string, because a key set to "" is a legitimate value.
A protobuf map is unordered on the wire, so the core's sorted-by-key ordering does not survive the
trip; RemoteClient::list_store_attributes collects into a BTreeMap and restores it.
Authentication
When the server is configured with method = "api_key", clients must send the key in the
x-api-key request metadata (header). The server checks the supplied key against every
configured key of the same length without early-exit, so a match is not leaked by timing; the
comparison is not blinded against the supplied key's length, which is treated as non-secret. A
missing or wrong key is rejected before the RPC runs. With method = "none" no metadata is
required. See Server Configuration.
Rust Client
infrastore-server ships an async RemoteClient that mirrors the read methods and returns core
types, mapping gRPC Status codes back onto the TimeSeriesError taxonomy:
gRPC Code | TimeSeriesError |
|---|---|
NotFound | NotFound |
AlreadyExists | DuplicateTimeSeries |
InvalidArgument | InvalidParameter(message) |
FailedPrecondition | InvalidParameter(message) |
DataLoss | IntegrityError(message) |
| anything else | ConnectionError(code: message) |
ConnectionError is only the fallback arm, so a remote NotFound or a rejected argument surfaces
with the same variant a local Store would return.
#![allow(unused)] fn main() { use infrastore_core::{ListFilter, OwnerCategory}; use infrastore_server::client::RemoteClient; let client = RemoteClient::connect("http://127.0.0.1:50051".into()).await?; let counts = client.get_counts().await?; // Identify, then act: the rows carry the ids every read takes. let rows = client .list_metadata(Some(42), Some(OwnerCategory::Component), None, None, None, None, None, None, None, None) .await?; let id = rows[0].id.expect("a served row always carries its id"); let data = client.read_by_id(id, None).await?; // A model holding ids from an earlier session hydrates them in one round trip, // after sifting the ones that no longer resolve. let live: Vec<_> = /* ids the model recorded */ vec![id]; let hydrated = client.list_metadata_by_ids(&live).await?; }
RemoteClient methods mirror the RPC table one for one: connect, from_channel, list_metadata,
list_metadata_by_ids, get_metadata_by_id, association_exists, has_any_time_series,
read_by_id, read_by_ids, get_resolutions, get_intervals, get_counts, counts_by_type,
time_series_counts_detailed, get_forecast_parameters, list_owner_ids, static_summary,
forecast_summary, check_static_consistency, verify_integrity, list_store_attributes,
get_store_attribute. The id-taking ones accept infrastore_core::TimeSeriesId, the same newtype
the local Store uses, so an owner_id cannot be passed where a series id belongs. See the
gRPC Server guide for end-to-end usage and adding an API key to client
requests.
Server Configuration
The gRPC server is configured by a single TOML file passed with --config. The starting point is
examples/server.toml.
infrastore-server --config my_server.toml
File Structure
[server]
host = "0.0.0.0"
port = 50051
[data]
file = "./store.h5"
[authentication]
# "none" or "api_key". For api_key, populate `keys`; clients must send the
# value in the `x-api-key` request header.
method = "none"
# keys = ["replace-me-with-a-secret-1", "replace-me-with-a-secret-2"]
Sections
[server]
| Key | Type | Required | Description |
|---|---|---|---|
host | string | yes | Bind address (e.g. 0.0.0.0, 127.0.0.1) |
port | integer | yes | TCP port |
[data]
| Key | Type | Required | Description |
|---|---|---|---|
file | string | yes | HDF5 file path to serve read-only |
The matching <path>.sqlite catalog must sit beside the HDF5 file. The server opens the store
read-only.
[authentication]
The whole section is optional; omitting it defaults to method = "none".
| Key | Type | Default | Description |
|---|---|---|---|
method | string | "none" | "none" or "api_key" (oauth reserved) |
keys | array of string | [] | Accepted API keys; required when method = "api_key" |
Validation runs at startup, so a bad [authentication] section fails loudly rather than at the
first request:
-
method = "api_key"with an emptykeyslist is rejected. -
An unknown
methodvalue is rejected. -
An unknown key or section is rejected, in every section. A misspelled
[authenticaton](sic) fails the parse instead of silently falling back tomethod = "none". The cost is that a config carrying a key from a newer version is refused rather than partly honored.
When method = "api_key", each request must carry a matching value in the x-api-key metadata
header. Keys are compared without early-exit — every configured key of the same length as the
supplied one is checked, so timing does not reveal which key matched or how far a wrong key got. The
supplied key's length is not blinded: keys of a different length are rejected before the byte-wise
compare, treating length as non-secret.
Startup Behavior
On launch the server:
- Loads and parses the TOML file.
- Validates the
[authentication]section. - Opens
[data].fileas a read-only store. - Binds
host:portand serves theCatalogStoregRPC service.
Logging honors the RUST_LOG environment variable (default info):
RUST_LOG=debug infrastore-server --config my_server.toml
See the gRPC Server guide for the end-to-end workflow and the gRPC API reference for the served methods.
CLI Reference
infrastore-cli builds the infrastore binary, which reads and writes a store directly on disk
(HDF5 + SQLite). For a task-oriented walkthrough, see Use the infrastore CLI.
The CLI covers time series and both association catalogs, read and write. The store holds only the relationship — the components and supplemental attributes themselves live in the consumer's object graph — which is why the association flags are bare ids and type names.
Synopsis
infrastore [--store <PATH.h5>] [-f <FORMAT>] [--log-level <FILTER>] [-y]
[--assume-timezone <ZONE> | --zoneless] <COMMAND>
Every global option is accepted after the command too (infrastore add --store demo.h5 …).
Global options
| Option | Description |
|---|---|
--store <PATH> | Path to the HDF5 store file. The <PATH>.sqlite catalog is implicit. Falls back to the INFRASTORE_STORE environment variable. |
-f, --format | Output format: table (default), json, jsonl, or csv. |
--log-level | Tracing filter; also read from RUST_LOG. Defaults to warn. |
-y, --yes | Answer every confirmation prompt with yes. |
--assume-timezone <ZONE> | Read timestamps that carry no time zone as being in this one: UTC (or Z), a fixed offset (-07:00, -0700, -07), or an IANA zone name (America/Denver). |
--zoneless | Store timestamps that carry no time zone as the wall clocks they are, naming no instant. Mutually exclusive with --assume-timezone. |
--store (or INFRASTORE_STORE) is required by every command except template and completions.
-f/--format applies to every command, read and write alike. The read/inspection commands
(list, get, grid, info, export, names, owner-types, owners, exists, stats,
store-info, store-attr list, store-attr get, arrays, summary, attributes, links,
diff, verify, check-consistency, resolutions, params, compact, and add --dry-run)
render their results in it. The write commands (init, add, merge, remove, copy,
replace-owner, clear, transform, persist, plot, attach, detach, link, unlink,
reassign, store-attr set, store-attr remove) report their outcome in it: prose under table,
and a one-object status document under json/jsonl, so a scripted mutation pipes into jq the
way a scripted query does.
$ infrastore --store s.h5 -f json --yes remove --all --owner-id 42 | jq .removed
3
Each command's document names what it did — {"removed": N}, {"added": N, "store": …},
{"merged": N, …} — and a --dry-run reports {"dry_run": true, "would_remove": N, …} instead.
The writing commands also name the catalog ids they created: add carries a series array of
{id, time_series_type, name, owner_id} objects, and attach / link an ids array. A filter
that matches nothing still reports its zero rather than printing nothing, so jq .removed reads 0
instead of failing on an empty document.
csv renders these status lines as prose alongside table: a status line has no rows to tabulate,
and a one-row CSV of it would give scripts a shape that changes every time the message is reworded.
JSON is the machine-readable channel for mutations. template always prints a JSON descriptor, and
plot writes the chart to its --out file — -f json shapes only the line reporting where it
went, and --out - puts the chart itself on stdout with no status line at all.
Diagnostics stay off stdout in every format: errors, the interactive [y/N] prompts, the Aborted.
notice, and add's progress counter all go to stderr, so -f json output is only ever the
document. Errors follow the format too — -f json renders them as
{"status": "error", "message": …} on stderr, so a caller parsing one stream can parse both:
$ infrastore --store missing.h5 -f json list 2>&1 >/dev/null | jq -r .message
store not found: missing.h5
jsonl is json line-delimited: one compact object per line with no enclosing {"items": [...]},
so a 100 000-row list streams into jq instead of having to be buffered whole.
export has no table form; with -f table (the global default) it writes CSV, which is both what
--dir is for and what add reads back. Its --dir files are named for the format they hold —
.csv, .json, or .jsonl — and under -f jsonl each series is one compact line rather than a
pretty document.
-y/--yes answers every prompt, so a script no longer has to know which commands prompt or which
flag each spells it with. The per-command --force flags still work and are what a one-off reaches
for.
Zoneless timestamps
A timestamp with no offset — 2024-01-01T00:00:00, or the 2024-01-01 00:00:00 that most CSV
writers produce — names a wall-clock reading, not an instant. The CLI will not guess which of the
two you mean, so such a timestamp is refused and the error names both flags that resolve it:
$ infrastore --store s.h5 add --csv load.csv ...
Error: timestamp '2024-01-01 00:00:00' names no time zone, so it names no instant. Give it an
offset (RFC3339, like 2024-01-01T00:00:00Z), pass --assume-timezone UTC (a fixed offset like
-07:00, or an IANA name like America/Denver) to read every zoneless timestamp with it, or pass
--zoneless to store them as the wall clocks they are.
--assume-timezone: resolve them to instants
$ infrastore --store s.h5 --assume-timezone UTC add --csv load.csv ...
$ infrastore --store s.h5 --assume-timezone -07:00 add --csv load.csv ...
$ infrastore --store s.h5 --assume-timezone America/Denver add --csv load.csv ...
Three things to know:
- It applies only where an offset is missing. A timestamp that carries its own offset is never overridden, so a mixed file loads correctly and a fully-offset file is unaffected.
- It is global, so it also covers
--time-rangebounds and--issue-time, which hit the same parser. - Whatever it resolves is also recorded, as the series'
time_reference(see Time references) — so a read hands the same spelling back rather than relabeling everything UTC.--assume-timezone -07:00over a midnight column stores07:00Zand prints2024-01-01T00:00:00-07:00.
Prefer a named zone to a fixed offset for anything that crosses a daylight-saving transition. A
year of Denver data read as -07:00 renders every timestamp after March an hour wrong; the same
data read as America/Denver renders all of it correctly, because the zone is applied per instant
rather than baked in once.
A named zone is the one place in the system that runs local → instant, and it has two wall clocks it cannot resolve. Both are errors naming the row, not guesses:
$ infrastore --store s.h5 --assume-timezone America/Denver add --csv fold.csv ...
Error: timestamp '2024-11-03 01:30:00' is ambiguous in America/Denver: daylight saving repeats
that wall clock, so it names two instants (2024-11-03T07:30:00+00:00 and
2024-11-03T08:30:00+00:00). The file has to say which — give the row an explicit offset, or
re-read the column with --assume-timezone -06:00 or -07:00.
--zoneless: keep them as wall clocks
For data that has no time zone and wants none — modeled profiles on 24-hour days, say — --zoneless
stores the fields as written, converts nothing, and reads them back unlabeled:
$ infrastore --store s.h5 --zoneless add --csv profile.csv ...
$ infrastore --store s.h5 get --owner-id 42 --name load -f csv
2024-01-01T00:00:00,1
The store then holds the series to that claim. An instant-bearing --time-range bound against it is
refused rather than coerced, and it cannot share one grid axis or one ranged bulk read with series
that do record instants — there is no single meaning either could carry for both. list --wide,
info, and export -f json all report the time_reference, and store-info lists the catalog's
distinct spellings with any unrecognized zone name flagged:
$ infrastore --store s.h5 store-info
...
time_references ["America/Denver", "utc", "America/Dever (unrecognized zone?)"]
A zone the store has never heard of is reported, never refused: the store does not gate on zone existence, because that would refuse legitimate data whenever IANA moves ahead of this build's database.
Commands
infrastore --help lists these under the same eight headings used below. The grouping is a display
aid only — every command is invoked flat, as infrastore <command>; there are no subcommand
namespaces to type.
Each group's examples are below its table, and every command carries the same ones in its own help:
infrastore <command> --help ends with a worked invocation. A test parses all of them, so an
example can never name a flag the command does not have.
Read data
| Command | Purpose |
|---|---|
list | List stored series matching the selector filters. |
get | Read and display a single series' values. |
grid | Render N series as N columns against one shared time axis. |
info | Metadata, content hash, HDF5 location, and stats for one series. |
export | Write series values to CSV/JSON/Parquet files (--dir), or stdout for one match. |
infrastore --store demo.h5 list # everything in the store
infrastore --store demo.h5 list --name-glob 'load_*' --limit 20 # filtered, bounded
infrastore --store demo.h5 get --owner-id 42 --name load --full # every row, not just 50
infrastore --store demo.h5 get --name load --plot # a terminal sparkline
infrastore --store demo.h5 get --name load --tail --limit 24 # the last day
infrastore --store demo.h5 -f csv grid --name-glob 'load_*' --resolution PT1H
infrastore --store demo.h5 info --name load --no-stats # catalog only, no array read
infrastore --store demo.h5 -f csv export --name-glob 'load_*' --dir out/
infrastore --store demo.h5 -f parquet export --name-glob 'load_*' --dir out/
Parquet
-f parquet export --dir <DIR> writes one file pair per (type, value type, time reference)
partition: <stem>.values.parquet holds every distinct array once, one row per value, and
<stem>.series.parquet holds one catalog row per series. Both carry the array key
(data_hash, time_axis) and are sorted by it, so they join on it — which is what keeps a profile
shared by a thousand components from being written a thousand times.
add --parquet <PATH> reads them back, from a file, a directory, or a partition stem
(out/SingleTimeSeries.f64.utc names the pair). The import is a merge join over the two halves, one
transaction per partition, and a dangling key on either side is an error — as is a pair whose
footers disagree about the partition they describe, which is what half of one export beside half of
another looks like. --no-checksum waives the data_hash comparison for values edited in a query
engine.
The inline flags split three ways: the owner, the name, the features and the five free-form
descriptors are overrides; --element-type, --element-shape, --resolution and --type are
assertions, which a contradicting file turns into an error and which name the reading for a
foreign file; and the grid flags (--initial-timestamp, --interval, --horizon, --count,
--percentile, --scenario-count) and the CSV layout flags (--layout, --owner-map,
--owner-id-from) are refused, because the values already carry the grid.
Parquet layout is the format reference — both column sets, the array key,
time_axis per type, footer keys, and the rules the import applies to a foreign file.
Three limits on the export, all deliberate:
--diris required. Parquet's footer sits at the end of the file and its offsets point backwards, so a writer has to seek and a pipe cannot.--dirmust hold no.parquetfiles yet.add --parquet <dir>imports every partition it finds, so a narrower export written over an earlier one would leave the earlier partitions in place for the next import to file silently. The export neither merges nor sweeps; empty the directory or name a fresh one. Other files in it are not in the way.- An empty series fails it. Every selected series with no values is named, and nothing is written — a series row whose key matches no values rows is indistinguishable from a truncated export, so it is refused rather than represented. Narrow the selection past it.
-f parquetis only accepted onexport. It is a binary container, not a rendering of a result; there is nolist -f parquet.
Parquet is on by default -- in the released binaries and in cargo install infrastore-cli
alike. It is still a cargo feature (parquet), so --no-default-features --features vendored
builds a binary without the Arrow dependency tree. That binary accepts -f parquet and --parquet
and fails with the feature to rebuild with, rather than reporting parquet as an unknown format --
which is also why --help, the shell completions, and the examples on this page read the same in
both builds.
The Arrow tree deliberately stays out of the library crates: infrastore-core, infrastore-py,
and infrastore-ffi never link it. A binding that wants Parquet has to_arrow() and its host
language's own writer, which is a much smaller ask than linking Arrow into a wheel or a cdylib.
Bounding the rows
get and grid separate the flags that select data from the flags that bound a display, and
the two reach different formats:
| Flag | Applies to | Why |
|---|---|---|
--time-range START..END | every format | A time slice is a different span of the series, so a pipe should carry exactly the rows asked for. |
--stride N (get, grid) | every format | Every Nth row is a different series, not a shorter view of one. -f json reports the strided shape and echoes stride. |
--limit N, --full, --tail (get, grid) | the table only | A CSV or JSON stream is read by another program, and a silently short one is a data bug in whatever reads it — not a shorter table. |
So -f csv get --limit 3 still writes every row; thin a pipe with --stride, or slice it with
--time-range. The table's own default cap is 50 rows, lifted by --full.
grid over series that share no grid
Every column in a grid sits on one timeline, and for SingleTimeSeries that means one
initial_timestamp and one length — which most real stores do not have. --window-start names
the span instead of deriving it, and each column then reads at an offset of its own:
infrastore --store demo.h5 grid --resolution PT1H
# Error: invalid parameter: StaticReader requires a uniform grid; series 'load' (owner 2) has
# grid (2024-01-01T02:00:00Z, PT1H, 4) but the reader grid is (2024-01-01T00:00:00Z, PT1H, 4).
# Build the reader over a window ...
infrastore --store demo.h5 grid --resolution PT1H --window-start 2024-01-01T02:00:00Z
--window-length N pins the extent; without it the sweep runs as far from the anchor as every
matched series reaches. The span is checked, not clamped — a matched series that does not cover it
is an error naming that series, rather than a column silently missing from the table — and the
anchor must land on each series' own step boundaries. It is SingleTimeSeries-only: the two
irregular types carry their timeline rather than deriving it.
--window-start and --time-range do different jobs and compose. The first decides which rows the
reader has at all; the second filters the rows it already has, and is the one that reaches every
output format the same way.
There is a third way to bound a grid, and it is a selector rather than a display bound:
--initial-timestamp / --length keep only the series already on one grid, so the ones that are
not never become columns at all. Reach for the window when the ragged series should all take part in
the sweep, and the selector when they should not — a stray day of data beside a year of it is
usually a different component, not a shorter view of the same sweep.
Write data
| Command | Purpose |
|---|---|
init | Create an empty store with an explicit compression and catalog policy. |
add | Add one or more series from a descriptor JSON + CSV, from Parquet, or from flags. |
merge | Copy matching series from another store into this one. |
transform | Derive DeterministicSingleTimeSeries from stored SingleTimeSeries. |
remove | Delete a single series, or every match with --all (prompts unless --force). |
copy | Copy the single series a selector resolves to onto another owner. |
replace-owner | Reassign every series from one owner to another. |
clear | Remove all series, or all for one owner (prompts unless --force). |
infrastore --store demo.h5 init --compression deflate:6
infrastore --store demo.h5 add --descriptor load.json
infrastore --store demo.h5 add --descriptor batch.json --dry-run
infrastore --store demo.h5 add --descriptor batch.json --replace --batch-size 500
infrastore --store demo.h5 add --csv load.csv --owner-id 42 --owner-type Generator \
--name load --type SingleTimeSeries --element-type f64 \
--resolution PT1H --initial-timestamp 2024-01-01T00:00:00Z
infrastore --store demo.h5 add --parquet out/
infrastore --store demo.h5 merge --from other.h5 --name-glob 'load_*'
infrastore --store demo.h5 transform --horizon PT24H --interval PT1H
infrastore --store demo.h5 remove --owner-id 42 --name load --type SingleTimeSeries
infrastore --store demo.h5 remove --all --name-glob 'scratch_*' --dry-run
infrastore --store demo.h5 copy --name load --dst-owner-id 43 --dst-owner-type Generator
infrastore --store demo.h5 replace-owner --old 42 --new 43 --owner-category Component
infrastore --store demo.h5 clear --owner-id 42 --owner-category Component
Discover
The step before writing a selector. stats says a store holds 5000 series and list shows the ones
matching a filter, but writing that filter means already knowing which names, owner types, and owner
ids exist. Each takes the same selector every read command does, so narrowing composes.
| Command | Purpose |
|---|---|
names | Distinct series names matching the selector. |
owner-types | Distinct owner types matching the selector. |
owners | Distinct owner ids that have a time series. |
exists | Whether anything matches; exit 0 for yes, 1 for no. |
infrastore --store demo.h5 names
infrastore --store demo.h5 names --owner-id 42
infrastore --store demo.h5 owner-types
infrastore --store demo.h5 owners --type SingleTimeSeries --resolution PT1H
infrastore --store demo.h5 exists --name load
owners projects to owner ids, so it takes only --owner-category (default Component), --type,
and --resolution; any other selector flag is refused rather than silently ignored. Use list when
you need the full filter.
Visualize
| Command | Purpose |
|---|---|
plot | Draw a chart to a self-contained SVG or HTML file. |
infrastore --store demo.h5 plot --name load --out load.svg
infrastore --store demo.h5 plot --name load --kind duration --out ldc.html
infrastore --store demo.h5 plot --name load --kind heatmap --out heat.svg
See Charts below for the five --kind values and what each is for. For a quick
in-terminal check, get --plot draws a sparkline with no file involved.
Inspect the store
| Command | Purpose |
|---|---|
stats | Association, owner, and distinct-array counts. |
store-info | HDF5 + SQLite paths and sizes, on-disk format version, catalog revision. |
store-attr | Key/value provenance stamped on the whole artifact (list/get/set/remove). |
upgrade | Bring a store written by an older build up to this one's catalog revision. |
arrays | Distinct stored arrays: content hash, HDF5 location, series sharing each. |
summary | Grouped static and/or forecast summaries (--static-only/--forecast-only). |
resolutions | List distinct resolutions and forecast intervals. |
params | Show the store's forecast parameters (--resolution/--interval). |
infrastore --store demo.h5 stats
infrastore --store demo.h5 store-info
infrastore --store demo.h5 store-attr list
infrastore --store demo.h5 store-attr set creator sienna-build
infrastore --store demo.h5 store-attr get creator
infrastore --store demo.h5 store-attr remove creator
infrastore --store demo.h5 upgrade
infrastore --store demo.h5 arrays --data-hash 2018057b
infrastore --store demo.h5 summary --static-only
infrastore --store demo.h5 resolutions
infrastore --store demo.h5 params --resolution PT1H --interval PT1H
store-attr reads and writes store attributes — free-form key/value provenance about the
artifact as a whole: who built it, from what source system, under which of your own schema versions.
The store never interprets a value, in the same spirit as a series' application_data; store JSON
if you want structure. See Store attributes.
Do not confuse it with attributes, which lists component <-> supplemental-attribute associations —
a different thing entirely, which is why this command carries the store- prefix.
Three details worth knowing:
setreplaces rather than appending. An artifact records one creator, not a history of them.getprints the bare value, so$(infrastore store-attr get creator)is the value and nothing else, and exits 1 when the key is unset so a script can branch on it.removeinstead reportsremoved: falseand exits 0 — there the outcome is the output.- Keys beginning with
infrastore.are reserved, on removal as well as on write.
store-info reports whatever is there under store_attributes, so infrastore -f json store-info
is the one call that answers "what is this artifact" completely.
upgrade is the writable open that runs the catalog migration ladder. It is needed only for a store
written by an older infrastore: such a store reports the store's catalog is at revision N … on
every read, because every read command opens it read-only and therefore cannot upgrade it.
upgrade does nothing else, and is a no-op on a store that is already current. Run it once against
any artifact the read-only gRPC server is about to serve.
Associations
| Command | Purpose |
|---|---|
attributes | Component <-> supplemental-attribute associations (--summary for counts). |
links | Directed parent -> child component associations. |
attach | Attach supplemental attributes to components. |
detach | Remove attachments matching the filter. |
link | Add directed parent -> child component links. |
unlink | Remove links matching the filter. |
reassign | Move a component's associations from one id to another. |
infrastore --store demo.h5 attributes --component-id 42
infrastore --store demo.h5 attributes --summary
infrastore --store demo.h5 links --parent-type Bus --child-type Generator
infrastore --store demo.h5 attach --component-id 42 --component-type Generator \
--attribute-id 7 --attribute-type GeographicInfo
infrastore --store demo.h5 attach --from attachments.csv
infrastore --store demo.h5 link --parent-id 42 --parent-type Generator \
--child-id 7 --child-type Bus
infrastore --store demo.h5 detach --component-id 42 --dry-run
infrastore --store demo.h5 unlink --child-type Bus --force
infrastore --store demo.h5 reassign --old 42 --new 43
attach --from and link --from import a whole table in one all-or-nothing transaction, from a
component_id,component_type,attribute_id,attribute_type or
parent_id,parent_type,child_id,child_type CSV. The header is mandatory and its names are checked:
the four columns are two interchangeable-looking (id, type) pairs, so a file with the pairs
swapped would import cleanly and silently invert every relationship.
Both report the catalog ids they created, in input order: ids on the JSON document and an ids:
line under table (which elides the tail past twenty). Those are the durable handles for the rows
just written, so reporting only a count would leave a caller re-reading the catalog to find them.
detach and unlink with no filter would empty the whole catalog, so they require --all to say
you meant it. reassign is the association counterpart of replace-owner, which moves time series;
with neither --attributes nor --links it moves both catalogs, which is what a renumbered
component needs.
Integrity & maintenance
| Command | Purpose |
|---|---|
verify | Verify store integrity; nonzero exit if errors are present. |
check-consistency | Verify the per-resolution static grid (--resolution). |
compact | Rewrite the .h5 to reclaim space (prompts unless --force); print the report. |
persist | Write the store to a new HDF5 + SQLite artifact (--dest). |
diff | Compare this store against another at the catalog level (--against). |
infrastore --store demo.h5 verify
infrastore --store demo.h5 check-consistency --resolution PT1H
infrastore --store demo.h5 compact --force
infrastore --store demo.h5 persist --dest backup.h5 --dry-run
infrastore --store demo.h5 persist --dest backup.h5 --force
infrastore --store demo.h5 diff --against baseline.h5
persist is the one write guarded even when the destination is explicit: a save that fails partway
may already have destroyed what was there, so replacing an existing artifact needs --force (or the
global --yes), and a non-interactive run without one stops rather than proceeding.
diff is the regression check for "did this model run change what I expected". Content addressing
makes it cheap — two series hold the same numbers exactly when they carry the same data_hash — so
the comparison is a set operation over the two catalogs and neither store's arrays are read. It
exits 1 when the stores differ, so it drops straight into a CI gate; --all also lists the
identical series.
Scaffolding
| Command | Purpose |
|---|---|
template | Print an example descriptor for a given type to stdout. |
completions | Generate shell completions to stdout (bash/zsh/fish/…). |
infrastore template SingleTimeSeries > load.json
infrastore completions zsh > ~/.zfunc/_infrastore
infrastore --store <PATH> init [--compression <none|deflate[:LEVEL]>] [--no-shuffle] [--catalog <attached|in-memory>]
infrastore --store <PATH> add --descriptor <FILE.json|-> [--csv <FILE.csv>] [--dry-run] [--replace] [--batch-size N] [-q|--quiet] [--compression <SPEC>] [--no-shuffle] [--catalog <MODE>]
infrastore --store <PATH> add --csv <FILE.csv> --owner-id <I> --owner-type <T> --name <N> --type <T> --element-type <E> [DESCRIPTOR FIELDS...]
infrastore --store <PATH> add --parquet <PATH> [--parquet <PATH>...] [--no-checksum] [DESCRIPTOR FIELDS...]
infrastore --store <PATH> merge --from <PATH.h5> [SELECTOR...] [--replace] [--dry-run]
infrastore --store <PATH> list [SELECTOR...] [--limit N] [--wide]
infrastore --store <PATH> get [SELECTOR...] [--time-range START..END] [--limit N | --full] [--tail] [--stride N] [--plot [--plot-width COLS]] [--window N | --issue-time <TS>]
infrastore --store <PATH> grid [SELECTOR...] [--window-start <TS> [--window-length N]] [--time-range START..END] [--limit N | --full] [--tail] [--stride N] [--label <auto|owner|full>]
infrastore --store <PATH> plot [SELECTOR...] [--out <FILE.svg|FILE.html|->] [--kind <line|duration|heatmap|fan|overlay>] [--time-range START..END] [--title <T>] [--width W] [--height H] [--window N] [--limit N]
infrastore --store <PATH> info [SELECTOR...] [--no-stats]
infrastore --store <PATH> export [SELECTOR...] [--dir <DIR>] [--time-range START..END]
infrastore --store <PATH> names [SELECTOR...]
infrastore --store <PATH> owner-types [SELECTOR...]
infrastore --store <PATH> owners [--owner-category <C>] [--type <T>] [--resolution <DUR>]
infrastore --store <PATH> exists [SELECTOR...]
infrastore --store <PATH> diff --against <PATH.h5> [SELECTOR...] [--all]
infrastore --store <PATH> remove [SELECTOR...] [--all] [--force] [--dry-run]
infrastore --store <PATH> copy [SELECTOR...] --dst-owner-id <I> --dst-owner-type <T> [--new-name <NAME>] [--dry-run]
infrastore --store <PATH> replace-owner --old <I> --new <I> --owner-category <C> [--dry-run]
infrastore --store <PATH> clear [--owner-id <I> --owner-category <C>] [--force] [--dry-run]
infrastore --store <PATH> transform --horizon <DUR> --interval <DUR> [--owner-category <C>] [--resolution <DUR>]
infrastore --store <PATH> persist --dest <PATH.h5> [--force] [--dry-run]
infrastore --store <PATH> compact [--force]
infrastore --store <PATH> stats
infrastore --store <PATH> store-info
infrastore --store <PATH> store-attr list
infrastore --store <PATH> store-attr get <KEY>
infrastore --store <PATH> store-attr set <KEY> <VALUE>
infrastore --store <PATH> store-attr remove <KEY>
infrastore --store <PATH> upgrade
infrastore --store <PATH> arrays [SELECTOR...] [--data-hash <HEX>]
infrastore --store <PATH> attributes [--component-id <I>] [--attribute-id <I>] [--component-type <T>] [--attribute-type <T>] [--summary]
infrastore --store <PATH> links [--parent-id <I>] [--child-id <I>] [--parent-type <T>] [--child-type <T>]
infrastore --store <PATH> attach [--component-id <I> --component-type <T> --attribute-id <I> --attribute-type <T> | --from <FILE.csv>] [--dry-run]
infrastore --store <PATH> detach [--component-id <I>] [--attribute-id <I>] [--component-type <T>] [--attribute-type <T>] [--all] [--force] [--dry-run]
infrastore --store <PATH> link [--parent-id <I> --parent-type <T> --child-id <I> --child-type <T> | --from <FILE.csv>] [--dry-run]
infrastore --store <PATH> unlink [--parent-id <I>] [--child-id <I>] [--parent-type <T>] [--child-type <T>] [--all] [--force] [--dry-run]
infrastore --store <PATH> reassign --old <I> --new <I> [--attributes] [--links] [--dry-run]
infrastore completions <SHELL>
infrastore --store <PATH> summary [--static-only | --forecast-only]
infrastore --store <PATH> verify
infrastore --store <PATH> check-consistency [--resolution <DUR>]
infrastore --store <PATH> resolutions
infrastore --store <PATH> params [--resolution <DUR>] [--interval <DUR>]
infrastore template <SingleTimeSeries|NonSequentialTimeSeries|PersistentTimeSeries|Deterministic|Probabilistic|Scenarios>
--csv overrides the csv path inside the descriptor, and only works when the descriptor is a
single object (a wide one that expands to many series included). Passing it alongside a descriptor
array holding more than one object fails with --csv cannot be used with an array descriptor.
transform takes no selector: it rewrites every SingleTimeSeries in the store, deriving a
DeterministicSingleTimeSeries from each. --owner-category and --resolution optionally scope it
to one category and/or resolution. --horizon must not exceed the shortest matched series
(horizon / resolution steps must fit within its length), or the command fails.
remove --all uses the selector as a filter that may match several series, removing them all in one
transaction; without --all, the selector must resolve to exactly one series. stats, summary,
verify, check-consistency, resolutions, and params are read-only inspection commands and
honor -f/--format; verify exits nonzero when the integrity report lists any errors.
export is the read-direction inverse of the batch add: the selector may match many series, and
each is written to <owner_id>_<owner_type>_<name>_<type>.csv|json inside --dir. Without --dir
the selector must match exactly one series, which goes to stdout. CSV output carries real timestamps
(see the CSV Layout section); JSON output is one structured object per series, including its
features and data_hash.
That plain filename omits resolution, interval, and features, all of which are part of a series'
identity. When two matched series would share one filename, each gains a suffix naming the fields
that distinguish them (..._PT1H_model_year-2030.csv), so an export never silently overwrites part
of its own output. Filenames are compared case-insensitively, so an export produces the same set of
files on Linux, macOS, and Windows.
--dry-run on remove, clear, replace-owner, copy, merge, persist, attach, detach,
link, unlink, and reassign prints what would change and exits without opening the store for
writing.
add --dry-run is a validate mode: it resolves every descriptor, reads every CSV in full, and
prints the resolved (owner, type, name, element type, shape) table without opening the store at
all. That catches the whole class of "I got the shape wrong" errors before a multi-GB load starts.
Because the store is opened lazily, on the first batch that actually has something to write, a load
that fails validation never leaves an empty store behind.
add --replace removes any series that already carries one of the identities being added, inside
the same transaction, which is what makes re-running a load after fixing the data idempotent.
add --batch-size N commits every N series instead of the whole load in one transaction, bounding
memory for a very large load at the cost of the load's atomicity; -q/--quiet silences everything
but errors, and above 20 series the per-series lines are replaced by a progress counter on stderr.
add --descriptor - reads the JSON from stdin, so a generator script can pipe descriptors straight
in. Relative csv paths in a piped descriptor resolve against the working directory, since there is
no descriptor file for them to sit beside.
init --compression (or add --compression) sets the HDF5 compression policy for a store the
command creates (none, deflate, or deflate:LEVEL with --no-shuffle to disable byte-shuffle;
a bare deflate is level 3, and the default when neither is given); passing it for an existing
store is an error, since the persisted policy governs.
--catalog decides where the SQLite catalog lives while the command runs. attached (the
default) commits to <store>.sqlite as it goes, so an interrupted load keeps what it had already
written. in-memory holds the catalog in RAM instead of journaling every commit — much faster for a
bulk load, and it loses everything if the process dies before the command finishes: arrays still
stream to the .h5 file, but without a catalog they are unreachable.
Either way the command writes the catalog out before it exits, so the store is complete when it
returns. The modes cannot differ on that: the CLI runs one command per process, so a catalog still
in RAM at exit is not deferred, it is gone — no later persist could write that process's
catalog.
Selectors
get, info, and remove identify exactly one series with these flags; list accepts the same
flags as filters. Every flag is optional. Only --feature may be repeated; the rest take a single
value:
| Flag | Meaning |
|---|---|
--id <N> | Catalog association ID. A point lookup — see below. |
--owner-id <I> | Owner identifier (i64 integer). |
--owner-category <C> | Restrict to Component or SupplementalAttribute; omit to match either. |
--name <N> | Series name (exact match). |
--name-glob <P> | Name pattern (SQLite GLOB: case-sensitive */?). ANDed with --name. |
--component-field <F> | Owning component's field, exact and case-sensitive. |
--type <T> | See the type spellings below. |
--resolution <DUR> | Resolution as an ISO-8601 duration, e.g. PT1H, PT15M, P1M. |
--initial-timestamp <TS> | Keep only the series whose own grid starts here (RFC3339 or epoch-ms). |
--length <N> | Keep only the series of exactly this many timesteps. |
--feature key=value | Feature filter; repeatable. Values are inferred as int/float/bool/string. |
--spelling <S> | zoned or zoneless: which timestamp spelling to keep. |
--initial-timestamp and --length join --resolution to name a whole grid, which is how a
store holding several is narrowed to the one you mean. That matters most for grid, whose columns
must share a timeline: a stray day of data beside a year of it, under the same name, leaves no
readable selection until one grid is picked. It is the counterpart to grid --window-start, and the
two answer different questions — the filter drops the series that are not on the grid, the window
sweeps a span across them all. Like every filter they select rather than assert, so a grid no row is
on is an empty result rather than an error, and a series that stores no start (the two irregular
types) matches no value at all.
infrastore --store system.h5 list --initial-timestamp 2024-01-01T07:00:00Z --length 8784
infrastore --store system.h5 grid --resolution PT1H --initial-timestamp 2024-01-01T07:00:00Z
infrastore --store system.h5 --yes remove --initial-timestamp 2024-01-01T00:00:00Z --length 24
--id is different in kind from the flags under it. The others narrow a set; --id names exactly
one row, by the catalog id that add, list, and info report. So it cannot be combined with them
(the error says as much), and it cannot stand in for them on a command that works over a set —
list --id 3 is refused rather than quietly returning one row. Use it where a caller has stored an
id in its own model and wants that series back:
infrastore --store system.h5 -f json info --id 214
infrastore --store system.h5 --yes remove --id 214
An id that names no row is an error saying so, and saying that it will stay that way: ids are never reissued, so a reference that stops resolving cannot later come to mean a different series.
remove deletes the row the selector resolved by its id, so it takes exactly that row; its JSON
document reports the id it removed alongside the name and owner.
--spelling is the constructive half of the time-reference coherence rule. zoneless keeps the
wall-clock series; zoned keeps the ones that record instants, including those that declare no
reference at all. grid and the bulk reads span one timestamp axis, so they refuse a selection
holding both groups — this is how a store containing both is split into one they can read. It is
unrelated to the global --zoneless, which says how timestamps arriving on the input side are to
be read.
--component-field selects every series that varies that field on its owner — the query the
descriptor exists for. It is descriptive rather than identifying, so it narrows a selector but
rarely resolves one on its own; and a series that declares no component_field matches no value, so
it cannot select the ones that left it unset. owners rejects it (along with --owner-id,
--name, --name-glob, and --feature) rather than silently ignoring it.
If a selector matches more than one series, infrastore errors and lists the candidates so the
query can be narrowed. Each candidate line spells out every field that is part of identity —
including features and a short data_hash — so the flag that separates them is always visible.
Long candidate lists are truncated after ten entries; rerun the same flags under list to see them
all. The owner identity is the pair (owner_id, owner_category), so a component and a supplemental
attribute may share a numeric owner_id; add --owner-category to disambiguate when both exist.
Type Spellings
--type (and the descriptor's type key) accepts seven concrete types. Matching is
case-insensitive and ignores underscores, so each has a short form and a full form:
| Type | Accepted spellings |
|---|---|
SingleTimeSeries | single, SingleTimeSeries |
NonSequentialTimeSeries | non_sequential, NonSequentialTimeSeries |
PersistentTimeSeries | persistent, PersistentTimeSeries |
Deterministic | deterministic |
DeterministicSingleTimeSeries | deterministic_single, DeterministicSingleTimeSeries |
Probabilistic | probabilistic |
Scenarios | scenarios |
--type deterministic matches a stored Deterministic and the DeterministicSingleTimeSeries
rows that transform produces — how a forecast came to exist is not something you need to know to
select it. Listed rows still report their own stored type, so you can see which are synthetic, and
--type deterministic_single selects only those.
deterministic_single is not writable from a descriptor (use transform), but it is selectable,
and it is often required: transform derives a series that shares (owner_id, name, resolution)
with its source SingleTimeSeries, so after a transform a query like
infrastore get --owner-id 42 --name load matches two series and errors. --type single or
--type deterministic_single is the only way to pick one.
Both spellings are accepted as input: --type single and --type SingleTimeSeries are equivalent,
as are --owner-category component and --owner-category Component (matching is case-insensitive
and ignores underscores). Everything the CLI emits uses the canonical CamelCase name — rendered
rows, -f json output, and the descriptors template prints — so those all string-match one
another. The lowercase forms are a command-line shorthand, not a second vocabulary.
Durations and Timestamps
-
Durations (
resolution,horizon,interval): an ISO-8601 duration, and nothing else —PT1H,PT15M,PT30S,PT0.5S,P1D,P7Dfor fixed spans,P1M/P3M/P1Yfor calendar ones. This is also the spelling every command prints, so a duration copied out oflist,info, orexport -f jsoncan be pasted straight back into a descriptor.The human form the CLI used to accept (
1h,15min,7d, and a bare integer meaning milliseconds) is rejected. -
Timestamps (
initial_timestamp, non-sequential timestamp column,--time-rangebounds,--issue-time): RFC3339 (e.g.2024-01-01T00:00:00Z) or a bare integer of epoch milliseconds. A stored timestamp must be a whole number of milliseconds — a finer one is refused byaddrather than truncated, and the epoch-millisecond form cannot express one at all. See timestamp precision. A timestamp with no offset needs--assume-timezoneor--zoneless, and whichever one you pass is also recorded as the series'time_reference. -
--time-rangeis a pair of timestamps, not a duration:START..END(half-open —STARTinclusive,ENDexclusive), where each side is parsed as a timestamp. For example--time-range 2024-01-01T01:00:00Z..2024-01-01T03:00:00Z. A duration such as--time-range 1his rejected withinvalid --time-range '1h' (expected START..END). A range bound need not be grid-aligned for a static series, and must be a window boundary for a forecast (except one before the first window, which clips to it); see reading a time range for what each type selects, and for the monthly-grid slice that is refused outright. A sliced forecast is rendered as the windows it kept — their own issue times, and acountandinitial_timestampin-f jsonthat describe the slice — andget's--window N/--issue-timethen address those windows:--window 0is the first selected one, and a window outside the range is reported as such rather than as one the forecast lacks.
Descriptor Schema
A descriptor JSON file is either a single object (one series) or an array of objects (batch add).
The CSV holds only numbers, preceded by a mandatory header row (plus a leading timestamp column for
NonSequentialTimeSeries).
| Key | Required for | Notes |
|---|---|---|
owner_id | long layout | Integer component identifier (i64). Rejected when wide. |
owner_type | long layout | Wide: the default for owner_map rows that name none. |
owner_category | optional | Component (default) or SupplementalAttribute. |
name | all | |
type | all | One of the five writable types; spellings as for --type. |
element_type | all | f64/f32/i64/…, tuple(N,f64), or a function-data kind. |
csv | unless --csv is passed | Path relative to the descriptor; --csv overrides it. |
element_shape | optional | Trailing per-step dims; default scalar ([]). |
units | optional | Free-form label. |
quantity_kind | optional | What the values measure, e.g. ActivePower (QUDT name). |
unit_system | optional | natural_units or component_base; unset = unspecified. |
time_reference | optional | utc / zoneless / -07:00 / an IANA name; normally inferred. |
component_field | optional | Owning component's field these values vary over time. |
application_data | optional | Opaque package-owned payload (e.g. JSON), stored verbatim. |
features | optional | JSON object; int/float/bool/string values. See below. |
initial_timestamp | all but non-sequential | Also decides the series' time_reference unless declared. |
resolution | all but non-sequential | ISO-8601 duration, e.g. PT1H. |
horizon, interval, count | forecasts | The two durations are ISO-8601, e.g. PT24H. |
percentiles | Probabilistic | Strictly increasing list of floats. |
scenario_count | Scenarios (optional) | Inferred from the data length if omitted. |
layout | optional | long (default) or wide. See below. |
owner_map | wide layout | Sidecar CSV path, or an inline {"column": owner_id} object. |
owner_id_from | wide layout | "header" (the only value) when the headers are owner ids. |
Unknown keys are rejected. Any key not in the table above — including a typo like resolutionn — is
a hard parse error listing the accepted fields, so hand-edited templates fail loudly rather than
silently dropping a setting.
Inside features, a name that shadows a time-series or key field (name, resolution, owner_id,
…) is rejected when the series is added — see
reserved feature names.
Every field above also exists as an add flag, for a one-off that does not deserve a file:
--owner-id, --owner-type, --owner-category, --name, --type, --element-type, --units,
--quantity-kind, --unit-system, --component-field, --application-data, --element-shape
(repeatable), --feature (repeatable), --initial-timestamp, --resolution, --horizon,
--interval, --count, --percentile (repeatable), --scenario-count, --layout, --owner-map,
--owner-id-from. The inline form is a shortcut for authoring one descriptor, not a second schema —
both go down the same code path — so --descriptor and the inline flags cannot be combined. Keep
the descriptor as the repeatable and batch form.
Wide layout
The canonical power-systems file is one column per component:
timestamp,gen_001,gen_002,...,gen_500
2024-01-01T00:00:00Z,101.5,88.2,...,44.0
In the default long layout every value column is part of one series' per-timestep element, so
loading that file would need 500 descriptors and 500 single-column CSVs. "layout": "wide" reads it
as 500 separate scalar series instead, sharing this descriptor's name, type, resolution,
units, quantity_kind, unit_system, component_field, application_data, and features, and
differing only by owner:
{
"csv": "gen_profiles.csv",
"layout": "wide",
"type": "SingleTimeSeries",
"name": "max_active_power",
"owner_type": "ThermalStandard",
"element_type": "f64",
"units": "MW",
"initial_timestamp": "2024-01-01T00:00:00Z",
"resolution": "PT1H",
"owner_map": "components.csv"
}
The store keys on an i64 owner_id but wide headers are component names, so the mapping has to
be an input. There are three ways to supply it:
| Form | When |
|---|---|
"owner_map": "components.csv" | The batch case: a column,owner_id[,owner_type] |
"owner_map": {"gen_001": 42, ...} | A handful of columns, written inline |
"owner_id_from": "header" | The headers already are integer owner ids |
The sidecar CSV's header is mandatory and checked (column,owner_id or
column,owner_id,owner_type). Where a row names an owner_type it wins; otherwise the descriptor's
owner_type is the default — and it is required whenever any column lacks one, which is always the
case for the inline object form, since that carries ids only. A column with no mapping is an error
that names the unmapped columns — a 500-column load that stopped at "some column is unmapped" would
leave you diffing two files by hand. Exactly one of owner_map and owner_id_from may be set, and
either one in a long descriptor is an error.
A leading timestamp column is required for a wide NonSequentialTimeSeries or
PersistentTimeSeries (whose instants are explicit rather than a grid). For a wide
SingleTimeSeries it is optional, and when present it is checked, not ignored — see
Reading back. The wide layout covers the three static types and
scalar elements only: a forecast's value block is already three axes deep before any per-column
split, and a multidimensional element would need a second header row to say which column belongs to
which (owner, element) pair. Both are rejected rather than guessed at.
infrastore grid writes this same shape back out — see below.
CSV Layout
infrastore computes the full array shape from the descriptor and reads the CSV's value cells in
row-major order to fill it. The total cell count must equal the product of the shape.
| Type | Shape | CSV |
|---|---|---|
SingleTimeSeries | [length, *element_shape] | One value column (or prod(element_shape) columns), one row per step. |
NonSequentialTimeSeries | [length, *element_shape] | First column is the timestamp, then value columns. |
PersistentTimeSeries | [length, *element_shape] | Same shape: first column is the breakpoint, then value columns. |
Deterministic | [H, count, *E] | Flat row-major values; H = horizon / resolution. |
Probabilistic | [num_percentiles, H, count, *E] | Flat row-major values. |
Scenarios | [scenario_count, H, count, *E] | Flat row-major values. |
bool cells accept true/false/1/0. The table above is the write layout: the rawest
form, which is what template prints and what a hand-authored CSV should look like.
The header row is mandatory. It carries no data, but it is the only input to the layout
detection below, and guessing wrong on a forecast transposes its axes without failing. A file whose
first row parses as values of the declared element_type is rejected — otherwise the CSV reader
would consume that row as column names and store the series one element short, silently:
$ infrastore --store demo.h5 add --descriptor load.json
Error: the first row of load.csv (1.5) is f64 data, not a header. Every data CSV must start with a
header row — add one (e.g. `value`, or `timestamp,value`), or delete the row if it is a stray value.
Reading back, and re-adding
Every CSV infrastore writes carries timestamps, because they are the useful part of the output and
because a piped file otherwise loses the time axis entirely — initial_timestamp and resolution
live in the catalog, not in the file.
| Type | get -f csv / export -f csv header |
|---|---|
SingleTimeSeries, NonSequentialTimeSeries, PersistentTimeSeries | timestamp,value... |
Deterministic | issue_time,target_time,value... |
Probabilistic | issue_time,target_time,value[p10],... (one column per percentile) |
Scenarios | issue_time,target_time,value[s0],... (one column per scenario) |
add reads both layouts. It picks between them from the header row, so a file written by export
can be handed straight back to add with no column surgery:
- a first column named
timestampis read as the time axis. For aNonSequentialTimeSeriesor aPersistentTimeSeriesit is the data; for aSingleTimeSeriesit is validated against the descriptor'sinitial_timestamp+resolutiongrid, row count included, so a file sliced out of an export and re-added under the original descriptor fails loudly rather than landing on the wrong instants; - leading
issue_time+target_timecolumns mark the timestamped forecast layout, whose rows run window-major with the percentiles/scenarios spread across columns —addtransposes them back into the stored[series, horizon, count, element]order; - anything else is the flat write layout above.
The round trip is exact for every type, including forecasts: values come back in the order they went
in. What a CSV cannot carry is the descriptor metadata — owner, name, features, units — so re-adding
still needs a descriptor supplying those. export -f json carries all of it, plus data_hash.
Grid: many series, one time axis
grid is the read-direction inverse of the wide ingest above, and the CLI surface for the core's
columnar reader. It emits one row per timestamp and one column per series:
$ infrastore --store demo.h5 -f csv grid --name max_active_power --resolution PT1H
timestamp,1,2,3
2024-01-01T00:00:00+00:00,101.5,88.2,44.0
2024-01-01T01:00:00+00:00,102.1,87.4,44.6
A reader spans exactly one timeline, which is what makes the columns line up row by row without
a presence mask. For SingleTimeSeries that means one resolution, so --resolution is required;
for NonSequentialTimeSeries it means one shared timestamp vector, and a selection spanning two is
an error naming how many were found rather than a padded result.
PersistentTimeSeries is the exception, and it is the core's rather than the CLI's: a step function
has a value at every instant from its first breakpoint onward, so its columns may hold different
breakpoint vectors. The rows are then the union of every column's breakpoints — every instant at
which some column changes — and each column shows the value in force there rather than a blank. A
selection whose earliest row precedes some column's first breakpoint is an error naming that column,
since a step function has no value before its first breakpoint.
Columns are named by --label:
| Value | Header |
|---|---|
auto (default) | The bare owner id when every column shares one series name, else |
name@owner. | |
owner | Always the bare owner id. |
full | Always name@owner. |
The bare form is what closes the loop: a grid CSV is re-readable by
add --layout wide --owner-id-from header, and grid → add → grid is a fixed point. Column order
is the reader's own — groups by (dtype, element_shape), keys in build order — so it is stable
across runs and two grid exports can be diffed.
Charts
plot writes one self-contained file: no external fonts, scripts, stylesheets, or images, so it
opens in a browser, drops into a report, and survives being emailed. Both light and dark themes are
written into the document, keyed on prefers-color-scheme. An .html destination wraps the same
SVG in a minimal page; --out - writes to stdout, and the default is chart.svg in the working
directory. --width/--height default to 960 × 440 (whole CSS pixels, at least 50); --limit caps
an overlay at 8 windows unless told otherwise.
--kind | What it shows |
|---|---|
line | The profile itself, one or more series against time. |
duration | The load duration curve: values sorted descending against the percent of time at |
| or above them. Standard in this field, and the fastest read on how peaky a | |
| profile is. | |
heatmap | Time-of-day against day. The fastest way to spot a timezone or DST error — the |
| bug class this data is most prone to. A correct profile shows vertical banding; a | |
| shifted one shows a diagonal seam. | |
fan | Percentile bands for a Probabilistic, overlaid traces for Scenarios, for one |
window (--window N). These types have no other readable rendering. | |
overlay | A Deterministic's windows drawn over the SingleTimeSeries it was transformed |
| from: forecast against actual. |
infrastore --store demo.h5 plot --name load --kind line --out load.svg
infrastore --store demo.h5 plot --name load --kind duration --out ldc.svg
infrastore --store demo.h5 plot --name load --kind heatmap --out heat.html
infrastore --store demo.h5 plot --name load_prob --type Probabilistic --kind fan --out fan.svg
infrastore --store demo.h5 plot --name load --type Deterministic --kind overlay --out fc.svg
The categorical palette has eight distinguishable colors, so a line or duration chart refuses a
selector matching more than eight series and points at grid instead — cycling colors would produce
a chart whose legend lies. heatmap draws one series. Scenarios past eight traces are drawn in
one color with the count in the legend, which is the honest reading of a spaghetti plot.
get --plot is the no-file version: a Unicode sparkline per element, with the series range printed
beside it. Each column shows its bucket's most extreme sample rather than its average, so a one-hour
spike in a year of hourly data still shows — the thing a sanity-check plot must not hide. The cost
is that a column is not a summary of its bucket; plot draws the real curve.
Content Addressing
Arrays are stored by the SHA-256 of their contents, so two series holding identical values share one array on disk. Three commands surface that, which is what makes an HDF5 file with fewer columns than the catalog has rows explicable rather than alarming:
listshows a 12-characterHashcolumn — equal hashes mean a shared array.infoshows the full 64-characterdata_hash, thelocationit maps to, and how manySingleTimeSeries/DeterministicSingleTimeSeriesassociations reference it.arraysgroups by hash: one row per distinct array with its location and the series sharing it.--data-hash <HEX>narrows to one array and accepts any prefix, in either case.
location is what lets you go look at the same bytes with an outside tool:
$ infrastore --store store.h5 info --name load --type single
data_hash 2018057b75043a0b2716c36cbf6c183f909edf96952894f06fdad000abf45952
location /time_series/single/sts_f64_s_6_PT1H[:, 0]
hdf5_dataset /time_series/single/sts_f64_s_6_PT1H
hdf5_column 0
The hash alone would not be enough. A packed array is one column of a dataset shared with other
same-shaped arrays, and the column index is only recoverable by scanning that dataset's companion
_h hash dataset; a packed pool that fills up also spills into {name}__1, {name}__2, so even
the dataset name is not derivable from metadata. A standalone array (irregular series, dense
forecast) reports its own dataset and no column.
info reads the array to compute its statistics: min, max, mean, stddev, the p5/p25/
p50/p75/p95 percentiles, first, last, num_elements, and a separate non_finite count (a
NaN in a load profile is a data bug, and a mean that quietly ignored it would hide the bug rather
than surface it). --no-stats skips all of that, leaving a purely catalog-side query that never
touches the HDF5 file.
Reading the SQLite catalog by hand
The catalog stores both hashes as BLOB, which sqlite3 renders as raw bytes in its default list
mode and in .mode box / .mode json — mangling the terminal, and in box mode the table borders
too. The store therefore ships a time_series_readable view with both hashes hex-encoded:
sqlite> SELECT name, data_hash FROM time_series_readable LIMIT 1;
load|2018057b75043a0b2716c36cbf6c183f909edf96952894f06fdad000abf45952
The view spells hashes in lowercase, matching what every binding and the CLI print, so a value
copied out of it pastes straight into arrays --data-hash. Querying the base table directly works
too, via hex(data_hash) or .mode quote; note that SQLite's hex() returns uppercase, which
--data-hash accepts.
The view is created on a store's first writable open, so a store last written by an older build gains it as soon as anything opens it for writing. It is a projection only — nothing in the store reads it — so its absence never affects reads.
Exit Status
| Code | Meaning |
|---|---|
0 | Success. |
1 | Runtime error. The message goes to stderr, in the --format that was asked for. |
2 | Usage error from argument parsing (unknown flag, missing --descriptor, …). |
A runtime error is Error: <message> under table and csv, and
{"status": "error", "message": "<message>"} under json and jsonl — pretty for json, one
compact line for jsonl. stdout is left empty either way.
Exit 2 is the exception: clap renders those itself, before there is a parsed --format to honor,
so an argument error is always prose no matter what -f says.
Three commands also use 1 as an answer rather than a failure, so they drop into a shell
conditional or a CI gate: verify when the integrity report lists any error, diff when the two
stores differ, and exists when nothing matches. All three still print their result to stdout, and
a genuine failure is distinguishable by the Error: line on stderr.
Contributing
This page covers the conventions for contributing code and documentation to infrastore.
Code Quality
All changes should pass the standard workspace checks before being committed:
cargo fmt --all -- --check # Rust formatting
cargo clippy --workspace --all-targets --all-features -- -D warnings # Rust linting
cargo test --workspace --all-features # Rust tests
dprint check # Markdown formatting
cargo deny check --config deny.toml # Dependency policy
The workspace targets edition 2024 and declares an MSRV of Rust 1.94 (rust-version in the
root Cargo.toml is the authority; there is no rust-toolchain file). Markdown in docs/ is
wrapped at 100 characters — dprint fmt does it for you.
Testing Across Bindings
A change to the core may need re-running the binding tests:
# Rust
cargo test --workspace
# Python
cd crates/infrastore-py && maturin develop && pytest ../../python/tests
# Julia (requires the cdylib + INFRASTORE_LIB)
cargo build -p infrastore-ffi --release
export INFRASTORE_LIB=$PWD/target/release/libinfrastore_ffi.dylib # .so on Linux
julia --project=julia/InfraStore.jl julia/InfraStore.jl/test/runtests.jl
# The ZonedDateTime tests need the TimeZones weak dependency, which is only
# loadable through the test target; the run above skips them with a warning:
julia --project=julia/InfraStore.jl -e 'using Pkg; Pkg.test()'
The On-Disk Format Is a Contract
The HDF5 layout, the SQLite schema, and the hashing rules
together form the on-disk format, versioned by DATA_FORMAT_VERSION. Any backward-incompatible
change to any of them must bump that version. The hash_golden test pins representative hashes; if
it fails because you changed the hashing domain, that is a format-breaking change, not a test to
"fix."
Generated Surfaces
- The C header
crates/infrastore-ffi/include/infrastore.his generated by cbindgen — do not hand-edit it; it is refreshed when you buildinfrastore-ffi. - The proto-derived Rust types in
infrastore-protoare generated bytonicfromcrates/infrastore-proto/proto/infrastore/v1/store.proto.
When you change the public surface of a binding, update the matching reference page in this book.
Documentation
Docs follow the Diataxis framework. Put new pages in the right category:
| Category | Location | When |
|---|---|---|
| Getting Started | src/getting-started/ | First-run, learning-oriented quick starts |
| Explanation | src/explanation/ | Concepts, architecture, design rationale |
| Developer Guides | src/guides/ | End-to-end usage, install through workflow |
| Reference | src/reference/ | Exact signatures, schemas, on-disk layouts |
There is deliberately no separate how-to section: a task-sized recipe belongs in the guide for the language it applies to, so a reader following one page is not sent between three.
After adding a page, add it to src/SUMMARY.md. Preview locally:
cd docs
mdbook serve --open
Significant design decisions belong in the Explanation section, covering the problem, the approach, the trade-offs, and the alternatives considered.
Releasing
infrastore ships from one repository to three registries, plus prebuilt binaries on the repository's own releases. This page is the procedure.
| Channel | Package(s) | Registry |
|---|---|---|
| Rust | infrastore-core, infrastore-proto, infrastore-ffi, infrastore-server, infrastore-cli | crates.io |
| Python | infrastore | PyPI |
| Julia | InfraStore (binaries via Artifacts.toml → GitHub Releases) | Julia General |
| Binaries | infrastore, infrastore-server, libinfrastore_ffi + header | GitHub Releases |
infrastore-py and infrastore-bench set publish = false: the first ships as the infrastore
wheel on PyPI rather than as a crate, and the second is an internal benchmarking tool.
Versioning
The Rust crates, InfraStore.jl, and the Python package share the workspace version. Bump them
together and tag the repo once per release, so every registry pins the same commit.
The version lives in four places that must agree, and cargo release (below) writes all four:
| File | Field | Written by |
|---|---|---|
Cargo.toml | [workspace.package] version | cargo-release, natively |
Cargo.toml | [workspace.dependencies] pins on infrastore-core/-proto | cargo-release, natively |
crates/infrastore-py/pyproject.toml | [project] version | crates/infrastore-core/release.toml |
julia/InfraStore.jl/Project.toml | version | crates/infrastore-core/release.toml |
The [workspace.dependencies] pins are easy to miss and fail late: cargo publish uploads
infrastore-core at the new version, then rejects infrastore-proto because its requirement still
names the old one. cargo-release moves Cargo.lock in the same commit.
Two more version strings ride along, neither load-bearing: the VERSION=v0.8.0 download example in
docs/src/getting-started/installation.md and in docs/src/guides/cli.md, so a version's published
docs point at that version's own binaries.
Two workflows guard this. crates-release refuses to publish if the tag does not match the
workspace version, and python-wheels opens with a versions agree job that parses all four files
above and fails unless they agree with each other and with the tag.
That job exists because v0.5.0 was tagged with pyproject.toml still at 0.4.0. maturin lets
[project] version win over the Cargo workspace version, so every wheel job built and tested
0.4.0 artifacts and passed; only the upload caught it, with 400 File already exists, because
0.4.0 was long since on PyPI. A PyPI filename can never be reused, so that tag was unpublishable
and the release had to move to 0.5.1. The guard runs before the wheel matrix, so the same mistake
now costs seconds instead of a burned version number.
HDF5 linkage
Every distribution channel — the Rust crates, the Python wheel, and InfraStore_jll — ships with
the vendored feature: HDF5 and zlib are compiled from source and linked statically.
pip install infrastore and cargo add infrastore-core need no system libraries, only cmake and
a C compiler at build time, and every channel is backed by the exact HDF5 version infrastore was
tested against rather than whatever the target environment resolves.
For the JLL this is a deliberate departure from Julia-ecosystem convention (linking HDF5_jll),
made for two reasons:
- Format control. The store is a data artifact with a compatibility contract
(
DATA_FORMAT_VERSION); pinning the HDF5 that backs it removes an entire class of environment-dependent behavior. AnHDF5_jllupgrade in a user's environment must not change how infrastore files are read or written. - No MPI dependency.
HDF5_jllis MPI-augmented and publishes no serial variant, so linking it forces an MPI runtime dependency and a 17-triplet build matrix onto a library that never calls MPI — and propagates that dependency toInfraStore.jland InfrastructureSystems.jl.
Two libhdf5 copies in one Julia process (ours plus HDF5.jl's) is safe here: the cdylib exports only
its own infrastore_* symbols — the statically linked HDF5 symbols stay local, so nothing can
cross-resolve. The one scenario that is genuinely hazardous, opening a live store's .h5 file
directly with HDF5.jl/NCDatasets.jl while a Store handle is open, is explicitly unsupported.
Never set
HDF5_DIRin CI. The vendored HDF5 build forwards it to cmake asHDF5_ROOTwhile still requesting static libraries; against a shared-only install such as conda-forge's this fails withCould NOT find HDF5 (missing: HDF5_LIBRARIES HDF5_HL_LIBRARIES). To build against system libraries, use--no-default-featuresinstead.
Multiple HDF5 copies in one Python interpreter
The same hazard applies in reverse to the wheels, which carry a statically linked HDF5 while a
typical downstream environment also has netCDF4 and h5py, each bundling its own. This is a
tested property, not an assumption: python/tests/test_hdf5_interop.py drives real reads and writes
through all three libraries in one process, in both initialization orders, and cibuildwheel runs the
suite against every built wheel in its target environment (test-requires / test-command in
pyproject.toml). Imports alone are not enough — on Linux a collision surfaces as silent symbol
interposition rather than a clean error, which is why the test exercises I/O.
If that check ever fails on a new platform, the fallbacks are to build that platform's wheels with
--no-default-features against system libraries, or to hide the HDF5 symbols with a version script.
musllinux stays in skip in pyproject.toml. Vendoring removed the original blocker (the
RPM-based before-all could not install HDF5 on musl), but the target has never been built, so
un-skipping it is a separate change that needs its own CI run.
Cutting a release
1. Bump and tag
The bump is automated with cargo-release
(cargo install cargo-release), configured by release.toml at the workspace root and
crates/infrastore-core/release.toml. From a clean tree on a branch:
cargo release minor # dry run -- prints every edit, changes nothing
cargo release minor --execute # 0.8.0 -> 0.9.0, one commit
patch and major work the same way, and an explicit cargo release 1.2.3 --execute sets a
version outright. That one command rewrites [workspace.package] version, the
[workspace.dependencies] pins, Cargo.lock, pyproject.toml, InfraStore.jl's Project.toml,
and the two docs download examples, then commits them as Release v0.9.0. The pre-commit hook runs
rustfmt, Clippy, dprint, and shellcheck on the way through.
Three details of the configuration are deliberate:
- It does not publish.
crates-release.ymlandpython-wheels.ymlown that, on the tag, with trusted publishing and no local credentials. Rehearse packaging separately withcargo publish --workspace --dry-run, which verifies every crate packages and builds. - It does not tag or push. The tag drives all three release workflows, so it has to name the
commit that ends up on
main— not the local bump commit, which changes identity when the pull request merges. - It refuses to run on a dirty tree. Untracked scratch files count; park them in
.git/info/excludeif you keep any.
So open the bump as a pull request, and once it is merged:
git switch main && git pull
git tag v0.9.0
git push origin v0.9.0
Pushing the tag triggers three workflows: crates-release, python-wheels, and release.
Tag the merge commit once its own CI is green. crates-release now enforces this itself (see
step 2), so a tag on a red or untested commit fails the gate instead of
publishing — but it enforces it by waiting, so tagging ahead of CI just means the release job sits
there. Nothing is lost either way; the tag does not need to be re-pushed once Test finishes.
When a version string moves to a new file, add a [[pre-release-replacements]] entry for it in
crates/infrastore-core/release.toml — not in the root release.toml, where cargo-release would
resolve it relative to each of the seven crate directories in turn. Every entry requires at least
one match, so a file that gets renamed fails the release instead of quietly going stale.
2. Rust → crates.io
Handled by .github/workflows/crates-release.yml on the tag.
Its verify-ci job runs first and refuses to publish a commit that has not passed Test on all
three platforms. The check is worth understanding, because the two workflows are joined by commit
rather than by ref: crates-release fires on the tag, but test.yml fires on pushes to main and
dev and never on tags, so there is no Test run attached to the tag itself. verify-ci looks up
the run for the commit the tag names — which exists because the release commit reaches main
before it is tagged — and requires conclusion == "success".
It fails closed. A tag on a commit that never reached main has no Test run at all, and that is an
error rather than a pass; so is a run that is still going after 70 minutes. A tag pushed in the same
breath as the branch gets a 15-minute grace period for its run to appear before the job gives up.
This exists because an upload is irreversible in a way the rest of the pipeline is not: crates.io never lets a version number be reused, so publishing from a broken commit burns that version permanently, and the recovery is to abandon it and release the next one — which is what happened to v0.5.0 on the PyPI side. Waiting for CI by hand worked only as long as whoever pushed the tag remembered to; local checks are not a substitute either, since a maintainer runs them on one OS.
cargo publish --workspace resolves the intra-workspace order itself (infrastore-core →
infrastore-proto → infrastore-ffi / infrastore-server / infrastore-cli) and waits for each
crate to land in the index before publishing its dependents, so the crates must not be published
individually.
Authentication uses crates.io trusted publishing, which
needs a one-time setup per crate: on the crate's page, Settings → Trusted Publishing → Add, with
owner NatLabRockies, repository infrastore, workflow crates-release.yml, environment
crates-io. No token is stored in the repository.
Bootstrapping. Unlike PyPI, crates.io has no "pending publisher" — a trusted publisher can only be attached to a crate that already exists, so the first version of any new crate must be published by hand with an API token (
cargo publish --workspacewithCARGO_REGISTRY_TOKENset). This is how v0.1.0 went out. It applies again only if a new crate joins the workspace, not to subsequent releases of the existing ones.
Because of that, and because a re-run of a release should not fail, the workflow first checks the registry for each publishable crate at the workspace version and skips the upload entirely when they are all present. Publishing a version that already exists is an error on crates.io, so without that check, tagging after a manual publish would fail the job.
To rehearse without uploading, run the workflow manually with dry_run left checked.
3. Python → PyPI
Handled by .github/workflows/python-wheels.yml on the tag. cibuildwheel builds one abi3 wheel per
platform, runs the full pytest suite against each, and the publish job uploads to PyPI via trusted
publishing (environment pypi).
The abi3 floor is abi3-py311, set in three places that must agree: the pyo3 feature in
crates/infrastore-py/Cargo.toml, build = "cp311-*" in pyproject.toml, and requires-python.
4. GitHub Release binaries
Handled by .github/workflows/release.yml on the tag. It builds the infrastore CLI, the
infrastore-server binary, and the libinfrastore_ffi cdylib plus its generated header, then
attaches one archive per platform — each with a .sha256 sidecar — to a draft GitHub Release
with generated release notes. Review the notes and publish the draft by hand.
Because the workflow creates the draft itself, cut releases by pushing the tag rather than by
authoring a release in the GitHub UI first. A hand-made release is not a shortcut that saves the
wait — it is an empty release until create-release runs, and that job is gated on the whole
build matrix, so the assets do not exist until the slowest target (Windows) finishes. Two things
follow from that, both of which have bitten a release:
- Step 5 below cannot start early.
generate_artifacts.jldownloads everylibinfrastore_ffi.<triplet>.tar.gzto hash it, so against an assetless release it fails, and against a partially uploaded one it would hash whatever happens to be there. - Publishing it early does not stick reliably.
softprops/action-gh-releaseupdates the existing release for the tag rather than erroring, and it sendsdraft: truein that update. It has left an already-published release published, but do not count on that: check the release's state after the workflow finishes, and publish it (again, if need be) once the assets are attached.
The supported sequence is: push the tag, wait for release to go green, then review and publish the
draft it left.
| Target | Runner | Archive contents |
|---|---|---|
aarch64-apple-darwin | macos-14 | executables and C library |
x86_64-unknown-linux-musl | ubuntu-latest | executables only |
x86_64-unknown-linux-gnu | ubuntu-latest | C library only |
x86_64-pc-windows-msvc | windows-latest | executables and C library |
Linux is built twice on purpose. musl gives statically linked executables that run on any
distribution, including HPC login nodes with an older glibc than the runner — but a musl-built
cdylib loaded into a glibc Julia or Python process puts two C libraries in one address space, so the
shared library comes from a separate gnu build. On macOS the packaging step rewrites the dylib's
LC_ID_DYLIB to @rpath/libinfrastore_ffi.dylib; cargo otherwise bakes in the runner's absolute
build path.
The workflow builds selected packages rather than --workspace, which would drag in the PyO3 cdylib
(it needs an interpreter to link against, and ships as a wheel instead) and infrastore-bench. It
also uses --locked, so a release builds exactly what CI tested.
The same workflow deploys versioned documentation to a /infrastore/<tag>/ subdirectory of
gh-pages and updates versions.json, which drives the docs version picker. It keeps the five most
recent releases and prunes older ones from both the manifest and disk. It shares the pages
concurrency group with docs.yml, which owns the latest build from main; the two must not push
to gh-pages at once.
To rehearse the builds without cutting a release, run the workflow manually — the create-release
and docs jobs are both gated on a tag ref, so a workflow_dispatch run only exercises the matrix.
5. Julia → General
InfraStore.jl ships its binaries as a self-hosted artifact: julia/InfraStore.jl/Artifacts.toml
names one libinfrastore_ffi.<triplet>.tar.gz per platform, built and attached to the GitHub
Release by release.yml on the tag. No JLL and no Yggdrasil review sits in the release path; the
only human gates are General's one-time three-day review of a new package and the 15-minute
AutoMerge on every version after. (The Yggdrasil recipe still exists for the day its PR merges — see
Switching back to the JLL.)
The ordering wrinkle this flow exists to solve: Artifacts.toml cannot be in the tagged commit,
because its URLs and hashes do not exist until the tag's binaries are built and uploaded.
Registration is therefore decoupled from the tag — Registrator registers whatever commit the comment
lands on:
-
Publish the GitHub Release for the tag (CI leaves it as a draft). This must come first: a draft's asset URLs are not publicly downloadable.
-
Regenerate
Artifacts.tomlon a branch:julia julia/generate_artifacts.jl v0.6.0 -
Run the suite against the artifact, with
INFRASTORE_LIBunset — this is the path users get, and CI'sjulia-artifactjob only smoke-tests it (between releases the wrapper onmainmay call FFI exports the released binary does not carry yet, so the full suite cannot run in CI unconditionally):julia --project=julia/InfraStore.jl -e 'using Pkg; Pkg.instantiate()' julia --project=julia/InfraStore.jl julia/InfraStore.jl/test/runtests.jl -
Merge, then comment on the merged commit with Registrator, passing the subdirectory, which is required because the package is not at the repository root:
@JuliaRegistrator register subdir=julia/InfraStore.jlThe Registrator GitHub app must be installed on the repository (an org owner approves that); the JuliaHub web interface is the fallback.
Write the release notes into that same comment. They are read from the trigger comment and nowhere else — there is no TagBot here and the GitHub Release's own notes are not consulted, so notes omitted at this point can only be restored by editing the registry PR body by hand, between its
<!-- BEGIN RELEASE NOTES -->markers, before AutoMerge closes it. Put a blank line after the register line, then aRelease notes:line, then the notes:@JuliaRegistrator register subdir=julia/InfraStore.jl Release notes: ## Breaking changes - ...Write them for someone consuming the Julia package, not for someone reading this repository's commits: what breaks, what is new, and what an existing artifact costs. A
DATA_FORMAT_VERSIONbump belongs at the top of "Breaking changes" every time it happens — it rejects every store an earlier version wrote, and the Julia user has no other channel that tells them so.
General's AutoMerge requires a public repository, an OSI-approved license file in the package
directory, and [compat] bounds for every non-stdlib dependency including julia. There is no
initial-version requirement — only prerelease and build metadata are rejected — so a package may
first register at any plain version (this one registered at 0.6.0). New packages sit a three-day
waiting period before merge. AutoMerge installs and loads the package on Linux x86_64, which
downloads the artifact, so a wrong hash or URL in Artifacts.toml fails registration instead of
shipping.
Release assets are permanent. Every registered version's Artifacts.toml points at this
repository's release URLs forever. Deleting an asset or a release — or moving the repository without
a redirect — breaks Pkg.add for every registered version that references it.
Switching back to the JLL
The Yggdrasil route was the original plan and remains the eventual destination; it stalled because
no maintainer would review a Rust recipe (see JULIA_ARTIFACT_PLAN.md for the full history). The
recipe lives on under yggdrasil/, pinned to the release it was last synced with. When the
Yggdrasil PR finally merges:
-
Refresh the recipe's
versionandGitSourceSHA to the current release (git rev-parse vX.Y.Z^{commit}; Yggdrasil requires a full commit SHA, not a tag). Only changes undercrates/,Cargo.toml, orCargo.lockneed a new SHA — edits to the recipe itself do not, since Yggdrasil builds from its own copy. To test it locally (BinaryBuilder needs Docker on macOS):cd yggdrasil julia build_tarballs.jl --verbose --debug x86_64-linux-gnuA platform argument replaces the recipe's
platformslist rather than filtering it; the listed platforms carry no extra tags, so bare triplets are exactly right. -
Once
InfraStore_jllis registered, cut the nextInfraStore.jlversion: deleteArtifacts.tomlandjulia/generate_artifacts.jl, swaplib_path()to the JLL (the shape is parked on thejulia-jll-depbranch, which predates thelibinfrastoreconstant: every@ccallmust keep naming a constant library, since Julia 1.13 refuses a call there), addInfraStore_jllto[deps]with a[compat]bound matching the version the JLL first registers as (check the registry: a bound below the earliest published version resolves to nothing), and drop thejulia-artifactCI job. JLL UUIDs are deterministic —BinaryBuilder.jll_uuid("InfraStore_jll"). -
Register that version with the same Registrator comment; it auto-merges in about 15 minutes. The artifact-era release assets stay up forever regardless (see above).
The recipe builds with the default vendored feature — see HDF5 linkage for why
the JLL statically links its own HDF5/zlib instead of depending on HDF5_jll. Expect Yggdrasil
reviewers to ask about that; the rationale is written out in the recipe's header comment. HDF5_DIR
must remain unset during the build. The recipe patches one thing in the source tree: it drops sha2's
asm feature, because BinaryBuilder forbids forcing an arch via -march and the ARMv8 crypto
kernels cannot be assembled there (x86-64 still detects SHA-NI at runtime).
6. Downstream
InfrastructureSystems.jl depends on InfraStore.jl for its Rust time-series backend: add
InfraStore to its [deps] and replace the raw ccalls in src/rust_time_series_store.jl with
calls into the package. infrasys consumes the PyPI wheel.