Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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, or bool values, 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 infrastore command-line tool — Load time series from CSV, and list, read, export, plot, diff, and inspect a store straight from a terminal, with table / json / jsonl / csv output (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.

AudienceStart here
Developers of a package on topEmbedding in a Parent Package
Deciding how to model your dataTime-Series Types
Python package developersPython Developer Guide
Julia package developersJulia Developer Guide
Rust developersRust Developer Guide
Command-line usersCLI Developer Guide
Anyone deploying the servergRPC Server & Client
Tooling & forensicsOn-Disk File Format

Next Steps

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.

ConcernOwner
Array bytes, dedup, hashing, compressioninfrastore
Which series belongs to which owner, under what keyinfrastore
Component ↔ attribute and parent ↔ child edgesinfrastore
The components and attributes themselvesparent package
The type hierarchy (abstract types, subtypes)parent package
Mapping your object identities to integer idsparent package
Unit conversion, per-unit basesparent package
Partial / fuzzy lookups over featuresparent 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:

  • features matches 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 tagged scenario=high".
  • exact_features pins 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 DeterministicSingleTimeSeries is derived from a stored SingleTimeSeries with transform_single_time_series and reads back as a Deterministic. The tag stays visible in keys, metadata, and counts, and a Deterministic filter 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 units or application_data are 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 path and the save is what writes the catalog beside them. persist_catalog does 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 InvalidParameter rather than truncating, because the C ABI exchanges instants as Unix milliseconds while Python's datetime is microsecond. Quantize now() before storing it. Query bounds are unconstrained.
  • Python requires timezone-aware datetimes everywhere and returns UTC; a naive value raises.
  • Julia reads a bare DateTime as a wall clock, recording ZonelessReference(); with using TimeZones a ZonedDateTime is accepted anywhere a timestamp goes and converted to the instant it names. Reads still return a bare DateTime holding 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=True stores in unit tests; they exercise the same core.
  • verify_integrity re-hashes every array and time axis the catalog references; is_empty is the cheap "nothing here" probe.
  • The infrastore CLI reads any artifact your package writes. infrastore list and infrastore info are the fastest way to see what a consumer actually stored, and infrastore diff --against compares two artifacts by hash without reading arrays — a usable CI gate for "the rewrite produced the same store". See Use the infrastore CLI.
  • 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.toml with the consumer's venv active; Pkg.develop(path=...) plus INFRASTORE_LIB for 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_type names 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_memory stores are for tests.
  • Save goes through persist_to; load-for-edit goes through open_copy; read-only loads pass read_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:

LanguageInstall
Rustcargo add infrastore-core
Pythonpip install infrastore
CLIdownload a binary, or cargo install infrastore-cli
Juliapkg> 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.

ArchiveContents
infrastore-x86_64-unknown-linux-musl.tar.gzLinux x86_64 — infrastore, infrastore-server
infrastore-x86_64-unknown-linux-gnu.tar.gzLinux x86_64 — libinfrastore_ffi.so + infrastore.h
infrastore-aarch64-apple-darwin.tar.gzmacOS Apple Silicon — executables and C library
infrastore-x86_64-pc-windows-msvc.zipWindows 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 as HDF5_ROOT while still requesting static libraries, which fails against a shared-only install. To build against system libraries instead, turn vendoring off with --no-default-features and install them (brew install hdf5 / apt-get install libhdf5-dev).

Because hdf5-metno-sys declares links = "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

CrateWhat it builds
infrastore-coreTypes, HDF5 + SQLite storage, hashing, Rust API
infrastore-protoProtobuf service definition + tonic codegen
infrastore-servergRPC server binary + Rust client
infrastore-pyPyO3 bindings, abi3-py311 wheel
infrastore-ffiC ABI cdylib (the foundation of the Julia binding)
infrastore-cliinfrastore CLI binary (CSV add/read, inspect on-disk stores)
infrastore-parquetParquet export/import behind the CLI's default-on parquet feature
infrastore-benchinfrastore-bench binary (bulk-ingest + simulation-read benchmarks)

Next Steps

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.metadataname, 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

  1. Store.create(in_memory=True) built a store backed by an in-memory array backend and an in-memory SQLite metadata database.
  2. 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.
  3. read_by_id(series_id) looked up the row by primary key, read the array back by its content hash, and reconstructed a SingleTimeSeries.

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

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

  1. Store(in_memory=true) built a store backed by an in-memory array backend and an in-memory SQLite metadata database.
  2. 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.
  3. read_by_id(store, id) looked up the row by primary key, read the array back by its content hash, and reconstructed a SingleTimeSeries. Note that resolution comes back as a Millisecond.

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

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.h5 and demo.h5.sqlite were 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. add reads it to tell a hand-written value-only file from one infrastore export wrote, so a file whose first row is data is rejected rather than silently losing that row.
  • The store assigned id 1. That id is how every later read and removal addresses the series — see Association IDs.
  • Timestamps must name an instant. 2024-01-01T00:00:00Z does; a bare 2024-01-01 00:00:00 does not, and is rejected. Pass --assume-timezone UTC to 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, diff and merge — 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 / packageRole
infrastore-coreThe whole engine: types, storage backends, hashing, the Store API
infrastore-protoThe .proto service compiled with tonic; shared message types
infrastore-serverA tonic gRPC server wrapping a Store, plus an async RemoteClient
infrastore-pyPyO3 classes exposing Store as the infrastore module
infrastoreThe importable Python module — user-facing surface of the PyO3 wheel
infrastore-ffiA extern "C" cdylib with an opaque-handle API over Store
InfraStore.jlA Julia package that ccalls into the FFI cdylib
infrastore-cliThe infrastore binary: read+write access to an on-disk store from a terminal
infrastore-parquetPartitioned Parquet export/import, linked only by the CLI (parquet feature)
infrastore-benchThe infrastore-bench binary: ingestion and simulation-read benchmarks

The Core: Store

Store is a thin orchestration layer that composes two collaborators:

  • A StorageBackend that 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 when in_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_bulk and bulk_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. NonSequentialTimeSeries and 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 ForecastReader sizes its in-memory cache to that same block width, so stepping the window timeline decompresses each block exactly once. Reach for ForecastReader (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 ForecastReader reads 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_VERSION describes 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_REVISION describes the SQLite catalog alone and lives in its schema_version table. 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 entry in MIGRATIONS.

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:

  1. Open the HDF5 half and evaluate its version stamp. This must come first so a store too old to migrate reports IncompatibleFormat rather than a confusing SQLite error, and so a bad path does not leave a freshly created empty .sqlite behind. An upgradable stamp is noted, not yet rewritten.
  2. Open the catalog, which is where the ladder runs.
  3. 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 dispatchSingleTimeSeries
at explicit instants, and undefined between them — outages, eventsNonSequentialTimeSeries
changing only at breakpoints and holding until the next — a monthly fuel pricePersistentTimeSeries
a local-clock grid — hourly by the wall clock, across DSTNonSequentialTimeSeries (why)
windows of values issued at successive timesa 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_type naming the curve. See the element axis.
  • A grid you cannot fill densely is not automatically irregular. A SingleTimeSeries costs 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:

TypeWrite pathDescription
SingleTimeSeriesadd_time_seriesOne array sampled at a fixed resolution
NonSequentialTimeSeriesadd_time_seriesValues at explicit, irregular timestamps
PersistentTimeSeriesadd_time_seriesSparse step function: breakpoints carried forward
Deterministicadd_time_seriesForecast: a (horizon × count) window matrix
DeterministicSingleTimeSeriesderived by transform_single_time_seriesForecast view over an underlying SingleTimeSeries
Probabilisticadd_time_seriesForecast with percentile bands
Scenariosadd_time_seriesForecast 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:

NonSequentialTimeSeriesPersistentTimeSeries
value at a stored instantthat instant's valuethat instant's value
value between stored instantsa hard errorthe previous value
value after the last instanta hard errorthe last value
value before the first instanta hard errora 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.

TypeConventional array shapeExtra metadata
Deterministic(horizon_count, count)
DeterministicSingleTimeSeriesthe 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), where initial_timestamp + i * resolution is computed by calendar arithmetic (so a monthly grid lands on the same day-of-month each step rather than every N milliseconds).

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:

AxisWhat it saysWhere it lives
series typethe time semantics — a fixed grid versus explicit instantsthe seven types above
element typethe value shape — a scalar, a tuple, a piecewise curveelement_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:

FieldTypeMeaning
owner_idi64Stable identity of the owning object (a component identifier)
owner_typestringThe owner's concrete type, e.g. "Generator"
owner_categoryOwnerCategoryComponent 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. Identifylist_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 compact and a save-and-reopen. Those are UPDATEs 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:

DirectionRustPythonJulia
Writeadd_time_seriesTimeSeriesIdintInt64
Identifylist_metadatalist_metadatalist_metadata
…by idlist_metadata_by_idslist_metadata_by_idslist_metadata_by_ids
Resolveget_metadata_by_idget_metadata_by_idget_metadata_by_id
Validateassociation_existsassociation_existsassociation_exists
Readread_by_idsread_by_idsread_by_ids
Read oneread_by_idread_by_idread_by_id
Read rangeread_by_ids_rangeread_by_ids_rangeread_by_ids
Removeremove_by_idsremove_by_idsremove_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 ownerread_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 QUDT QuantityKind local name. It sits above units rather 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 when unit_system is component_base the 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 by units) or component_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 idea UnitSystem, with NATURAL_UNITS and DEVICE_BASE. It is a label, not a conversion: the store holds no base value and rescales nothing, so converting component_base values 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 as natural_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, where name only 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) and name is 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 is element_type below, 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 a PersistentTimeSeries to 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, else tuple(N,dtype) or one of the function-data kinds (linear_function, quadratic_function, piecewise_linear, piecewise_step). It supersedes a separate physical dtype: the dtype of the stored bytes is derived from it. Unlike units and application_data it 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

FieldMeaning
component_id, component_typeThe component carrying the attribute
attribute_id, attribute_typeThe 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

FieldMeaning
parent_id, parent_typeThe parent component, e.g. a generator
child_id, child_typeThe 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_associations table — 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. key is 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 reading get_metadata_by_id takes. 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_empty reports false for 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.

SpellingMeaning
utcAn instant, written as UTC.
-07:00An instant, written at a fixed offset from UTC.
America/DenverAn instant, written in a named IANA zone. Held opaquely.
zonelessA wall clock. Names no instant; the store holds it as if UTC.
unsetUnspecified — 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:

Bindingutcfixed offsetnamed zonezoneless
Pythontimezone.utcfixed-offset tzinfotzinfo exposing a key (ZoneInfo)naive datetime
JuliaUTC ZonedDateTimeFixedTimeZoneVariableTimeZone, by its namebare DateTime
CLIZ in text, or the flag-07:00 in text or flag--assume-timezone America/Denverbare timestamp, --zoneless
RustDateTime<Utc>declare itdeclare itdeclare 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 SingleTimeSeries and the dense forecasts, whose grid is a Period: a fixed count of milliseconds. Use NonSequentialTimeSeries, 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 P1D series stepped from local midnight in Denver lands on Nov 3 23:00 after the November transition, on the same calendar day as its predecessor, and stays an hour off forever. P1M is worse — it steps the UTC calendar. Both are InvalidParameter at 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:

ResultMeaningCLI behavior
a single instantthe ordinary caseingest it
two candidatesthe repeated fall-back hourerror, naming the row and both candidates
nonethe skipped spring-forward hourerror, 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 referenceWall-clock boundInstant bound
utc / offset / zoneerroraccept — any offset names the same instant
zonelessaccepterror
unseterroraccept

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:

  1. 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.
  2. A StaticReader materializes one timestamp axis, so a mixed cohort is refused at build time, where the error can name the series that disagree. Mixing utc, 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 and utc when they merely agree on naming instants.
  3. ListFilter::zoneless is the constructive half: true selects the wall-clock series, false selects 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 trap component_field documents), 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):

ReaderSweeps
StaticReaderall three static types — one value per column per instant
ForecastReaderthe 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 typeResolutionThe columns must…
SingleTimeSeriespinnedshare one grid — initial_timestamp and length, unless the caller names the span (see below)
NonSequentialTimeSeriesnonelie on one timestamp vector (the on-disk cohort)
PersistentTimeSeriesnonenothing — 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_at reports 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_anchorable rule 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 windowThe grid filter
Ragged seriesall take part, each at its own offsetonly those on the named grid
The axisthe span you namedthe grid the matched series share
Reachbuild_static_reader onlyevery filter-taking call
A mismatchan error naming the seriesthat 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:

ConcernArraysMetadata
SizeLarge (thousands of values each)Small (a row plus a shared feature set)
Access patternBulk read by contentFiltered queries by owner, name, features
MutationImmutable; whole-array add/delete, dedup-on-writeInsert / delete with constraints
Best toolHDF5 (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&nbsp;sts_f64_s_8760_PT1H&nbsp;&nbsp;shape&nbsp;(8760,&nbsp;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 of u8; row i holds the SHA-256 hex of column i as 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 compression global 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 the data_hash that links it to a packed column or standalone variable, the array typing (dtype, element_shape), the opaque package-owned application_data payload, plus temporal fields, forecast parameters (horizon, interval, count, percentiles), the unit descriptors (units, quantity_kind, unit_system), the time_reference recording how the timestamps were spelled, the component_field the values vary over time, and the features_hash.
  • feature_sets — the expanded key/value pairs of a feature map, one row per key, typed by a value_kind discriminator. 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 to time_series_associations and no ON DELETE CASCADE (see Compaction below).
  • schema_version — a single version column holding the catalog schema revision (CATALOG_SCHEMA_REVISION, currently 2). Its own contract, independent of the artifact's data_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 revision 1. 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_array is 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 DuplicateTimeSeries constraint 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 buffered bulk_add session) 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 standalone arr_ 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:

ModeCatalogDurability
Attachedis the <path>.sqlite fileevery commit, as soon as the OS writes back
InMemoryheld in RAM, loaded on openonly 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:

  1. A dtype tag: the dtype name (f64, f32, i64, i32, i16, i8, u64, u32, u16, u8, bool) followed by a NUL byte.
  2. The shape: the rank as a little-endian u64, then each dimension as a little-endian u64.
  3. The elements in row-major order. Float dtypes canonicalize NaN to a single quiet-NaN bit pattern before hashing; integer and bool bytes 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.
  • NaN is canonicalized (float dtypes only). Any NaN, regardless of its payload bits, hashes as a single canonical quiet-NaN pattern, so semantically equal float arrays never collide on NaN representation.

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_hash column 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_sets table 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 a StaticReader sweep 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 new arr_{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:

  1. Deletes the matching association rows inside a SQLite transaction, collecting their data_hashes.
  2. For each freed hash, counts how many associations still reference it.
  3. 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::compact sweeps them. It deletes every feature set and every timestamp vector no association references any more, reporting the row counts as feature_sets_reclaimed and timestamp_sets_reclaimed. On an on-disk store the same call then rewrites the .h5 file, 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, or length misdescribes 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 .sqlite half 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 compact is 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/timedelta to chrono types and NumPy arrays (any shape) to TypedArrays at the boundary, supporting the full dtype set (f64, f32, the integer widths, bool).
  • Translates the typed TimeSeriesError variants into a Python exception hierarchy rooted at TimeSeriesError (NotFoundError, DuplicateTimeSeriesError, InvalidParameterError, IntegrityError, ReadOnlyStoreError).
  • Builds an abi3-py311 wheel, so one wheel works across CPython 3.11+ without recompiling.
  • Converts a static series to a pyarrow.Table with to_arrow(), behind the optional arrow extra. 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 its timestamp(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. InfraStore and InfraStoreKey are pointers; the Julia structs wrap them and register finalizers (close!, _finalize_key) that call the matching ts_*_free function.
  • Status codes plus thread-local error messages. Every C function returns an int32_t code. On a non-zero code, Julia calls infrastore_last_error_message to 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, or infrastore_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 Features map.
  • Forecasts are wrapped. InfraStore.jl exposes Deterministic / Probabilistic / Scenarios structs passed to the generic add_time_series!, id-addressed read_by_id getters, and transform_single_time_series!, so all four forecast types are usable from Julia.
  • Bulk reads use a result handle. read_by_ids reads many full SingleTimeSeries at once: the FFI fetches them in one decompress-once pass per dataset into a InfraStoreBulkReadHandle (infrastore_store_bulk_read_single), and Julia reads each element out, then frees the handle. Python's store.read_by_ids exposes the same operation directly. read_by_ids addresses the same read by catalog association id and fills the same handle, so both reads decode by the same route: infrastore_bulk_result_item_name hands each item's name back beside its values, as infrastore_bulk_result_item_type does its type. Managed bulk writes already take the fast block-write path through the existing batch / add_time_series_bulk APIs.

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/--format selects table (default), json, jsonl, or csv. Read commands render their results in it; write commands report their outcome in it (prose under table, a one-object status document under json/jsonl). Only template ignores 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

ConcernSingle source of truth
Types & validationinfrastore-core (Store, TimeSeriesId, Features)
On-disk formatHdf5Backend + MetadataStore — identical regardless of caller
Hashingarray_hash / features_hash — the cross-language contract
Error taxonomyTimeSeriesError, 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:

CapabilityRust coreC ABIPythonJuliaCLIgRPC
SingleTimeSeries r/wread-only
NonSequentialTimeSeries r/wread-only
PersistentTimeSeries r/wread-only
dtypes beyond f64read-only
Create forecasts
Read forecast values
Forecast metadata / countslist/counts
Readers (columnar sweep)grid
Association catalogs
Store attributesstore-attrread-only
Materialized timestamps
from_timestamps (verified)
Arrow tables (to_arrow)
Parquet filescrate-f parquet
Store summary (show)store-info
Forecast windows as ArrowDeterministic

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-core directly.
  • Python — Use the infrastore PyO3 wheel.
  • Julia — Use the InfraStore.jl package 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

  • ImportError for the extension — Ensure you ran maturin develop in the active venv, or that you pip install-ed the wheel into the interpreter you are running.
  • HDF5 build errors with HDF5_DIR set — 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).
  • InvalidParameterError on add — Pass a NumPy array (any shape) whose dtype is one of float64, float32, int64, int32, int16, int8, uint64, uint32, uint16, uint8, or bool; any other dtype (e.g. complex128 or a string dtype) raises. Feature values must be int/float/bool/str. Timestamps for a NonSequentialTimeSeries must 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 float64 series 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 entryelement 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 Ntuple(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:

TargetWhat it covers
infrastore_coreAll 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:

  1. The INFRASTORE_LIB environment variable — the development override.
  2. The libinfrastore_ffi artifact Pkg downloaded 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() (or Pkg.add("InfraStore") again) fetches it. If it is your own path, export INFRASTORE_LIB before the first store call.
  • could not load library — Check the path exists and has the right extension for your OS (.dylib on macOS, .so on Linux, .dll on Windows), and that you built with --release if your variable points at target/release.
  • InvalidParameterError on addowner_id must be an integer (e.g. 42, an Int64), and features values 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_id is an integer (Int64) — the component identifier, e.g. 42.
  • resolution is a Period such as Hour(1) or Minute(5).
  • features is a Dict serialized 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 Float64 series 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 typeelement_typeConstructor
LinearFunctionlinear_function(proportional, constant)
QuadraticFunctionquadratic_function(quadratic, proportional, constant)
PiecewiseLinearpiecewise_linear(points), a vector of XYCoords
PiecewiseSteppiecewise_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.
  • OwnerCategory distinguishes Component from SupplementalAttribute and 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) plus get_metadata_by_id and get_array_by_hash let 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_slots surface 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:

TargetWhat it covers
infrastore_coreAll 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 p5p95 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:

TypeShape
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_COLOR is set, so -f json/-f csv stay clean for other tools.
  • --log-level (or RUST_LOG) controls logging; the default is quiet (warn).
  • The .h5 and .h5.sqlite files 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

  1. Produce a store with any binding and flush() it to disk.

  2. 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"
    
  3. 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

SubcommandWhat it measures
addadd_time_series_bulk throughput — HDF5 packing and SQLite transaction cost
readPer-timestep simulation I/O — reading all N components at each step t
allRuns add then read back-to-back

Common flags

FlagDefaultDescription
--count N1 000Number of components (time series)
--length L168Timesteps per SingleTimeSeries; window count for Deterministic
--in-memoryoffUse an in-memory store — eliminates disk I/O to isolate CPU cost
--path DIRtemp dirDirectory 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 AddRequest objects 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_id implementation reads the full [H × count] array from storage and then slices to the requested window in memory. For large count (e.g. 168 windows) each single-step read transfers H × count × 8 bytes, which is 24 × 168 × 8 = 32 KB per component. The benchmark output flags this explicitly so the overhead is visible.

Interpreting results

The two metrics to watch:

  • add throughput 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 packed SingleTimeSeries layout 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:

SpanLayerKey fields
add_time_series_bulkStorecount — number of items in the bulk request
read_by_idStoreid, window
copy_time_seriesStoresrc_id — the source association
remove_by_idsStorecount — number of ids in the request
read_by_idsStorecount — number of ids read in one pass
list_metadata_by_idsStorecount — number of ids requested
put_arrayHDF5 backendbytes, packed
put_packedHDF5 backendbytes
put_packed_blockHDF5 backendn — series written in one batch-sized block
put_standaloneHDF5 backendbytes
get_array / get_sliceHDF5 backendstart, end (slice only)
read_arraysHDF5 backendn — arrays fetched in one decompress-once pass
read_index_intoHDF5 backendn, index — one timestep across n series
read_window_intoHDF5 backendcount_axis, window_index
read_lockedHDF5 backend
rebuild_indexHDF5 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_arrayput_standalone span per standalone item; a single add_time_series call instead emits one put_arrayput_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 parquet writes and add --parquet reads.
  • Element Types — What element_type means, the row layouts it names, and the conformance corpus every binding's codec is tested against.
  • Rust APIinfrastore-core public types and Store methods.
  • Python API — The infrastore module.
  • Julia API — The InfraStore.jl package.
  • C ABI — The infrastore_ffi cdylib functions.
  • gRPC API — The infrastore.v1 service.
  • 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_VERSION and 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. CatalogMigrationRequired comes from a stale CATALOG_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 with IncompatibleFormat, 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:

CodedtypeWidthCodedtypeWidth
0f6486i162
1f3247i81
2i6488u324
3i3249u162
4u64810u81
5bool1

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:

ElementMeaning
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 columnsMAX_CHUNK_BYTES over 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 not DEFAULT_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_BYTES over one column's byteslength × 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: scalar f64 is 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 are min(MAX_CHUNK_BYTES / element_block, MAX_PENDING_BYTES / (length × element_block)).
  • Across every pool, MAX_PENDING_BYTES = 128 MiB of 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:

chunkwritefile (1.074 GB raw)8,760 consecutive timestamp reads
(1, 64) = 512 B59.5 s1.098 GB0.97 s
(8, 64) = 4 KiB19.1 s0.909 GB1.26 s
(32, 64) = 16 KiB13.8 s0.843 GB1.26 s
(64, 64) = 32 KiB14.8 s0.813 GB1.24 s
(128, 64) = 64 KiB16.5 s0.787 GB1.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 i holds one complete series.
  • Hash companion dataset. Each packed dataset has a sibling {dataset}_h dataset of u8, shaped (cols, 64). Row i holds the lowercase hex SHA-256 (64 characters) of column i as 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 its hash → (dataset, column) map. (The backend also recovers each dataset's cols from 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 (NonSequentialTimeSeries or PersistentTimeSeries) 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 the timestamps group below. Packing is only a win once a cohort is several columns wide — a packed dataset spreads one array over length chunks — 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), where count is the number of forecast windows. They are chunked in bounded blocks along the count (window) axis — full on every other axis, cols windows wide, where cols is 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 the ForecastReader aligns 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 valueMeaning
noneNo compression filter
deflate:{level}:shuffleDEFLATE at level (0–9), byte-shuffle on
deflate:{level}:noshuffleDEFLATE 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 as feature_sets_reclaimed and timestamp_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:

  1. 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.
  2. 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.
  3. 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 .repack file, 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.

ColumnTypeNotes
idINTEGERAUTOINCREMENT primary key; never reissued — see below
owner_idINTEGEROwner identity; signed 64-bit integer identifier (part of key)
owner_typeTEXTOwner's concrete type, descriptive
owner_categoryINTEGERCode, CHECK in (0, 1); part of key — see below
time_series_typeINTEGERCode, CHECK >= 0; part of key — see below
nameTEXTSeries name
initial_timestampTEXTRFC 3339 string; NULL for the explicit-time-axis types
resolutionTEXTISO-8601 duration (PT1H, P1M, …); NULL for those types too
lengthINTEGERNumber of timesteps
horizonTEXTISO-8601 forecast horizon; NULL for non-forecasts
intervalTEXTISO-8601 forecast interval; NULL for non-forecasts
countINTEGERForecast window count; NULL for non-forecasts
timestamps_hashBLOB32-byte hash of the timestamp/breakpoint vector; see below
unitsTEXTFree-form units label
quantity_kindTEXTWhat the values measure (QUDT QuantityKind name); NULL if unset
unit_systemTEXTnatural_units or component_base; NULL means unspecified
time_referenceTEXTHow the timestamps were spelled (below); NULL means unspecified
component_fieldTEXTOwning component's field these values vary; NULL if unset
percentiles_jsonTEXTJSON array of percentiles for Probabilistic; NULL else
element_typeTEXTCanonical element-type string (NOT NULL DEFAULT 'f64')
element_shapeTEXTJSON array of per-step dims ([] = scalar)
application_dataTEXTOpaque package-owned payload (JSON), verbatim; NULL if unset
data_hashBLOB32-byte SHA-256 of the array; links to an HDF5 column/variable
features_hashBLOB32-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.

ColumnTypeNotes
keyTEXTFeature name
value_kindTEXTCHECK in (int, float, bool, str)
value_intINTEGERSet when value_kind = 'int'
value_floatREALSet when value_kind = 'float'
value_boolINTEGER0/1, set when value_kind = 'bool'
value_strTEXTSet when value_kind = 'str'
features_hashBLOB32-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 …_h dataset for packed arrays, the arr_ dataset name for standalone arrays). BLOB rather than hex TEXT in SQLite because the two hash columns sit in the association table, in idx_hash, and in both unique indexes — hex would cost roughly 32% more catalog space — and because a BLOB literal (X'…') compares case-insensitively while a hex TEXT column would not. The time_series_readable view supplies the readable form.
  • element_shape is the per-step shape only (the trailing axes); the time length is a separate column.

Discriminant encoding

owner_category and time_series_type are stored as small INTEGER codes, not names:

owner_categorycodetime_series_typecode
Component0SingleTimeSeries0
SupplementalAttribute1NonSequentialTimeSeries1
Deterministic2
DeterministicSingleTimeSeries3
Probabilistic4
Scenarios5
PersistentTimeSeries6

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) and DeterministicSingleTimeSeries (3) are adjacent. A request for Deterministic matches both (see the data model), so adjacency lets that widen to time_series_type BETWEEN 2 AND 3 — one index seek instead of a two-value IN.
  • 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() and from_arrow() are a different thing again: per-series, in-memory conveniences. The relationship is one sentence — a series file's columns are to_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
FileRowsCarries
<stem>.valuesone per valuethe array key, the time coordinates, the value
<stem>.seriesone per seriesthe 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:

Typetime_axis
SingleTimeSeriesR<length>/<initial>/<resolution>, an ISO 8601 repeat
NonSequentialTimeSeries, PersistentTimeSeriesthe axis's timestamps_hash, hex — the catalog's own key for it
dense forecastsR<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

ColumnTypeFilesNotes
data_hashutf8 (hex)allHalf the array key; also a checksum on import.
time_axisutf8allThe other half.
timestamptimestamp(ms, zone)allThe target time for a forecast, the breakpoint for a PersistentTimeSeries.
issue_timetimestamp(ms, zone)forecastsWhich window the row belongs to. Same zone as timestamp.
percentilefloat64ProbabilisticOne row per (issue, target, percentile).
scenarioint64ScenariosZero-based trajectory index.
valuesee belowall

Nothing about who owns the array is here, which is exactly what stops a shared profile being written once per component.

Series file columns

ColumnTypeFilesNotes
data_hash, time_axisutf8allThe array this series reads.
idint64allProvenance only; ignored on import.
owner_idint64all
owner_typeutf8all
owner_categoryutf8allComponent or SupplementalAttribute.
time_series_typeutf8allConstant per partition.
nameutf8all
initial_timestamptimestamp(ms,zone)SingleTimeSeries, forecastsThe anchor the values start at.
resolutionutf8 (ISO-8601)SingleTimeSeries, forecastsAbsent from the irregular types, which have no constant step.
lengthint64SingleTimeSeries
interval, horizonutf8 (ISO-8601)forecasts
countint64forecastsWindows.
featuresutf8 (JSON object)all{} when empty. Plain scalars: {"model_year":2030}.
element_type, element_shapeutf8allConstant per partition except a composite's width.
time_referenceutf8allConstant per partition; the literal unspecified when the series has none.
units, quantity_kind, unit_system, component_field, application_datautf8allEmpty 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 shapeArrow 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 kindsFixedSizeList<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.

Both files carry it.

KeyValue
infrastore.formatnormalized_v1. Read first, so a later format is refused by version rather than by a missing column.
infrastore.rolevalues or series.
time_series_typeThe partition's type.
element_typeThe partition's element type.
element_shapeJSON list; a composite's is the width the partition settled on.
time_referenceThe partition's spelling, unspecified included.
rows_contiguous_by_keytrue.

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 stemout/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 SingleTimeSeries whose resolution says PT1H must walk one — checked against the grid that resolution generates, not against successive differences, since P1M clamps to month end.
  • Struct and List value columns. Those are the decoded form this version does not write.
  • A DeterministicSingleTimeSeries partition. The type is derived from a stored SingleTimeSeries rather than added; import that and run transform.

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:

MissingRead as
time_series_typeSingleTimeSeries when the timestamps walk a grid, NonSequentialTimeSeries otherwise. PersistentTimeSeries is never inferred — name it with --type.
element_typeThe leaf Arrow type. A FixedSizeList<double>[3] becomes f64 with element shape [3]dense, not tuple(3,f64), because the bytes cannot say.
time_referenceThe 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_typeNothing. 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. add never accepts one — "never reissued" is a guarantee of the catalog's AUTOINCREMENT, and a caller free to name an id could re-file a retired one — so the id column is reported at --dry-run and 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_hash may 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_typeelement shaperow 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 for piecewise_step);
  • every ragged row's leading count n must 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:

  • Rustinfrastore_core::{decode, encode} over TypedArray + ElementType. Prefer the paired forms: every value type has a from_values constructor that encodes the values and declares the element type they imply, and TimeSeriesData::decoded_values reads them back. An element_type and 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_as is the declared-type encoder for the one series from_values cannot 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_values classmethod that encodes the values and declares the element type they imply, and .decoded_values() on a series reads them back. Underneath sit infrastore.decode_element_values(array, element_type, leading_dims) and encode_element_values(values, element_type, leading_dims), for the cases the pair cannot name — an empty tuple(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 DecodedValues or a Julia Vector{PiecewiseLinear}, so from_values reads the element type off the shape of a row. The five shapes are disjoint, which makes that a decision rather than a guess:

    values entryelement 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 Ntuple(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 empty values, 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 empty tuple(N,f64), whose arity lives in rows it does not have.

  • JuliaInfraStore.encode_element_values / decode_element_values, over the value types LinearFunction, QuadraticFunction, PiecewiseLinear and PiecewiseStep. Decode takes a types keyword, so a consumer with its own domain types — InfrastructureSystems.jl's FunctionData — decodes straight into them and pays no conversion; encode is three small generic functions it extends instead. The names follow the wire vocabulary (PiecewiseLinear, not PiecewiseLinearData) so that using InfraStore, InfrastructureSystems is 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_type string, so the type to build is chosen by name: decode_element_values(...; types = ...), and read_by_id(store, id; types = ...).
  • Encoding starts from a value, so it is open dispatch: add element_type_tag, element_row_width and write_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) — creates path (HDF5) and path.sqlite (metadata). Fails with StoreExists if 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 and CatalogMode.
  • Store::create_replacing(...) — discards any artifact already at path, both halves plus the catalog's -wal/-shm sidecars, 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 = true rejects all writes.
  • Store::open_copy(src, dest, catalog) — copies both halves to dest and opens 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.
  • 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 any TimeSeriesData variant — 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 with DuplicateTimeSeries if the key already exists or ReadOnlyStore on a read-only store. It is a convenience wrapper over add_time_series_bulk.
  • transform_single_time_series — Derives a DeterministicSingleTimeSeries from every stored SingleTimeSeries, sharing the underlying array (with count derived from the series length), and returns the number of series transformed. This is the only way to create a DeterministicSingleTimeSeries; it is never added directly. The optional owner_category and resolution filters 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 a TimeSeriesData variant (static series and all forecast types). A read names only an id, so the row's own time_series_type decides what comes back — there is no requested type to disagree with it. A ReadWindow is checked: a start off the series' grid, or an extent past its end, is InvalidParameter rather than the smaller answer a range would clip to.
  • read_by_ids_range — The bounds read. start is inclusive and end is 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_seriesSome((id, category)) removes one owner's series (the owner is the (owner_id, owner_category) pair); None removes 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's owner_category and every descriptive column — crucially time_series_type, so a DeterministicSingleTimeSeries stays one rather than being materialized into a dense Deterministic (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 = None keeps the source name. Errors with DuplicateTimeSeries if 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 the id that addresses it. Its ListFilter reads time_series_type through TimeSeriesType::accepts, so asking for Deterministic also selects a stored DeterministicSingleTimeSeries, 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 answer None / false for 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 a data_hash off any catalog row.
  • count_array_references(sts, dst) association counts referencing one data_hash, so a caller can tell whether removing a SingleTimeSeries would orphan a DeterministicSingleTimeSeries derived 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 — Issues H5Fflush so the files can be copied for persistence without closing.
  • persist_to — Writes both halves of the artifact to path and <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. Unlike persist_to, writes no arrays: they are already in place. A checkpoint, not a mode switch — the catalog stays in RAM afterwards. For a CatalogMode::Attached store this is flush.

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:

TypeSelected
NonSequentialTimeSeriesevery timestamp t with start <= t < end
PersistentTimeSeriesthe breakpoint in force at start, then every one with start < b < end
SingleTimeSeriesevery step whose covered interval [t, t + resolution) overlaps the range
Deterministic / Probabilistic / Scenarios / DeterministicSingleTimeSeriesevery 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 — one StaticConsistency { resolution, initial_timestamp, length } per resolution present (empty Vec when there are no SingleTimeSeries), ordered by resolution; each row is the grid shared by every SingleTimeSeries at that resolution. Consistency is only required within a resolution — series at different resolutions legitimately have different grids — so pass Some(resolution) to scope the check to one grid. Returns IntegrityError when the series at a single resolution disagree.
  • counts_by_type — Association count per TimeSeriesType.
  • num_distinct_arrays — Distinct stored content hashes; series sharing an array count once.
  • time_series_counts_detailedTimeSeriesCountsDetailed: 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 — One StaticSummaryRow / ForecastSummaryRow per 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:

Typedata shapeextra metadata
Deterministic[H, count, *E]
DeterministicSingleTimeSeriesthe 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 one SupplementalAttributeAssociation. Errors with DuplicateAssociation if 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 is list_supplemental_attribute_associations with a default filter.
  • list_supplemental_attribute_associations / has_supplemental_attribute_association / count_supplemental_attribute_associations — The SupplementalAttributeFilter predicate 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 when component_id is set, and the components carrying an attribute when attribute_id is 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 component old_id to new_id, returning the rows updated. Errors with DuplicateAssociation if new_id already 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 one ParentChildAssociation. Errors with DuplicateAssociation if 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 is list_parent_child_associations with a default filter.
  • list_parent_child_associations / has_parent_child_association / count_parent_child_associations — The ParentChildFilter predicate 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 when parent_id is set, and its parents when child_id is set.
  • remove_parent_child_associations — Removes every matching edge and returns the count.
  • replace_parent_child_component_id — Rewrites component old_id to new_id on both ends of every edge, returning the rows updated. Errors with DuplicateAssociation if the rewrite would duplicate an edge new_id already 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), or bool; whatever dtype is given round-trips unchanged. What those elements mean is the association's element_type (see Element types). A composite series is built with the from_values classmethod, 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, and encode_element_values / decode_element_values are 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:

Inputtime_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; path and compression arguments are ignored.
  • create(path=...) — writes path (HDF5) and path + ".sqlite" (metadata).
  • create(path=..., compression="none") — store arrays uncompressed; compression="deflate" with a compression_level / shuffle of your choice tunes the filter. The policy persists with the store and is reused on later appends. An unknown compression or out-of-range level raises InvalidParameterError.
  • catalog="attached" makes the catalog the .sqlite file, where every commit is durable; catalog="memory" holds it in RAM so it reaches disk only through persist_to(). Arrays stream to the HDF5 file either way. The default (None) matches the backend — "memory" when in_memory=True, else "attached" — so existing call sites are unchanged. An unknown catalog raises InvalidParameterError. See Where the Catalog Lives.
  • create(path=...) raises StoreExistsError if path or path + ".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=True discards both halves on purpose; it is rejected for in_memory=True, which has no artifact to replace.
  • open(path, read_only=True) — read-only open; writes raise ReadOnlyStoreError.
  • open(path, catalog="memory") — loads the catalog into RAM; the HDF5 half is still opened in place. store.catalog reports the mode.
  • open_copy(src, dest) — copies both halves to dest and opens the copy read-write, leaving src untouched. 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 and persist_to(src) — one atomic rename replaces the original. Raises StoreExistsError if dest already 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 raises TypeError. The wheel ships a infrastore.pyi stub, so IDEs and type checkers see the full signatures.

Return shapes

  • add_time_series accepts a SingleTimeSeries, a NonSequentialTimeSeries, a PersistentTimeSeries, or a dense forecast object (Deterministic / Probabilistic / Scenarios) — see Forecasts. transform_single_time_series derives a DeterministicSingleTimeSeries from every stored SingleTimeSeries (or the subset its owner_category / resolution arguments select) and returns the count transformed. read_by_id returns whichever matches the stored type — a read names only an id, so the row's own time_series_type decides, with no requested type to disagree with it.

  • read_by_ids returns 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 to read_by_id: packed SingleTimeSeries are read in one decompress-once pass per dataset instead of one read each. An id naming no row raises NotFoundError and fails the whole call, unlike association_exists, which asks the question rather than committing to a read.

  • read_by_ids_range is the bounds read: it clips every series to what falls between the two instants, where read_by_id's window is checked. An export names bounds and does not know how many steps each series has inside them.

  • remove_by_ids is the removal direction of the same reference: one all-or-nothing transaction, the count removed, and NotFoundError if 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 from NotFoundError, 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 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. 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_metadata returns a list of dicts (the same shape get_metadata_by_id returns 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, or None for non-sequential series), length, resolution (ISO 8601 duration string, e.g. PT1H, or None), 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. timestamps is a list of RFC 3339 strings for non-sequential series and None otherwise; horizon / interval / count are set for forecasts and percentiles for Probabilistic only. timestamps is always None on 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 and read_by_id returns the series with its axis. The features filter 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 a SingleTimeSeries together with a DeterministicSingleTimeSeries derived from it). That replaced a separate array-group listing, which was this same query projected differently.

  • list_metadata_by_ids is 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_counts returns {"components_with_time_series": int, "static_time_series": int, "forecasts": int}; time_series_counts_detailed adds supplemental_attributes_with_time_series and spells the other two static_time_series_count / forecast_count.

  • show prints those same counts as a block of text and returns None. It is the hand-inspection surface — what a store holds, at a glance, without composing four calls and formatting the result. See show().

  • static_summary returns one dict per distinct (owner_type, owner_category, time_series_type, name, initial_timestamp, resolution, time_step_count) with its count; forecast_summary does the same for forecasts, adding horizon, interval, and window_count.

  • get_forecast_parameters returns {"horizon": str, "interval": str, "count": int, "resolution": str, "initial_timestamp": str}, where horizon, interval, and resolution are ISO 8601 duration strings (e.g. "PT1H") and initial_timestamp is an RFC 3339 string. Every value is None when the store holds no forecasts. The keyword-only resolution / interval arguments scope the query to forecasts matching that grid.

  • get_compression returns {"compression": "deflate" | "none", "level": int, "shuffle": bool} — the policy the store was created with (restored from the file on open; "none" for in-memory).

  • compact returns {"slots_reclaimed": int, "datasets_dropped": int, "feature_sets_reclaimed": int, "timestamp_sets_reclaimed": int, "bytes_reclaimed": int}. feature_sets_reclaimed counts content-addressed feature rows that no association referenced any more; see the file format. On an on-disk store the call rewrites the .h5 file from the live set and replaces it, which is what makes bytes_reclaimed nonzero — nothing else may have the store open while it runs.

  • verify_integrity returns {"ok": bool, "errors": list[str]}; ok is True when the error list is empty. It checks stored arrays against their recorded hashes and does not inspect the SQLite catalog, so ok is not a statement about the store as a whole — see content addressing.

  • read_by_ids_range with time_range=(start, end) slices on the time axis; end is 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) — or from_timestamps will (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.

ArgumentMeaning
unitsFree-form label for the values, e.g. "MW". Never interpreted or validated.
quantity_kindWhat 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_fieldThe owning component's field these values are the time-varying form of, e.g. "max_active_power". The one filterable one.
application_dataOpaque, package-owned payload (typically JSON) stored verbatim. End users are not expected to set it.
element_typeWhat the array's elements mean, e.g. "tuple(3,f64)". Omit for plain numbers; the property then reports the dtype.
time_referenceOverrides 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_referenceArrow 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.

MissingRead as
resolutionInferred from the timestamps, which must then walk a grid.
element_typeThe 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_referenceThe 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.
nameNothing. 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.

Typedata shapeextra 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 timeinitial_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 InvalidParameterError naming 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_id floors, 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_associations returns rows in insertion order, so exporting with no filter and importing the result with add_supplemental_attribute_associations is a round trip.
  • list_supplemental_attribute_ids returns the distinct attribute ids of the matching rows, ascending — the attributes attached to component c with component_id=c. list_components_with_attributes is the other end: the components carrying attribute a with attribute_id=a. 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, returning the rows updated, and raises DuplicateAssociationError if new_id already carries one of the attributes being moved.
  • supplemental_attribute_counts_by_type returns [(attribute_type, count), …] ordered by type; supplemental_attribute_summary returns one dict per distinct pair with keys component_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_associations returns rows in insertion order, so a no-filter export and an add_parent_child_associations import round-trip.
  • list_children returns the distinct child ids of the matching edges, ascending — the children of component p with parent_id=p; list_parents is the other end, the parents of component c with child_id=c.
  • replace_parent_child_component_id rewrites old_id to new_id on both ends of every edge, returning the rows updated, and raises DuplicateAssociationError if the rewrite would duplicate an edge new_id already 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:

ExceptionRaised when
NotFoundErrorA key or array does not exist
OwnerMismatchErrorAn id-addressed call named an owner the row is not
DuplicateTimeSeriesErrorAdding a series whose key already exists
DuplicateAssociationErrorRe-adding an attachment or edge that already exists
InvalidParameterErrorBad arguments (bad feature type, malformed period, …)
IntegrityErrorOn-disk inconsistency detected
ReadOnlyStoreErrorA write on a read-only store
IoErrorFilesystem I/O failure
ConnectionErrorConnection failure (module-scoped, not the builtin)
IncompatibleFormatErrorStore written in an incompatible on-disk format
StorageErrorSQLite catalog or serialization failure
StoreExistsErrorCreating a store where one already exists
MismatchedArtifactErrorThe .h5 and .sqlite halves came from two saves
CatalogMigrationRequiredErrorRead-only open of a store whose catalog needs upgrading
CatalogTooNewErrorThe 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 to system.h5 plus system.h5.sqlite.
  • compression=:none stores arrays uncompressed; :deflate (default) applies DEFLATE at compression_level (0–9) with optional byte shuffle. The policy is persisted with the store and reused on later appends; it is ignored for in-memory stores. An unknown compression throws ArgumentError.
  • catalog=:attached makes the catalog the .sqlite file, where every commit is durable; catalog=:memory holds it in RAM so it reaches disk only through persist! or persist_catalog!. Arrays stream to the HDF5 file either way. The default (nothing) matches the backend — :memory when in_memory is true, else :attached — so existing call sites are unchanged. An unknown catalog throws ArgumentError. See Where the Catalog Lives.
  • Store(in_memory=false, path=...) throws StoreExistsError if path or $path.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=true discards both halves on purpose; it throws ArgumentError for 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 to dest and opens the copy read-write, leaving src untouched. This is the safe way to load a store you intend to change. open_store 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 and persist!(store, src) — one atomic rename replaces the original. Throws StoreExistsError if dest already holds a store. Has a do-block form.
  • catalog_mode(store) returns :attached or :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 typeelement_typeConstructor
LinearFunctionlinear_function(proportional, constant)
QuadraticFunctionquadratic_function(quadratic, proportional, constant)
PiecewiseLinearpiecewise_linear(points), a vector of XYCoords
PiecewiseSteppiecewise_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_typeelement_shapetime_series_typewith 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.

StructReturned byFields
TimeSeriesCountsget_countscomponents_with_time_series, static_time_series, forecasts
TimeSeriesCountsDetailedtime_series_countscomponents_with_time_series, supplemental_attributes_with_time_series, static_time_series_count, forecast_count
TimeSeriesTypeCountcounts_by_typetime_series_type, count
ArrayReferenceCountscount_array_referencessts, dst
StaticSummaryRowstatic_summaryowner_type, owner_category, time_series_type, name, initial_timestamp, resolution, time_step_count, count
ForecastSummaryRowforecast_summaryowner_type, owner_category, time_series_type, name, initial_timestamp, resolution, horizon, interval, window_count, count
SupplementalAttributeTypeCountsupplemental_attribute_counts_by_typeattribute_type, count
SupplementalAttributeSummaryRowsupplemental_attribute_summarycomponent_type, attribute_type, count
ForecastParametersget_forecast_parametershorizon, interval, count, resolution, initial_timestamp (all nothing when nothing matches)
StaticGridstatic_grid, check_static_consistencyinitial_timestamp, resolution (nothing for an irregular reader), length, time_reference
ForecastTimelineforecast_timelineinitial_timestamp, resolution, interval, count, time_reference
CompressionSettingsget_compressioncompression (:deflate / :none), level, shuffle
CompactionReportcompact!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 the time_series_type field of a returned row carries. Deterministic additionally matches DeterministicSingleTimeSeries rows; each row still reports its own stored type, and passing DeterministicSingleTimeSeries selects only those.
  • name — exact association name.
  • name_glob — a SQLite GLOB pattern over the name (* and ?, case-sensitive), e.g. "wind_*". ANDed with name rather than replacing it: set both and a row must satisfy both.
  • resolution — a Period.
  • interval — a Period; 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 no component_field matches no value, so this cannot select the rows that left it unset.
  • zoneless — the coherence group: true selects the wall-clock series, false the 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:

TypeMapped from FFI code
NotFoundErrorINFRASTORE_ERR_NOT_FOUND
DuplicateTimeSeriesErrorINFRASTORE_ERR_DUPLICATE
DuplicateAssociationErrorINFRASTORE_ERR_DUPLICATE_ASSOCIATION
InvalidParameterErrorINFRASTORE_ERR_INVALID_PARAMETER / INFRASTORE_ERR_INVALID_UTF8 / INFRASTORE_ERR_NULL_POINTER
IntegrityErrorINFRASTORE_ERR_INTEGRITY
ReadOnlyStoreErrorINFRASTORE_ERR_READ_ONLY
IncompatibleFormatErrorINFRASTORE_ERR_INCOMPATIBLE_FORMAT
IOErrorINFRASTORE_ERR_IO
StoreExistsErrorINFRASTORE_ERR_STORE_EXISTS
MismatchedArtifactErrorINFRASTORE_ERR_MISMATCHED_ARTIFACT
OwnerMismatchErrorINFRASTORE_ERR_OWNER_MISMATCH
CatalogMigrationRequiredErrorINFRASTORE_ERR_CATALOG_MIGRATION_REQUIRED
CatalogTooNewErrorINFRASTORE_ERR_CATALOG_TOO_NEW
GenericErrorAny 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:

  • show renders compact one-liners for Store and the value types; every result struct (TimeSeriesMetadata, StaticSummaryRow, …) gets generated ==/hash/show, so results work as Dict/Set members, and AddBatch defines length.
  • length, eltype, getindex, and iterate on SingleTimeSeries / NonSequentialTimeSeries / PersistentTimeSeries delegate to the wrapped data array (element count, not time steps, for multi-dimensional values). Forecast types define length = 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

  • DateTime is converted to/from Unix milliseconds at the boundary. A bare DateTime carries no zone, so it names a wall clock, not an instant: it is stored as its own fields and recorded as ZonelessReference(). 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.ZonedDateTime is accepted wherever a DateTime is — an initial timestamp, a timestamp vector, a time_range bound, a reader's t — and is converted to the instant it names, recording the spelling its zone names. TimeZones is a weak dependency: the conversion lives in the InfraStoreTimeZonesExt extension, which loads when you using TimeZones, so nobody else pays for the tz database. Passing one without loading TimeZones raises an InvalidParameterError saying so.
  • Reads always return a DateTime holding the instant, whichever kind went in, with the spelling beside it as a time_reference. Widening the return type was rejected: it would make the type depend on package load order, and zdt == dt raises in Julia, so it would turn working comparisons against a DateTime literal 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.)
  • resolution is passed as a Period and converted to an ISO-8601 duration string; reads return resolution as a Period (Millisecond for 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:

Inputtime_reference
ZonedDateTime(..., tz"UTC")UTCReference()
ZonedDateTime(..., tz"-07:00")FixedOffsetReference(-420)
ZonedDateTime(..., tz"America/Denver")ZoneReference("America/Denver")
a bare DateTime or DateZonelessReference()

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 with infrastore_last_error_message.
  • Opaque handles. InfraStore, InfraStoreBulkRead, the readers and the batch are incomplete struct types; you only ever hold pointers. Free them with infrastore_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: f64 percentile buffers (double **) with infrastore_buffer_free_f64, raw element-byte buffers (uint8_t **) with infrastore_buffer_free_u8, timestamp and shape buffers (int64_t **) with infrastore_buffer_free_i64, and the u64 dims buffer from infrastore_bulk_result_get_forecast (uint64_t **) with infrastore_buffer_free_u64.
  • Typed arrays. Add functions take an element_type string (a dtype spelling such as "f64", or a composite kind such as "tuple(3,f64)" / "piecewise_linear" — see Element types), ndims plus a dims_ptr shape array ([length, k1, …]), and the raw little-endian data_ptr of data_byte_len bytes. 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 optional out_element_type string saying what those bytes mean; infrastore_bulk_result_get_single follows the same dtype-generic convention.
  • owner_category is an int32_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 numeric owner_id and stay distinct — and the category is recorded with the association at add time.
  • application_data is an optional opaque, package-owned payload (typically JSON) passed verbatim to the add functions and stored uninterpreted. Element typing does not go here — that is element_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) accept NULL.
  • time_reference records how a series' timestamps were spelled: "utc", "zoneless", a fixed offset ("-07:00"), or an IANA zone name ("America/Denver"). An unparseable value fails with INFRASTORE_ERR_INVALID_PARAMETER rather 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, so time_range_zoneless is 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. The zoneless filter argument on the list/filter exports is a tri-state int32_t: negative means no filter, 0 selects the instant-bearing rows (including those with no reference), and 1 selects 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 with INFRASTORE_ERR_INVALID_PARAMETER; see reserved feature names.
  • Timestamps are int64_t Unix milliseconds. Resolutions/horizons/intervals are ISO-8601 duration strings (e.g. "PT1H", "P1M", "P1Y"); a NULL (or empty) string means unset. On output they are owned char * strings — free each with infrastore_string_free.

Status Codes

MacroValueMeaning
INFRASTORE_OK0Success
INFRASTORE_ERR_NULL_POINTER1A required pointer was NULL
INFRASTORE_ERR_INVALID_UTF82A string argument was not UTF-8
INFRASTORE_ERR_INVALID_PARAMETER3A bad argument value
INFRASTORE_ERR_NOT_FOUND4No matching series / array
INFRASTORE_ERR_DUPLICATE5Key already exists
INFRASTORE_ERR_INTEGRITY6On-disk inconsistency
INFRASTORE_ERR_READ_ONLY7Write on a read-only store
INFRASTORE_ERR_IO8I/O failure
INFRASTORE_ERR_INCOMPATIBLE_FORMAT9The 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_ASSOCIATION10An 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_EXISTS11A 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_ARTIFACT12The HDF5 file and its .sqlite catalog do not carry the same generation stamp: they are halves of two different saves.
INFRASTORE_ERR_DUPLICATE_ASSOCIATION_ID13An 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_MISMATCH14An 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_REQUIRED15The .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_NEW16The .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_INTERNAL99Unexpected 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 = Deterministic matches a stored Deterministic or a stored DeterministicSingleTimeSeries. A DST is a synthetic view that reads back as a Deterministic, so a caller selects a deterministic forecast without knowing which form the store holds. Each row still reports its own concrete time_series_type.
  • 3 = DeterministicSingleTimeSeries narrows to the derived form alone — for callers auditing which forecasts are synthetic rather than reading values.
  • 4 = Probabilistic and 5 = Scenarios match 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 guardhas_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:

  • get spells "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 is INFRASTORE_OK, not INFRASTORE_ERR_NOT_FOUND.
  • list returns 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

RPCRequestResponsePurpose
ListMetadataListMetadataReqListMetadataRespCatalog rows matching a filter
ListMetadataByIdsListMetadataByIdsReqListMetadataByIdsRespCatalog rows for a set of ids
GetMetadataByIdGetMetadataByIdReqTimeSeriesMetadataOne catalog row by id
AssociationExistsAssociationExistsReqAssociationExistsRespIs an id still filed? (fetches no row)
HasAnyTimeSeriesHasAnyTimeSeriesReqHasAnyTimeSeriesRespAttribute-addressed existence probe
ReadByIdReadByIdReqReadByIdRespOne series' values (opt. range)
ReadByIdsReadByIdsReqReadByIdsRespMany series at once (opt. range)
GetResolutionsGetResolutionsReqGetResolutionsRespDistinct resolutions present
GetIntervalsGetIntervalsReqGetIntervalsRespDistinct forecast intervals
GetCountsGetCountsReqGetCountsRespAggregate counts
GetDetailedCountsGetDetailedCountsReqGetDetailedCountsRespDistinct owners/arrays per kind
GetCountsByTypeGetCountsByTypeReqGetCountsByTypeRespAssociation count per type
GetForecastParametersGetForecastParametersReqGetForecastParametersRespHorizon, interval, count, resolution
ListOwnerIdsListOwnerIdsReqListOwnerIdsRespDistinct owner ids in a category
GetStaticSummaryGetStaticSummaryReqGetStaticSummaryRespGrouped static-series summary
GetForecastSummaryGetForecastSummaryReqGetForecastSummaryRespGrouped forecast summary
CheckStaticConsistencyCheckStaticConsistencyReqCheckStaticConsistencyRespPer-resolution static-grid check
VerifyIntegrityVerifyIntegrityReqVerifyIntegrityRespRecompute and compare stored hashes
ListStoreAttributesListStoreAttributesReqListStoreAttributesRespEvery store attribute
GetStoreAttributeGetStoreAttributeReqGetStoreAttributeRespOne 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 ListMetadataTimeSeriesMetadata 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_data is not carried in ReadByIdResp. The opaque package-owned payload is returned by ListMetadata (on TimeSeriesMetadata) but left empty by ReadById, 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 CodeTimeSeriesError
NotFoundNotFound
AlreadyExistsDuplicateTimeSeries
InvalidArgumentInvalidParameter(message)
FailedPreconditionInvalidParameter(message)
DataLossIntegrityError(message)
anything elseConnectionError(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]

KeyTypeRequiredDescription
hoststringyesBind address (e.g. 0.0.0.0, 127.0.0.1)
portintegeryesTCP port

[data]

KeyTypeRequiredDescription
filestringyesHDF5 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".

KeyTypeDefaultDescription
methodstring"none""none" or "api_key" (oauth reserved)
keysarray 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 empty keys list is rejected.

  • An unknown method value is rejected.

  • An unknown key or section is rejected, in every section. A misspelled [authenticaton] (sic) fails the parse instead of silently falling back to method = "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:

  1. Loads and parses the TOML file.
  2. Validates the [authentication] section.
  3. Opens [data].file as a read-only store.
  4. Binds host:port and serves the CatalogStore gRPC 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

OptionDescription
--store <PATH>Path to the HDF5 store file. The <PATH>.sqlite catalog is implicit. Falls back to the INFRASTORE_STORE environment variable.
-f, --formatOutput format: table (default), json, jsonl, or csv.
--log-levelTracing filter; also read from RUST_LOG. Defaults to warn.
-y, --yesAnswer 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).
--zonelessStore 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-range bounds 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:00 over a midnight column stores 07:00Z and prints 2024-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

CommandPurpose
listList stored series matching the selector filters.
getRead and display a single series' values.
gridRender N series as N columns against one shared time axis.
infoMetadata, content hash, HDF5 location, and stats for one series.
exportWrite 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:

  • --dir is 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.
  • --dir must hold no .parquet files 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 parquet is only accepted on export. It is a binary container, not a rendering of a result; there is no list -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:

FlagApplies toWhy
--time-range START..ENDevery formatA time slice is a different span of the series, so a pipe should carry exactly the rows asked for.
--stride N (get, grid)every formatEvery 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 onlyA 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

CommandPurpose
initCreate an empty store with an explicit compression and catalog policy.
addAdd one or more series from a descriptor JSON + CSV, from Parquet, or from flags.
mergeCopy matching series from another store into this one.
transformDerive DeterministicSingleTimeSeries from stored SingleTimeSeries.
removeDelete a single series, or every match with --all (prompts unless --force).
copyCopy the single series a selector resolves to onto another owner.
replace-ownerReassign every series from one owner to another.
clearRemove 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.

CommandPurpose
namesDistinct series names matching the selector.
owner-typesDistinct owner types matching the selector.
ownersDistinct owner ids that have a time series.
existsWhether 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

CommandPurpose
plotDraw 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

CommandPurpose
statsAssociation, owner, and distinct-array counts.
store-infoHDF5 + SQLite paths and sizes, on-disk format version, catalog revision.
store-attrKey/value provenance stamped on the whole artifact (list/get/set/remove).
upgradeBring a store written by an older build up to this one's catalog revision.
arraysDistinct stored arrays: content hash, HDF5 location, series sharing each.
summaryGrouped static and/or forecast summaries (--static-only/--forecast-only).
resolutionsList distinct resolutions and forecast intervals.
paramsShow 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:

  • set replaces rather than appending. An artifact records one creator, not a history of them.
  • get prints 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. remove instead reports removed: false and 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

CommandPurpose
attributesComponent <-> supplemental-attribute associations (--summary for counts).
linksDirected parent -> child component associations.
attachAttach supplemental attributes to components.
detachRemove attachments matching the filter.
linkAdd directed parent -> child component links.
unlinkRemove links matching the filter.
reassignMove 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

CommandPurpose
verifyVerify store integrity; nonzero exit if errors are present.
check-consistencyVerify the per-resolution static grid (--resolution).
compactRewrite the .h5 to reclaim space (prompts unless --force); print the report.
persistWrite the store to a new HDF5 + SQLite artifact (--dest).
diffCompare 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

CommandPurpose
templatePrint an example descriptor for a given type to stdout.
completionsGenerate 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:

FlagMeaning
--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=valueFeature 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:

TypeAccepted spellings
SingleTimeSeriessingle, SingleTimeSeries
NonSequentialTimeSeriesnon_sequential, NonSequentialTimeSeries
PersistentTimeSeriespersistent, PersistentTimeSeries
Deterministicdeterministic
DeterministicSingleTimeSeriesdeterministic_single, DeterministicSingleTimeSeries
Probabilisticprobabilistic
Scenariosscenarios

--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, P7D for fixed spans, P1M / P3M / P1Y for calendar ones. This is also the spelling every command prints, so a duration copied out of list, info, or export -f json can 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-range bounds, --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 by add rather than truncated, and the epoch-millisecond form cannot express one at all. See timestamp precision. A timestamp with no offset needs --assume-timezone or --zoneless, and whichever one you pass is also recorded as the series' time_reference.

  • --time-range is a pair of timestamps, not a duration: START..END (half-open — START inclusive, END exclusive), 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 1h is rejected with invalid --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 a count and initial_timestamp in -f json that describe the slice — and get's --window N / --issue-time then address those windows: --window 0 is 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).

KeyRequired forNotes
owner_idlong layoutInteger component identifier (i64). Rejected when wide.
owner_typelong layoutWide: the default for owner_map rows that name none.
owner_categoryoptionalComponent (default) or SupplementalAttribute.
nameall
typeallOne of the five writable types; spellings as for --type.
element_typeallf64/f32/i64/…, tuple(N,f64), or a function-data kind.
csvunless --csv is passedPath relative to the descriptor; --csv overrides it.
element_shapeoptionalTrailing per-step dims; default scalar ([]).
unitsoptionalFree-form label.
quantity_kindoptionalWhat the values measure, e.g. ActivePower (QUDT name).
unit_systemoptionalnatural_units or component_base; unset = unspecified.
time_referenceoptionalutc / zoneless / -07:00 / an IANA name; normally inferred.
component_fieldoptionalOwning component's field these values vary over time.
application_dataoptionalOpaque package-owned payload (e.g. JSON), stored verbatim.
featuresoptionalJSON object; int/float/bool/string values. See below.
initial_timestampall but non-sequentialAlso decides the series' time_reference unless declared.
resolutionall but non-sequentialISO-8601 duration, e.g. PT1H.
horizon, interval, countforecastsThe two durations are ISO-8601, e.g. PT24H.
percentilesProbabilisticStrictly increasing list of floats.
scenario_countScenarios (optional)Inferred from the data length if omitted.
layoutoptionallong (default) or wide. See below.
owner_mapwide layoutSidecar CSV path, or an inline {"column": owner_id} object.
owner_id_fromwide 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:

FormWhen
"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.

TypeShapeCSV
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.

Typeget -f csv / export -f csv header
SingleTimeSeries, NonSequentialTimeSeries, PersistentTimeSeriestimestamp,value...
Deterministicissue_time,target_time,value...
Probabilisticissue_time,target_time,value[p10],... (one column per percentile)
Scenariosissue_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 timestamp is read as the time axis. For a NonSequentialTimeSeries or a PersistentTimeSeries it is the data; for a SingleTimeSeries it is validated against the descriptor's initial_timestamp + resolution grid, 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_time columns mark the timestamped forecast layout, whose rows run window-major with the percentiles/scenarios spread across columns — add transposes 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:

ValueHeader
auto (default)The bare owner id when every column shares one series name, else
name@owner.
ownerAlways the bare owner id.
fullAlways 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.

--kindWhat it shows
lineThe profile itself, one or more series against time.
durationThe 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.
heatmapTime-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.
fanPercentile bands for a Probabilistic, overlaid traces for Scenarios, for one
window (--window N). These types have no other readable rendering.
overlayA 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:

  • list shows a 12-character Hash column — equal hashes mean a shared array.
  • info shows the full 64-character data_hash, the location it maps to, and how many SingleTimeSeries / DeterministicSingleTimeSeries associations reference it.
  • arrays groups 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

CodeMeaning
0Success.
1Runtime error. The message goes to stderr, in the --format that was asked for.
2Usage 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.h is generated by cbindgen — do not hand-edit it; it is refreshed when you build infrastore-ffi.
  • The proto-derived Rust types in infrastore-proto are generated by tonic from crates/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:

CategoryLocationWhen
Getting Startedsrc/getting-started/First-run, learning-oriented quick starts
Explanationsrc/explanation/Concepts, architecture, design rationale
Developer Guidessrc/guides/End-to-end usage, install through workflow
Referencesrc/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.

ChannelPackage(s)Registry
Rustinfrastore-core, infrastore-proto, infrastore-ffi, infrastore-server, infrastore-clicrates.io
PythoninfrastorePyPI
JuliaInfraStore (binaries via Artifacts.toml → GitHub Releases)Julia General
Binariesinfrastore, infrastore-server, libinfrastore_ffi + headerGitHub 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:

FileFieldWritten by
Cargo.toml[workspace.package] versioncargo-release, natively
Cargo.toml[workspace.dependencies] pins on infrastore-core/-protocargo-release, natively
crates/infrastore-py/pyproject.toml[project] versioncrates/infrastore-core/release.toml
julia/InfraStore.jl/Project.tomlversioncrates/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. An HDF5_jll upgrade in a user's environment must not change how infrastore files are read or written.
  • No MPI dependency. HDF5_jll is 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 to InfraStore.jl and 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_DIR in CI. The vendored HDF5 build forwards it to cmake as HDF5_ROOT while still requesting static libraries; against a shared-only install such as conda-forge's this fails with Could NOT find HDF5 (missing: HDF5_LIBRARIES HDF5_HL_LIBRARIES). To build against system libraries, use --no-default-features instead.

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.yml and python-wheels.yml own that, on the tag, with trusted publishing and no local credentials. Rehearse packaging separately with cargo 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/exclude if 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-coreinfrastore-protoinfrastore-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 --workspace with CARGO_REGISTRY_TOKEN set). 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.jl downloads every libinfrastore_ffi.<triplet>.tar.gz to 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-release updates the existing release for the tag rather than erroring, and it sends draft: true in 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.

TargetRunnerArchive contents
aarch64-apple-darwinmacos-14executables and C library
x86_64-unknown-linux-muslubuntu-latestexecutables only
x86_64-unknown-linux-gnuubuntu-latestC library only
x86_64-pc-windows-msvcwindows-latestexecutables 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:

  1. 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.

  2. Regenerate Artifacts.toml on a branch:

    julia julia/generate_artifacts.jl v0.6.0
    
  3. Run the suite against the artifact, with INFRASTORE_LIB unset — this is the path users get, and CI's julia-artifact job only smoke-tests it (between releases the wrapper on main may 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
    
  4. 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.jl
    

    The 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 a Release 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_VERSION bump 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:

  1. Refresh the recipe's version and GitSource SHA to the current release (git rev-parse vX.Y.Z^{commit}; Yggdrasil requires a full commit SHA, not a tag). Only changes under crates/, Cargo.toml, or Cargo.lock need 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-gnu
    

    A platform argument replaces the recipe's platforms list rather than filtering it; the listed platforms carry no extra tags, so bare triplets are exactly right.

  2. Once InfraStore_jll is registered, cut the next InfraStore.jl version: delete Artifacts.toml and julia/generate_artifacts.jl, swap lib_path() to the JLL (the shape is parked on the julia-jll-dep branch, which predates the libinfrastore constant: every @ccall must keep naming a constant library, since Julia 1.13 refuses a call there), add InfraStore_jll to [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 the julia-artifact CI job. JLL UUIDs are deterministic — BinaryBuilder.jll_uuid("InfraStore_jll").

  3. 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.