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

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.