Skip to content

feat: Use infrastore as the backend for time series and component/supplemental_attribute associations - #162

Draft
daniel-thom wants to merge 48 commits into
mainfrom
feat/rust-time-series-store
Draft

feat: Use infrastore as the backend for time series and component/supplemental_attribute associations#162
daniel-thom wants to merge 48 commits into
mainfrom
feat/rust-time-series-store

Conversation

@daniel-thom

@daniel-thom daniel-thom commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator

Changes

  1. Replaces infrasys-managed time series and component/supplemental_attribute associations with centralized functionality in infrastore. This unifies behavior with Sienna.
  2. Replaces component and supplemental attribute UUIDs with integer IDs.

Adopting infrastore brings additional functionality (full Sienna time series support) and loses functionality. We will drop support for time series Arrow files and chronify. The only supported time series backends will be those provided by infrastore - currently NetCDF and in-memory. We will extend infrastore as needed.

TODO

Provide automated migration of existing serialized systems.

pesap and others added 26 commits May 23, 2026 19:45
Components, supplemental attributes, and time series metadata now use
monotonically-increasing integer IDs as their primary identifier.
Legacy UUIDs are preserved as `legacy_uuid` for backward compatibility.

Key changes:
- InfraSysBaseModelWithIdentifiers: new id field (int), legacy_uuid for BC
- IDManager: tracks next available ID, supports advance_past() for
  serialized data with pre-assigned IDs
- SQLite tables use INTEGER foreign keys with referential integrity
- ComponentAssociations, SupplementalAttributeAssociations, and
  TimeSeriesMetadataStore all migrated to integer ID columns
- Migration logic for legacy serialized data and existing SQLite schemas
- New get_by_id() methods on ComponentManager, SupplementalAttributeManager
- SerializedComponentReference uses integer id instead of UUID
- Fixed double-negative bug in metadata_store_needs_migration
- copy.deepcopy replaces manual model_dump() reconstruction
- Backward-compat alias InfraSysBaseModelWithIdentifers preserved
- Fix ComponentManager.remove() index cleanup only triggering on
  empty container (P1 bug)
- Fix get_by_label() to try name-based lookup before ID for
  numeric labels (P1 ambiguity)
- Add owner_category to time series unique index to prevent false
  collisions across owner types
- Extract migration functions to src/infrasys/utils/migrations.py
- Raise NotImplementedError for deprecated
  get_component_uuids_with_attribute
- Merge duplicate query strings in supplemental attribute associations
- Add Field descriptions to SerializedComponentReference
- Add cascade warning when removing components with time series
- Add migration path tests and remove-index cleanup test
…nent associations

Covers:
- SerializedComponentReference.uuid property (success + error)
- get_class_and_name_from_label with UUID and unknown string labels
- ComponentAssociations.clear operation
- list_child_components/list_parent_components with type filter
- get_by_label with UUID-based labels
- fix duplicate list_time_series_metadata call in remove_component
- widen sql() params type from Sequence[str] to Sequence[Any]
- guard MAX(*_id) queries with column existence checks for legacy DBs
- add 17 tests covering component associations, ID management, migrations,
  supplemental attribute queries, and time series metadata normalization
ts-store moved the association `name` (required) and
`scaling_factor_multiplier` (optional) off the `add_time_series` call and
onto the time series constructors. Pass `metadata.name` to
RustSingleTimeSeries / RustNonSequentialTimeSeries and drop the now-rejected
`name=` keyword from add_time_series.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Surface the time-series-store backend's NetCDF compression policy as System
kwargs: time_series_compression ("deflate"/"none"), time_series_compression_level
(0-9), and time_series_shuffle. They flow System -> TimeSeriesManager ->
TimeSeriesStoreStorage.create_* -> TimeSeriesStore.create, defaulting to the
prior behavior (DEFLATE level 3 + shuffle). The memory backend ignores them.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… type, replace asserts with domain exceptions

Agent-Logs-Url: https://github.com/NatLabRockies/infrasys/sessions/070e4fb1-22bd-4d30-8a82-321e1f62f210

Co-authored-by: pesap <2238996+pesap@users.noreply.github.com>
# Conflicts:
#	src/infrasys/time_series_manager.py
#	tests/test_serialization.py
The rust core replaced time-series owner identity (owner_uuid: str) with
owner_id: i64, matching the integer IDs introduced on feat/ids. Switch the
storage layer to pass metadata.time_series_id as owner_id for adds and key
lookups, and fix the dependency pin to point at ../time-series-store.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…identity

The Rust time-series-store core now owns time-series identity (content hash
plus the owner/name/features association key), so infrasys no longer keeps its
own parallel metadata store or assigns UUIDs/integer IDs to time series.

- Remove the Python TimeSeriesMetadataStore, in-memory backend, storage base
  class, and legacy UUID->ID DB migrations.
- Make TimeSeriesStoreStorage the single backend: it owns data plus an
  in-memory index of lightweight records, with the Rust store as the
  persistent authority for data and associations.
- Reparent TimeSeriesData to InfraSysBaseModel (no id/uuid) and delete the
  TimeSeriesMetadata model hierarchy; list_time_series_metadata now returns
  TimeSeriesKey objects. Extract SingleTimeSeries slicing into
  single_time_series_range.
- Define TimeSeriesCounts via content-addressed array groups: time_series_count
  is the number of unique stored arrays and reference_count is total owner
  associations (surfacing dedup/sharing).
- Drop the MEMORY storage type and convert_storage; normalization is no longer
  echoed back on retrieval (it was never persisted).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…s-store backend

Wire Deterministic forecasts through the Rust time-series-store adapter, which
previously raised NotImplementedError for anything but SingleTimeSeries and
NonSequentialTimeSeries.

- Add explicit Deterministic forecasts via add_time_series; read them back with
  get_time_series(..., time_series_type=Deterministic).
- Add System.transform_single_time_series(horizon, interval) to derive "perfect
  forecasts" from stored SingleTimeSeries (store-wide, mirrors
  InfrastructureSystems.jl). Forecast windows are materialized in Rust.
- A Deterministic query matches both explicitly-stored and transform-derived
  (DeterministicSingleTimeSeries) records; reads always return a Deterministic.
- Transpose between the infrasys (window_count, horizon_steps) layout and the
  store's (horizon_steps, count) layout on add/get.
- Reuse DeterministicTimeSeriesKey and add AbstractDeterministic.length.

Also refresh documentation left stale by the time-series-store and integer-id
migrations: serialization JSON examples now use integer ids, the removed storage
backends / in-memory option are dropped, and dangling
DeterministicSingleTimeSeries / from_single_time_series references are fixed. Add
developer instructions for building the Rust time-series-store extension with uv.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add explicit per-window value checks for forecasts derived via
transform_single_time_series, beyond the existing shape/round-trip coverage:

- Parametrized cases (overlapping, max-overlap, contiguous, partial final
  stride) assert each window i equals underlying[i*interval : i*interval +
  horizon], using random non-monotonic data so an orientation/transpose bug
  cannot pass by symmetry.
- Verify window values survive a to_json/from_json round trip.
- Verify pint units are preserved through the transform.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add bulk-add and rollback tests for Deterministic and
NonSequentialTimeSeries, closing the gap where only SingleTimeSeries
exercised the open_time_series_store transaction path.

Add negative tests asserting the Deterministic vs
DeterministicSingleTimeSeries mutual-exclusion guard in both
directions (explicit add after transform, transform after explicit
add).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace infrasys's own SQLite association tables with the association API
that now lives in the Rust-backed `time_series_store` package. Both
supplemental attribute associations and component parent/child
associations are stored in the time series store's SQLite catalog
(`<store>.nc.sqlite`), which is saved and restored with the system.

Deleted:
- src/infrasys/supplemental_attribute_associations.py
- src/infrasys/component_associations.py
- src/infrasys/utils/metadata_utils.py
- src/infrasys/utils/sqlite.py (every function lost its last caller)

SupplementalAttributeManager and ComponentManager now take the store
instead of a sqlite3.Connection, so System.__init__ builds the time
series manager first and hands its store to both. `open_metadata_store`
no longer wraps a SQLite transaction; the store exposes no transaction
primitive, so it snapshots the association rows on entry and restores
them verbatim if an exception escapes. Duplicate supplemental attribute
adds are now detected by the store, which raises
DuplicateAssociationError; that is translated back to ISAlreadyAttached
so the public contract is unchanged.

Component associations used to live in a private in-memory database that
was rebuilt on every deserialization. They are now persisted, so
ComponentManager.add skips the association write when
deserialization_in_progress is set, and a component that references the
same child from two fields is de-duplicated before the bulk insert.

BREAKING CHANGE: clean break on persistence. System.DB_FILENAME
("time_series_metadata.db"), the backup/restore of that file, and
System._con are gone. Supplemental attribute associations now travel
inside the time series store artifact instead of a sidecar SQLite file,
so systems saved by an earlier build will no longer load. This is
intended. TimeSeriesStorageContext.metadata_conn was removed for the
same reason.

Also removed the UUID-era association schema migration
(migrate_legacy_uuid_table / migrate_legacy_association_schema), which
no longer has an on-disk format to migrate from.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The Rust store package was renamed from time-series-store to castore, and
its main Python class TimeSeriesStore is now exported as Store. Repoint the
dependency (pyproject + uv source), switch imports to `from castore import
...`, and use `Store` directly in place of the old `TimeSeriesStore` name.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`[tool.uv.sources]` pinned infrastore to `../castore/crates/infrastore-py`,
a sibling working copy. CI checks out only this repository, so `../` is
empty there and every job died in `uv sync` before running a single hook
or test:

    error: Failed to generate package metadata for `infrastore==0.1.0`
      Caused by: Distribution not found at: file:///home/runner/work/infrasys/castore/...

The path was stale locally too, since that checkout is now `../infrastore`.

infrastore 0.1.1 is on PyPI as abi3-py311 wheels for macOS arm64,
manylinux x86_64, and win_amd64, which covers the full test matrix, so
the source override can go entirely.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`serialize` flushed the store and then copied `time_series_store.nc` and
its SQLite catalog while the Rust store still held them open. POSIX
allows that; Windows denies the read, so all six Windows test jobs failed
26 tests with:

    PermissionError: [Errno 13] Permission denied
      src/infrasys/time_series_store_storage.py:596: in _copy_store
        shutil.copyfile(src, dst)

Close the store around the copy and reopen it afterwards, preserving the
read-only mode. The reopen is in a `finally`, so a failed copy leaves the
storage usable instead of stranded on a closed handle. Only the live
directory takes this path; an explicit `src` elsewhere is not held open.

Closing yields a new handle, and `ComponentManager` and
`SupplementalAttributeManager` had captured the old one at construction,
which would leave them raising "store is closed" after the first save.
Both now resolve it from the storage on each access, so the swap is
invisible to them.

These jobs had never run before: they are gated behind `needs:
pre-commit`, which failed at dependency installation on every prior push.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…store

refactor: store associations in the Rust time-series-store
@codecov-commenter

codecov-commenter commented Jul 26, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.46669% with 96 lines in your changes missing coverage. Please review.
✅ Project coverage is 96.37%. Comparing base (e3979f2) to head (7a412ed).

Files with missing lines Patch % Lines
src/infrasys/time_series_store_storage.py 88.81% 48 Missing ⚠️
src/infrasys/supplemental_attribute_manager.py 89.79% 10 Missing ⚠️
src/infrasys/time_series_context.py 94.40% 8 Missing ⚠️
src/infrasys/time_series_transaction.py 80.55% 7 Missing ⚠️
src/infrasys/time_series_manager.py 94.00% 6 Missing ⚠️
tests/test_time_series_store_storage.py 98.54% 6 Missing ⚠️
src/infrasys/component_manager.py 97.03% 4 Missing ⚠️
src/infrasys/time_series_models.py 93.18% 3 Missing ⚠️
src/infrasys/time_series_reader.py 95.83% 3 Missing ⚠️
src/infrasys/utils/time_utils.py 97.14% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #162      +/-   ##
==========================================
+ Coverage   94.82%   96.37%   +1.54%     
==========================================
  Files          61       52       -9     
  Lines        6072     5988      -84     
==========================================
+ Hits         5758     5771      +13     
+ Misses        314      217      -97     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@daniel-thom daniel-thom changed the title Use infrastore as the backend for time series and component/supplemental_attribute associations feat: Use infrastore as the backend for time series and component/supplemental_attribute associations Jul 26, 2026
@daniel-thom
daniel-thom marked this pull request as draft July 26, 2026 15:14
daniel-thom and others added 22 commits July 26, 2026 11:26
…ydration

The Rust store exposes add_time_series_bulk/bulk_read, but nothing here
called them: every write paid one catalog transaction and every read one
decompress pass. On a file-backed store that is roughly two orders of
magnitude of avoidable work.

- open_time_series_store now buffers additions and commits them in one
  batch instead of yielding None. The index is updated as each series is
  staged so metadata queries inside the block are unchanged; anything that
  must touch the store flushes first. Staged additions are dropped without
  a store round trip when the block raises, which also simplifies
  rollback_to.
- add_time_series validates all owners before mutating anything and commits
  them together, so a duplicate on the last owner no longer leaves the
  earlier ones attached.
- list_time_series reads through bulk_read, grouping records by time range
  since one range applies to the whole batch. get_time_series is split into
  _plan_read/_build_result so both paths share the conversion logic.
- Store keys are cached on _StoredSeries: from the keys add_time_series_bulk
  returns, from a single list_keys() call during rehydrate, and memoized on
  the fallback scan. Reads no longer rescan an owner's keys.
- _record_from_store reads initial_timestamp, horizon, interval, and count
  off the store's metadata record, which already carries them, so rehydrate
  no longer reads array data.

100 series of 8760 points, file-backed store: adds 38.7s -> 0.3s,
from_json 13.8s -> 0.01s, list_time_series 4.9s -> 0.06s.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Simulations step through time and need every component's value at one
timestamp. Everything the time series API offered was series-oriented, so
the only ways to drive a stepping loop were to hold every array in memory
or to re-read per component per step. The store's reader API is built for
exactly this and was unexposed.

- System.build_time_series_reader(resolution, ...) returns a TimeSeriesReader
  whose read(timestamp) is {component id: value}. A dict keeps the caller
  free of any iteration order; read_columns() is the escape hatch that hands
  back the store's arrays with no per-value Python objects.
- System.build_forecast_reader(resolution, ...) returns a ForecastReader whose
  read(timestamp) is {component id: window array}. Slot deduplication is
  exposed rather than hidden: num_slots, slots, and components_by_slot()
  describe the collapse, read_slots() reads one window per slot, and read()
  hands every component in a slot the same array object instead of a copy.
  A fleet sharing one profile therefore materializes one window, not N.
- Both readers filter on name, name_glob, component_type, and features, expose
  the stored units so raw magnitudes are not silently mistaken for quantities,
  and flush any open add batch at build time.

_as_utc/_as_naive_utc move to utils.time_utils as as_utc/as_naive_utc so the
reader module can convert timestamps without importing the storage backend.

5000 components x 168 steps: 0.16s stepping through readers (0.95 ms/step,
or 0.45 ms/step via read_columns) against 0.52s just to preload the arrays,
which is the part that stops scaling. Transformed forecasts over the same
fleet collapse 5000 components to 2501 slots.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The readers landed with docstrings only. The rendered docs still described a
purely series-oriented API, so a user driving a simulation loop had no way to
discover them and the obvious-looking paths --- preload every array, or
get_time_series per component per step --- are the slow ones.

- A how-to walks the stepping loop end to end: filters, the two build-time
  failures (non-uniform grid, empty match), the snapshot semantics, read_columns
  as the escape hatch, units, and forecast slot deduplication including the
  shared array object that makes read() cheap. Every snippet and its output was
  executed against the repo rather than written by hand.
- reference/api/time_series.md autoclasses TimeSeriesReader and ForecastReader,
  which were absent from the API reference entirely; only the System build
  methods were reachable, via the System autoclass.
- explanation/time_series.md gains a section on the series-oriented vs
  cross-sectional distinction, which is where a reader of that page would expect
  to find out these exist.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
open_time_series_store yielded a TimeSeriesStorageContext that callers threaded
into add/get, but the object was empty --- one field, data_context, always None ---
and the batching it appeared to control lived on the storage instance. Passing the
context, omitting it inside the block, and running with no block produced [3], [3],
and [1,1,1] bulk writes: the token did nothing. Two defects followed from storage
owning the batch. rollback_to undid "everything added since a snapshot of the shared
index", so a second open batch would have had its work reverted. And eight methods
called _flush_pending() without taking a context, so any operation anywhere drained
whichever batch happened to be open.

The context is now the transaction object. It owns the staged additions, the index
entries they will produce, and the record of what it has already written so a failure
undoes exactly its own work. Storage keeps no batch state and holds no reference to
any context; ownership points one way. The same measurement now reads [3], [1,1,1],
[1,1,1] --- passing the context is what puts a call in the batch.

- New time_series_context module holds TimeSeriesStorageContext plus the _StoredSeries
  and _PendingAdd dataclasses moved from storage. flush() writes one bulk call,
  discard() drops staged work and removes anything already flushed.
- Staged additions resolve only through the context that staged them, so two open
  batches never observe or disturb each other. snapshot_index/rollback_to are gone:
  staging no longer dirties the shared index, so rollback is "drop the context".
- Every public method takes context=None and falls back to a transient context that
  commits the single operation, leaving existing call sites valid. system.py gains the
  parameter on the eleven methods that lacked it, including to_json, which without it
  serializes committed state only.
- Storage owns every read of its index: one merge point resolves committed against a
  context's staged entries. Drops the dead mode parameter and the FileMode alias, both
  ignored by the backend, where mode="r" implied a read-only guarantee it never gave.

Fixes a latent bug carried over from rollback_to: the OwnerCategory was reconstructed
from its name with a bare else, so an unrecognized category silently became
SupplementalAttribute and removed the wrong association.

The one behavior change: inside a block, a call that omits the context sees committed
state only. Four tests asserted the old ambient visibility and now pass their context.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…paths

- TimeSeriesManager.close probed for close/dispose methods that
  TimeSeriesStoreStorage never defined, so System.close never released
  the Rust store's file handles. Give the storage a real close() and
  call it directly.
- remove_component and remove_supplemental_attribute filtered on the
  default SingleTimeSeries type, orphaning Deterministic and
  NonSequentialTimeSeries associations under dead owner ids, and their
  per-key removal loop crashed when one series' features were a subset
  of another's. Both now remove everything with one match-all call.
- Accept time_series_type=None as a match-any filter in has/list/remove
  (previously an AttributeError).
- Storage removal now resolves every matching association first and
  removes them via one remove_time_series_bulk call, updating the index
  only after the store accepts, so a failure cannot diverge the two.
- Add a TODO for rolling back removals on context discard.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Add an explanation page describing how bulk adds, bulk reads, and the
cross-sectional readers work, with colored mermaid diagrams of the
write/read paths and the reader build/step split. Requires the new
sphinxcontrib-mermaid extension.

Also make a clean build warning-free: wire the orphaned storage_backends,
save_system, and benchmarks pages into their toctrees, drop the
nonexistent _static path, start system_json at H1, and disable the JSON
schema dropdown for NonSequentialTimeSeries, whose ndarray field cannot
be schema-serialized.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The context owned a _committed log and undid a failed block by replaying
compensating removals through the store. That could restore a catalog row but
never an array: outside a transaction the store frees an array as soon as its
last association goes, so a removal was irreversible and discard() carried a TODO
saying so. It also made every mid-block flush a one-way door -- a read, a counts
call, or a reader build inside a block quietly converted "drop the buffer" into
"replay removals and hope".

infrastore now has transactions spanning operations, so rollback is one call.
open_time_series_store begins one and commits or rolls it back on exit.
_committed and undo_committed are gone. Removals roll back, because the store
defers freeing an array to the outermost commit; the TODO goes with them. An
early flush costs nothing in recoverability, since the write lands inside the
transaction.

The client-side add buffer stays. Batching is what buys block-sized NetCDF writes
and feature-set dedup, and a transaction deliberately does not provide it. The
staged overlay stays too, narrowed to the one thing the store cannot answer: an
addition still in the buffer has not reached the store, so a duplicate against it
would go undetected until the flush.

A context created for a single operation does not begin a transaction -- that
operation is already atomic, and taking a write lock for it would be wasted work
and would fail outright on a read-only store. discard() rebuilds the index from
the store after rolling back, since entries recorded as work was flushed describe
a catalog state that no longer exists. A rollback that itself fails is logged, not
raised, so the error that caused the unwind is the one the caller sees.

Two behavior changes, both consequences of the mechanism:

- Transactional blocks nest LIFO. SQLite savepoints are a stack, so an inner block
  must finish before its enclosing one; two interleaved blocks that each commit or
  discard on their own schedule are not supported. Non-transactional batches from
  new_context() are unaffected and stay concurrent.
- Serializing inside an open block raises. Copying the artifact closes and reopens
  it, which would discard the transaction, and a durable copy of state that may
  still roll back is not coherent. to_json moves outside the block.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…transaction

The old name described an implementation that no longer exists: it dates from
when the call opened and held a file handle. Nothing is opened now -- the store
is already open -- and the name says nothing about the property that matters
most since the block became a store transaction, which is that it is atomic and
rolls back, removals included.

`time_series_transaction` names the guarantee rather than a mechanism, and
matches the vocabulary of the layer below, where infrastore exposes
`Store.transaction()`. Chosen jointly with InfrastructureSystems.jl so the two
packages read the same.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…sh large batches

Resolve the batch-API design debate in favor of a facade: time_series_transaction
now yields a TimeSeriesTransaction whose methods mirror System's and route through
the batch, replacing the context= kwarg that every System time series method (and
to_json) previously threaded. The context kwarg was easy to omit silently, and the
opaque token had no other user-facing purpose; TimeSeriesStorageContext remains as
internal plumbing. to_json also loses context= — serializing inside an open
transaction already raises unconditionally, so the parameter was vestigial.

The same reasoning applies one layer down, so the context is now the *receiver* for
the operations rather than a parameter to them: ctx.add_time_series(ts, gen,
**features) instead of storage.add_time_series(ts, gen, context=ctx, **features).
Twelve operations moved onto TimeSeriesStorageContext; the storage-side
implementations became private and take their context positional-only; and
TimeSeriesTransaction binds the manager to its batch once at construction
(TimeSeriesManager.bind_context) instead of forwarding context= on every call.

That closes a live collision rather than a theoretical one. A time series feature
named "context" bound the plumbing parameter and died with AttributeError: 'str'
object has no attribute 'check_owns' — plumbing had no business reserving a name in
the namespace a caller owns. It also makes a context reaching the wrong storage
unrepresentable instead of merely checked: check_owns survives at one site,
bind_context, rather than eight.

A transaction now also flushes on its own at AUTO_FLUSH_THRESHOLD (10,000) staged
additions or AUTO_FLUSH_BYTES (256 MiB) of staged array data, whichever first, so
adding hundreds of thousands of series holds bounded memory. The count keeps each
bulk write near the store's chunk layout sweet spot (chunk width equals batch
width; 10,000 f64 series produce 80 KiB chunks against infrastore's 1 MiB cap, at
~2% throughput cost vs an unlimited batch), while the byte limit bounds memory
when individual arrays are long, which a count cannot. Auto-flushed work lands
inside the open store transaction and rolls back with the block.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`units` became a user-declared, immutable, non-filterable label on every time
series struct in InfrastructureSystems.jl, InfraStore.jl, and the Rust core.
infrasys was left out, deliberately: it already writes the store's `units`
column, but with a different payload and different provenance.

Today `_units_from_data` derives a `QuantityMetadata` blob
(`{module, quantity_type, units}`) from `pint.Quantity` data, serializes it
into the column, and `_build_result` reads it back to rehydrate the original
quantity subclass on every read. So the column holds a JSON object where the
other bindings now expect a bare label, and infrasys infers the value where
the agreed design has the user declare it.

That is not a naming nit: one column cannot hold both payloads, a store
written by infrasys and read by IS.jl surfaces a blob where a label belongs,
and `QuantityMetadata` is load-bearing -- `module` and `quantity_type` are
what let a read reconstruct a custom `BaseQuantity` subclass.

The note records the current behavior with line references, three options
(move the blob to the unused `ext` column, flatten to a string and lose
subclass reconstruction, or opt infrasys out), and the questions still open --
including precedence when a user declares `units=` and passes a
`pint.Quantity` whose units disagree.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The store gained `quantity_kind` and `unit_system` and renamed `ext` to
`application_data` (data format 0.15.0). Nothing in infrasys breaks -- its
suite passes unchanged, because it never used the renamed column -- but the
change materially alters the options this note was left open on.

Option A got cheaper and splits three ways: `application_data` is now
documented as the home for exactly this kind of package-owned payload, and
`QuantityMetadata`'s three fields have three natural homes instead of one
opaque blob. Seven of the eight classes in `quantities.py` are already QUDT
`QuantityKind` local names verbatim, so `quantity_type` maps onto
`quantity_kind` and only `module` stays binding-private. `Current` is QUDT's
`ElectricCurrent`; that one name is the whole cost of adopting the vocabulary.

This decides nothing: open questions 2 and 3 (precedence between a declared
`units=` and a `pint.Quantity`, and whether infrasys exposes `units=` at all)
are untouched. `unit_system` also does not map cleanly -- infrasys spells the
device base `DEVICE_BASE` where the store spells it `component_base`, and its
third value `SYSTEM_BASE` has no storage spelling at all.

Also replaces the `infrastore-core` line-number citations with symbol names.
The note itself flagged those as a contract that breaks silently on refactor,
and 0.15.0 is what broke them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Upgrade to infrastore v0.9.0 and stop flattening timestamps to naive UTC.

infrasys used to force every timestamp to aware UTC on write and strip it
back to naive on read. That shim existed only because the old binding
*refused* a naive datetime; the cost was that it silently destroyed any
zone a caller supplied and told the catalog every series was UTC, which a
Julia or CLI reader of the same store then believed.

v0.9.0 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 -- and hands the same spelling back. So the shim is
obsolete: timestamps now cross the boundary as written. A naive datetime is
a wall clock and comes back naive; an aware one comes back aware in the
same zone. One system can hold both, because the spelling is per series.

Three parts of this were more than deleting a conversion:

- Rehydration. The catalog renders the instant and the spelling in separate
  columns, so `from_catalog_timestamp` reassembles them when the index is
  rebuilt from a saved store.

- Python's datetime arithmetic is *wall clock* whenever both operands share
  a tzinfo. A grid steps instants, so `initial_timestamp + n * resolution`
  drifts across a DST transition, and `start_time - initial_timestamp` on a
  Denver series answers 31 hours where the store means 30. `advance` and
  `as_instant` do that arithmetic in one frame; without them a slice across
  2024-03-10 read back index 31 instead of 30.

- Coherence. A reader materializes one timestamp axis, so the store refuses
  a cohort mixing wall clocks with instants. `zoneless=True|False` is
  threaded through build_time_series_reader/build_forecast_reader as the
  constructive half of that rule, and a mismatched `start_time` now raises
  ISConflictingArguments naming both values rather than leaking a TypeError.

NonSequentialTimeSeries no longer pushes aware timestamps through datetime64
(which dropped the offset with a warning) and rejects mixed spellings.
`make_timestamps` is unchanged but documented: datetime64 has no room for a
zone, so a zoned series' array holds the instants in UTC.

BREAKING CHANGE: a naive timestamp is now stored as a wall clock rather than
relabelled UTC, and an aware one is returned aware rather than stripped. The
store's data format went to 0.17.0, so stores written by earlier infrastore
versions are rejected on open; there is no migration, only rewriting.

infrasys still does not populate the store's quantity_kind, unit_system, or
application_data descriptors -- UNITS_FIELD_NOTE.md tracks that as an open
decision and this change does not preempt it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V9S2KTTESCV9Ac9kgFzM2Z
Each manager previously owned its own IDManager starting at 1, so a
component and a supplemental attribute could be assigned the same ID.
The System now owns a single IDManager and shares it with both, making an
ID identify exactly one object whatever its kind. This matches what
utils/migrations.py already does when assigning IDs to legacy data.

Removed the supplemental_attribute_manager parameter from System. Only
from_dict passed it, with the same storage System already builds itself,
and it was the one way a caller could hand the system a manager with a
divergent ID counter.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V9S2KTTESCV9Ac9kgFzM2Z
Ports the fixes from InfrastructureSystems.jl's f3dd1d2ba and c20308b35 that
apply here. Both managers mutate their in-memory containers before the store
write that persists the change, so a refusal arriving from that write left the
system describing a store it no longer agreed with.

- Add TimeSeriesStoreStorage.raise_if_read_only(). Every manager in a system
  writes through the one store -- component parent/child associations and
  supplemental attribute associations as well as time series -- so that is the
  shared gate. ComponentManager.add/remove and SupplementalAttributeManager
  .add/remove/remove_association call it up front rather than discovering the
  refusal after the containers are updated. Deserialization is exempt: it
  populates from a store that already holds the rows and writes nothing.
  System._raise_if_read_only() asks both gates, the store's and the time series
  manager's (which can be read-only while the store is not), so a removal
  spanning three managers refuses before the first of them.

- remove_component detaches the component's supplemental attributes. An
  attribute left with no other component holding it is removed from the system,
  taking its own time series with it. Previously the association rows survived
  the component and pointed at an id that no longer existed.

- Drive the cascade_down cascade from System instead of recursing inside
  ComponentManager.remove, which now returns the ids it orphaned. A cascaded
  child had been removed without any of the System-level cleanup, leaking its
  time series as well as its attributes. The loop re-checks that each id is
  still stored, since removing one orphan can cascade to another in the same
  list -- an ISNotStored the old recursion could hit as well.

- Route the last-association cascade in
  remove_supplemental_attribute_from_component back through System, so the
  attribute's time series go with it. The manager owns no handle on the time
  series manager, so remove_attribute_from_component becomes remove_association
  and returns whether the attribute is now unreferenced.

- Run the force=False reference check before detaching anything. It lived
  inside ComponentManager.remove, i.e. after System had stripped the component,
  so a refused removal left the component in place without its attributes or
  time series.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RXNMsqZBdT19YcfFVv8ch5
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants