v1 migration pattern: wrapping a tool for GATDatabase#
This page documents the pattern for getting a power-system tool’s data into
GAT’s v1 duckdb-backed pipeline (GATDatabase). Use it when adding support
for a new tool, or when migrating an existing legacy scenario handler from
the pandas/h5py path to the v1 path.
The Plexos POC (gat/systems/plexos.py,
gat/simulations/plexos_v1.py,
tests/handlers/test_plexos_v1_migration.py) is the worked
example. The Sienna implementation
(gat/systems/sienna.py,
gat/simulations/sienna_v1.py) is the second reference point;
read both if you’re adding a third tool.
The contract#
The v1 architecture has three pieces:
┌─────────────────────┐
│ BaseSystem (your │
│ tool wraps it) ─┼─── default CategoryMap[s]
│ │
│ list_datasets() │── DatasetInfo[]
│ get_dataset(name) ─┼─── pd.DataFrame
└──────────┬──────────┘
│
│ ingest_system()
▼
┌─────────────────────┐
│ GATDatabase │── query_grouped(schema, dataset, group_by)
│ (DuckDB engine) │
└──────────▲──────────┘
│
│ ingest_simulation()
│
┌──────────┴──────────┐
│ BaseSimulation │
│ (your tool wraps) │── DatasetInfo[]
│ │
│ list_datasets() │── (raw + composed)
│ get_dataset(name) ─┼─── pd.DataFrame
└─────────────────────┘ (timestamp × entity)
Three things to implement:
A
BaseSystemsubclass that exposes generators/loads/lines as datasets and provides defaultCategoryMaps for the relations needed forGROUP BY(e.g.gen_area,gen_tech).A
BaseSimulationsubclass that exposes raw simulation datasets (timestamp × entity) and at least one composed dataset (the union you want to be able to group by).Smoke + parity tests that ingest into
GATDatabase, runquery_grouped, and confirm the result matches the legacy pandas path within tolerance.
The Plexos POC, walked end-to-end#
PlexosSystem#
gat/systems/plexos.py wraps the existing PlexosParser. Two things it
exposes:
list_datasets()returns[]for now (POC scope — no system component tables ingested yet; Plexos relations are sufficient for the migration POC).get_default_category_maps()returns agen_areaCategoryMappopulated fromparser.get_metadata("metadata/relations/regions_generators", reverse=True). Optionally agen_techmap if a tech-simplified mapping was passed in.
from gat.categories import CategoryMap
class PlexosSystem(BaseSystem):
def list_datasets(self) -> list[DatasetInfo]:
return []
def get_default_category_maps(self) -> list[CategoryMap]:
gen_area = self._parser.get_metadata(
"metadata/relations/regions_generators", reverse=True
)
return [CategoryMap(
name="gen_area",
description="Generator → region/area",
mapping={str(k): str(v) for k, v in gen_area.items()},
)]
CategoryMap supports three sources: in-memory mapping, an external
mapping_file (CSV/Excel), or a geometry_file for spatial joins. The
POC uses the in-memory dict form.
PlexosSimulation#
gat/simulations/plexos_v1.py exposes one raw and one composed dataset:
class PlexosSimulation(BaseSimulation):
def list_datasets(self) -> list[DatasetInfo]:
return [
DatasetInfo(
name="generation_raw",
kind=DatasetKind.RAW_SIMULATION,
entity_column="entity_id",
...
),
DatasetInfo(
name="generation",
kind=DatasetKind.COMPOSED,
entity_column="entity_id",
source_datasets=["generation_raw"],
...
),
]
def get_dataset(self, name: str) -> pd.DataFrame:
# Plexos parser returns entity-rows × timestamp-cols with a
# MultiIndex (generator, category) on the rows. Transpose to the
# v1 contract (timestamp-rows × entity-cols) and flatten the index
# to just the generator name.
raw = self._parser.get_h5dataset("ST", "interval", "generators", "Generation")
if isinstance(raw.index, pd.MultiIndex):
raw = raw.copy()
raw.index = raw.index.get_level_values(0)
df = raw.T
df.index = pd.to_datetime(df.index)
df.index.name = "DATETIME"
return df.astype("float32", errors="ignore")
Orientation matters. The contract is timestamp-rows × entity-cols, with
a DatetimeIndex. GATDatabase.ingest_simulation calls
_prepare_sim_dataframe which converts the index to an ISO string column
named datetime and casts floats to f32. Composed datasets are then built
by UNPIVOTing the raw tables and PIVOTing back as entity-rows ×
timestamp-cols (for efficient GROUP BY across many timestamps).
Driving it end-to-end#
from gat.backends import GATDatabase
from gat.systems import PlexosSystem
from gat.simulations import PlexosSimulation
db = GATDatabase()
system = PlexosSystem(solution_dir="example_data/plexos")
sim = PlexosSimulation(solution_dir="example_data/plexos")
db.ingest_system("plexos", system)
db.ingest_simulation("plexos", sim)
for cmap in system.get_default_category_maps():
db.register_category_map("plexos", cmap)
# Area-aggregated generation, summed across all timestamps:
df = db.query_grouped("plexos", "generation", group_by=["gen_area"])
# → wide DataFrame: gen_area | t0 | t1 | … | tN
Parity test#
tests/handlers/test_plexos_v1_migration.py::test_v1_area_aggregation_matches_legacy
asserts that the duckdb path produces the same area totals as the legacy
PlexosScenario.get_generation() aggregated by area, within rel=1e-4.
This is the load-bearing test — every get_* method migrated from the
legacy handler should add a similar parity check before flipping its
implementation to use duckdb.
A third tool, and a shortcut for already-relational sources#
gat/systems/plexos_duckdb.py /
gat/simulations/plexos_duckdb.py is a second Plexos
backend, alongside the H5-based POC above. It ingests native PLEXOS
Solution.zip files (not h5plexos-converted .h5) by shelling out to the
optional plexos2duckdb tool
(pip install nlr-gat[plexos-duckdb] — a Rust CLI, beta as of this writing).
Quickest way to try it — Scenario.from_plexos_duckdb builds
PlexosDuckDBSystem + PlexosDuckDBSimulation + GATDatabase and ingests
in one call:
from gat import Scenario
scenario = Scenario.from_plexos_duckdb("/path/to/Solution.zip")
scenario.query("generation", group_by=["gen_area"])
By default this only ingests what the generation composition needs
(one raw report table), not the 100+ report tables a real solution can
expose — pass full_ingest=True to pull everything, but expect it to be
slow (minutes on a multi-thousand-generator solution) and to hit the
identifier-quoting bug below on any property with a special character in
its name (real example: PLEXOS’s “Start & Shutdown Cost”).
The interesting bit for future plugin authors: plexos2duckdb’s output is
already a DuckDB file with a report schema — one view per
(Phase, Period, Collection, Property) triple, timestamp + entity-name +
value columns. So instead of parsing into pandas and letting
GATDatabase.ingest_simulation do the transpose, get_dataset() runs a
PIVOT directly against the source connection
(gat/datahelpers/plexos_duckdb.py) and only
crosses into pandas at the very end via .fetchdf(). Same
BaseSystem/BaseSimulation contract as every other backend — the
shortcut is internal to get_dataset(), not a new interface.
Multi-file overlap is a shared primitive, not a per-backend concern.
Before this backend, GAT had three independent implementations of
“combine multiple time-ordered files/blocks, earlier one wins”: Sienna’s
SimulationAggregator._combine_frames, the legacy Plexos h5 path’s
gat.datahelpers.parsers.combine_frames_skip_prev, and (briefly, during
this backend’s first pass) a bespoke SQL version here. They’d drifted —
combine_frames_skip_prev has a documented quirk where its output isn’t
re-sorted chronologically if the input list wasn’t already in order (see
tests/datahelpers/test_combine_frames.py), which surfaced as a real
~9% generation under-count when a 12-month real fixture exposed it.
The fix: gat.simulations.utils.combine_overlapping_frames is now the one
canonical function — sort input frames by first timestamp, dedupe via
dedup_slices, concatenate, sort the result. SimulationAggregator. _combine_frames is a thin wrapper over it. PlexosDuckDBSource.pivot_wide
uses it too: each attached file gets pivoted independently
(_pivot_one_file — genuinely “parse one file, nothing else”) into its
own timestamp × entity DataFrame, and only those per-file DataFrames get
handed to combine_overlapping_frames. combine_frames_skip_prev itself
hasn’t been migrated yet (it’s mid-investigation for the under-count bug;
migrating it is the natural follow-up once that’s root-caused) — new
backends should use combine_overlapping_frames directly rather than
that one.
Composed datasets are also expressed the same way Sienna does it: a
{composed_name: [glob_patterns]} dict resolved against raw dataset names.
That resolution logic (resolve_compositions) was extracted from
SiennaSimulation into gat/simulations/utils.py so both backends (and any
future one) share it instead of reimplementing fnmatch-against-raw-names
each time.
A legacy handler that speaks both formats#
PlexosScenario (gat/scenariohandlers/plexos.py) now
detects its input format and routes internally to one of two engines —
the legacy pandas/h5py PlexosParser for h5plexos-converted .h5 files
(unchanged), or PlexosDuckDBSystem/PlexosDuckDBSimulation for native
PLEXOS Solution.zip/.duckdb files. Same class, same public API either
way — BaseScenario’s higher-level methods (get_system_dispatch(),
get_area_dispatch(), curtailment, peak stats, line loading) work
unmodified on top of whichever engine is active:
from gat.scenariohandlers import PlexosScenario
scenario = PlexosScenario("/path/to/Solution.zip") # duckdb backend
# scenario = PlexosScenario("/path/to/h5_dir") # h5 backend, unchanged
dispatch = scenario.get_system_dispatch()
This is a variant of step 7 below (“flip the legacy method’s
implementation to delegate to the v1 path”) — gated on input format
rather than a hard cutover, since native .zip and h5plexos .h5 are
different upstream artifacts that must both keep working. Detection lives
in _resolve_plexos_backend_and_files (a free function, independently
tested in tests/handlers/test_plexos_backend_detection.py): explicit
file paths resolve regardless of extension and are classified by it;
directories/globs retry under *.zip/*.duckdb patterns only if the
default *.h5 pattern found nothing. Every abstract method branches
if self._backend == "duckdb": ... else: <existing h5 code, untouched> —
the h5 branch is never touched, so the regression snapshots in
tests/handlers/test_plexos_regression.py gate zero --force-regen.
The duckdb branch talks straight to PlexosDuckDBSystem/
PlexosDuckDBSimulation — never GATDatabase/Scenario — which sidesteps
the identifier-quoting concern entirely (that bug lives in GATDatabase’s
view-registration path).
A real-data surprise worth remembering: PLEXOS Storage-class objects
looked, at first, like h5plexos’s “batteries” group (which the legacy path
unions additively into generation/gen_area — see gat/systems/ plexos.py). They’re not. Verified against a real converted solution:
Storage objects are named <generator>_head/<generator>_tail,
children of their Generator (not Region) via Head Storage/
Tail Storage membership collections, and their Generation is
bit-identical to the parent Generator’s own Generation — internal
reservoir bookkeeping for battery-capable generators, not a separate
generation source. Unioning it in would have double-counted. Only
Storages__Pump_Load (charging) is genuinely separate information, and
only its _head column carries a real value. Moral: h5plexos and
plexos2duckdb convert the same underlying PLEXOS solution but don’t
necessarily share a data model — verify against real data before porting
a union/aggregation pattern from one to the other, don’t assume it
transfers.
Pattern to apply when adding a new tool#
Step |
What to do |
|---|---|
1 |
List the raw datasets your tool emits (e.g. one per component type, one per output variable). |
2 |
Decide which composed unions you need ( |
3 |
Implement |
4 |
Implement |
5 |
Smoke-test: |
6 |
Parity-test against the legacy handler’s equivalent method. Use |
7 |
Once parity holds, flip the legacy method’s implementation to delegate to the v1 path. The regression snapshot tests ( |
When to skip a step#
No legacy parity test target: if you’re adding support for a tool GAT didn’t have before, skip step 6 — there’s nothing to compare against. Use
test_v1_pipeline_ingests_and_groups(smoke) as your only test; add snapshots once the output stabilizes.No clean composition: if your tool’s data is already aggregated in a way that doesn’t fit the raw-then-composed model, expose it as a single raw dataset and use
query()(raw SQL) instead ofquery_groupedfor one-off needs. The composition story is a convenience; not every dataset needs to fit it.