From 96d16bce5007662711607d1d9f6cb48d9d5ca58c Mon Sep 17 00:00:00 2001 From: Aadil Latif Date: Sat, 28 Feb 2026 01:48:30 -0700 Subject: [PATCH 1/6] Add SQLite DB persistence modules, tests, and docs --- docs/_toc.yml | 1 + docs/dist_system/sqlite_persistence.md | 75 + src/gdm/db/__init__.py | 15 + src/gdm/db/sqlite_store.py | 893 +++++++++ .../db/sqlite_store_cap_voltage_xfmr_reg.py | 1718 +++++++++++++++++ src/gdm/db/sqlite_store_controls_curves.py | 632 ++++++ src/gdm/db/sqlite_store_geometry.py | 462 +++++ src/gdm/db/sqlite_store_identity.py | 31 + src/gdm/db/sqlite_store_impedance.py | 68 + src/gdm/db/sqlite_store_load_solar_battery.py | 1078 +++++++++++ src/gdm/db/sqlite_store_network_branches.py | 561 ++++++ src/gdm/db/sqlite_store_recloser.py | 226 +++ src/gdm/db/sqlite_store_schema.py | 76 + src/gdm/db/sqlite_store_snapshot.py | 70 + src/gdm/db/sqlite_store_switchgear_loaders.py | 429 ++++ src/gdm/db/sqlite_store_switchgear_writers.py | 641 ++++++ src/gdm/distribution/catalog_system.py | 27 + src/gdm/distribution/distribution_system.py | 48 +- .../mocks/sample_distribution_system.sqlite3 | Bin 0 -> 1081344 bytes tests/test_db_io.py | 448 +++++ 20 files changed, 7497 insertions(+), 2 deletions(-) create mode 100644 docs/dist_system/sqlite_persistence.md create mode 100644 src/gdm/db/__init__.py create mode 100644 src/gdm/db/sqlite_store.py create mode 100644 src/gdm/db/sqlite_store_cap_voltage_xfmr_reg.py create mode 100644 src/gdm/db/sqlite_store_controls_curves.py create mode 100644 src/gdm/db/sqlite_store_geometry.py create mode 100644 src/gdm/db/sqlite_store_identity.py create mode 100644 src/gdm/db/sqlite_store_impedance.py create mode 100644 src/gdm/db/sqlite_store_load_solar_battery.py create mode 100644 src/gdm/db/sqlite_store_network_branches.py create mode 100644 src/gdm/db/sqlite_store_recloser.py create mode 100644 src/gdm/db/sqlite_store_schema.py create mode 100644 src/gdm/db/sqlite_store_snapshot.py create mode 100644 src/gdm/db/sqlite_store_switchgear_loaders.py create mode 100644 src/gdm/db/sqlite_store_switchgear_writers.py create mode 100644 tests/mocks/sample_distribution_system.sqlite3 create mode 100644 tests/test_db_io.py diff --git a/docs/_toc.yml b/docs/_toc.yml index bdec4917..a729827a 100644 --- a/docs/_toc.yml +++ b/docs/_toc.yml @@ -15,6 +15,7 @@ parts: - file: gdm_intro/units - file: gdm_intro/timeseries - file: dist_system/import_export + - file: dist_system/sqlite_persistence - file: dist_system/plotting - caption: Advanced Usage chapters: diff --git a/docs/dist_system/sqlite_persistence.md b/docs/dist_system/sqlite_persistence.md new file mode 100644 index 00000000..2513630b --- /dev/null +++ b/docs/dist_system/sqlite_persistence.md @@ -0,0 +1,75 @@ +# SQLite Persistence + +GDM supports persisting systems directly to SQLite through high-level APIs on `DistributionSystem` and `CatalogSystem`. + +This is useful for: + +- storing complete model snapshots in a single database file, +- preserving component UUID identity across save/load cycles, +- loading from a normalized relational representation for faster selective reconstruction. + +## DistributionSystem: write and load + +```python +from gdm.distribution import DistributionSystem + +# write +system: DistributionSystem = ... +system.to_db("distribution.sqlite") + +# load (default snapshot path) +loaded = DistributionSystem.from_db("distribution.sqlite") +``` + +By default, `to_db` writes a snapshot payload and normalized distribution tables. + +### Load from normalized representation + +Use `prefer_normalized=True` to reconstruct from normalized topology/component tables first. + +```python +loaded = DistributionSystem.from_db( + "distribution.sqlite", + prefer_normalized=True, +) +``` + +If normalized rows are unavailable for the stored system, loading falls back to snapshot reconstruction. + +## CatalogSystem: write and load + +```python +from gdm.distribution import CatalogSystem + +catalog: CatalogSystem = ... +catalog.to_db("catalog.sqlite") + +loaded_catalog = CatalogSystem.from_db("catalog.sqlite") +``` + +`CatalogSystem` persistence uses snapshot storage. + +## Replace semantics and schema initialization + +For both system types, writes replace existing records for that `system_kind` by default. + +- `replace=True` (default): replace previously persisted record(s) for that system kind. +- `initialize_schema=True` (default): bootstrap schema/tables when needed. + +In repeated writes to an existing database, `initialize_schema=False` can be used once schema is already present. + +## Time series behavior + +When persisting a `DistributionSystem`, time-series associations are stored in DB metadata tables, and loading restores component time-series attachments from persisted snapshot data. + +## Inspecting stored snapshot payloads + +For diagnostics, raw snapshot payload can be inspected via `gdm.db.sqlite_store.load_snapshot_payload`. + +```python +from gdm.db.sqlite_store import load_snapshot_payload + +payload = load_snapshot_payload("distribution.sqlite", system_kind="distribution") +``` + +For metadata-only inspection, `gdm.db.sqlite_store_schema.inspect_snapshot_metadata` is also available. diff --git a/src/gdm/db/__init__.py b/src/gdm/db/__init__.py new file mode 100644 index 00000000..13ec3c39 --- /dev/null +++ b/src/gdm/db/__init__.py @@ -0,0 +1,15 @@ +"""Database adapters for Grid Data Models.""" + +from gdm.db.sqlite_store import ( + DEFAULT_DB_FORMAT_VERSION, + default_schema_path, + load_system_from_db, + write_system_to_db, +) + +__all__ = [ + "DEFAULT_DB_FORMAT_VERSION", + "default_schema_path", + "load_system_from_db", + "write_system_to_db", +] diff --git a/src/gdm/db/sqlite_store.py b/src/gdm/db/sqlite_store.py new file mode 100644 index 00000000..ee133160 --- /dev/null +++ b/src/gdm/db/sqlite_store.py @@ -0,0 +1,893 @@ +"""SQLite persistence helpers for GDM systems. + +This module provides an initial, transactional DB persistence layer that: +1) bootstraps the reference distribution schema, +2) stores system payloads in additive GDM-owned tables, and +3) reconstructs systems through existing JSON serialization routines. +""" + +from __future__ import annotations + +import json +import sqlite3 +import tempfile +from pathlib import Path +from typing import Type +from uuid import UUID + +from infrasys import Location +from infrasys.time_series_models import NonSequentialTimeSeries, SingleTimeSeries + +from gdm.distribution import DistributionSystem +from gdm.distribution.common.limitset import VoltageLimitSet +from gdm.distribution.components import ( + DistributionBus, +) +from gdm.distribution.components.distribution_feeder import DistributionFeeder +from gdm.distribution.components.distribution_substation import DistributionSubstation +from gdm.distribution.enums import ( + LimitType, + Phase, + VoltageTypes, +) +from gdm.db.sqlite_store_identity import _fetch_component_uuid, _upsert_component_uuid_map +from gdm.db.sqlite_store_schema import ( + _ensure_gdm_tables, + _initialize_schema, + _upsert_metadata, +) +from gdm.db.sqlite_store_switchgear_loaders import ( + _load_matrix_impedance_fuses_from_normalized, + _load_matrix_impedance_reclosers_from_normalized, + _load_matrix_impedance_switches_from_normalized, +) +from gdm.db.sqlite_store_geometry import ( + _load_geometry_branches_from_normalized, +) +from gdm.db.sqlite_store_switchgear_writers import ( + _write_geometry_branches, + _write_matrix_impedance_fuses, + _write_matrix_impedance_reclosers, + _write_matrix_impedance_switches, +) +from gdm.db.sqlite_store_network_branches import ( + _load_matrix_impedance_branches_from_normalized, + _load_sequence_impedance_branches_from_normalized, + _write_matrix_impedance_branches, + _write_sequence_impedance_branches, +) +from gdm.db.sqlite_store_load_solar_battery import ( + _load_distribution_batteries_from_normalized, + _load_distribution_loads_from_normalized, + _load_distribution_solar_from_normalized, + _write_distribution_batteries, + _write_distribution_loads, + _write_distribution_solar, +) +from gdm.db.sqlite_store_cap_voltage_xfmr_reg import ( + _load_distribution_capacitors_from_normalized, + _load_distribution_regulators_from_normalized, + _load_distribution_transformers_from_normalized, + _load_distribution_voltage_sources_from_normalized, + _write_distribution_capacitors, + _write_distribution_regulators, + _write_distribution_transformers, + _write_distribution_voltage_sources, +) +from gdm.db.sqlite_store_snapshot import ( + _decode_snapshot_payload, + _restore_time_series_sidecar, + _serialize_system_to_json_text, +) +from gdm.quantities import ( + Voltage, +) + + +DEFAULT_DB_FORMAT_VERSION = "1" + + +def write_system_to_db( + *, + system, + db_path: str | Path, + schema_path: str | Path | None = None, + replace: bool = True, + initialize_schema: bool = True, + system_kind: str, +) -> None: + """Write a system to SQLite with transactional replace semantics. + + Parameters + ---------- + system : System + The GDM system instance to serialize and persist. + db_path : str | Path + Target SQLite database path. + schema_path : str | Path | None + Optional path to SQL schema script. If omitted, repository default is used. + replace : bool + If True, existing snapshot for this system kind is replaced. + initialize_schema : bool + If True, bootstrap schema if missing. + system_kind : str + Logical discriminator for stored system payloads. + """ + + db_path = Path(db_path) + payload = _serialize_system_to_json_text(system) + with sqlite3.connect(db_path) as conn: + conn.execute("PRAGMA foreign_keys = ON") + if initialize_schema: + _initialize_schema(conn, schema_path=schema_path) + + _ensure_gdm_tables(conn) + + with conn: + if replace: + conn.execute( + "DELETE FROM gdm_system_snapshots WHERE system_kind = ?", + (system_kind,), + ) + conn.execute( + """ + INSERT OR REPLACE INTO gdm_system_snapshots(system_kind, payload_json, created_at) + VALUES(?, ?, CURRENT_TIMESTAMP) + """, + (system_kind, payload), + ) + if system_kind == "distribution": + _write_distribution_topology(conn, system=system, replace=replace) + _write_time_series_associations(conn, system=system, replace=replace) + _upsert_metadata( + conn, + f"{system_kind}_storage_mode", + "snapshot+normalized+timeseries-associations-v1", + ) + _upsert_metadata(conn, "gdm_db_format_version", DEFAULT_DB_FORMAT_VERSION) + _upsert_metadata( + conn, f"{system_kind}_data_format_version", system.data_format_version + ) + + +def load_system_from_db( + *, + system_cls: Type, + db_path: str | Path, + system_kind: str, + prefer_normalized: bool = False, +) -> object: + """Load a system from SQLite snapshot tables. + + Parameters + ---------- + system_cls : Type + Target class used for deserialization (`DistributionSystem`, `CatalogSystem`). + db_path : str | Path + Source SQLite database path. + system_kind : str + Logical discriminator for stored system payloads. + + Returns + ------- + object + Deserialized system instance. + """ + + db_path = Path(db_path) + with sqlite3.connect(db_path) as conn: + conn.execute("PRAGMA foreign_keys = ON") + if system_kind == "distribution" and prefer_normalized: + normalized = _load_distribution_topology_from_normalized(conn) + if normalized is not None: + _attach_time_series_from_snapshot(conn, normalized) + return normalized + + row = conn.execute( + "SELECT payload_json FROM gdm_system_snapshots WHERE system_kind = ?", + (system_kind,), + ).fetchone() + + if row is None: + raise ValueError(f"No persisted '{system_kind}' system found in {db_path}") + + payload = row[0] + if not payload: + raise ValueError(f"Persisted payload for '{system_kind}' is empty in {db_path}") + + snapshot = _decode_snapshot_payload(payload) + with tempfile.TemporaryDirectory() as tmp_dir: + temp_json = Path(tmp_dir) / f"{system_kind}_snapshot.json" + temp_json.write_text(snapshot["system_json"]) + _restore_time_series_sidecar(Path(tmp_dir), snapshot) + return system_cls.from_json(temp_json) + + +def _write_distribution_topology(conn: sqlite3.Connection, system, replace: bool) -> None: + if not isinstance(system, DistributionSystem): + return + + component_types = { + "distribution_feeders", + "distribution_substations", + "distribution_buses", + "voltage_limit_sets", + "distribution_loads", + "distribution_load_phases", + "load_equipment", + "load_equipment_phases", + "phase_load_equipment", + "distribution_solar", + "distribution_solar_phases", + "solar_equipment", + "inverter_equipment", + "inverter_controllers", + "inverter_active_power_controls", + "inverter_reactive_power_controls", + "curves", + "distribution_batteries", + "distribution_battery_phases", + "battery_equipment", + "distribution_capacitors", + "distribution_capacitor_phases", + "capacitor_controllers", + "capacitor_equipment", + "capacitor_equipment_phases", + "phase_capacitor_equipment", + "distribution_voltage_sources", + "distribution_voltage_source_phases", + "voltage_source_equipment", + "voltage_source_phases", + "phase_voltage_source_equipment", + "distribution_transformers", + "transformer_winding_buses", + "transformer_winding_phases", + "distribution_transformer_equipment", + "winding_equipment", + "winding_tap_positions", + "transformer_coupling_sequences", + "distribution_regulators", + "regulator_winding_buses", + "regulator_winding_phases", + "regulator_controllers", + "matrix_impedance_branches", + "matrix_impedance_branch_phases", + "matrix_impedance_branch_equipment", + "sequence_impedance_branches", + "sequence_impedance_branch_phases", + "sequence_impedance_branch_equipment", + "matrix_impedance_switches", + "matrix_impedance_switch_phases", + "switch_phase_states", + "matrix_impedance_switch_equipment", + "switch_controllers", + "matrix_impedance_fuses", + "matrix_impedance_fuse_phases", + "fuse_phase_states", + "matrix_impedance_fuse_equipment", + "matrix_impedance_reclosers", + "matrix_impedance_recloser_phases", + "recloser_phase_states", + "matrix_impedance_recloser_equipment", + "recloser_controllers", + "recloser_reclose_intervals", + "recloser_controller_equipment", + "time_current_curves", + "geometry_branches", + "geometry_branch_phases", + "geometry_branch_equipment", + "geometry_branch_conductors", + "bare_conductor_equipment", + "concentric_cable_equipment", + "impedance_matrix_entries", + } + + if replace: + conn.execute("DELETE FROM geometry_branch_phases") + conn.execute("DELETE FROM geometry_branches") + conn.execute("DELETE FROM geometry_branch_conductors") + conn.execute("DELETE FROM geometry_branch_equipment") + conn.execute("DELETE FROM matrix_impedance_fuse_phases") + conn.execute("DELETE FROM fuse_phase_states") + conn.execute("DELETE FROM matrix_impedance_fuses") + conn.execute("DELETE FROM impedance_matrix_entries WHERE equipment_type = 'FUSE'") + conn.execute("DELETE FROM matrix_impedance_fuse_equipment") + conn.execute("DELETE FROM matrix_impedance_recloser_phases") + conn.execute("DELETE FROM recloser_phase_states") + conn.execute("DELETE FROM matrix_impedance_reclosers") + conn.execute("DELETE FROM impedance_matrix_entries WHERE equipment_type = 'RECLOSER'") + conn.execute("DELETE FROM matrix_impedance_recloser_equipment") + conn.execute("DELETE FROM recloser_reclose_intervals") + conn.execute("DELETE FROM recloser_controllers") + conn.execute("DELETE FROM recloser_controller_equipment") + conn.execute("DELETE FROM time_current_curve_points") + conn.execute("DELETE FROM time_current_curves") + conn.execute("DELETE FROM switch_phase_states") + conn.execute("DELETE FROM matrix_impedance_switch_phases") + conn.execute("DELETE FROM matrix_impedance_switches") + conn.execute("DELETE FROM impedance_matrix_entries WHERE equipment_type = 'SWITCH'") + conn.execute("DELETE FROM matrix_impedance_switch_equipment") + conn.execute("DELETE FROM switch_controllers") + conn.execute("DELETE FROM sequence_impedance_branch_phases") + conn.execute("DELETE FROM sequence_impedance_branches") + conn.execute("DELETE FROM sequence_impedance_branch_equipment") + conn.execute("DELETE FROM matrix_impedance_branch_phases") + conn.execute("DELETE FROM matrix_impedance_branches") + conn.execute("DELETE FROM impedance_matrix_entries WHERE equipment_type = 'LINE'") + conn.execute("DELETE FROM matrix_impedance_branch_equipment") + conn.execute("DELETE FROM regulator_controllers") + conn.execute("DELETE FROM regulator_winding_phases") + conn.execute("DELETE FROM regulator_winding_buses") + conn.execute("DELETE FROM distribution_regulators") + conn.execute("DELETE FROM transformer_winding_phases") + conn.execute("DELETE FROM transformer_winding_buses") + conn.execute("DELETE FROM distribution_transformers") + conn.execute("DELETE FROM winding_tap_positions") + conn.execute("DELETE FROM winding_equipment") + conn.execute("DELETE FROM transformer_coupling_sequences") + conn.execute("DELETE FROM distribution_transformer_equipment") + conn.execute("DELETE FROM distribution_voltage_source_phases") + conn.execute("DELETE FROM distribution_voltage_sources") + conn.execute("DELETE FROM voltage_source_phases") + conn.execute("DELETE FROM voltage_source_equipment") + conn.execute("DELETE FROM phase_voltage_source_equipment") + conn.execute("DELETE FROM distribution_capacitor_phases") + conn.execute("DELETE FROM capacitor_controllers") + conn.execute("DELETE FROM distribution_capacitors") + conn.execute("DELETE FROM capacitor_equipment_phases") + conn.execute("DELETE FROM capacitor_equipment") + conn.execute("DELETE FROM phase_capacitor_equipment") + conn.execute("DELETE FROM distribution_battery_phases") + conn.execute("DELETE FROM distribution_batteries") + conn.execute("DELETE FROM battery_equipment") + conn.execute("DELETE FROM inverter_controllers") + conn.execute("DELETE FROM inverter_active_power_controls") + conn.execute("DELETE FROM inverter_reactive_power_controls") + conn.execute("DELETE FROM curve_points") + conn.execute("DELETE FROM curves") + conn.execute("DELETE FROM distribution_solar_phases") + conn.execute("DELETE FROM distribution_solar") + conn.execute("DELETE FROM solar_equipment") + conn.execute("DELETE FROM inverter_equipment") + conn.execute("DELETE FROM distribution_load_phases") + conn.execute("DELETE FROM distribution_loads") + conn.execute("DELETE FROM load_equipment_phases") + conn.execute("DELETE FROM load_equipment") + conn.execute("DELETE FROM phase_load_equipment") + conn.execute("DELETE FROM bus_voltage_limits") + conn.execute("DELETE FROM bus_phases") + conn.execute("DELETE FROM distribution_buses") + conn.execute("DELETE FROM substation_feeders") + conn.execute("DELETE FROM distribution_substations") + conn.execute("DELETE FROM distribution_feeders") + conn.execute( + "DELETE FROM voltage_limit_sets WHERE id NOT IN (SELECT limit_set_id FROM bus_voltage_limits)" + ) + conn.execute( + f"DELETE FROM gdm_component_uuid_map WHERE component_type IN ({', '.join(['?'] * len(component_types))})", + tuple(component_types), + ) + + feeder_id_by_name: dict[str, int] = {} + substation_id_by_name: dict[str, int] = {} + + feeders_by_name = {feeder.name: feeder for feeder in system.get_components(DistributionFeeder)} + substations_by_name = { + substation.name: substation for substation in system.get_components(DistributionSubstation) + } + for bus in system.get_components(DistributionBus): + if bus.feeder is not None: + feeders_by_name.setdefault(bus.feeder.name, bus.feeder) + if bus.substation is not None: + substations_by_name.setdefault(bus.substation.name, bus.substation) + for substation in system.get_components(DistributionSubstation): + for feeder in substation.feeders: + feeders_by_name.setdefault(feeder.name, feeder) + + for feeder in feeders_by_name.values(): + cursor = conn.execute("INSERT INTO distribution_feeders(name) VALUES(?)", (feeder.name,)) + feeder_id = int(cursor.lastrowid) + feeder_id_by_name[feeder.name] = feeder_id + _upsert_component_uuid_map(conn, "distribution_feeders", feeder_id, feeder.uuid) + + for substation in substations_by_name.values(): + cursor = conn.execute( + "INSERT INTO distribution_substations(name) VALUES(?)", (substation.name,) + ) + substation_id = int(cursor.lastrowid) + substation_id_by_name[substation.name] = substation_id + _upsert_component_uuid_map( + conn, + "distribution_substations", + substation_id, + substation.uuid, + ) + + for feeder in substation.feeders: + feeder_id = feeder_id_by_name.get(feeder.name) + if feeder_id is None: + raise ValueError( + f"Feeder '{feeder.name}' attached to substation '{substation.name}' was not found" + ) + + conn.execute( + "INSERT INTO substation_feeders(substation_id, feeder_id) VALUES(?, ?)", + (substation_id, feeder_id), + ) + + for bus in system.get_components(DistributionBus): + if bus.substation is None or bus.feeder is None: + raise ValueError( + f"DistributionBus '{bus.name}' must have substation and feeder assigned for DB export" + ) + + substation_id = substation_id_by_name.get(bus.substation.name) + feeder_id = feeder_id_by_name.get(bus.feeder.name) + if substation_id is None or feeder_id is None: + raise ValueError( + f"DistributionBus '{bus.name}' references substation/feeder not present in system" + ) + + coordinate_x = bus.coordinate.x if bus.coordinate is not None else None + coordinate_y = bus.coordinate.y if bus.coordinate is not None else None + + cursor = conn.execute( + """ + INSERT INTO distribution_buses( + name, + substation_id, + feeder_id, + voltage_type, + rated_voltage, + rated_voltage_unit, + coordinate_x, + coordinate_y, + in_service + ) + VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + bus.name, + substation_id, + feeder_id, + bus.voltage_type.value, + float(bus.rated_voltage.magnitude), + str(bus.rated_voltage.units), + coordinate_x, + coordinate_y, + 1 if bus.in_service else 0, + ), + ) + bus_id = int(cursor.lastrowid) + _upsert_component_uuid_map(conn, "distribution_buses", bus_id, bus.uuid) + + for position_index, phase in enumerate(bus.phases): + conn.execute( + "INSERT INTO bus_phases(bus_id, phase, position_index) VALUES(?, ?, ?)", + (bus_id, phase.value, position_index), + ) + + for limit_set in bus.voltagelimits: + limit_cursor = conn.execute( + "INSERT INTO voltage_limit_sets(name, limit_type, value, value_unit) VALUES(?, ?, ?, ?)", + ( + limit_set.name, + limit_set.limit_type.value, + float(limit_set.value.magnitude), + str(limit_set.value.units), + ), + ) + limit_id = int(limit_cursor.lastrowid) + _upsert_component_uuid_map(conn, "voltage_limit_sets", limit_id, limit_set.uuid) + conn.execute( + "INSERT INTO bus_voltage_limits(bus_id, limit_set_id) VALUES(?, ?)", + (bus_id, limit_id), + ) + + curve_id_by_uuid: dict[UUID, int] = {} + active_control_id_by_uuid: dict[UUID, int] = {} + reactive_control_id_by_uuid: dict[UUID, int] = {} + controller_id_by_uuid: dict[UUID, int] = {} + + _write_distribution_loads(conn, system) + _write_distribution_solar( + conn, + system, + curve_id_by_uuid, + active_control_id_by_uuid, + reactive_control_id_by_uuid, + controller_id_by_uuid, + ) + _write_distribution_batteries( + conn, + system, + curve_id_by_uuid, + active_control_id_by_uuid, + reactive_control_id_by_uuid, + controller_id_by_uuid, + ) + _write_distribution_capacitors(conn, system) + _write_distribution_voltage_sources(conn, system) + _write_distribution_transformers(conn, system) + _write_distribution_regulators(conn, system) + _write_matrix_impedance_branches(conn, system) + _write_sequence_impedance_branches(conn, system) + _write_matrix_impedance_switches(conn, system) + _write_geometry_branches(conn, system) + _write_matrix_impedance_fuses(conn, system) + _write_matrix_impedance_reclosers(conn, system) + + +def _load_distribution_topology_from_normalized( + conn: sqlite3.Connection, +) -> DistributionSystem | None: + feeder_rows = conn.execute("SELECT id, name FROM distribution_feeders ORDER BY id").fetchall() + substation_rows = conn.execute( + "SELECT id, name FROM distribution_substations ORDER BY id" + ).fetchall() + bus_rows = conn.execute( + """ + SELECT id, name, substation_id, feeder_id, voltage_type, rated_voltage, + rated_voltage_unit, coordinate_x, coordinate_y, in_service + FROM distribution_buses + ORDER BY id + """ + ).fetchall() + + if not feeder_rows and not substation_rows and not bus_rows: + return None + + system = DistributionSystem(auto_add_composed_components=True) + + feeders_by_id: dict[int, DistributionFeeder] = {} + for feeder_id, name in feeder_rows: + feeder = DistributionFeeder(name=name) + feeder_uuid = _fetch_component_uuid(conn, "distribution_feeders", feeder_id) + if feeder_uuid is not None: + feeder = feeder.model_copy(update={"uuid": feeder_uuid}) + system.add_component(feeder) + feeders_by_id[feeder_id] = feeder + + substation_to_feeders = conn.execute( + "SELECT substation_id, feeder_id FROM substation_feeders ORDER BY substation_id, feeder_id" + ).fetchall() + feeders_per_substation: dict[int, list[DistributionFeeder]] = {} + for substation_id, feeder_id in substation_to_feeders: + feeders_per_substation.setdefault(substation_id, []).append(feeders_by_id[feeder_id]) + + substations_by_id: dict[int, DistributionSubstation] = {} + for substation_id, name in substation_rows: + feeders = feeders_per_substation.get(substation_id, []) + substation = DistributionSubstation(name=name, feeders=feeders) + substation_uuid = _fetch_component_uuid(conn, "distribution_substations", substation_id) + if substation_uuid is not None: + substation = substation.model_copy(update={"uuid": substation_uuid}) + system.add_component(substation) + substations_by_id[substation_id] = substation + + buses_by_id: dict[int, DistributionBus] = {} + + for ( + bus_id, + name, + substation_id, + feeder_id, + voltage_type, + rated_voltage, + rated_voltage_unit, + coordinate_x, + coordinate_y, + in_service, + ) in bus_rows: + phase_rows = conn.execute( + "SELECT phase FROM bus_phases WHERE bus_id = ? ORDER BY position_index", + (bus_id,), + ).fetchall() + phases = [Phase(phase) for (phase,) in phase_rows] + + limit_rows = conn.execute( + """ + SELECT v.id, v.name, v.limit_type, v.value, v.value_unit + FROM bus_voltage_limits bvl + JOIN voltage_limit_sets v ON v.id = bvl.limit_set_id + WHERE bvl.bus_id = ? + ORDER BY v.id + """, + (bus_id,), + ).fetchall() + voltage_limits: list[VoltageLimitSet] = [] + for limit_id, limit_name, limit_type, limit_value, limit_unit in limit_rows: + limit_set = VoltageLimitSet( + name=limit_name, + limit_type=LimitType(limit_type), + value=Voltage(limit_value, limit_unit), + ) + limit_uuid = _fetch_component_uuid(conn, "voltage_limit_sets", limit_id) + if limit_uuid is not None: + limit_set = limit_set.model_copy(update={"uuid": limit_uuid}) + voltage_limits.append(limit_set) + + coordinate = None + if coordinate_x is not None and coordinate_y is not None: + coordinate = Location(x=coordinate_x, y=coordinate_y) + + bus = DistributionBus( + name=name, + substation=substations_by_id[substation_id], + feeder=feeders_by_id[feeder_id], + voltage_type=VoltageTypes(voltage_type), + phases=phases, + voltagelimits=voltage_limits, + rated_voltage=Voltage(rated_voltage, rated_voltage_unit), + coordinate=coordinate, + in_service=bool(in_service), + ) + bus_uuid = _fetch_component_uuid(conn, "distribution_buses", bus_id) + if bus_uuid is not None: + bus = bus.model_copy(update={"uuid": bus_uuid}) + system.add_component(bus) + buses_by_id[bus_id] = bus + + _load_distribution_loads_from_normalized( + conn, + system, + buses_by_id=buses_by_id, + substations_by_id=substations_by_id, + feeders_by_id=feeders_by_id, + ) + _load_distribution_solar_from_normalized( + conn, + system, + buses_by_id=buses_by_id, + substations_by_id=substations_by_id, + feeders_by_id=feeders_by_id, + ) + _load_distribution_batteries_from_normalized( + conn, + system, + buses_by_id=buses_by_id, + substations_by_id=substations_by_id, + feeders_by_id=feeders_by_id, + ) + _load_distribution_capacitors_from_normalized( + conn, + system, + buses_by_id=buses_by_id, + substations_by_id=substations_by_id, + feeders_by_id=feeders_by_id, + ) + _load_distribution_voltage_sources_from_normalized( + conn, + system, + buses_by_id=buses_by_id, + substations_by_id=substations_by_id, + feeders_by_id=feeders_by_id, + ) + _load_distribution_transformers_from_normalized( + conn, + system, + buses_by_id=buses_by_id, + substations_by_id=substations_by_id, + feeders_by_id=feeders_by_id, + ) + _load_distribution_regulators_from_normalized( + conn, + system, + buses_by_id=buses_by_id, + substations_by_id=substations_by_id, + feeders_by_id=feeders_by_id, + ) + _load_matrix_impedance_branches_from_normalized( + conn, + system, + buses_by_id=buses_by_id, + substations_by_id=substations_by_id, + feeders_by_id=feeders_by_id, + ) + _load_sequence_impedance_branches_from_normalized( + conn, + system, + buses_by_id=buses_by_id, + substations_by_id=substations_by_id, + feeders_by_id=feeders_by_id, + ) + _load_matrix_impedance_switches_from_normalized( + conn, + system, + buses_by_id=buses_by_id, + substations_by_id=substations_by_id, + feeders_by_id=feeders_by_id, + ) + _load_geometry_branches_from_normalized( + conn, + system, + buses_by_id=buses_by_id, + substations_by_id=substations_by_id, + feeders_by_id=feeders_by_id, + ) + _load_matrix_impedance_fuses_from_normalized( + conn, + system, + buses_by_id=buses_by_id, + substations_by_id=substations_by_id, + feeders_by_id=feeders_by_id, + ) + _load_matrix_impedance_reclosers_from_normalized( + conn, + system, + buses_by_id=buses_by_id, + substations_by_id=substations_by_id, + feeders_by_id=feeders_by_id, + ) + + return system + + +def _component_type_for_time_series_owner(component) -> str | None: + mapping = { + "DistributionBus": "distribution_buses", + "DistributionLoad": "distribution_loads", + "DistributionSolar": "distribution_solar", + "DistributionBattery": "distribution_batteries", + "DistributionCapacitor": "distribution_capacitors", + "DistributionVoltageSource": "distribution_voltage_sources", + "DistributionTransformer": "distribution_transformers", + "DistributionRegulator": "distribution_regulators", + "MatrixImpedanceBranch": "matrix_impedance_branches", + "SequenceImpedanceBranch": "sequence_impedance_branches", + "GeometryBranch": "geometry_branches", + "MatrixImpedanceFuse": "matrix_impedance_fuses", + "MatrixImpedanceRecloser": "matrix_impedance_reclosers", + "MatrixImpedanceSwitch": "matrix_impedance_switches", + } + return mapping.get(component.__class__.__name__) + + +def _write_time_series_associations( + conn: sqlite3.Connection, system: DistributionSystem, replace: bool +) -> None: + if replace: + conn.execute("DELETE FROM time_series_associations") + + for component in system.iter_all_components(): + if not system.has_time_series(component): + continue + + component_type = _component_type_for_time_series_owner(component) + if component_type is None: + continue + + owner_row = conn.execute( + """ + SELECT component_id + FROM gdm_component_uuid_map + WHERE component_type = ? AND uuid = ? + """, + (component_type, str(component.uuid)), + ).fetchone() + if owner_row is None: + continue + owner_id = int(owner_row[0]) + owner_type = component.__class__.__name__ + + for metadata in system.list_time_series_metadata(component): + resolution = getattr(metadata, "resolution", None) + initial_timestamp = getattr(metadata, "initial_timestamp", None) + horizon = getattr(metadata, "horizon", None) + interval = getattr(metadata, "interval", None) + window_count = getattr(metadata, "window_count", None) + length = getattr(metadata, "length", None) + units = getattr(metadata, "units", None) + normalization = getattr(metadata, "normalization", None) + + units_payload = ( + json.dumps(units.model_dump(), sort_keys=True) + if units is not None and hasattr(units, "model_dump") + else (str(units) if units is not None else None) + ) + + scaling_factor_multiplier = None + if normalization is not None: + scaling_factor_multiplier = str( + getattr(normalization, "scaling_factor_multiplier", normalization) + ) + + conn.execute( + """ + INSERT OR REPLACE INTO time_series_associations( + time_series_uuid, + time_series_type, + initial_timestamp, + resolution, + horizon, + "interval", + window_count, + length, + name, + owner_id, + owner_type, + owner_category, + features, + scaling_factor_multiplier, + metadata_uuid, + units + ) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + str(metadata.time_series_uuid), + str(getattr(metadata, "type", metadata.__class__.__name__)), + initial_timestamp.isoformat() if initial_timestamp is not None else "", + str(resolution) if resolution is not None else "", + str(horizon) if horizon is not None else None, + str(interval) if interval is not None else None, + int(window_count) if window_count is not None else None, + int(length) if length is not None else None, + metadata.name, + owner_id, + owner_type, + "component", + json.dumps(metadata.features, sort_keys=True), + scaling_factor_multiplier, + str(metadata.uuid), + units_payload, + ), + ) + + +def _attach_time_series_from_snapshot( + conn: sqlite3.Connection, target_system: DistributionSystem +) -> None: + row = conn.execute( + "SELECT payload_json FROM gdm_system_snapshots WHERE system_kind = ?", + ("distribution",), + ).fetchone() + if row is None: + return + + snapshot = _decode_snapshot_payload(row[0]) + with tempfile.TemporaryDirectory() as tmp_dir: + temp_json = Path(tmp_dir) / "distribution_snapshot.json" + temp_json.write_text(snapshot["system_json"]) + _restore_time_series_sidecar(Path(tmp_dir), snapshot) + source_system = DistributionSystem.from_json(temp_json) + + target_by_uuid = { + component.uuid: component for component in target_system.iter_all_components() + } + + for source_component in source_system.iter_all_components(): + target_component = target_by_uuid.get(source_component.uuid) + if target_component is None: + continue + if not source_system.has_time_series(source_component): + continue + + for metadata in source_system.list_time_series_metadata(source_component): + try: + ts_type_name = str(getattr(metadata, "type", "")) + ts_data_type = { + "SingleTimeSeries": SingleTimeSeries, + "NonSequentialTimeSeries": NonSequentialTimeSeries, + }.get(ts_type_name, SingleTimeSeries) + ts_data = source_system.get_time_series( + source_component, + metadata.name, + ts_data_type, + **metadata.features, + ) + target_system.add_time_series(ts_data, target_component, **metadata.features) + except Exception: + continue + + +def load_snapshot_payload(db_path: str | Path, system_kind: str) -> dict: + """Return raw snapshot payload as a JSON dictionary for inspection.""" + db_path = Path(db_path) + with sqlite3.connect(db_path) as conn: + row = conn.execute( + "SELECT payload_json FROM gdm_system_snapshots WHERE system_kind = ?", + (system_kind,), + ).fetchone() + if row is None: + raise ValueError(f"No persisted '{system_kind}' system found in {db_path}") + return json.loads(row[0]) diff --git a/src/gdm/db/sqlite_store_cap_voltage_xfmr_reg.py b/src/gdm/db/sqlite_store_cap_voltage_xfmr_reg.py new file mode 100644 index 00000000..543406b1 --- /dev/null +++ b/src/gdm/db/sqlite_store_cap_voltage_xfmr_reg.py @@ -0,0 +1,1718 @@ +"""Capacitor, voltage source, transformer, and regulator SQLite helpers.""" + +from __future__ import annotations + +import sqlite3 +from datetime import time + +from infrasys.quantities import Angle, Time + +from gdm.db.sqlite_store_identity import _fetch_component_uuid, _upsert_component_uuid_map +from gdm.distribution import DistributionSystem +from gdm.distribution.common.sequence_pair import SequencePair +from gdm.distribution.components import ( + DistributionBus, + DistributionCapacitor, + DistributionRegulator, + DistributionTransformer, + DistributionVoltageSource, +) +from gdm.distribution.components.distribution_feeder import DistributionFeeder +from gdm.distribution.components.distribution_substation import DistributionSubstation +from gdm.distribution.controllers.distribution_capacitor_controller import ( + ActivePowerCapacitorController, + CurrentCapacitorController, + DailyTimedCapacitorController, + ReactivePowerCapacitorController, + VoltageCapacitorController, +) +from gdm.distribution.controllers.distribution_regulator_controller import RegulatorController +from gdm.distribution.equipment.capacitor_equipment import CapacitorEquipment +from gdm.distribution.equipment.distribution_transformer_equipment import ( + DistributionTransformerEquipment, + WindingEquipment, +) +from gdm.distribution.equipment.phase_capacitor_equipment import PhaseCapacitorEquipment +from gdm.distribution.equipment.phase_voltagesource_equipment import PhaseVoltageSourceEquipment +from gdm.distribution.equipment.voltagesource_equipment import VoltageSourceEquipment +from gdm.distribution.enums import ConnectionType, Phase, TransformerMounting, VoltageTypes +from gdm.quantities import ( + ActivePower, + ApparentPower, + Current, + ReactivePower, + Reactance, + Resistance, + Voltage, +) + + +def _write_distribution_capacitors(conn: sqlite3.Connection, system: DistributionSystem) -> None: + bus_rows = conn.execute( + "SELECT id, name, substation_id, feeder_id FROM distribution_buses" + ).fetchall() + bus_ref_by_name: dict[str, tuple[int, int, int]] = { + name: (bus_id, substation_id, feeder_id) + for bus_id, name, substation_id, feeder_id in bus_rows + } + + phase_cap_equipment_id_by_name: dict[str, int] = {} + cap_equipment_id_by_name: dict[str, int] = {} + + for capacitor in system.get_components(DistributionCapacitor): + bus_ref = bus_ref_by_name.get(capacitor.bus.name) + if bus_ref is None: + raise ValueError( + f"DistributionCapacitor '{capacitor.name}' references missing bus '{capacitor.bus.name}'" + ) + + bus_id, substation_id, feeder_id = bus_ref + capacitor_equipment_id = _upsert_capacitor_equipment( + conn, + capacitor.equipment, + phase_cap_equipment_id_by_name, + cap_equipment_id_by_name, + ) + + cursor = conn.execute( + """ + INSERT INTO distribution_capacitors( + name, + bus_id, + substation_id, + feeder_id, + capacitor_equipment_id, + in_service + ) VALUES(?, ?, ?, ?, ?, ?) + """, + ( + capacitor.name, + bus_id, + substation_id, + feeder_id, + capacitor_equipment_id, + 1 if capacitor.in_service else 0, + ), + ) + capacitor_id = int(cursor.lastrowid) + _upsert_component_uuid_map(conn, "distribution_capacitors", capacitor_id, capacitor.uuid) + + for position_index, phase in enumerate(capacitor.phases): + conn.execute( + "INSERT INTO distribution_capacitor_phases(capacitor_id, phase, position_index) VALUES(?, ?, ?)", + (capacitor_id, phase.value, position_index), + ) + + _insert_capacitor_controllers(conn, capacitor_id, capacitor.controllers) + + +def _upsert_capacitor_equipment( + conn: sqlite3.Connection, + equipment: CapacitorEquipment, + phase_cap_equipment_id_by_name: dict[str, int], + cap_equipment_id_by_name: dict[str, int], +) -> int: + cap_equipment_id = cap_equipment_id_by_name.get(equipment.name) + if cap_equipment_id is None: + existing = conn.execute( + "SELECT id FROM capacitor_equipment WHERE name = ?", + (equipment.name,), + ).fetchone() + if existing is None: + cursor = conn.execute( + """ + INSERT INTO capacitor_equipment( + name, + connection_type, + rated_voltage, + rated_voltage_unit, + voltage_type + ) VALUES(?, ?, ?, ?, ?) + """, + ( + equipment.name, + equipment.connection_type.value, + float(equipment.rated_voltage.magnitude), + str(equipment.rated_voltage.units), + equipment.voltage_type.value, + ), + ) + cap_equipment_id = int(cursor.lastrowid) + else: + cap_equipment_id = int(existing[0]) + + cap_equipment_id_by_name[equipment.name] = cap_equipment_id + _upsert_component_uuid_map(conn, "capacitor_equipment", cap_equipment_id, equipment.uuid) + + for position_index, phase_equipment in enumerate(equipment.phase_capacitors): + phase_cap_id = _upsert_phase_capacitor_equipment( + conn, + phase_equipment, + phase_cap_equipment_id_by_name, + ) + conn.execute( + """ + INSERT INTO capacitor_equipment_phases( + capacitor_equipment_id, + phase_capacitor_equipment_id, + position_index + ) VALUES(?, ?, ?) + """, + (cap_equipment_id, phase_cap_id, position_index), + ) + + return cap_equipment_id + + +def _upsert_phase_capacitor_equipment( + conn: sqlite3.Connection, + phase_equipment: PhaseCapacitorEquipment, + phase_cap_equipment_id_by_name: dict[str, int], +) -> int: + phase_cap_id = phase_cap_equipment_id_by_name.get(phase_equipment.name) + if phase_cap_id is None: + existing = conn.execute( + "SELECT id FROM phase_capacitor_equipment WHERE name = ?", + (phase_equipment.name,), + ).fetchone() + if existing is None: + cursor = conn.execute( + """ + INSERT INTO phase_capacitor_equipment( + name, + resistance, + resistance_unit, + reactance, + reactance_unit, + rated_reactive_power, + rated_reactive_power_unit, + num_banks_on, + num_banks + ) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + phase_equipment.name, + float(phase_equipment.resistance.magnitude), + str(phase_equipment.resistance.units), + float(phase_equipment.reactance.magnitude), + str(phase_equipment.reactance.units), + float(phase_equipment.rated_reactive_power.magnitude), + str(phase_equipment.rated_reactive_power.units), + phase_equipment.num_banks_on, + phase_equipment.num_banks, + ), + ) + phase_cap_id = int(cursor.lastrowid) + else: + phase_cap_id = int(existing[0]) + + phase_cap_equipment_id_by_name[phase_equipment.name] = phase_cap_id + _upsert_component_uuid_map( + conn, "phase_capacitor_equipment", phase_cap_id, phase_equipment.uuid + ) + + return phase_cap_id + + +def _insert_capacitor_controllers( + conn: sqlite3.Connection, + capacitor_id: int, + controllers: list, +) -> None: + for position_index, controller in enumerate(controllers): + controller_type = None + values = { + "delay_on": None, + "delay_on_unit": None, + "delay_off": None, + "delay_off_unit": None, + "dead_time": None, + "dead_time_unit": None, + "on_voltage": None, + "on_voltage_unit": None, + "off_voltage": None, + "off_voltage_unit": None, + "pt_ratio": None, + "on_active_power": None, + "on_active_power_unit": None, + "off_active_power": None, + "off_active_power_unit": None, + "on_reactive_power": None, + "on_reactive_power_unit": None, + "off_reactive_power": None, + "off_reactive_power_unit": None, + "on_current": None, + "on_current_unit": None, + "off_current": None, + "off_current_unit": None, + "ct_ratio": None, + "on_time": None, + "off_time": None, + } + + if controller.delay_on is not None: + values["delay_on"] = float(controller.delay_on.magnitude) + values["delay_on_unit"] = str(controller.delay_on.units) + if controller.delay_off is not None: + values["delay_off"] = float(controller.delay_off.magnitude) + values["delay_off_unit"] = str(controller.delay_off.units) + if controller.dead_time is not None: + values["dead_time"] = float(controller.dead_time.magnitude) + values["dead_time_unit"] = str(controller.dead_time.units) + + if isinstance(controller, VoltageCapacitorController): + controller_type = "VOLTAGE" + values["on_voltage"] = float(controller.on_voltage.magnitude) + values["on_voltage_unit"] = str(controller.on_voltage.units) + values["off_voltage"] = float(controller.off_voltage.magnitude) + values["off_voltage_unit"] = str(controller.off_voltage.units) + values["pt_ratio"] = controller.pt_ratio + elif isinstance(controller, ActivePowerCapacitorController): + controller_type = "ACTIVE_POWER" + values["on_active_power"] = float(controller.on_power.magnitude) + values["on_active_power_unit"] = str(controller.on_power.units) + values["off_active_power"] = float(controller.off_power.magnitude) + values["off_active_power_unit"] = str(controller.off_power.units) + elif isinstance(controller, ReactivePowerCapacitorController): + controller_type = "REACTIVE_POWER" + values["on_reactive_power"] = float(controller.on_power.magnitude) + values["on_reactive_power_unit"] = str(controller.on_power.units) + values["off_reactive_power"] = float(controller.off_power.magnitude) + values["off_reactive_power_unit"] = str(controller.off_power.units) + elif isinstance(controller, CurrentCapacitorController): + controller_type = "CURRENT" + values["on_current"] = float(controller.on_current.magnitude) + values["on_current_unit"] = str(controller.on_current.units) + values["off_current"] = float(controller.off_current.magnitude) + values["off_current_unit"] = str(controller.off_current.units) + values["ct_ratio"] = controller.ct_ratio + elif isinstance(controller, DailyTimedCapacitorController): + controller_type = "DAILY_TIMED" + values["on_time"] = controller.on_time.strftime("%H:%M:%S") + values["off_time"] = controller.off_time.strftime("%H:%M:%S") + else: + continue + + cursor = conn.execute( + """ + INSERT INTO capacitor_controllers( + capacitor_id, + position_index, + name, + controller_type, + delay_on, + delay_on_unit, + delay_off, + delay_off_unit, + dead_time, + dead_time_unit, + on_voltage, + on_voltage_unit, + off_voltage, + off_voltage_unit, + pt_ratio, + on_active_power, + on_active_power_unit, + off_active_power, + off_active_power_unit, + on_reactive_power, + on_reactive_power_unit, + off_reactive_power, + off_reactive_power_unit, + on_current, + on_current_unit, + off_current, + off_current_unit, + ct_ratio, + on_time, + off_time + ) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + capacitor_id, + position_index, + controller.name, + controller_type, + values["delay_on"], + values["delay_on_unit"], + values["delay_off"], + values["delay_off_unit"], + values["dead_time"], + values["dead_time_unit"], + values["on_voltage"], + values["on_voltage_unit"], + values["off_voltage"], + values["off_voltage_unit"], + values["pt_ratio"], + values["on_active_power"], + values["on_active_power_unit"], + values["off_active_power"], + values["off_active_power_unit"], + values["on_reactive_power"], + values["on_reactive_power_unit"], + values["off_reactive_power"], + values["off_reactive_power_unit"], + values["on_current"], + values["on_current_unit"], + values["off_current"], + values["off_current_unit"], + values["ct_ratio"], + values["on_time"], + values["off_time"], + ), + ) + controller_id = int(cursor.lastrowid) + _upsert_component_uuid_map(conn, "capacitor_controllers", controller_id, controller.uuid) + + +def _write_distribution_voltage_sources( + conn: sqlite3.Connection, system: DistributionSystem +) -> None: + bus_rows = conn.execute( + "SELECT id, name, substation_id, feeder_id FROM distribution_buses" + ).fetchall() + bus_ref_by_name: dict[str, tuple[int, int, int]] = { + name: (bus_id, substation_id, feeder_id) + for bus_id, name, substation_id, feeder_id in bus_rows + } + + phase_vs_equipment_id_by_name: dict[str, int] = {} + vs_equipment_id_by_name: dict[str, int] = {} + + for vsource in system.get_components(DistributionVoltageSource): + bus_ref = bus_ref_by_name.get(vsource.bus.name) + if bus_ref is None: + raise ValueError( + f"DistributionVoltageSource '{vsource.name}' references missing bus '{vsource.bus.name}'" + ) + bus_id, substation_id, feeder_id = bus_ref + + vs_equipment_id = _upsert_voltage_source_equipment( + conn, + vsource.equipment, + phase_vs_equipment_id_by_name, + vs_equipment_id_by_name, + ) + + cursor = conn.execute( + """ + INSERT INTO distribution_voltage_sources( + name, + bus_id, + substation_id, + feeder_id, + voltage_source_equipment_id, + in_service + ) VALUES(?, ?, ?, ?, ?, ?) + """, + ( + vsource.name, + bus_id, + substation_id, + feeder_id, + vs_equipment_id, + 1 if vsource.in_service else 0, + ), + ) + vsource_id = int(cursor.lastrowid) + _upsert_component_uuid_map( + conn, + "distribution_voltage_sources", + vsource_id, + vsource.uuid, + ) + + for position_index, phase in enumerate(vsource.phases): + conn.execute( + "INSERT INTO distribution_voltage_source_phases(vsource_id, phase, position_index) VALUES(?, ?, ?)", + (vsource_id, phase.value, position_index), + ) + + +def _upsert_voltage_source_equipment( + conn: sqlite3.Connection, + equipment: VoltageSourceEquipment, + phase_vs_equipment_id_by_name: dict[str, int], + vs_equipment_id_by_name: dict[str, int], +) -> int: + vs_equipment_id = vs_equipment_id_by_name.get(equipment.name) + if vs_equipment_id is None: + existing = conn.execute( + "SELECT id FROM voltage_source_equipment WHERE name = ?", + (equipment.name,), + ).fetchone() + if existing is None: + cursor = conn.execute( + "INSERT INTO voltage_source_equipment(name) VALUES(?)", + (equipment.name,), + ) + vs_equipment_id = int(cursor.lastrowid) + else: + vs_equipment_id = int(existing[0]) + vs_equipment_id_by_name[equipment.name] = vs_equipment_id + _upsert_component_uuid_map( + conn, "voltage_source_equipment", vs_equipment_id, equipment.uuid + ) + + for position_index, source in enumerate(equipment.sources): + phase_source_id = _upsert_phase_voltage_source_equipment( + conn, + source, + phase_vs_equipment_id_by_name, + ) + conn.execute( + """ + INSERT INTO voltage_source_phases( + voltage_source_equipment_id, + phase_source_equipment_id, + position_index + ) VALUES(?, ?, ?) + """, + (vs_equipment_id, phase_source_id, position_index), + ) + + return vs_equipment_id + + +def _upsert_phase_voltage_source_equipment( + conn: sqlite3.Connection, + source: PhaseVoltageSourceEquipment, + phase_vs_equipment_id_by_name: dict[str, int], +) -> int: + phase_source_id = phase_vs_equipment_id_by_name.get(source.name) + if phase_source_id is None: + existing = conn.execute( + "SELECT id FROM phase_voltage_source_equipment WHERE name = ?", + (source.name,), + ).fetchone() + if existing is None: + cursor = conn.execute( + """ + INSERT INTO phase_voltage_source_equipment( + name, + r0, + r0_unit, + r1, + r1_unit, + x0, + x0_unit, + x1, + x1_unit, + voltage, + voltage_unit, + voltage_type, + angle, + angle_unit + ) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + source.name, + float(source.r0.magnitude), + str(source.r0.units), + float(source.r1.magnitude), + str(source.r1.units), + float(source.x0.magnitude), + str(source.x0.units), + float(source.x1.magnitude), + str(source.x1.units), + float(source.voltage.magnitude), + str(source.voltage.units), + source.voltage_type.value, + float(source.angle.magnitude), + str(source.angle.units), + ), + ) + phase_source_id = int(cursor.lastrowid) + else: + phase_source_id = int(existing[0]) + phase_vs_equipment_id_by_name[source.name] = phase_source_id + _upsert_component_uuid_map( + conn, + "phase_voltage_source_equipment", + phase_source_id, + source.uuid, + ) + + return phase_source_id + + +def _write_distribution_transformers(conn: sqlite3.Connection, system: DistributionSystem) -> None: + bus_rows = conn.execute( + "SELECT id, name, substation_id, feeder_id FROM distribution_buses" + ).fetchall() + bus_id_by_name: dict[str, int] = {name: bus_id for bus_id, name, _, _ in bus_rows} + + transformer_equipment_id_by_name: dict[str, int] = {} + + for transformer in system.get_components(DistributionTransformer): + transformer_substation = transformer.substation + transformer_feeder = transformer.feeder + if (transformer_substation is None or transformer_feeder is None) and transformer.buses: + fallback_bus = transformer.buses[0] + transformer_substation = transformer_substation or fallback_bus.substation + transformer_feeder = transformer_feeder or fallback_bus.feeder + if transformer_substation is None or transformer_feeder is None: + raise ValueError( + f"DistributionTransformer '{transformer.name}' must have substation and feeder" + ) + + substation_row = conn.execute( + "SELECT id FROM distribution_substations WHERE name = ?", + (transformer_substation.name,), + ).fetchone() + feeder_row = conn.execute( + "SELECT id FROM distribution_feeders WHERE name = ?", + (transformer_feeder.name,), + ).fetchone() + if substation_row is None or feeder_row is None: + raise ValueError( + f"DistributionTransformer '{transformer.name}' references missing substation/feeder" + ) + substation_id = int(substation_row[0]) + feeder_id = int(feeder_row[0]) + + equipment_id = _upsert_distribution_transformer_equipment( + conn, + transformer.equipment, + transformer_equipment_id_by_name, + ) + + cursor = conn.execute( + """ + INSERT INTO distribution_transformers( + name, + substation_id, + feeder_id, + equipment_id, + in_service + ) VALUES(?, ?, ?, ?, ?) + """, + ( + transformer.name, + substation_id, + feeder_id, + equipment_id, + 1 if transformer.in_service else 0, + ), + ) + transformer_id = int(cursor.lastrowid) + _upsert_component_uuid_map( + conn, + "distribution_transformers", + transformer_id, + transformer.uuid, + ) + + for winding_index, bus in enumerate(transformer.buses): + bus_id = bus_id_by_name.get(bus.name) + if bus_id is None: + raise ValueError( + f"DistributionTransformer '{transformer.name}' references unknown bus '{bus.name}'" + ) + conn.execute( + "INSERT INTO transformer_winding_buses(transformer_id, winding_index, bus_id) VALUES(?, ?, ?)", + (transformer_id, winding_index, bus_id), + ) + + for winding_index, phases in enumerate(transformer.winding_phases): + for phase_index, phase in enumerate(phases): + conn.execute( + """ + INSERT INTO transformer_winding_phases( + transformer_id, + winding_index, + phase, + phase_index + ) VALUES(?, ?, ?, ?) + """, + (transformer_id, winding_index, phase.value, phase_index), + ) + + +def _upsert_distribution_transformer_equipment( + conn: sqlite3.Connection, + equipment: DistributionTransformerEquipment, + transformer_equipment_id_by_name: dict[str, int], +) -> int: + equipment_id = transformer_equipment_id_by_name.get(equipment.name) + if equipment_id is None: + existing = conn.execute( + "SELECT id FROM distribution_transformer_equipment WHERE name = ?", + (equipment.name,), + ).fetchone() + if existing is None: + cursor = conn.execute( + """ + INSERT INTO distribution_transformer_equipment( + name, + mounting, + pct_no_load_loss, + pct_full_load_loss, + is_center_tapped + ) VALUES(?, ?, ?, ?, ?) + """, + ( + equipment.name, + equipment.mounting.value, + equipment.pct_no_load_loss, + equipment.pct_full_load_loss, + 1 if equipment.is_center_tapped else 0, + ), + ) + equipment_id = int(cursor.lastrowid) + else: + equipment_id = int(existing[0]) + + transformer_equipment_id_by_name[equipment.name] = equipment_id + _upsert_component_uuid_map( + conn, + "distribution_transformer_equipment", + equipment_id, + equipment.uuid, + ) + + for winding_index, winding in enumerate(equipment.windings): + winding_cursor = conn.execute( + """ + INSERT INTO winding_equipment( + name, + transformer_equipment_id, + winding_index, + resistance, + is_grounded, + rated_voltage, + rated_voltage_unit, + voltage_type, + rated_power, + rated_power_unit, + num_phases, + connection_type, + total_taps, + min_tap_pu, + max_tap_pu + ) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + winding.name, + equipment_id, + winding_index, + winding.resistance, + 1 if winding.is_grounded else 0, + float(winding.rated_voltage.magnitude), + str(winding.rated_voltage.units), + winding.voltage_type.value, + float(winding.rated_power.magnitude), + str(winding.rated_power.units), + winding.num_phases, + winding.connection_type.value, + winding.total_taps, + winding.min_tap_pu, + winding.max_tap_pu, + ), + ) + winding_id = int(winding_cursor.lastrowid) + _upsert_component_uuid_map(conn, "winding_equipment", winding_id, winding.uuid) + + for position_index, tap in enumerate(winding.tap_positions): + conn.execute( + "INSERT INTO winding_tap_positions(winding_id, position_index, tap_value) VALUES(?, ?, ?)", + (winding_id, position_index, tap), + ) + + for sequence_index, (pair, reactance) in enumerate( + zip(equipment.coupling_sequences, equipment.winding_reactances) + ): + conn.execute( + """ + INSERT INTO transformer_coupling_sequences( + transformer_equipment_id, + sequence_index, + from_winding_index, + to_winding_index, + reactance, + reactance_unit + ) VALUES(?, ?, ?, ?, ?, ?) + """, + ( + equipment_id, + sequence_index, + pair.from_index, + pair.to_index, + reactance, + "percent", + ), + ) + + return equipment_id + + +def _write_distribution_regulators(conn: sqlite3.Connection, system: DistributionSystem) -> None: + bus_rows = conn.execute( + "SELECT id, name, substation_id, feeder_id FROM distribution_buses" + ).fetchall() + bus_id_by_name: dict[str, int] = {name: bus_id for bus_id, name, _, _ in bus_rows} + + transformer_equipment_id_by_name: dict[str, int] = {} + + for regulator in system.get_components(DistributionRegulator): + regulator_substation = regulator.substation + regulator_feeder = regulator.feeder + if (regulator_substation is None or regulator_feeder is None) and regulator.buses: + fallback_bus = regulator.buses[0] + regulator_substation = regulator_substation or fallback_bus.substation + regulator_feeder = regulator_feeder or fallback_bus.feeder + if regulator_substation is None or regulator_feeder is None: + raise ValueError( + f"DistributionRegulator '{regulator.name}' must have substation and feeder" + ) + + substation_row = conn.execute( + "SELECT id FROM distribution_substations WHERE name = ?", + (regulator_substation.name,), + ).fetchone() + feeder_row = conn.execute( + "SELECT id FROM distribution_feeders WHERE name = ?", + (regulator_feeder.name,), + ).fetchone() + if substation_row is None or feeder_row is None: + raise ValueError( + f"DistributionRegulator '{regulator.name}' references missing substation/feeder" + ) + substation_id = int(substation_row[0]) + feeder_id = int(feeder_row[0]) + + equipment_id = _upsert_distribution_transformer_equipment( + conn, + regulator.equipment, + transformer_equipment_id_by_name, + ) + + cursor = conn.execute( + """ + INSERT INTO distribution_regulators( + name, + substation_id, + feeder_id, + equipment_id, + in_service + ) VALUES(?, ?, ?, ?, ?) + """, + ( + regulator.name, + substation_id, + feeder_id, + equipment_id, + 1 if regulator.in_service else 0, + ), + ) + regulator_id = int(cursor.lastrowid) + _upsert_component_uuid_map(conn, "distribution_regulators", regulator_id, regulator.uuid) + + for winding_index, bus in enumerate(regulator.buses): + bus_id = bus_id_by_name.get(bus.name) + if bus_id is None: + raise ValueError( + f"DistributionRegulator '{regulator.name}' references unknown bus '{bus.name}'" + ) + conn.execute( + "INSERT INTO regulator_winding_buses(regulator_id, winding_index, bus_id) VALUES(?, ?, ?)", + (regulator_id, winding_index, bus_id), + ) + + for winding_index, phases in enumerate(regulator.winding_phases): + for phase_index, phase in enumerate(phases): + conn.execute( + """ + INSERT INTO regulator_winding_phases( + regulator_id, + winding_index, + phase, + phase_index + ) VALUES(?, ?, ?, ?) + """, + (regulator_id, winding_index, phase.value, phase_index), + ) + + for position_index, controller in enumerate(regulator.controllers): + controlled_bus_id = bus_id_by_name.get(controller.controlled_bus.name) + if controlled_bus_id is None: + raise ValueError( + f"RegulatorController '{controller.name}' references unknown bus '{controller.controlled_bus.name}'" + ) + cursor = conn.execute( + """ + INSERT INTO regulator_controllers( + regulator_id, + position_index, + name, + delay, + delay_unit, + v_setpoint, + v_setpoint_unit, + min_v_limit, + min_v_limit_unit, + max_v_limit, + max_v_limit_unit, + pt_ratio, + use_ldc, + is_reversible, + ldc_R, + ldc_R_unit, + ldc_X, + ldc_X_unit, + ct_primary, + ct_primary_unit, + max_step, + bandwidth, + bandwidth_unit, + controlled_bus_id, + controlled_phase + ) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + regulator_id, + position_index, + controller.name, + float(controller.delay.magnitude) if controller.delay is not None else None, + str(controller.delay.units) if controller.delay is not None else None, + float(controller.v_setpoint.magnitude), + str(controller.v_setpoint.units), + float(controller.min_v_limit.magnitude), + str(controller.min_v_limit.units), + float(controller.max_v_limit.magnitude), + str(controller.max_v_limit.units), + controller.pt_ratio, + 1 if controller.use_ldc else 0, + 1 if controller.is_reversible else 0, + float(controller.ldc_R.magnitude) if controller.ldc_R is not None else None, + str(controller.ldc_R.units) if controller.ldc_R is not None else None, + float(controller.ldc_X.magnitude) if controller.ldc_X is not None else None, + str(controller.ldc_X.units) if controller.ldc_X is not None else None, + float(controller.ct_primary.magnitude) + if controller.ct_primary is not None + else None, + str(controller.ct_primary.units) + if controller.ct_primary is not None + else None, + controller.max_step, + float(controller.bandwidth.magnitude), + str(controller.bandwidth.units), + controlled_bus_id, + controller.controlled_phase.value, + ), + ) + controller_id = int(cursor.lastrowid) + _upsert_component_uuid_map( + conn, "regulator_controllers", controller_id, controller.uuid + ) + + +def _load_distribution_capacitors_from_normalized( + conn: sqlite3.Connection, + system: DistributionSystem, + buses_by_id: dict[int, DistributionBus], + substations_by_id: dict[int, DistributionSubstation], + feeders_by_id: dict[int, DistributionFeeder], +) -> None: + capacitor_rows = conn.execute( + """ + SELECT + id, + name, + bus_id, + substation_id, + feeder_id, + capacitor_equipment_id, + in_service + FROM distribution_capacitors + ORDER BY id + """ + ).fetchall() + if not capacitor_rows: + return + + phase_equipment_cache: dict[int, PhaseCapacitorEquipment] = {} + equipment_cache: dict[int, CapacitorEquipment] = {} + + for ( + capacitor_id, + capacitor_name, + bus_id, + substation_id, + feeder_id, + capacitor_equipment_id, + in_service, + ) in capacitor_rows: + phase_rows = conn.execute( + "SELECT phase FROM distribution_capacitor_phases WHERE capacitor_id = ? ORDER BY position_index", + (capacitor_id,), + ).fetchall() + phases = [Phase(phase) for (phase,) in phase_rows] + + equipment = equipment_cache.get(capacitor_equipment_id) + if equipment is None: + equipment_row = conn.execute( + """ + SELECT name, connection_type, rated_voltage, rated_voltage_unit, voltage_type + FROM capacitor_equipment + WHERE id = ? + """, + (capacitor_equipment_id,), + ).fetchone() + if equipment_row is None: + raise ValueError(f"capacitor_equipment_id={capacitor_equipment_id} not found") + ( + equipment_name, + connection_type, + rated_voltage, + rated_voltage_unit, + voltage_type, + ) = equipment_row + + phase_links = conn.execute( + """ + SELECT phase_capacitor_equipment_id + FROM capacitor_equipment_phases + WHERE capacitor_equipment_id = ? + ORDER BY position_index + """, + (capacitor_equipment_id,), + ).fetchall() + phase_caps: list[PhaseCapacitorEquipment] = [] + for (phase_cap_id,) in phase_links: + phase_cap = phase_equipment_cache.get(phase_cap_id) + if phase_cap is None: + phase_row = conn.execute( + """ + SELECT + name, + resistance, + resistance_unit, + reactance, + reactance_unit, + rated_reactive_power, + rated_reactive_power_unit, + num_banks_on, + num_banks + FROM phase_capacitor_equipment + WHERE id = ? + """, + (phase_cap_id,), + ).fetchone() + if phase_row is None: + raise ValueError(f"phase_capacitor_equipment_id={phase_cap_id} not found") + ( + phase_name, + resistance, + resistance_unit, + reactance, + reactance_unit, + rated_reactive_power, + rated_reactive_power_unit, + num_banks_on, + num_banks, + ) = phase_row + phase_cap = PhaseCapacitorEquipment( + name=phase_name, + resistance=Resistance(resistance, resistance_unit), + reactance=Reactance(reactance, reactance_unit), + rated_reactive_power=ReactivePower( + rated_reactive_power, rated_reactive_power_unit + ), + num_banks_on=num_banks_on, + num_banks=num_banks, + ) + phase_cap_uuid = _fetch_component_uuid( + conn, + "phase_capacitor_equipment", + phase_cap_id, + ) + if phase_cap_uuid is not None: + phase_cap = phase_cap.model_copy(update={"uuid": phase_cap_uuid}) + phase_equipment_cache[phase_cap_id] = phase_cap + phase_caps.append(phase_cap) + + equipment = CapacitorEquipment( + name=equipment_name, + phase_capacitors=phase_caps, + connection_type=ConnectionType(connection_type), + rated_voltage=Voltage(rated_voltage, rated_voltage_unit), + voltage_type=VoltageTypes(voltage_type), + ) + equipment_uuid = _fetch_component_uuid( + conn, "capacitor_equipment", capacitor_equipment_id + ) + if equipment_uuid is not None: + equipment = equipment.model_copy(update={"uuid": equipment_uuid}) + equipment_cache[capacitor_equipment_id] = equipment + + controller_rows = conn.execute( + """ + SELECT + id, + name, + controller_type, + delay_on, + delay_on_unit, + delay_off, + delay_off_unit, + dead_time, + dead_time_unit, + on_voltage, + on_voltage_unit, + off_voltage, + off_voltage_unit, + pt_ratio, + on_active_power, + on_active_power_unit, + off_active_power, + off_active_power_unit, + on_reactive_power, + on_reactive_power_unit, + off_reactive_power, + off_reactive_power_unit, + on_current, + on_current_unit, + off_current, + off_current_unit, + ct_ratio, + on_time, + off_time + FROM capacitor_controllers + WHERE capacitor_id = ? + ORDER BY position_index + """, + (capacitor_id,), + ).fetchall() + controllers = [] + for row in controller_rows: + ( + controller_id, + name, + controller_type, + delay_on, + delay_on_unit, + delay_off, + delay_off_unit, + dead_time, + dead_time_unit, + on_voltage, + on_voltage_unit, + off_voltage, + off_voltage_unit, + pt_ratio, + on_active_power, + on_active_power_unit, + off_active_power, + off_active_power_unit, + on_reactive_power, + on_reactive_power_unit, + off_reactive_power, + off_reactive_power_unit, + on_current, + on_current_unit, + off_current, + off_current_unit, + ct_ratio, + on_time, + off_time, + ) = row + + common = { + "name": name, + "delay_on": Time(delay_on, delay_on_unit) + if delay_on is not None and delay_on_unit is not None + else None, + "delay_off": Time(delay_off, delay_off_unit) + if delay_off is not None and delay_off_unit is not None + else None, + "dead_time": Time(dead_time, dead_time_unit) + if dead_time is not None and dead_time_unit is not None + else None, + } + controller = None + if controller_type == "VOLTAGE": + controller = VoltageCapacitorController( + **common, + on_voltage=Voltage(on_voltage, on_voltage_unit), + off_voltage=Voltage(off_voltage, off_voltage_unit), + pt_ratio=pt_ratio, + ) + elif controller_type == "ACTIVE_POWER": + controller = ActivePowerCapacitorController( + **common, + on_power=ActivePower(on_active_power, on_active_power_unit), + off_power=ActivePower(off_active_power, off_active_power_unit), + ) + elif controller_type == "REACTIVE_POWER": + controller = ReactivePowerCapacitorController( + **common, + on_power=ReactivePower(on_reactive_power, on_reactive_power_unit), + off_power=ReactivePower(off_reactive_power, off_reactive_power_unit), + ) + elif controller_type == "CURRENT": + controller = CurrentCapacitorController( + **common, + on_current=Current(on_current, on_current_unit), + off_current=Current(off_current, off_current_unit), + ct_ratio=ct_ratio, + ) + elif controller_type == "DAILY_TIMED": + controller = DailyTimedCapacitorController( + **common, + on_time=time.fromisoformat(on_time), + off_time=time.fromisoformat(off_time), + ) + + if controller is not None: + controller_uuid = _fetch_component_uuid( + conn, "capacitor_controllers", controller_id + ) + if controller_uuid is not None: + controller = controller.model_copy(update={"uuid": controller_uuid}) + controllers.append(controller) + + capacitor = DistributionCapacitor( + name=capacitor_name, + bus=buses_by_id[bus_id], + substation=substations_by_id[substation_id], + feeder=feeders_by_id[feeder_id], + phases=phases, + controllers=controllers, + equipment=equipment, + in_service=bool(in_service), + ) + capacitor_uuid = _fetch_component_uuid(conn, "distribution_capacitors", capacitor_id) + if capacitor_uuid is not None: + capacitor = capacitor.model_copy(update={"uuid": capacitor_uuid}) + system.add_component(capacitor) + + +def _load_distribution_voltage_sources_from_normalized( + conn: sqlite3.Connection, + system: DistributionSystem, + buses_by_id: dict[int, DistributionBus], + substations_by_id: dict[int, DistributionSubstation], + feeders_by_id: dict[int, DistributionFeeder], +) -> None: + vsource_rows = conn.execute( + """ + SELECT + id, + name, + bus_id, + substation_id, + feeder_id, + voltage_source_equipment_id, + in_service + FROM distribution_voltage_sources + ORDER BY id + """ + ).fetchall() + if not vsource_rows: + return + + phase_source_cache: dict[int, PhaseVoltageSourceEquipment] = {} + source_equipment_cache: dict[int, VoltageSourceEquipment] = {} + + for ( + vsource_id, + name, + bus_id, + substation_id, + feeder_id, + source_equipment_id, + in_service, + ) in vsource_rows: + source_equipment = source_equipment_cache.get(source_equipment_id) + if source_equipment is None: + equipment_row = conn.execute( + "SELECT name FROM voltage_source_equipment WHERE id = ?", + (source_equipment_id,), + ).fetchone() + if equipment_row is None: + raise ValueError(f"voltage_source_equipment_id={source_equipment_id} not found") + (equipment_name,) = equipment_row + + phase_links = conn.execute( + """ + SELECT phase_source_equipment_id + FROM voltage_source_phases + WHERE voltage_source_equipment_id = ? + ORDER BY position_index + """, + (source_equipment_id,), + ).fetchall() + sources: list[PhaseVoltageSourceEquipment] = [] + for (phase_source_id,) in phase_links: + phase_source = phase_source_cache.get(phase_source_id) + if phase_source is None: + source_row = conn.execute( + """ + SELECT + name, + r0, + r0_unit, + r1, + r1_unit, + x0, + x0_unit, + x1, + x1_unit, + voltage, + voltage_unit, + voltage_type, + angle, + angle_unit + FROM phase_voltage_source_equipment + WHERE id = ? + """, + (phase_source_id,), + ).fetchone() + if source_row is None: + raise ValueError( + f"phase_voltage_source_equipment_id={phase_source_id} not found" + ) + ( + phase_name, + r0, + r0_unit, + r1, + r1_unit, + x0, + x0_unit, + x1, + x1_unit, + voltage, + voltage_unit, + voltage_type, + angle, + angle_unit, + ) = source_row + phase_source = PhaseVoltageSourceEquipment( + name=phase_name, + r0=Resistance(r0, r0_unit), + r1=Resistance(r1, r1_unit), + x0=Reactance(x0, x0_unit), + x1=Reactance(x1, x1_unit), + voltage=Voltage(voltage, voltage_unit), + voltage_type=VoltageTypes(voltage_type), + angle=Angle(angle, angle_unit), + ) + phase_source_uuid = _fetch_component_uuid( + conn, + "phase_voltage_source_equipment", + phase_source_id, + ) + if phase_source_uuid is not None: + phase_source = phase_source.model_copy(update={"uuid": phase_source_uuid}) + phase_source_cache[phase_source_id] = phase_source + sources.append(phase_source) + + source_equipment = VoltageSourceEquipment(name=equipment_name, sources=sources) + source_equipment_uuid = _fetch_component_uuid( + conn, + "voltage_source_equipment", + source_equipment_id, + ) + if source_equipment_uuid is not None: + source_equipment = source_equipment.model_copy( + update={"uuid": source_equipment_uuid} + ) + source_equipment_cache[source_equipment_id] = source_equipment + + phase_rows = conn.execute( + "SELECT phase FROM distribution_voltage_source_phases WHERE vsource_id = ? ORDER BY position_index", + (vsource_id,), + ).fetchall() + phases = [Phase(phase) for (phase,) in phase_rows] + + vsource = DistributionVoltageSource( + name=name, + bus=buses_by_id[bus_id], + substation=substations_by_id[substation_id], + feeder=feeders_by_id[feeder_id], + phases=phases, + equipment=source_equipment, + in_service=bool(in_service), + ) + vsource_uuid = _fetch_component_uuid(conn, "distribution_voltage_sources", vsource_id) + if vsource_uuid is not None: + vsource = vsource.model_copy(update={"uuid": vsource_uuid}) + system.add_component(vsource) + + +def _load_distribution_transformers_from_normalized( + conn: sqlite3.Connection, + system: DistributionSystem, + buses_by_id: dict[int, DistributionBus], + substations_by_id: dict[int, DistributionSubstation], + feeders_by_id: dict[int, DistributionFeeder], +) -> None: + transformer_rows = conn.execute( + """ + SELECT + id, + name, + substation_id, + feeder_id, + equipment_id, + in_service + FROM distribution_transformers + ORDER BY id + """ + ).fetchall() + if not transformer_rows: + return + + equipment_cache: dict[int, DistributionTransformerEquipment] = {} + + for ( + transformer_id, + name, + substation_id, + feeder_id, + equipment_id, + in_service, + ) in transformer_rows: + equipment = equipment_cache.get(equipment_id) + if equipment is None: + equipment = _load_transformer_equipment(conn, equipment_id) + equipment_cache[equipment_id] = equipment + + winding_bus_rows = conn.execute( + """ + SELECT winding_index, bus_id + FROM transformer_winding_buses + WHERE transformer_id = ? + ORDER BY winding_index + """, + (transformer_id,), + ).fetchall() + buses = [buses_by_id[bus_id] for _, bus_id in winding_bus_rows] + + winding_phase_rows = conn.execute( + """ + SELECT winding_index, phase, phase_index + FROM transformer_winding_phases + WHERE transformer_id = ? + ORDER BY winding_index, phase_index + """, + (transformer_id,), + ).fetchall() + winding_phases: list[list[Phase]] = [[] for _ in range(len(buses))] + for winding_index, phase, _ in winding_phase_rows: + winding_phases[winding_index].append(Phase(phase)) + + transformer = DistributionTransformer( + name=name, + buses=buses, + substation=substations_by_id[substation_id], + feeder=feeders_by_id[feeder_id], + winding_phases=winding_phases, + equipment=equipment, + in_service=bool(in_service), + ) + transformer_uuid = _fetch_component_uuid(conn, "distribution_transformers", transformer_id) + if transformer_uuid is not None: + transformer = transformer.model_copy(update={"uuid": transformer_uuid}) + system.add_component(transformer) + + +def _load_distribution_regulators_from_normalized( + conn: sqlite3.Connection, + system: DistributionSystem, + buses_by_id: dict[int, DistributionBus], + substations_by_id: dict[int, DistributionSubstation], + feeders_by_id: dict[int, DistributionFeeder], +) -> None: + regulator_rows = conn.execute( + """ + SELECT + id, + name, + substation_id, + feeder_id, + equipment_id, + in_service + FROM distribution_regulators + ORDER BY id + """ + ).fetchall() + if not regulator_rows: + return + + equipment_cache: dict[int, DistributionTransformerEquipment] = {} + + for ( + regulator_id, + name, + substation_id, + feeder_id, + equipment_id, + in_service, + ) in regulator_rows: + equipment = equipment_cache.get(equipment_id) + if equipment is None: + equipment = _load_transformer_equipment(conn, equipment_id) + equipment_cache[equipment_id] = equipment + + winding_bus_rows = conn.execute( + """ + SELECT winding_index, bus_id + FROM regulator_winding_buses + WHERE regulator_id = ? + ORDER BY winding_index + """, + (regulator_id,), + ).fetchall() + buses = [buses_by_id[bus_id] for _, bus_id in winding_bus_rows] + + winding_phase_rows = conn.execute( + """ + SELECT winding_index, phase, phase_index + FROM regulator_winding_phases + WHERE regulator_id = ? + ORDER BY winding_index, phase_index + """, + (regulator_id,), + ).fetchall() + winding_phases: list[list[Phase]] = [[] for _ in range(len(buses))] + for winding_index, phase, _ in winding_phase_rows: + winding_phases[winding_index].append(Phase(phase)) + + controller_rows = conn.execute( + """ + SELECT + id, + name, + delay, + delay_unit, + v_setpoint, + v_setpoint_unit, + min_v_limit, + min_v_limit_unit, + max_v_limit, + max_v_limit_unit, + pt_ratio, + use_ldc, + is_reversible, + ldc_R, + ldc_R_unit, + ldc_X, + ldc_X_unit, + ct_primary, + ct_primary_unit, + max_step, + bandwidth, + bandwidth_unit, + controlled_bus_id, + controlled_phase + FROM regulator_controllers + WHERE regulator_id = ? + ORDER BY position_index + """, + (regulator_id,), + ).fetchall() + controllers: list[RegulatorController] = [] + for ( + controller_id, + controller_name, + delay, + delay_unit, + v_setpoint, + v_setpoint_unit, + min_v_limit, + min_v_limit_unit, + max_v_limit, + max_v_limit_unit, + pt_ratio, + use_ldc, + is_reversible, + ldc_R, + ldc_R_unit, + ldc_X, + ldc_X_unit, + ct_primary, + ct_primary_unit, + max_step, + bandwidth, + bandwidth_unit, + controlled_bus_id, + controlled_phase, + ) in controller_rows: + controller = RegulatorController( + name=controller_name, + delay=Time(delay, delay_unit) + if delay is not None and delay_unit is not None + else None, + v_setpoint=Voltage(v_setpoint, v_setpoint_unit), + min_v_limit=Voltage(min_v_limit, min_v_limit_unit), + max_v_limit=Voltage(max_v_limit, max_v_limit_unit), + pt_ratio=pt_ratio, + use_ldc=bool(use_ldc), + is_reversible=bool(is_reversible), + ldc_R=Voltage(ldc_R, ldc_R_unit) + if ldc_R is not None and ldc_R_unit is not None + else None, + ldc_X=Voltage(ldc_X, ldc_X_unit) + if ldc_X is not None and ldc_X_unit is not None + else None, + ct_primary=Current(ct_primary, ct_primary_unit) + if ct_primary is not None and ct_primary_unit is not None + else None, + max_step=max_step, + bandwidth=Voltage(bandwidth, bandwidth_unit), + controlled_bus=buses_by_id[controlled_bus_id], + controlled_phase=Phase(controlled_phase), + ) + controller_uuid = _fetch_component_uuid(conn, "regulator_controllers", controller_id) + if controller_uuid is not None: + controller = controller.model_copy(update={"uuid": controller_uuid}) + controllers.append(controller) + + regulator = DistributionRegulator( + name=name, + buses=buses, + substation=substations_by_id[substation_id], + feeder=feeders_by_id[feeder_id], + winding_phases=winding_phases, + equipment=equipment, + controllers=controllers, + in_service=bool(in_service), + ) + regulator_uuid = _fetch_component_uuid(conn, "distribution_regulators", regulator_id) + if regulator_uuid is not None: + regulator = regulator.model_copy(update={"uuid": regulator_uuid}) + system.add_component(regulator) + + +def _load_transformer_equipment( + conn: sqlite3.Connection, + equipment_id: int, +) -> DistributionTransformerEquipment: + equipment_row = conn.execute( + """ + SELECT + name, + mounting, + pct_no_load_loss, + pct_full_load_loss, + is_center_tapped + FROM distribution_transformer_equipment + WHERE id = ? + """, + (equipment_id,), + ).fetchone() + if equipment_row is None: + raise ValueError(f"distribution_transformer_equipment_id={equipment_id} not found") + ( + equipment_name, + mounting, + pct_no_load_loss, + pct_full_load_loss, + is_center_tapped, + ) = equipment_row + + winding_rows = conn.execute( + """ + SELECT + id, + name, + winding_index, + resistance, + is_grounded, + rated_voltage, + rated_voltage_unit, + voltage_type, + rated_power, + rated_power_unit, + num_phases, + connection_type, + total_taps, + min_tap_pu, + max_tap_pu + FROM winding_equipment + WHERE transformer_equipment_id = ? + ORDER BY winding_index + """, + (equipment_id,), + ).fetchall() + windings: list[WindingEquipment] = [] + for ( + winding_id, + winding_name, + _, + resistance, + is_grounded, + rated_voltage, + rated_voltage_unit, + voltage_type, + rated_power, + rated_power_unit, + num_phases, + connection_type, + total_taps, + min_tap_pu, + max_tap_pu, + ) in winding_rows: + tap_rows = conn.execute( + """ + SELECT tap_value + FROM winding_tap_positions + WHERE winding_id = ? + ORDER BY position_index + """, + (winding_id,), + ).fetchall() + tap_positions = [tap for (tap,) in tap_rows] + + winding = WindingEquipment( + name=winding_name, + resistance=resistance, + is_grounded=bool(is_grounded), + rated_voltage=Voltage(rated_voltage, rated_voltage_unit), + voltage_type=VoltageTypes(voltage_type), + rated_power=ApparentPower(rated_power, rated_power_unit), + num_phases=num_phases, + connection_type=ConnectionType(connection_type), + tap_positions=tap_positions, + total_taps=total_taps, + min_tap_pu=min_tap_pu, + max_tap_pu=max_tap_pu, + ) + winding_uuid = _fetch_component_uuid(conn, "winding_equipment", winding_id) + if winding_uuid is not None: + winding = winding.model_copy(update={"uuid": winding_uuid}) + windings.append(winding) + + coupling_rows = conn.execute( + """ + SELECT from_winding_index, to_winding_index, reactance + FROM transformer_coupling_sequences + WHERE transformer_equipment_id = ? + ORDER BY sequence_index + """, + (equipment_id,), + ).fetchall() + coupling_sequences = [SequencePair(a, b) for a, b, _ in coupling_rows] + winding_reactances = [reactance for _, _, reactance in coupling_rows] + + equipment = DistributionTransformerEquipment( + name=equipment_name, + mounting=TransformerMounting(mounting), + pct_no_load_loss=pct_no_load_loss, + pct_full_load_loss=pct_full_load_loss, + windings=windings, + coupling_sequences=coupling_sequences, + winding_reactances=winding_reactances, + is_center_tapped=bool(is_center_tapped), + ) + equipment_uuid = _fetch_component_uuid( + conn, + "distribution_transformer_equipment", + equipment_id, + ) + if equipment_uuid is not None: + equipment = equipment.model_copy(update={"uuid": equipment_uuid}) + return equipment diff --git a/src/gdm/db/sqlite_store_controls_curves.py b/src/gdm/db/sqlite_store_controls_curves.py new file mode 100644 index 00000000..f2d72a0c --- /dev/null +++ b/src/gdm/db/sqlite_store_controls_curves.py @@ -0,0 +1,632 @@ +"""Shared inverter control and curve helpers for SQLite GDM persistence.""" + +from __future__ import annotations + +import sqlite3 +from datetime import time +from uuid import UUID + +from infrasys.quantities import Time + +from gdm.db.sqlite_store_identity import _fetch_component_uuid, _upsert_component_uuid_map +from gdm.distribution.common.curve import Curve, TimeCurrentCurve +from gdm.distribution.controllers.distribution_inverter_controller import ( + CapacityFirmingControlSetting, + DemandChargeControlSetting, + InverterController, + PeakShavingBaseLoadingControlSetting, + PowerfactorControlSetting, + SelfConsumptionControlSetting, + TimeBasedControlSetting, + TimeOfUseControlSetting, + VoltVarControlSetting, + VoltWattControlSetting, +) +from gdm.quantities import ActivePower, ActivePowerOverTime, Current + + +def _upsert_curve( + conn: sqlite3.Connection, curve: Curve, curve_id_by_uuid: dict[UUID, int] +) -> int: + curve_id = curve_id_by_uuid.get(curve.uuid) + if curve_id is not None: + return curve_id + + cursor = conn.execute( + "INSERT INTO curves(name) VALUES(?)", + (curve.name,), + ) + curve_id = int(cursor.lastrowid) + _upsert_component_uuid_map(conn, "curves", curve_id, curve.uuid) + for position_index, (x_value, y_value) in enumerate(zip(curve.curve_x, curve.curve_y)): + conn.execute( + "INSERT INTO curve_points(curve_id, position_index, x_value, y_value) VALUES(?, ?, ?, ?)", + (curve_id, position_index, float(x_value), float(y_value)), + ) + + curve_id_by_uuid[curve.uuid] = curve_id + return curve_id + + +def _upsert_inverter_controller( + conn: sqlite3.Connection, + controller: InverterController | None, + curve_id_by_uuid: dict[UUID, int], + active_control_id_by_uuid: dict[UUID, int], + reactive_control_id_by_uuid: dict[UUID, int], + controller_id_by_uuid: dict[UUID, int], +) -> int | None: + if controller is None: + return None + + existing = controller_id_by_uuid.get(controller.uuid) + if existing is not None: + return existing + + active_control_id = _upsert_inverter_active_control( + conn, + controller.active_power_control, + curve_id_by_uuid, + active_control_id_by_uuid, + ) + reactive_control_id = _upsert_inverter_reactive_control( + conn, + controller.reactive_power_control, + curve_id_by_uuid, + reactive_control_id_by_uuid, + ) + + cursor = conn.execute( + """ + INSERT INTO inverter_controllers( + name, + prioritize_active_power, + night_mode, + active_power_control_id, + reactive_power_control_id + ) VALUES(?, ?, ?, ?, ?) + """, + ( + controller.name, + 1 if controller.prioritize_active_power else 0, + 1 if controller.night_mode else 0, + active_control_id, + reactive_control_id, + ), + ) + controller_id = int(cursor.lastrowid) + _upsert_component_uuid_map(conn, "inverter_controllers", controller_id, controller.uuid) + controller_id_by_uuid[controller.uuid] = controller_id + return controller_id + + +def _upsert_inverter_active_control( + conn: sqlite3.Connection, + active_control, + curve_id_by_uuid: dict[UUID, int], + active_control_id_by_uuid: dict[UUID, int], +) -> int | None: + if active_control is None: + return None + + existing = active_control_id_by_uuid.get(active_control.uuid) + if existing is not None: + return existing + + values = { + "name": active_control.name, + "controller_type": None, + "supported_by": active_control.supported_by.value, + "volt_watt_curve_id": None, + "peak_shaving_target": None, + "peak_shaving_target_unit": None, + "base_loading_target": None, + "base_loading_target_unit": None, + "max_active_power_roc": None, + "max_active_power_roc_unit": None, + "min_active_power_roc": None, + "min_active_power_roc_unit": None, + "charging_start_time": None, + "charging_end_time": None, + "discharging_start_time": None, + "discharging_end_time": None, + "charging_power": None, + "charging_power_unit": None, + "discharging_power": None, + "discharging_power_unit": None, + "tariff_id": None, + } + + if isinstance(active_control, VoltWattControlSetting): + values["controller_type"] = "VOLT_WATT" + values["volt_watt_curve_id"] = _upsert_curve( + conn, active_control.volt_watt_curve, curve_id_by_uuid + ) + elif isinstance(active_control, PeakShavingBaseLoadingControlSetting): + values["controller_type"] = "PEAK_SHAVING" + values["peak_shaving_target"] = float(active_control.peak_shaving_target.magnitude) + values["peak_shaving_target_unit"] = str(active_control.peak_shaving_target.units) + values["base_loading_target"] = float(active_control.base_loading_target.magnitude) + values["base_loading_target_unit"] = str(active_control.base_loading_target.units) + elif isinstance(active_control, CapacityFirmingControlSetting): + values["controller_type"] = "CAPACITY_FIRMING" + values["max_active_power_roc"] = float(active_control.max_active_power_roc.magnitude) + values["max_active_power_roc_unit"] = str(active_control.max_active_power_roc.units) + values["min_active_power_roc"] = float(active_control.min_active_power_roc.magnitude) + values["min_active_power_roc_unit"] = str(active_control.min_active_power_roc.units) + elif isinstance(active_control, TimeBasedControlSetting): + values["controller_type"] = "TIME_BASED" + values["charging_start_time"] = active_control.charging_start_time.strftime("%H:%M:%S") + values["charging_end_time"] = active_control.charging_end_time.strftime("%H:%M:%S") + values["discharging_start_time"] = active_control.discharging_start_time.strftime( + "%H:%M:%S" + ) + values["discharging_end_time"] = active_control.discharging_end_time.strftime("%H:%M:%S") + values["charging_power"] = float(active_control.charging_power.magnitude) + values["charging_power_unit"] = str(active_control.charging_power.units) + values["discharging_power"] = float(active_control.discharging_power.magnitude) + values["discharging_power_unit"] = str(active_control.discharging_power.units) + elif isinstance(active_control, SelfConsumptionControlSetting): + values["controller_type"] = "SELF_CONSUMPTION" + elif isinstance(active_control, TimeOfUseControlSetting): + values["controller_type"] = "TIME_OF_USE" + elif isinstance(active_control, DemandChargeControlSetting): + values["controller_type"] = "DEMAND_CHARGE" + else: + return None + + cursor = conn.execute( + """ + INSERT INTO inverter_active_power_controls( + name, + controller_type, + supported_by, + volt_watt_curve_id, + peak_shaving_target, + peak_shaving_target_unit, + base_loading_target, + base_loading_target_unit, + max_active_power_roc, + max_active_power_roc_unit, + min_active_power_roc, + min_active_power_roc_unit, + charging_start_time, + charging_end_time, + discharging_start_time, + discharging_end_time, + charging_power, + charging_power_unit, + discharging_power, + discharging_power_unit, + tariff_id + ) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + values["name"], + values["controller_type"], + values["supported_by"], + values["volt_watt_curve_id"], + values["peak_shaving_target"], + values["peak_shaving_target_unit"], + values["base_loading_target"], + values["base_loading_target_unit"], + values["max_active_power_roc"], + values["max_active_power_roc_unit"], + values["min_active_power_roc"], + values["min_active_power_roc_unit"], + values["charging_start_time"], + values["charging_end_time"], + values["discharging_start_time"], + values["discharging_end_time"], + values["charging_power"], + values["charging_power_unit"], + values["discharging_power"], + values["discharging_power_unit"], + values["tariff_id"], + ), + ) + active_control_id = int(cursor.lastrowid) + _upsert_component_uuid_map( + conn, + "inverter_active_power_controls", + active_control_id, + active_control.uuid, + ) + active_control_id_by_uuid[active_control.uuid] = active_control_id + return active_control_id + + +def _upsert_inverter_reactive_control( + conn: sqlite3.Connection, + reactive_control, + curve_id_by_uuid: dict[UUID, int], + reactive_control_id_by_uuid: dict[UUID, int], +) -> int | None: + if reactive_control is None: + return None + + existing = reactive_control_id_by_uuid.get(reactive_control.uuid) + if existing is not None: + return existing + + values = { + "name": reactive_control.name, + "controller_type": None, + "supported_by": reactive_control.supported_by.value, + "power_factor": None, + "volt_var_curve_id": None, + "var_follow": None, + } + + if isinstance(reactive_control, PowerfactorControlSetting): + values["controller_type"] = "POWER_FACTOR" + values["power_factor"] = reactive_control.power_factor + elif isinstance(reactive_control, VoltVarControlSetting): + values["controller_type"] = "VOLT_VAR" + values["volt_var_curve_id"] = _upsert_curve( + conn, reactive_control.volt_var_curve, curve_id_by_uuid + ) + values["var_follow"] = 1 if reactive_control.var_follow else 0 + else: + return None + + cursor = conn.execute( + """ + INSERT INTO inverter_reactive_power_controls( + name, + controller_type, + supported_by, + power_factor, + volt_var_curve_id, + var_follow + ) VALUES(?, ?, ?, ?, ?, ?) + """, + ( + values["name"], + values["controller_type"], + values["supported_by"], + values["power_factor"], + values["volt_var_curve_id"], + values["var_follow"], + ), + ) + reactive_control_id = int(cursor.lastrowid) + _upsert_component_uuid_map( + conn, + "inverter_reactive_power_controls", + reactive_control_id, + reactive_control.uuid, + ) + reactive_control_id_by_uuid[reactive_control.uuid] = reactive_control_id + return reactive_control_id + + +def _load_curve(conn: sqlite3.Connection, curve_id: int, curve_cache: dict[int, Curve]) -> Curve: + curve = curve_cache.get(curve_id) + if curve is not None: + return curve + + row = conn.execute("SELECT name FROM curves WHERE id = ?", (curve_id,)).fetchone() + if row is None: + raise ValueError(f"curve_id={curve_id} not found") + (curve_name,) = row + + points = conn.execute( + "SELECT x_value, y_value FROM curve_points WHERE curve_id = ? ORDER BY position_index", + (curve_id,), + ).fetchall() + curve = Curve(name=curve_name, curve_x=[x for x, _ in points], curve_y=[y for _, y in points]) + curve_uuid = _fetch_component_uuid(conn, "curves", curve_id) + if curve_uuid is not None: + curve = curve.model_copy(update={"uuid": curve_uuid}) + curve_cache[curve_id] = curve + return curve + + +def _load_inverter_active_control( + conn: sqlite3.Connection, + control_id: int, + curve_cache: dict[int, Curve], + active_control_cache: dict[int, object], +): + existing = active_control_cache.get(control_id) + if existing is not None: + return existing + + row = conn.execute( + """ + SELECT + name, + controller_type, + supported_by, + volt_watt_curve_id, + peak_shaving_target, + peak_shaving_target_unit, + base_loading_target, + base_loading_target_unit, + max_active_power_roc, + max_active_power_roc_unit, + min_active_power_roc, + min_active_power_roc_unit, + charging_start_time, + charging_end_time, + discharging_start_time, + discharging_end_time, + charging_power, + charging_power_unit, + discharging_power, + discharging_power_unit + FROM inverter_active_power_controls + WHERE id = ? + """, + (control_id,), + ).fetchone() + if row is None: + raise ValueError(f"inverter_active_power_control_id={control_id} not found") + + ( + name, + controller_type, + _, + volt_watt_curve_id, + peak_shaving_target, + peak_shaving_target_unit, + base_loading_target, + base_loading_target_unit, + max_active_power_roc, + max_active_power_roc_unit, + min_active_power_roc, + min_active_power_roc_unit, + charging_start_time, + charging_end_time, + discharging_start_time, + discharging_end_time, + charging_power, + charging_power_unit, + discharging_power, + discharging_power_unit, + ) = row + + control = None + if controller_type == "VOLT_WATT" and volt_watt_curve_id is not None: + control = VoltWattControlSetting( + name=name, + volt_watt_curve=_load_curve(conn, volt_watt_curve_id, curve_cache), + ) + elif controller_type == "PEAK_SHAVING": + control = PeakShavingBaseLoadingControlSetting( + name=name, + peak_shaving_target=ActivePower(peak_shaving_target, peak_shaving_target_unit), + base_loading_target=ActivePower(base_loading_target, base_loading_target_unit), + ) + elif controller_type == "CAPACITY_FIRMING": + control = CapacityFirmingControlSetting( + name=name, + max_active_power_roc=ActivePowerOverTime( + max_active_power_roc, max_active_power_roc_unit + ), + min_active_power_roc=ActivePowerOverTime( + min_active_power_roc, min_active_power_roc_unit + ), + ) + elif controller_type == "TIME_BASED": + control = TimeBasedControlSetting( + name=name, + charging_start_time=time.fromisoformat(charging_start_time), + charging_end_time=time.fromisoformat(charging_end_time), + discharging_start_time=time.fromisoformat(discharging_start_time), + discharging_end_time=time.fromisoformat(discharging_end_time), + charging_power=ActivePower(charging_power, charging_power_unit), + discharging_power=ActivePower(discharging_power, discharging_power_unit), + ) + elif controller_type == "SELF_CONSUMPTION": + control = SelfConsumptionControlSetting(name=name) + + if control is not None: + control_uuid = _fetch_component_uuid(conn, "inverter_active_power_controls", control_id) + if control_uuid is not None: + control = control.model_copy(update={"uuid": control_uuid}) + active_control_cache[control_id] = control + + return control + + +def _load_inverter_reactive_control( + conn: sqlite3.Connection, + control_id: int, + curve_cache: dict[int, Curve], + reactive_control_cache: dict[int, object], +): + existing = reactive_control_cache.get(control_id) + if existing is not None: + return existing + + row = conn.execute( + """ + SELECT + name, + controller_type, + supported_by, + power_factor, + volt_var_curve_id, + var_follow + FROM inverter_reactive_power_controls + WHERE id = ? + """, + (control_id,), + ).fetchone() + if row is None: + raise ValueError(f"inverter_reactive_power_control_id={control_id} not found") + + name, controller_type, _, power_factor, volt_var_curve_id, var_follow = row + + control = None + if controller_type == "POWER_FACTOR": + control = PowerfactorControlSetting(name=name, power_factor=power_factor) + elif controller_type == "VOLT_VAR" and volt_var_curve_id is not None: + control = VoltVarControlSetting( + name=name, + volt_var_curve=_load_curve(conn, volt_var_curve_id, curve_cache), + var_follow=bool(var_follow), + ) + + if control is not None: + control_uuid = _fetch_component_uuid(conn, "inverter_reactive_power_controls", control_id) + if control_uuid is not None: + control = control.model_copy(update={"uuid": control_uuid}) + reactive_control_cache[control_id] = control + + return control + + +def _load_inverter_controller( + conn: sqlite3.Connection, + controller_id: int, + curve_cache: dict[int, Curve], + active_control_cache: dict[int, object], + reactive_control_cache: dict[int, object], + controller_cache: dict[int, InverterController], +) -> InverterController | None: + existing = controller_cache.get(controller_id) + if existing is not None: + return existing + + row = conn.execute( + """ + SELECT + name, + prioritize_active_power, + night_mode, + active_power_control_id, + reactive_power_control_id + FROM inverter_controllers + WHERE id = ? + """, + (controller_id,), + ).fetchone() + if row is None: + return None + + ( + name, + prioritize_active_power, + night_mode, + active_power_control_id, + reactive_power_control_id, + ) = row + + active_control = None + reactive_control = None + if active_power_control_id is not None: + active_control = _load_inverter_active_control( + conn, + active_power_control_id, + curve_cache, + active_control_cache, + ) + if reactive_power_control_id is not None: + reactive_control = _load_inverter_reactive_control( + conn, + reactive_power_control_id, + curve_cache, + reactive_control_cache, + ) + + controller = InverterController( + name=name, + prioritize_active_power=bool(prioritize_active_power), + night_mode=bool(night_mode), + active_power_control=active_control, + reactive_power_control=reactive_control, + ) + controller_uuid = _fetch_component_uuid(conn, "inverter_controllers", controller_id) + if controller_uuid is not None: + controller = controller.model_copy(update={"uuid": controller_uuid}) + controller_cache[controller_id] = controller + return controller + + +def _upsert_time_current_curve( + conn: sqlite3.Connection, + curve: TimeCurrentCurve, + curve_id_by_uuid: dict[UUID, int], +) -> int: + existing = curve_id_by_uuid.get(curve.uuid) + if existing is not None: + return existing + + cursor = conn.execute( + "INSERT INTO time_current_curves(name) VALUES(?)", + (curve.name,), + ) + curve_id = int(cursor.lastrowid) + _upsert_component_uuid_map(conn, "time_current_curves", curve_id, curve.uuid) + + for position_index, (current_value, time_value) in enumerate( + zip(curve.curve_x.magnitude, curve.curve_y.magnitude) + ): + conn.execute( + """ + INSERT INTO time_current_curve_points( + curve_id, + position_index, + current_value, + current_unit, + time_value, + time_unit + ) VALUES(?, ?, ?, ?, ?, ?) + """, + ( + curve_id, + position_index, + float(current_value), + str(curve.curve_x.units), + float(time_value), + str(curve.curve_y.units), + ), + ) + + curve_id_by_uuid[curve.uuid] = curve_id + return curve_id + + +def _load_time_current_curve( + conn: sqlite3.Connection, + curve_id: int, + curve_cache: dict[int, TimeCurrentCurve], +) -> TimeCurrentCurve: + cached = curve_cache.get(curve_id) + if cached is not None: + return cached + + row = conn.execute( + "SELECT name FROM time_current_curves WHERE id = ?", + (curve_id,), + ).fetchone() + if row is None: + raise ValueError(f"time_current_curve_id={curve_id} not found") + + points = conn.execute( + """ + SELECT current_value, current_unit, time_value, time_unit + FROM time_current_curve_points + WHERE curve_id = ? + ORDER BY position_index + """, + (curve_id,), + ).fetchall() + if not points: + raise ValueError(f"time_current_curve_id={curve_id} has no points") + + current_unit = points[0][1] + time_unit = points[0][3] + curve = TimeCurrentCurve( + name=row[0], + curve_x=Current([current for current, _, _, _ in points], current_unit), + curve_y=Time([time_value for _, _, time_value, _ in points], time_unit), + ) + curve_uuid = _fetch_component_uuid(conn, "time_current_curves", curve_id) + if curve_uuid is not None: + curve = curve.model_copy(update={"uuid": curve_uuid}) + curve_cache[curve_id] = curve + return curve diff --git a/src/gdm/db/sqlite_store_geometry.py b/src/gdm/db/sqlite_store_geometry.py new file mode 100644 index 00000000..15c26303 --- /dev/null +++ b/src/gdm/db/sqlite_store_geometry.py @@ -0,0 +1,462 @@ +"""Geometry branch helpers for SQLite GDM persistence.""" + +from __future__ import annotations + +import sqlite3 + +from gdm.db.sqlite_store_identity import _fetch_component_uuid, _upsert_component_uuid_map +from gdm.distribution import DistributionSystem +from gdm.distribution.components import DistributionBus, GeometryBranch +from gdm.distribution.components.distribution_feeder import DistributionFeeder +from gdm.distribution.components.distribution_substation import DistributionSubstation +from gdm.distribution.enums import Phase, WireInsulationType +from gdm.distribution.equipment.bare_conductor_equipment import BareConductorEquipment +from gdm.distribution.equipment.concentric_cable_equipment import ConcentricCableEquipment +from gdm.distribution.equipment.geometry_branch_equipment import GeometryBranchEquipment +from gdm.quantities import Current, Distance, ResistancePULength, Voltage + + +def _upsert_bare_conductor_equipment( + conn: sqlite3.Connection, + conductor: BareConductorEquipment, + conductor_id_by_name: dict[str, int], +) -> int: + existing = conductor_id_by_name.get(conductor.name) + if existing is not None: + return existing + + row = conn.execute( + "SELECT id FROM bare_conductor_equipment WHERE name = ?", + (conductor.name,), + ).fetchone() + if row is None: + cursor = conn.execute( + """ + INSERT INTO bare_conductor_equipment( + name, + conductor_diameter, + conductor_diameter_unit, + conductor_gmr, + conductor_gmr_unit, + ampacity, + ampacity_unit, + emergency_ampacity, + emergency_ampacity_unit, + ac_resistance, + ac_resistance_unit, + dc_resistance, + dc_resistance_unit + ) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + conductor.name, + float(conductor.conductor_diameter.magnitude), + str(conductor.conductor_diameter.units), + float(conductor.conductor_gmr.magnitude), + str(conductor.conductor_gmr.units), + float(conductor.ampacity.magnitude), + str(conductor.ampacity.units), + float(conductor.emergency_ampacity.magnitude), + str(conductor.emergency_ampacity.units), + float(conductor.ac_resistance.magnitude), + str(conductor.ac_resistance.units), + float(conductor.dc_resistance.magnitude), + str(conductor.dc_resistance.units), + ), + ) + conductor_id = int(cursor.lastrowid) + else: + conductor_id = int(row[0]) + + _upsert_component_uuid_map(conn, "bare_conductor_equipment", conductor_id, conductor.uuid) + conductor_id_by_name[conductor.name] = conductor_id + return conductor_id + + +def _upsert_concentric_cable_equipment( + conn: sqlite3.Connection, + conductor: ConcentricCableEquipment, + conductor_id_by_name: dict[str, int], +) -> int: + existing = conductor_id_by_name.get(conductor.name) + if existing is not None: + return existing + + row = conn.execute( + "SELECT id FROM concentric_cable_equipment WHERE name = ?", + (conductor.name,), + ).fetchone() + if row is None: + cursor = conn.execute( + """ + INSERT INTO concentric_cable_equipment( + name, + strand_diameter, + strand_diameter_unit, + conductor_diameter, + conductor_diameter_unit, + cable_diameter, + cable_diameter_unit, + insulation_thickness, + insulation_thickness_unit, + insulation_diameter, + insulation_diameter_unit, + ampacity, + ampacity_unit, + conductor_gmr, + conductor_gmr_unit, + strand_gmr, + strand_gmr_unit, + phase_ac_resistance, + phase_ac_resistance_unit, + strand_ac_resistance, + strand_ac_resistance_unit, + num_neutral_strands, + rated_voltage, + rated_voltage_unit, + insulation + ) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + conductor.name, + float(conductor.strand_diameter.magnitude), + str(conductor.strand_diameter.units), + float(conductor.conductor_diameter.magnitude), + str(conductor.conductor_diameter.units), + float(conductor.cable_diameter.magnitude), + str(conductor.cable_diameter.units), + float(conductor.insulation_thickness.magnitude), + str(conductor.insulation_thickness.units), + float(conductor.insulation_diameter.magnitude), + str(conductor.insulation_diameter.units), + float(conductor.ampacity.magnitude), + str(conductor.ampacity.units), + float(conductor.conductor_gmr.magnitude), + str(conductor.conductor_gmr.units), + float(conductor.strand_gmr.magnitude), + str(conductor.strand_gmr.units), + float(conductor.phase_ac_resistance.magnitude), + str(conductor.phase_ac_resistance.units), + float(conductor.strand_ac_resistance.magnitude), + str(conductor.strand_ac_resistance.units), + conductor.num_neutral_strands, + float(conductor.rated_voltage.magnitude), + str(conductor.rated_voltage.units), + conductor.insulation.name, + ), + ) + conductor_id = int(cursor.lastrowid) + else: + conductor_id = int(row[0]) + + _upsert_component_uuid_map(conn, "concentric_cable_equipment", conductor_id, conductor.uuid) + conductor_id_by_name[conductor.name] = conductor_id + return conductor_id + + +def _upsert_geometry_branch_equipment( + conn: sqlite3.Connection, + equipment: GeometryBranchEquipment, + geometry_equipment_id_by_name: dict[str, int], + bare_conductor_id_by_name: dict[str, int], + concentric_cable_id_by_name: dict[str, int], +) -> int: + existing = geometry_equipment_id_by_name.get(equipment.name) + if existing is not None: + return existing + + row = conn.execute( + "SELECT id FROM geometry_branch_equipment WHERE name = ?", + (equipment.name,), + ).fetchone() + if row is None: + cursor = conn.execute( + "INSERT INTO geometry_branch_equipment(name, insulation) VALUES(?, ?)", + (equipment.name, equipment.insulation.name), + ) + equipment_id = int(cursor.lastrowid) + else: + equipment_id = int(row[0]) + + _upsert_component_uuid_map(conn, "geometry_branch_equipment", equipment_id, equipment.uuid) + geometry_equipment_id_by_name[equipment.name] = equipment_id + + for position_index, conductor in enumerate(equipment.conductors): + if isinstance(conductor, BareConductorEquipment): + bare_id = _upsert_bare_conductor_equipment(conn, conductor, bare_conductor_id_by_name) + concentric_id = None + elif isinstance(conductor, ConcentricCableEquipment): + bare_id = None + concentric_id = _upsert_concentric_cable_equipment( + conn, + conductor, + concentric_cable_id_by_name, + ) + else: + raise TypeError(f"Unsupported geometry conductor type {type(conductor)}") + + conn.execute( + """ + INSERT INTO geometry_branch_conductors( + equipment_id, + position_index, + horizontal_position, + horizontal_position_unit, + vertical_position, + vertical_position_unit, + bare_conductor_id, + concentric_cable_id + ) VALUES(?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + equipment_id, + position_index, + float(equipment.horizontal_positions[position_index].magnitude), + str(equipment.horizontal_positions.units), + float(equipment.vertical_positions[position_index].magnitude), + str(equipment.vertical_positions.units), + bare_id, + concentric_id, + ), + ) + + return equipment_id + + +def _load_geometry_branches_from_normalized( + conn: sqlite3.Connection, + system: DistributionSystem, + buses_by_id: dict[int, DistributionBus], + substations_by_id: dict[int, DistributionSubstation], + feeders_by_id: dict[int, DistributionFeeder], +) -> None: + rows = conn.execute( + """ + SELECT + id, + name, + from_bus_id, + to_bus_id, + substation_id, + feeder_id, + length, + length_unit, + equipment_id, + in_service + FROM geometry_branches + ORDER BY id + """ + ).fetchall() + if not rows: + return + + bare_cache: dict[int, BareConductorEquipment] = {} + concentric_cache: dict[int, ConcentricCableEquipment] = {} + equipment_cache: dict[int, GeometryBranchEquipment] = {} + + for ( + branch_id, + name, + from_bus_id, + to_bus_id, + substation_id, + feeder_id, + length, + length_unit, + equipment_id, + in_service, + ) in rows: + equipment = equipment_cache.get(equipment_id) + if equipment is None: + equipment_row = conn.execute( + "SELECT name, insulation FROM geometry_branch_equipment WHERE id = ?", + (equipment_id,), + ).fetchone() + if equipment_row is None: + raise ValueError(f"geometry_branch_equipment_id={equipment_id} not found") + + conductor_rows = conn.execute( + """ + SELECT + position_index, + horizontal_position, + horizontal_position_unit, + vertical_position, + vertical_position_unit, + bare_conductor_id, + concentric_cable_id + FROM geometry_branch_conductors + WHERE equipment_id = ? + ORDER BY position_index + """, + (equipment_id,), + ).fetchall() + + conductors: list[BareConductorEquipment | ConcentricCableEquipment] = [] + horizontal_values: list[float] = [] + vertical_values: list[float] = [] + horizontal_unit = "meter" + vertical_unit = "meter" + + for ( + _, + horizontal_position, + horizontal_position_unit, + vertical_position, + vertical_position_unit, + bare_conductor_id, + concentric_cable_id, + ) in conductor_rows: + horizontal_values.append(horizontal_position) + vertical_values.append(vertical_position) + horizontal_unit = horizontal_position_unit + vertical_unit = vertical_position_unit + + if bare_conductor_id is not None: + conductor = bare_cache.get(bare_conductor_id) + if conductor is None: + bare_row = conn.execute( + """ + SELECT + name, + conductor_diameter, + conductor_diameter_unit, + conductor_gmr, + conductor_gmr_unit, + ampacity, + ampacity_unit, + emergency_ampacity, + emergency_ampacity_unit, + ac_resistance, + ac_resistance_unit, + dc_resistance, + dc_resistance_unit + FROM bare_conductor_equipment + WHERE id = ? + """, + (bare_conductor_id,), + ).fetchone() + if bare_row is None: + raise ValueError( + f"bare_conductor_equipment_id={bare_conductor_id} not found" + ) + conductor = BareConductorEquipment( + name=bare_row[0], + conductor_diameter=Distance(bare_row[1], bare_row[2]), + conductor_gmr=Distance(bare_row[3], bare_row[4]), + ampacity=Current(bare_row[5], bare_row[6]), + emergency_ampacity=Current(bare_row[7], bare_row[8]), + ac_resistance=ResistancePULength(bare_row[9], bare_row[10]), + dc_resistance=ResistancePULength(bare_row[11], bare_row[12]), + ) + conductor_uuid = _fetch_component_uuid( + conn, + "bare_conductor_equipment", + bare_conductor_id, + ) + if conductor_uuid is not None: + conductor = conductor.model_copy(update={"uuid": conductor_uuid}) + bare_cache[bare_conductor_id] = conductor + conductors.append(conductor) + else: + conductor = concentric_cache.get(concentric_cable_id) + if conductor is None: + concentric_row = conn.execute( + """ + SELECT + name, + strand_diameter, + strand_diameter_unit, + conductor_diameter, + conductor_diameter_unit, + cable_diameter, + cable_diameter_unit, + insulation_thickness, + insulation_thickness_unit, + insulation_diameter, + insulation_diameter_unit, + ampacity, + ampacity_unit, + conductor_gmr, + conductor_gmr_unit, + strand_gmr, + strand_gmr_unit, + phase_ac_resistance, + phase_ac_resistance_unit, + strand_ac_resistance, + strand_ac_resistance_unit, + num_neutral_strands, + rated_voltage, + rated_voltage_unit, + insulation + FROM concentric_cable_equipment + WHERE id = ? + """, + (concentric_cable_id,), + ).fetchone() + if concentric_row is None: + raise ValueError( + f"concentric_cable_equipment_id={concentric_cable_id} not found" + ) + conductor = ConcentricCableEquipment( + name=concentric_row[0], + strand_diameter=Distance(concentric_row[1], concentric_row[2]), + conductor_diameter=Distance(concentric_row[3], concentric_row[4]), + cable_diameter=Distance(concentric_row[5], concentric_row[6]), + insulation_thickness=Distance(concentric_row[7], concentric_row[8]), + insulation_diameter=Distance(concentric_row[9], concentric_row[10]), + ampacity=Current(concentric_row[11], concentric_row[12]), + conductor_gmr=Distance(concentric_row[13], concentric_row[14]), + strand_gmr=Distance(concentric_row[15], concentric_row[16]), + phase_ac_resistance=ResistancePULength( + concentric_row[17], + concentric_row[18], + ), + strand_ac_resistance=ResistancePULength( + concentric_row[19], + concentric_row[20], + ), + num_neutral_strands=concentric_row[21], + rated_voltage=Voltage(concentric_row[22], concentric_row[23]), + insulation=WireInsulationType[concentric_row[24]], + ) + conductor_uuid = _fetch_component_uuid( + conn, + "concentric_cable_equipment", + concentric_cable_id, + ) + if conductor_uuid is not None: + conductor = conductor.model_copy(update={"uuid": conductor_uuid}) + concentric_cache[concentric_cable_id] = conductor + conductors.append(conductor) + + equipment = GeometryBranchEquipment( + name=equipment_row[0], + insulation=WireInsulationType[equipment_row[1]], + conductors=conductors, + horizontal_positions=Distance(horizontal_values, horizontal_unit), + vertical_positions=Distance(vertical_values, vertical_unit), + ) + equipment_uuid = _fetch_component_uuid(conn, "geometry_branch_equipment", equipment_id) + if equipment_uuid is not None: + equipment = equipment.model_copy(update={"uuid": equipment_uuid}) + equipment_cache[equipment_id] = equipment + + phases_rows = conn.execute( + "SELECT phase FROM geometry_branch_phases WHERE branch_id = ? ORDER BY position_index", + (branch_id,), + ).fetchall() + branch = GeometryBranch( + name=name, + buses=[buses_by_id[from_bus_id], buses_by_id[to_bus_id]], + substation=substations_by_id[substation_id], + feeder=feeders_by_id[feeder_id], + length=Distance(length, length_unit), + phases=[Phase(phase) for (phase,) in phases_rows], + equipment=equipment, + in_service=bool(in_service), + ) + branch_uuid = _fetch_component_uuid(conn, "geometry_branches", branch_id) + if branch_uuid is not None: + branch = branch.model_copy(update={"uuid": branch_uuid}) + system.add_component(branch) diff --git a/src/gdm/db/sqlite_store_identity.py b/src/gdm/db/sqlite_store_identity.py new file mode 100644 index 00000000..b980181f --- /dev/null +++ b/src/gdm/db/sqlite_store_identity.py @@ -0,0 +1,31 @@ +"""UUID identity map helpers for SQLite GDM persistence.""" + +from __future__ import annotations + +import sqlite3 +from uuid import UUID + + +def _upsert_component_uuid_map( + conn: sqlite3.Connection, component_type: str, component_id: int, component_uuid: UUID +) -> None: + conn.execute( + """ + INSERT INTO gdm_component_uuid_map(component_type, component_id, uuid) + VALUES(?, ?, ?) + ON CONFLICT(component_type, component_id) DO UPDATE SET uuid=excluded.uuid + """, + (component_type, component_id, str(component_uuid)), + ) + + +def _fetch_component_uuid( + conn: sqlite3.Connection, component_type: str, component_id: int +) -> UUID | None: + row = conn.execute( + "SELECT uuid FROM gdm_component_uuid_map WHERE component_type = ? AND component_id = ?", + (component_type, component_id), + ).fetchone() + if row is None: + return None + return UUID(row[0]) diff --git a/src/gdm/db/sqlite_store_impedance.py b/src/gdm/db/sqlite_store_impedance.py new file mode 100644 index 00000000..8d99ec5c --- /dev/null +++ b/src/gdm/db/sqlite_store_impedance.py @@ -0,0 +1,68 @@ +"""Shared impedance matrix helpers for SQLite GDM persistence.""" + +from __future__ import annotations + +import sqlite3 + + +def _insert_impedance_matrix_entries( + conn: sqlite3.Connection, + equipment_id: int, + equipment_type: str, + matrix_type: str, + matrix_values, + value_unit: str, +) -> None: + for row_idx, row_values in enumerate(matrix_values): + for col_idx, value in enumerate(row_values): + conn.execute( + """ + INSERT INTO impedance_matrix_entries( + equipment_id, + equipment_type, + matrix_type, + row_idx, + col_idx, + value, + value_unit + ) VALUES(?, ?, ?, ?, ?, ?, ?) + """, + ( + equipment_id, + equipment_type, + matrix_type, + row_idx, + col_idx, + float(value), + value_unit, + ), + ) + + +def _load_impedance_matrix( + conn: sqlite3.Connection, + equipment_id: int, + equipment_type: str, + matrix_type: str, +) -> tuple[list[list[float]], str]: + rows = conn.execute( + """ + SELECT row_idx, col_idx, value, value_unit + FROM impedance_matrix_entries + WHERE equipment_id = ? AND equipment_type = ? AND matrix_type = ? + ORDER BY row_idx, col_idx + """, + (equipment_id, equipment_type, matrix_type), + ).fetchall() + if not rows: + raise ValueError( + f"Missing impedance matrix entries for equipment_id={equipment_id}, equipment_type={equipment_type}, matrix_type={matrix_type}" + ) + + size = max(max(row_idx, col_idx) for row_idx, col_idx, _, _ in rows) + 1 + matrix = [[0.0 for _ in range(size)] for _ in range(size)] + unit = rows[0][3] + for row_idx, col_idx, value, _ in rows: + matrix[row_idx][col_idx] = value + + return matrix, unit diff --git a/src/gdm/db/sqlite_store_load_solar_battery.py b/src/gdm/db/sqlite_store_load_solar_battery.py new file mode 100644 index 00000000..dfdc1fde --- /dev/null +++ b/src/gdm/db/sqlite_store_load_solar_battery.py @@ -0,0 +1,1078 @@ +"""Load, solar, and battery read-write helpers for SQLite GDM persistence.""" + +from __future__ import annotations + +import sqlite3 +from uuid import UUID + +from gdm.db.sqlite_store_controls_curves import ( + _load_inverter_controller, + _upsert_inverter_controller, +) +from gdm.db.sqlite_store_identity import _fetch_component_uuid, _upsert_component_uuid_map +from gdm.distribution import DistributionSystem +from gdm.distribution.common.curve import Curve +from gdm.distribution.components import ( + DistributionBattery, + DistributionBus, + DistributionLoad, + DistributionSolar, +) +from gdm.distribution.components.distribution_feeder import DistributionFeeder +from gdm.distribution.components.distribution_substation import DistributionSubstation +from gdm.distribution.controllers.distribution_inverter_controller import InverterController +from gdm.distribution.equipment.battery_equipment import BatteryEquipment +from gdm.distribution.equipment.inverter_equipment import InverterEquipment +from gdm.distribution.equipment.load_equipment import LoadEquipment +from gdm.distribution.equipment.phase_load_equipment import PhaseLoadEquipment +from gdm.distribution.equipment.solar_equipment import SolarEquipment +from gdm.distribution.enums import ConnectionType, Phase, VoltageTypes +from gdm.quantities import ( + ActivePower, + ActivePowerOverTime, + ApparentPower, + EnergyDC, + Irradiance, + ReactivePower, + Voltage, +) + + +def _write_distribution_loads(conn: sqlite3.Connection, system: DistributionSystem) -> None: + bus_rows = conn.execute( + "SELECT id, name, substation_id, feeder_id FROM distribution_buses" + ).fetchall() + bus_ref_by_name: dict[str, tuple[int, int, int]] = { + name: (bus_id, substation_id, feeder_id) + for bus_id, name, substation_id, feeder_id in bus_rows + } + + phase_equipment_id_by_name: dict[str, int] = {} + load_equipment_id_by_name: dict[str, int] = {} + + for load in system.get_components(DistributionLoad): + bus_ref = bus_ref_by_name.get(load.bus.name) + if bus_ref is None: + raise ValueError( + f"DistributionLoad '{load.name}' references missing bus '{load.bus.name}'" + ) + + bus_id, substation_id, feeder_id = bus_ref + load_equipment_id = _upsert_load_equipment_chain( + conn, + load.equipment, + phase_equipment_id_by_name, + load_equipment_id_by_name, + ) + + cursor = conn.execute( + """ + INSERT INTO distribution_loads( + name, bus_id, substation_id, feeder_id, load_equipment_id, in_service + ) + VALUES(?, ?, ?, ?, ?, ?) + """, + ( + load.name, + bus_id, + substation_id, + feeder_id, + load_equipment_id, + 1 if load.in_service else 0, + ), + ) + load_id = int(cursor.lastrowid) + _upsert_component_uuid_map(conn, "distribution_loads", load_id, load.uuid) + + for position_index, phase in enumerate(load.phases): + conn.execute( + "INSERT INTO distribution_load_phases(load_id, phase, position_index) VALUES(?, ?, ?)", + (load_id, phase.value, position_index), + ) + + +def _upsert_load_equipment_chain( + conn: sqlite3.Connection, + equipment: LoadEquipment, + phase_equipment_id_by_name: dict[str, int], + load_equipment_id_by_name: dict[str, int], +) -> int: + load_equipment_id = load_equipment_id_by_name.get(equipment.name) + if load_equipment_id is None: + existing = conn.execute( + "SELECT id FROM load_equipment WHERE name = ?", + (equipment.name,), + ).fetchone() + if existing is None: + cursor = conn.execute( + "INSERT INTO load_equipment(name, connection_type) VALUES(?, ?)", + (equipment.name, equipment.connection_type.value), + ) + load_equipment_id = int(cursor.lastrowid) + else: + load_equipment_id = int(existing[0]) + load_equipment_id_by_name[equipment.name] = load_equipment_id + _upsert_component_uuid_map(conn, "load_equipment", load_equipment_id, equipment.uuid) + + for position_index, phase_equipment in enumerate(equipment.phase_loads): + phase_equipment_id = _upsert_phase_load_equipment( + conn, + phase_equipment, + phase_equipment_id_by_name, + ) + conn.execute( + """ + INSERT INTO load_equipment_phases( + load_equipment_id, + phase_load_equipment_id, + position_index + ) VALUES(?, ?, ?) + """, + (load_equipment_id, phase_equipment_id, position_index), + ) + + return load_equipment_id + + +def _upsert_phase_load_equipment( + conn: sqlite3.Connection, + phase_equipment: PhaseLoadEquipment, + phase_equipment_id_by_name: dict[str, int], +) -> int: + phase_equipment_id = phase_equipment_id_by_name.get(phase_equipment.name) + if phase_equipment_id is None: + existing = conn.execute( + "SELECT id FROM phase_load_equipment WHERE name = ?", + (phase_equipment.name,), + ).fetchone() + if existing is None: + cursor = conn.execute( + """ + INSERT INTO phase_load_equipment( + name, + real_power, + real_power_unit, + reactive_power, + reactive_power_unit, + z_real, + z_imag, + i_real, + i_imag, + p_real, + p_imag, + num_customers + ) + VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + phase_equipment.name, + float(phase_equipment.real_power.magnitude), + str(phase_equipment.real_power.units), + float(phase_equipment.reactive_power.magnitude), + str(phase_equipment.reactive_power.units), + phase_equipment.z_real, + phase_equipment.z_imag, + phase_equipment.i_real, + phase_equipment.i_imag, + phase_equipment.p_real, + phase_equipment.p_imag, + phase_equipment.num_customers, + ), + ) + phase_equipment_id = int(cursor.lastrowid) + else: + phase_equipment_id = int(existing[0]) + phase_equipment_id_by_name[phase_equipment.name] = phase_equipment_id + _upsert_component_uuid_map( + conn, + "phase_load_equipment", + phase_equipment_id, + phase_equipment.uuid, + ) + + return phase_equipment_id + + +def _write_distribution_solar( + conn: sqlite3.Connection, + system: DistributionSystem, + curve_id_by_uuid: dict[UUID, int], + active_control_id_by_uuid: dict[UUID, int], + reactive_control_id_by_uuid: dict[UUID, int], + controller_id_by_uuid: dict[UUID, int], +) -> None: + bus_rows = conn.execute( + "SELECT id, name, substation_id, feeder_id FROM distribution_buses" + ).fetchall() + bus_ref_by_name: dict[str, tuple[int, int, int]] = { + name: (bus_id, substation_id, feeder_id) + for bus_id, name, substation_id, feeder_id in bus_rows + } + + solar_equipment_id_by_name: dict[str, int] = {} + inverter_equipment_id_by_name: dict[str, int] = {} + for solar in system.get_components(DistributionSolar): + bus_ref = bus_ref_by_name.get(solar.bus.name) + if bus_ref is None: + raise ValueError( + f"DistributionSolar '{solar.name}' references missing bus '{solar.bus.name}'" + ) + + bus_id, substation_id, feeder_id = bus_ref + + solar_equipment_id = _upsert_solar_equipment( + conn, + solar.equipment, + solar_equipment_id_by_name, + ) + inverter_equipment_id = _upsert_inverter_equipment( + conn, + solar.inverter, + inverter_equipment_id_by_name, + ) + inverter_controller_id = _upsert_inverter_controller( + conn, + solar.controller, + curve_id_by_uuid, + active_control_id_by_uuid, + reactive_control_id_by_uuid, + controller_id_by_uuid, + ) + + cursor = conn.execute( + """ + INSERT INTO distribution_solar( + name, + bus_id, + substation_id, + feeder_id, + irradiance, + irradiance_unit, + active_power, + active_power_unit, + reactive_power, + reactive_power_unit, + solar_equipment_id, + inverter_equipment_id, + inverter_controller_id, + in_service + ) + VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + solar.name, + bus_id, + substation_id, + feeder_id, + float(solar.irradiance.magnitude), + str(solar.irradiance.units), + float(solar.active_power.magnitude), + str(solar.active_power.units), + float(solar.reactive_power.magnitude), + str(solar.reactive_power.units), + solar_equipment_id, + inverter_equipment_id, + inverter_controller_id, + 1 if solar.in_service else 0, + ), + ) + solar_id = int(cursor.lastrowid) + _upsert_component_uuid_map(conn, "distribution_solar", solar_id, solar.uuid) + + for position_index, phase in enumerate(solar.phases): + conn.execute( + "INSERT INTO distribution_solar_phases(solar_id, phase, position_index) VALUES(?, ?, ?)", + (solar_id, phase.value, position_index), + ) + + +def _upsert_solar_equipment( + conn: sqlite3.Connection, + equipment: SolarEquipment, + solar_equipment_id_by_name: dict[str, int], +) -> int: + solar_equipment_id = solar_equipment_id_by_name.get(equipment.name) + if solar_equipment_id is None: + existing = conn.execute( + "SELECT id FROM solar_equipment WHERE name = ?", + (equipment.name,), + ).fetchone() + if existing is None: + cursor = conn.execute( + """ + INSERT INTO solar_equipment( + name, + rated_power, + rated_power_unit, + power_temp_curve_id, + resistance, + reactance, + rated_voltage, + rated_voltage_unit, + voltage_type + ) + VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + equipment.name, + float(equipment.rated_power.magnitude), + str(equipment.rated_power.units), + None, + equipment.resistance, + equipment.reactance, + float(equipment.rated_voltage.magnitude), + str(equipment.rated_voltage.units), + equipment.voltage_type.value, + ), + ) + solar_equipment_id = int(cursor.lastrowid) + else: + solar_equipment_id = int(existing[0]) + solar_equipment_id_by_name[equipment.name] = solar_equipment_id + _upsert_component_uuid_map(conn, "solar_equipment", solar_equipment_id, equipment.uuid) + + return solar_equipment_id + + +def _upsert_inverter_equipment( + conn: sqlite3.Connection, + equipment: InverterEquipment, + inverter_equipment_id_by_name: dict[str, int], +) -> int: + inverter_equipment_id = inverter_equipment_id_by_name.get(equipment.name) + if inverter_equipment_id is None: + existing = conn.execute( + "SELECT id FROM inverter_equipment WHERE name = ?", + (equipment.name,), + ).fetchone() + if existing is None: + cursor = conn.execute( + """ + INSERT INTO inverter_equipment( + name, + rated_apparent_power, + rated_apparent_power_unit, + rise_limit, + rise_limit_unit, + fall_limit, + fall_limit_unit, + cutout_percent, + cutin_percent, + dc_to_ac_efficiency, + eff_curve_id + ) + VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + equipment.name, + float(equipment.rated_apparent_power.magnitude), + str(equipment.rated_apparent_power.units), + float(equipment.rise_limit.magnitude) + if equipment.rise_limit is not None + else None, + str(equipment.rise_limit.units) if equipment.rise_limit is not None else None, + float(equipment.fall_limit.magnitude) + if equipment.fall_limit is not None + else None, + str(equipment.fall_limit.units) if equipment.fall_limit is not None else None, + equipment.cutout_percent, + equipment.cutin_percent, + equipment.dc_to_ac_efficiency, + None, + ), + ) + inverter_equipment_id = int(cursor.lastrowid) + else: + inverter_equipment_id = int(existing[0]) + inverter_equipment_id_by_name[equipment.name] = inverter_equipment_id + _upsert_component_uuid_map( + conn, + "inverter_equipment", + inverter_equipment_id, + equipment.uuid, + ) + + return inverter_equipment_id + + +def _write_distribution_batteries( + conn: sqlite3.Connection, + system: DistributionSystem, + curve_id_by_uuid: dict[UUID, int], + active_control_id_by_uuid: dict[UUID, int], + reactive_control_id_by_uuid: dict[UUID, int], + controller_id_by_uuid: dict[UUID, int], +) -> None: + bus_rows = conn.execute( + "SELECT id, name, substation_id, feeder_id FROM distribution_buses" + ).fetchall() + bus_ref_by_name: dict[str, tuple[int, int, int]] = { + name: (bus_id, substation_id, feeder_id) + for bus_id, name, substation_id, feeder_id in bus_rows + } + + battery_equipment_id_by_name: dict[str, int] = {} + inverter_equipment_id_by_name: dict[str, int] = {} + + for battery in system.get_components(DistributionBattery): + bus_ref = bus_ref_by_name.get(battery.bus.name) + if bus_ref is None: + raise ValueError( + f"DistributionBattery '{battery.name}' references missing bus '{battery.bus.name}'" + ) + + bus_id, substation_id, feeder_id = bus_ref + + battery_equipment_id = _upsert_battery_equipment( + conn, + battery.equipment, + battery_equipment_id_by_name, + ) + inverter_equipment_id = _upsert_inverter_equipment( + conn, + battery.inverter, + inverter_equipment_id_by_name, + ) + inverter_controller_id = _upsert_inverter_controller( + conn, + battery.controller, + curve_id_by_uuid, + active_control_id_by_uuid, + reactive_control_id_by_uuid, + controller_id_by_uuid, + ) + + cursor = conn.execute( + """ + INSERT INTO distribution_batteries( + name, + bus_id, + substation_id, + feeder_id, + active_power, + active_power_unit, + reactive_power, + reactive_power_unit, + battery_equipment_id, + inverter_equipment_id, + inverter_controller_id, + in_service + ) + VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + battery.name, + bus_id, + substation_id, + feeder_id, + float(battery.active_power.magnitude), + str(battery.active_power.units), + float(battery.reactive_power.magnitude), + str(battery.reactive_power.units), + battery_equipment_id, + inverter_equipment_id, + inverter_controller_id, + 1 if battery.in_service else 0, + ), + ) + battery_id = int(cursor.lastrowid) + _upsert_component_uuid_map(conn, "distribution_batteries", battery_id, battery.uuid) + + for position_index, phase in enumerate(battery.phases): + conn.execute( + "INSERT INTO distribution_battery_phases(battery_id, phase, position_index) VALUES(?, ?, ?)", + (battery_id, phase.value, position_index), + ) + + +def _upsert_battery_equipment( + conn: sqlite3.Connection, + equipment: BatteryEquipment, + battery_equipment_id_by_name: dict[str, int], +) -> int: + battery_equipment_id = battery_equipment_id_by_name.get(equipment.name) + if battery_equipment_id is None: + existing = conn.execute( + "SELECT id FROM battery_equipment WHERE name = ?", + (equipment.name,), + ).fetchone() + if existing is None: + cursor = conn.execute( + """ + INSERT INTO battery_equipment( + name, + rated_energy, + rated_energy_unit, + rated_power, + rated_power_unit, + charging_efficiency, + discharging_efficiency, + idling_efficiency, + rated_voltage, + rated_voltage_unit, + voltage_type + ) + VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + equipment.name, + float(equipment.rated_energy.magnitude), + str(equipment.rated_energy.units), + float(equipment.rated_power.magnitude), + str(equipment.rated_power.units), + equipment.charging_efficiency, + equipment.discharging_efficiency, + equipment.idling_efficiency, + float(equipment.rated_voltage.magnitude), + str(equipment.rated_voltage.units), + equipment.voltage_type.value, + ), + ) + battery_equipment_id = int(cursor.lastrowid) + else: + battery_equipment_id = int(existing[0]) + battery_equipment_id_by_name[equipment.name] = battery_equipment_id + _upsert_component_uuid_map( + conn, + "battery_equipment", + battery_equipment_id, + equipment.uuid, + ) + + return battery_equipment_id + + +def _load_distribution_loads_from_normalized( + conn: sqlite3.Connection, + system: DistributionSystem, + buses_by_id: dict[int, DistributionBus], + substations_by_id: dict[int, DistributionSubstation], + feeders_by_id: dict[int, DistributionFeeder], +) -> None: + load_rows = conn.execute( + """ + SELECT id, name, bus_id, substation_id, feeder_id, load_equipment_id, in_service + FROM distribution_loads + ORDER BY id + """ + ).fetchall() + if not load_rows: + return + + phase_equipment_cache: dict[int, PhaseLoadEquipment] = {} + load_equipment_cache: dict[int, LoadEquipment] = {} + + for ( + load_id, + load_name, + bus_id, + substation_id, + feeder_id, + load_equipment_id, + in_service, + ) in load_rows: + load_phase_rows = conn.execute( + "SELECT phase FROM distribution_load_phases WHERE load_id = ? ORDER BY position_index", + (load_id,), + ).fetchall() + phases = [Phase(phase) for (phase,) in load_phase_rows] + + load_equipment = load_equipment_cache.get(load_equipment_id) + if load_equipment is None: + equipment_row = conn.execute( + "SELECT name, connection_type FROM load_equipment WHERE id = ?", + (load_equipment_id,), + ).fetchone() + if equipment_row is None: + raise ValueError(f"load_equipment_id={load_equipment_id} not found") + equipment_name, connection_type = equipment_row + + phase_link_rows = conn.execute( + """ + SELECT phase_load_equipment_id + FROM load_equipment_phases + WHERE load_equipment_id = ? + ORDER BY position_index + """, + (load_equipment_id,), + ).fetchall() + phase_loads: list[PhaseLoadEquipment] = [] + for (phase_equipment_id,) in phase_link_rows: + phase_equipment = phase_equipment_cache.get(phase_equipment_id) + if phase_equipment is None: + phase_row = conn.execute( + """ + SELECT + name, + real_power, + real_power_unit, + reactive_power, + reactive_power_unit, + z_real, + z_imag, + i_real, + i_imag, + p_real, + p_imag, + num_customers + FROM phase_load_equipment + WHERE id = ? + """, + (phase_equipment_id,), + ).fetchone() + if phase_row is None: + raise ValueError(f"phase_load_equipment_id={phase_equipment_id} not found") + + ( + phase_name, + real_power, + real_power_unit, + reactive_power, + reactive_power_unit, + z_real, + z_imag, + i_real, + i_imag, + p_real, + p_imag, + num_customers, + ) = phase_row + + phase_equipment = PhaseLoadEquipment( + name=phase_name, + real_power=ActivePower(real_power, real_power_unit), + reactive_power=ReactivePower(reactive_power, reactive_power_unit), + z_real=z_real, + z_imag=z_imag, + i_real=i_real, + i_imag=i_imag, + p_real=p_real, + p_imag=p_imag, + num_customers=num_customers, + ) + phase_uuid = _fetch_component_uuid( + conn, + "phase_load_equipment", + phase_equipment_id, + ) + if phase_uuid is not None: + phase_equipment = phase_equipment.model_copy(update={"uuid": phase_uuid}) + phase_equipment_cache[phase_equipment_id] = phase_equipment + phase_loads.append(phase_equipment) + + load_equipment = LoadEquipment( + name=equipment_name, + phase_loads=phase_loads, + connection_type=ConnectionType(connection_type), + ) + load_equipment_uuid = _fetch_component_uuid(conn, "load_equipment", load_equipment_id) + if load_equipment_uuid is not None: + load_equipment = load_equipment.model_copy(update={"uuid": load_equipment_uuid}) + load_equipment_cache[load_equipment_id] = load_equipment + + load = DistributionLoad( + name=load_name, + bus=buses_by_id[bus_id], + substation=substations_by_id[substation_id], + feeder=feeders_by_id[feeder_id], + phases=phases, + equipment=load_equipment, + in_service=bool(in_service), + ) + load_uuid = _fetch_component_uuid(conn, "distribution_loads", load_id) + if load_uuid is not None: + load = load.model_copy(update={"uuid": load_uuid}) + system.add_component(load) + + +def _load_distribution_solar_from_normalized( + conn: sqlite3.Connection, + system: DistributionSystem, + buses_by_id: dict[int, DistributionBus], + substations_by_id: dict[int, DistributionSubstation], + feeders_by_id: dict[int, DistributionFeeder], +) -> None: + solar_rows = conn.execute( + """ + SELECT + id, + name, + bus_id, + substation_id, + feeder_id, + irradiance, + irradiance_unit, + active_power, + active_power_unit, + reactive_power, + reactive_power_unit, + solar_equipment_id, + inverter_equipment_id, + inverter_controller_id, + in_service + FROM distribution_solar + ORDER BY id + """ + ).fetchall() + if not solar_rows: + return + + solar_equipment_cache: dict[int, SolarEquipment] = {} + inverter_equipment_cache: dict[int, InverterEquipment] = {} + curve_cache: dict[int, Curve] = {} + active_control_cache: dict[int, object] = {} + reactive_control_cache: dict[int, object] = {} + controller_cache: dict[int, InverterController] = {} + + for ( + solar_id, + solar_name, + bus_id, + substation_id, + feeder_id, + irradiance, + irradiance_unit, + active_power, + active_power_unit, + reactive_power, + reactive_power_unit, + solar_equipment_id, + inverter_equipment_id, + inverter_controller_id, + in_service, + ) in solar_rows: + phase_rows = conn.execute( + "SELECT phase FROM distribution_solar_phases WHERE solar_id = ? ORDER BY position_index", + (solar_id,), + ).fetchall() + phases = [Phase(phase) for (phase,) in phase_rows] + + solar_equipment = solar_equipment_cache.get(solar_equipment_id) + if solar_equipment is None: + equipment_row = conn.execute( + """ + SELECT name, rated_power, rated_power_unit, resistance, reactance, + rated_voltage, rated_voltage_unit, voltage_type + FROM solar_equipment + WHERE id = ? + """, + (solar_equipment_id,), + ).fetchone() + if equipment_row is None: + raise ValueError(f"solar_equipment_id={solar_equipment_id} not found") + ( + equipment_name, + rated_power, + rated_power_unit, + resistance, + reactance, + rated_voltage, + rated_voltage_unit, + voltage_type, + ) = equipment_row + solar_equipment = SolarEquipment( + name=equipment_name, + rated_power=ActivePower(rated_power, rated_power_unit), + power_temp_curve=None, + resistance=resistance, + reactance=reactance, + rated_voltage=Voltage(rated_voltage, rated_voltage_unit), + voltage_type=VoltageTypes(voltage_type), + ) + solar_equipment_uuid = _fetch_component_uuid( + conn, "solar_equipment", solar_equipment_id + ) + if solar_equipment_uuid is not None: + solar_equipment = solar_equipment.model_copy(update={"uuid": solar_equipment_uuid}) + solar_equipment_cache[solar_equipment_id] = solar_equipment + + inverter_equipment = inverter_equipment_cache.get(inverter_equipment_id) + if inverter_equipment is None: + inverter_row = conn.execute( + """ + SELECT name, rated_apparent_power, rated_apparent_power_unit, + rise_limit, rise_limit_unit, fall_limit, fall_limit_unit, + cutout_percent, cutin_percent, dc_to_ac_efficiency + FROM inverter_equipment + WHERE id = ? + """, + (inverter_equipment_id,), + ).fetchone() + if inverter_row is None: + raise ValueError(f"inverter_equipment_id={inverter_equipment_id} not found") + ( + inverter_name, + rated_apparent_power, + rated_apparent_power_unit, + rise_limit, + rise_limit_unit, + fall_limit, + fall_limit_unit, + cutout_percent, + cutin_percent, + dc_to_ac_efficiency, + ) = inverter_row + inverter_equipment = InverterEquipment( + name=inverter_name, + rated_apparent_power=ApparentPower( + rated_apparent_power, rated_apparent_power_unit + ), + rise_limit=( + ActivePowerOverTime(rise_limit, rise_limit_unit) + if rise_limit is not None and rise_limit_unit is not None + else None + ), + fall_limit=( + ActivePowerOverTime(fall_limit, fall_limit_unit) + if fall_limit is not None and fall_limit_unit is not None + else None + ), + cutout_percent=cutout_percent, + cutin_percent=cutin_percent, + dc_to_ac_efficiency=dc_to_ac_efficiency, + eff_curve=None, + ) + inverter_equipment_uuid = _fetch_component_uuid( + conn, + "inverter_equipment", + inverter_equipment_id, + ) + if inverter_equipment_uuid is not None: + inverter_equipment = inverter_equipment.model_copy( + update={"uuid": inverter_equipment_uuid} + ) + inverter_equipment_cache[inverter_equipment_id] = inverter_equipment + + inverter_controller = None + if inverter_controller_id is not None: + inverter_controller = _load_inverter_controller( + conn, + inverter_controller_id, + curve_cache, + active_control_cache, + reactive_control_cache, + controller_cache, + ) + + solar = DistributionSolar( + name=solar_name, + bus=buses_by_id[bus_id], + substation=substations_by_id[substation_id], + feeder=feeders_by_id[feeder_id], + phases=phases, + irradiance=Irradiance(irradiance, irradiance_unit), + active_power=ActivePower(active_power, active_power_unit), + reactive_power=ReactivePower(reactive_power, reactive_power_unit), + controller=inverter_controller, + inverter=inverter_equipment, + equipment=solar_equipment, + in_service=bool(in_service), + ) + solar_uuid = _fetch_component_uuid(conn, "distribution_solar", solar_id) + if solar_uuid is not None: + solar = solar.model_copy(update={"uuid": solar_uuid}) + system.add_component(solar) + + +def _load_distribution_batteries_from_normalized( + conn: sqlite3.Connection, + system: DistributionSystem, + buses_by_id: dict[int, DistributionBus], + substations_by_id: dict[int, DistributionSubstation], + feeders_by_id: dict[int, DistributionFeeder], +) -> None: + battery_rows = conn.execute( + """ + SELECT + id, + name, + bus_id, + substation_id, + feeder_id, + active_power, + active_power_unit, + reactive_power, + reactive_power_unit, + battery_equipment_id, + inverter_equipment_id, + inverter_controller_id, + in_service + FROM distribution_batteries + ORDER BY id + """ + ).fetchall() + if not battery_rows: + return + + battery_equipment_cache: dict[int, BatteryEquipment] = {} + inverter_equipment_cache: dict[int, InverterEquipment] = {} + curve_cache: dict[int, Curve] = {} + active_control_cache: dict[int, object] = {} + reactive_control_cache: dict[int, object] = {} + controller_cache: dict[int, InverterController] = {} + + for ( + battery_id, + battery_name, + bus_id, + substation_id, + feeder_id, + active_power, + active_power_unit, + reactive_power, + reactive_power_unit, + battery_equipment_id, + inverter_equipment_id, + inverter_controller_id, + in_service, + ) in battery_rows: + phase_rows = conn.execute( + "SELECT phase FROM distribution_battery_phases WHERE battery_id = ? ORDER BY position_index", + (battery_id,), + ).fetchall() + phases = [Phase(phase) for (phase,) in phase_rows] + + battery_equipment = battery_equipment_cache.get(battery_equipment_id) + if battery_equipment is None: + equipment_row = conn.execute( + """ + SELECT + name, + rated_energy, + rated_energy_unit, + rated_power, + rated_power_unit, + charging_efficiency, + discharging_efficiency, + idling_efficiency, + rated_voltage, + rated_voltage_unit, + voltage_type + FROM battery_equipment + WHERE id = ? + """, + (battery_equipment_id,), + ).fetchone() + if equipment_row is None: + raise ValueError(f"battery_equipment_id={battery_equipment_id} not found") + + ( + equipment_name, + rated_energy, + rated_energy_unit, + rated_power, + rated_power_unit, + charging_efficiency, + discharging_efficiency, + idling_efficiency, + rated_voltage, + rated_voltage_unit, + voltage_type, + ) = equipment_row + + battery_equipment = BatteryEquipment( + name=equipment_name, + rated_energy=EnergyDC(rated_energy, rated_energy_unit), + rated_power=ActivePower(rated_power, rated_power_unit), + charging_efficiency=charging_efficiency, + discharging_efficiency=discharging_efficiency, + idling_efficiency=idling_efficiency, + rated_voltage=Voltage(rated_voltage, rated_voltage_unit), + voltage_type=VoltageTypes(voltage_type), + ) + battery_equipment_uuid = _fetch_component_uuid( + conn, + "battery_equipment", + battery_equipment_id, + ) + if battery_equipment_uuid is not None: + battery_equipment = battery_equipment.model_copy( + update={"uuid": battery_equipment_uuid} + ) + battery_equipment_cache[battery_equipment_id] = battery_equipment + + inverter_equipment = inverter_equipment_cache.get(inverter_equipment_id) + if inverter_equipment is None: + inverter_row = conn.execute( + """ + SELECT name, rated_apparent_power, rated_apparent_power_unit, + rise_limit, rise_limit_unit, fall_limit, fall_limit_unit, + cutout_percent, cutin_percent, dc_to_ac_efficiency + FROM inverter_equipment + WHERE id = ? + """, + (inverter_equipment_id,), + ).fetchone() + if inverter_row is None: + raise ValueError(f"inverter_equipment_id={inverter_equipment_id} not found") + ( + inverter_name, + rated_apparent_power, + rated_apparent_power_unit, + rise_limit, + rise_limit_unit, + fall_limit, + fall_limit_unit, + cutout_percent, + cutin_percent, + dc_to_ac_efficiency, + ) = inverter_row + inverter_equipment = InverterEquipment( + name=inverter_name, + rated_apparent_power=ApparentPower( + rated_apparent_power, rated_apparent_power_unit + ), + rise_limit=( + ActivePowerOverTime(rise_limit, rise_limit_unit) + if rise_limit is not None and rise_limit_unit is not None + else None + ), + fall_limit=( + ActivePowerOverTime(fall_limit, fall_limit_unit) + if fall_limit is not None and fall_limit_unit is not None + else None + ), + cutout_percent=cutout_percent, + cutin_percent=cutin_percent, + dc_to_ac_efficiency=dc_to_ac_efficiency, + eff_curve=None, + ) + inverter_equipment_uuid = _fetch_component_uuid( + conn, + "inverter_equipment", + inverter_equipment_id, + ) + if inverter_equipment_uuid is not None: + inverter_equipment = inverter_equipment.model_copy( + update={"uuid": inverter_equipment_uuid} + ) + inverter_equipment_cache[inverter_equipment_id] = inverter_equipment + + inverter_controller = None + if inverter_controller_id is not None: + inverter_controller = _load_inverter_controller( + conn, + inverter_controller_id, + curve_cache, + active_control_cache, + reactive_control_cache, + controller_cache, + ) + + battery = DistributionBattery( + name=battery_name, + bus=buses_by_id[bus_id], + substation=substations_by_id[substation_id], + feeder=feeders_by_id[feeder_id], + phases=phases, + active_power=ActivePower(active_power, active_power_unit), + reactive_power=ReactivePower(reactive_power, reactive_power_unit), + controller=inverter_controller, + inverter=inverter_equipment, + equipment=battery_equipment, + in_service=bool(in_service), + ) + battery_uuid = _fetch_component_uuid(conn, "distribution_batteries", battery_id) + if battery_uuid is not None: + battery = battery.model_copy(update={"uuid": battery_uuid}) + system.add_component(battery) diff --git a/src/gdm/db/sqlite_store_network_branches.py b/src/gdm/db/sqlite_store_network_branches.py new file mode 100644 index 00000000..a10c2142 --- /dev/null +++ b/src/gdm/db/sqlite_store_network_branches.py @@ -0,0 +1,561 @@ +"""Matrix/sequence branch read-write helpers for SQLite GDM persistence.""" + +from __future__ import annotations + +import sqlite3 + +from gdm.db.sqlite_store_identity import _fetch_component_uuid, _upsert_component_uuid_map +from gdm.db.sqlite_store_impedance import _insert_impedance_matrix_entries, _load_impedance_matrix +from gdm.distribution import DistributionSystem +from gdm.distribution.components import ( + DistributionBus, + MatrixImpedanceBranch, + SequenceImpedanceBranch, +) +from gdm.distribution.components.distribution_feeder import DistributionFeeder +from gdm.distribution.components.distribution_substation import DistributionSubstation +from gdm.distribution.enums import LineType, Phase +from gdm.distribution.equipment.matrix_impedance_branch_equipment import ( + MatrixImpedanceBranchEquipment, +) +from gdm.distribution.equipment.sequence_impedance_branch_equipment import ( + SequenceImpedanceBranchEquipment, +) +from gdm.quantities import ( + CapacitancePULength, + Current, + Distance, + ReactancePULength, + ResistancePULength, +) + + +def _write_matrix_impedance_branches(conn: sqlite3.Connection, system: DistributionSystem) -> None: + bus_rows = conn.execute( + "SELECT id, name, substation_id, feeder_id FROM distribution_buses" + ).fetchall() + bus_ref_by_name: dict[str, tuple[int, int, int]] = { + name: (bus_id, substation_id, feeder_id) + for bus_id, name, substation_id, feeder_id in bus_rows + } + matrix_equipment_id_by_name: dict[str, int] = {} + + for branch in system.get_components(MatrixImpedanceBranch): + from_bus_ref = bus_ref_by_name.get(branch.buses[0].name) + to_bus_ref = bus_ref_by_name.get(branch.buses[1].name) + if from_bus_ref is None or to_bus_ref is None: + raise ValueError(f"MatrixImpedanceBranch '{branch.name}' references unknown buses") + + from_bus_id, from_substation_id, from_feeder_id = from_bus_ref + to_bus_id, _, _ = to_bus_ref + + substation_id: int | None = None + feeder_id: int | None = None + if branch.substation is not None: + row = conn.execute( + "SELECT id FROM distribution_substations WHERE name = ?", + (branch.substation.name,), + ).fetchone() + substation_id = int(row[0]) if row is not None else None + if branch.feeder is not None: + row = conn.execute( + "SELECT id FROM distribution_feeders WHERE name = ?", + (branch.feeder.name,), + ).fetchone() + feeder_id = int(row[0]) if row is not None else None + + if substation_id is None: + substation_id = from_substation_id + if feeder_id is None: + feeder_id = from_feeder_id + + equipment_id = _upsert_matrix_impedance_branch_equipment( + conn, + branch.equipment, + matrix_equipment_id_by_name, + ) + + cursor = conn.execute( + """ + INSERT INTO matrix_impedance_branches( + name, + from_bus_id, + to_bus_id, + substation_id, + feeder_id, + length, + length_unit, + equipment_id, + in_service + ) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + branch.name, + from_bus_id, + to_bus_id, + substation_id, + feeder_id, + float(branch.length.magnitude), + str(branch.length.units), + equipment_id, + 1 if branch.in_service else 0, + ), + ) + branch_id = int(cursor.lastrowid) + _upsert_component_uuid_map(conn, "matrix_impedance_branches", branch_id, branch.uuid) + + for position_index, phase in enumerate(branch.phases): + conn.execute( + """ + INSERT INTO matrix_impedance_branch_phases(branch_id, phase, position_index) + VALUES(?, ?, ?) + """, + (branch_id, phase.value, position_index), + ) + + +def _upsert_matrix_impedance_branch_equipment( + conn: sqlite3.Connection, + equipment: MatrixImpedanceBranchEquipment, + equipment_id_by_name: dict[str, int], +) -> int: + existing = equipment_id_by_name.get(equipment.name) + if existing is not None: + return existing + + row = conn.execute( + "SELECT id FROM matrix_impedance_branch_equipment WHERE name = ?", + (equipment.name,), + ).fetchone() + if row is not None: + equipment_id = int(row[0]) + equipment_id_by_name[equipment.name] = equipment_id + return equipment_id + + cursor = conn.execute( + """ + INSERT INTO matrix_impedance_branch_equipment( + name, + construction, + ampacity, + ampacity_unit + ) VALUES(?, ?, ?, ?) + """, + ( + equipment.name, + equipment.construction.value, + float(equipment.ampacity.magnitude), + str(equipment.ampacity.units), + ), + ) + equipment_id = int(cursor.lastrowid) + _upsert_component_uuid_map( + conn, + "matrix_impedance_branch_equipment", + equipment_id, + equipment.uuid, + ) + + _insert_impedance_matrix_entries( + conn, + equipment_id, + "LINE", + "R", + equipment.r_matrix.magnitude, + str(equipment.r_matrix.units), + ) + _insert_impedance_matrix_entries( + conn, + equipment_id, + "LINE", + "X", + equipment.x_matrix.magnitude, + str(equipment.x_matrix.units), + ) + _insert_impedance_matrix_entries( + conn, + equipment_id, + "LINE", + "C", + equipment.c_matrix.magnitude, + str(equipment.c_matrix.units), + ) + + equipment_id_by_name[equipment.name] = equipment_id + return equipment_id + + +def _load_matrix_impedance_branches_from_normalized( + conn: sqlite3.Connection, + system: DistributionSystem, + buses_by_id: dict[int, DistributionBus], + substations_by_id: dict[int, DistributionSubstation], + feeders_by_id: dict[int, DistributionFeeder], +) -> None: + branch_rows = conn.execute( + """ + SELECT + id, + name, + from_bus_id, + to_bus_id, + substation_id, + feeder_id, + length, + length_unit, + equipment_id, + in_service + FROM matrix_impedance_branches + ORDER BY id + """ + ).fetchall() + if not branch_rows: + return + + equipment_cache: dict[int, MatrixImpedanceBranchEquipment] = {} + + for ( + branch_id, + name, + from_bus_id, + to_bus_id, + substation_id, + feeder_id, + length, + length_unit, + equipment_id, + in_service, + ) in branch_rows: + equipment = equipment_cache.get(equipment_id) + if equipment is None: + equipment_header = conn.execute( + """ + SELECT name, construction, ampacity, ampacity_unit + FROM matrix_impedance_branch_equipment + WHERE id = ? + """, + (equipment_id,), + ).fetchone() + if equipment_header is None: + raise ValueError(f"matrix_impedance_branch_equipment_id={equipment_id} not found") + ( + equipment_name, + construction, + ampacity, + ampacity_unit, + ) = equipment_header + + r_matrix, r_unit = _load_impedance_matrix(conn, equipment_id, "LINE", "R") + x_matrix, x_unit = _load_impedance_matrix(conn, equipment_id, "LINE", "X") + c_matrix, c_unit = _load_impedance_matrix(conn, equipment_id, "LINE", "C") + + equipment = MatrixImpedanceBranchEquipment( + name=equipment_name, + construction=LineType(construction), + r_matrix=ResistancePULength(r_matrix, r_unit), + x_matrix=ReactancePULength(x_matrix, x_unit), + c_matrix=CapacitancePULength(c_matrix, c_unit), + ampacity=Current(ampacity, ampacity_unit), + ) + equipment_uuid = _fetch_component_uuid( + conn, + "matrix_impedance_branch_equipment", + equipment_id, + ) + if equipment_uuid is not None: + equipment = equipment.model_copy(update={"uuid": equipment_uuid}) + equipment_cache[equipment_id] = equipment + + phases_rows = conn.execute( + """ + SELECT phase + FROM matrix_impedance_branch_phases + WHERE branch_id = ? + ORDER BY position_index + """, + (branch_id,), + ).fetchall() + + branch = MatrixImpedanceBranch( + name=name, + buses=[buses_by_id[from_bus_id], buses_by_id[to_bus_id]], + substation=substations_by_id[substation_id], + feeder=feeders_by_id[feeder_id], + length=Distance(length, length_unit), + phases=[Phase(phase) for (phase,) in phases_rows], + equipment=equipment, + in_service=bool(in_service), + ) + branch_uuid = _fetch_component_uuid(conn, "matrix_impedance_branches", branch_id) + if branch_uuid is not None: + branch = branch.model_copy(update={"uuid": branch_uuid}) + system.add_component(branch) + + +def _write_sequence_impedance_branches( + conn: sqlite3.Connection, system: DistributionSystem +) -> None: + bus_rows = conn.execute( + "SELECT id, name, substation_id, feeder_id FROM distribution_buses" + ).fetchall() + bus_ref_by_name: dict[str, tuple[int, int, int]] = { + name: (bus_id, substation_id, feeder_id) + for bus_id, name, substation_id, feeder_id in bus_rows + } + equipment_id_by_name: dict[str, int] = {} + + for branch in system.get_components(SequenceImpedanceBranch): + from_bus_ref = bus_ref_by_name.get(branch.buses[0].name) + to_bus_ref = bus_ref_by_name.get(branch.buses[1].name) + if from_bus_ref is None or to_bus_ref is None: + raise ValueError(f"SequenceImpedanceBranch '{branch.name}' references unknown buses") + + from_bus_id, from_substation_id, from_feeder_id = from_bus_ref + to_bus_id, _, _ = to_bus_ref + + substation_id: int | None = None + feeder_id: int | None = None + if branch.substation is not None: + row = conn.execute( + "SELECT id FROM distribution_substations WHERE name = ?", + (branch.substation.name,), + ).fetchone() + substation_id = int(row[0]) if row is not None else None + if branch.feeder is not None: + row = conn.execute( + "SELECT id FROM distribution_feeders WHERE name = ?", + (branch.feeder.name,), + ).fetchone() + feeder_id = int(row[0]) if row is not None else None + + if substation_id is None: + substation_id = from_substation_id + if feeder_id is None: + feeder_id = from_feeder_id + + equipment_id = _upsert_sequence_impedance_branch_equipment( + conn, + branch.equipment, + equipment_id_by_name, + ) + + cursor = conn.execute( + """ + INSERT INTO sequence_impedance_branches( + name, + from_bus_id, + to_bus_id, + substation_id, + feeder_id, + length, + length_unit, + equipment_id, + in_service + ) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + branch.name, + from_bus_id, + to_bus_id, + substation_id, + feeder_id, + float(branch.length.magnitude), + str(branch.length.units), + equipment_id, + 1 if branch.in_service else 0, + ), + ) + branch_id = int(cursor.lastrowid) + _upsert_component_uuid_map(conn, "sequence_impedance_branches", branch_id, branch.uuid) + + for position_index, phase in enumerate(branch.phases): + conn.execute( + """ + INSERT INTO sequence_impedance_branch_phases(branch_id, phase, position_index) + VALUES(?, ?, ?) + """, + (branch_id, phase.value, position_index), + ) + + +def _upsert_sequence_impedance_branch_equipment( + conn: sqlite3.Connection, + equipment: SequenceImpedanceBranchEquipment, + equipment_id_by_name: dict[str, int], +) -> int: + existing = equipment_id_by_name.get(equipment.name) + if existing is not None: + return existing + + row = conn.execute( + "SELECT id FROM sequence_impedance_branch_equipment WHERE name = ?", + (equipment.name,), + ).fetchone() + if row is not None: + equipment_id = int(row[0]) + equipment_id_by_name[equipment.name] = equipment_id + return equipment_id + + cursor = conn.execute( + """ + INSERT INTO sequence_impedance_branch_equipment( + name, + pos_seq_resistance, + pos_seq_resistance_unit, + zero_seq_resistance, + zero_seq_resistance_unit, + pos_seq_reactance, + pos_seq_reactance_unit, + zero_seq_reactance, + zero_seq_reactance_unit, + pos_seq_capacitance, + pos_seq_capacitance_unit, + zero_seq_capacitance, + zero_seq_capacitance_unit, + ampacity, + ampacity_unit + ) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + equipment.name, + float(equipment.pos_seq_resistance.magnitude), + str(equipment.pos_seq_resistance.units), + float(equipment.zero_seq_resistance.magnitude), + str(equipment.zero_seq_resistance.units), + float(equipment.pos_seq_reactance.magnitude), + str(equipment.pos_seq_reactance.units), + float(equipment.zero_seq_reactance.magnitude), + str(equipment.zero_seq_reactance.units), + float(equipment.pos_seq_capacitance.magnitude), + str(equipment.pos_seq_capacitance.units), + float(equipment.zero_seq_capacitance.magnitude), + str(equipment.zero_seq_capacitance.units), + float(equipment.ampacity.magnitude), + str(equipment.ampacity.units), + ), + ) + equipment_id = int(cursor.lastrowid) + _upsert_component_uuid_map( + conn, + "sequence_impedance_branch_equipment", + equipment_id, + equipment.uuid, + ) + equipment_id_by_name[equipment.name] = equipment_id + return equipment_id + + +def _load_sequence_impedance_branches_from_normalized( + conn: sqlite3.Connection, + system: DistributionSystem, + buses_by_id: dict[int, DistributionBus], + substations_by_id: dict[int, DistributionSubstation], + feeders_by_id: dict[int, DistributionFeeder], +) -> None: + rows = conn.execute( + """ + SELECT + id, + name, + from_bus_id, + to_bus_id, + substation_id, + feeder_id, + length, + length_unit, + equipment_id, + in_service + FROM sequence_impedance_branches + ORDER BY id + """ + ).fetchall() + if not rows: + return + + equipment_cache: dict[int, SequenceImpedanceBranchEquipment] = {} + + for ( + branch_id, + name, + from_bus_id, + to_bus_id, + substation_id, + feeder_id, + length, + length_unit, + equipment_id, + in_service, + ) in rows: + equipment = equipment_cache.get(equipment_id) + if equipment is None: + equipment_row = conn.execute( + """ + SELECT + name, + pos_seq_resistance, + pos_seq_resistance_unit, + zero_seq_resistance, + zero_seq_resistance_unit, + pos_seq_reactance, + pos_seq_reactance_unit, + zero_seq_reactance, + zero_seq_reactance_unit, + pos_seq_capacitance, + pos_seq_capacitance_unit, + zero_seq_capacitance, + zero_seq_capacitance_unit, + ampacity, + ampacity_unit + FROM sequence_impedance_branch_equipment + WHERE id = ? + """, + (equipment_id,), + ).fetchone() + if equipment_row is None: + raise ValueError( + f"sequence_impedance_branch_equipment_id={equipment_id} not found" + ) + + equipment = SequenceImpedanceBranchEquipment( + name=equipment_row[0], + pos_seq_resistance=ResistancePULength(equipment_row[1], equipment_row[2]), + zero_seq_resistance=ResistancePULength(equipment_row[3], equipment_row[4]), + pos_seq_reactance=ReactancePULength(equipment_row[5], equipment_row[6]), + zero_seq_reactance=ReactancePULength(equipment_row[7], equipment_row[8]), + pos_seq_capacitance=CapacitancePULength(equipment_row[9], equipment_row[10]), + zero_seq_capacitance=CapacitancePULength(equipment_row[11], equipment_row[12]), + ampacity=Current(equipment_row[13], equipment_row[14]), + ) + equipment_uuid = _fetch_component_uuid( + conn, + "sequence_impedance_branch_equipment", + equipment_id, + ) + if equipment_uuid is not None: + equipment = equipment.model_copy(update={"uuid": equipment_uuid}) + equipment_cache[equipment_id] = equipment + + phases = conn.execute( + """ + SELECT phase + FROM sequence_impedance_branch_phases + WHERE branch_id = ? + ORDER BY position_index + """, + (branch_id,), + ).fetchall() + + branch = SequenceImpedanceBranch( + name=name, + buses=[buses_by_id[from_bus_id], buses_by_id[to_bus_id]], + substation=substations_by_id[substation_id], + feeder=feeders_by_id[feeder_id], + length=Distance(length, length_unit), + phases=[Phase(phase) for (phase,) in phases], + equipment=equipment, + in_service=bool(in_service), + ) + branch_uuid = _fetch_component_uuid(conn, "sequence_impedance_branches", branch_id) + if branch_uuid is not None: + branch = branch.model_copy(update={"uuid": branch_uuid}) + system.add_component(branch) diff --git a/src/gdm/db/sqlite_store_recloser.py b/src/gdm/db/sqlite_store_recloser.py new file mode 100644 index 00000000..ddea20e1 --- /dev/null +++ b/src/gdm/db/sqlite_store_recloser.py @@ -0,0 +1,226 @@ +"""Recloser controller helpers for SQLite GDM persistence.""" + +from __future__ import annotations + +import sqlite3 +from uuid import UUID + +from infrasys.quantities import Time + +from gdm.db.sqlite_store_controls_curves import ( + _load_time_current_curve, + _upsert_time_current_curve, +) +from gdm.db.sqlite_store_identity import _fetch_component_uuid, _upsert_component_uuid_map +from gdm.distribution.common.curve import TimeCurrentCurve +from gdm.distribution.controllers.distribution_recloser_controller import ( + DistributionRecloserController, +) +from gdm.distribution.equipment.recloser_controller_equipment import ( + RecloserControllerEquipment, +) + + +def _upsert_recloser_controller_equipment( + conn: sqlite3.Connection, + equipment: RecloserControllerEquipment, + equipment_id_by_name: dict[str, int], +) -> int: + existing = equipment_id_by_name.get(equipment.name) + if existing is not None: + return existing + + row = conn.execute( + "SELECT id FROM recloser_controller_equipment WHERE name = ?", + (equipment.name,), + ).fetchone() + if row is None: + cursor = conn.execute( + "INSERT INTO recloser_controller_equipment(name) VALUES(?)", + (equipment.name,), + ) + equipment_id = int(cursor.lastrowid) + else: + equipment_id = int(row[0]) + + _upsert_component_uuid_map( + conn, + "recloser_controller_equipment", + equipment_id, + equipment.uuid, + ) + equipment_id_by_name[equipment.name] = equipment_id + return equipment_id + + +def _upsert_distribution_recloser_controller( + conn: sqlite3.Connection, + controller: DistributionRecloserController, + controller_id_by_uuid: dict[UUID, int], + equipment_id_by_name: dict[str, int], + curve_id_by_uuid: dict[UUID, int], +) -> int: + existing = controller_id_by_uuid.get(controller.uuid) + if existing is not None: + return existing + + equipment_id = _upsert_recloser_controller_equipment( + conn, + controller.equipment, + equipment_id_by_name, + ) + ground_delayed_curve_id = _upsert_time_current_curve( + conn, + controller.ground_delayed, + curve_id_by_uuid, + ) + ground_fast_curve_id = _upsert_time_current_curve( + conn, + controller.ground_fast, + curve_id_by_uuid, + ) + phase_delayed_curve_id = _upsert_time_current_curve( + conn, + controller.phase_delayed, + curve_id_by_uuid, + ) + phase_fast_curve_id = _upsert_time_current_curve( + conn, + controller.phase_fast, + curve_id_by_uuid, + ) + + cursor = conn.execute( + """ + INSERT INTO recloser_controllers( + name, + delay, + delay_unit, + ground_delayed_curve_id, + ground_fast_curve_id, + phase_delayed_curve_id, + phase_fast_curve_id, + num_fast_ops, + num_shots, + reset_time, + reset_time_unit, + equipment_id + ) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + controller.name, + float(controller.delay.magnitude), + str(controller.delay.units), + ground_delayed_curve_id, + ground_fast_curve_id, + phase_delayed_curve_id, + phase_fast_curve_id, + controller.num_fast_ops, + controller.num_shots, + float(controller.reset_time.magnitude), + str(controller.reset_time.units), + equipment_id, + ), + ) + controller_id = int(cursor.lastrowid) + _upsert_component_uuid_map(conn, "recloser_controllers", controller_id, controller.uuid) + for position_index, interval in enumerate(controller.reclose_intervals.magnitude): + conn.execute( + """ + INSERT INTO recloser_reclose_intervals( + recloser_controller_id, + position_index, + interval_value, + interval_unit + ) VALUES(?, ?, ?, ?) + """, + ( + controller_id, + position_index, + float(interval), + str(controller.reclose_intervals.units), + ), + ) + + controller_id_by_uuid[controller.uuid] = controller_id + return controller_id + + +def _load_distribution_recloser_controller( + conn: sqlite3.Connection, + controller_id: int, + controller_cache: dict[int, DistributionRecloserController], + curve_cache: dict[int, TimeCurrentCurve], + equipment_cache: dict[int, RecloserControllerEquipment], +) -> DistributionRecloserController: + cached = controller_cache.get(controller_id) + if cached is not None: + return cached + + row = conn.execute( + """ + SELECT + name, + delay, + delay_unit, + ground_delayed_curve_id, + ground_fast_curve_id, + phase_delayed_curve_id, + phase_fast_curve_id, + num_fast_ops, + num_shots, + reset_time, + reset_time_unit, + equipment_id + FROM recloser_controllers + WHERE id = ? + """, + (controller_id,), + ).fetchone() + if row is None: + raise ValueError(f"recloser_controller_id={controller_id} not found") + + equipment = equipment_cache.get(row[11]) + if equipment is None: + equipment_row = conn.execute( + "SELECT name FROM recloser_controller_equipment WHERE id = ?", + (row[11],), + ).fetchone() + if equipment_row is None: + raise ValueError(f"recloser_controller_equipment_id={row[11]} not found") + equipment = RecloserControllerEquipment(name=equipment_row[0]) + equipment_uuid = _fetch_component_uuid(conn, "recloser_controller_equipment", row[11]) + if equipment_uuid is not None: + equipment = equipment.model_copy(update={"uuid": equipment_uuid}) + equipment_cache[row[11]] = equipment + + interval_rows = conn.execute( + """ + SELECT interval_value, interval_unit + FROM recloser_reclose_intervals + WHERE recloser_controller_id = ? + ORDER BY position_index + """, + (controller_id,), + ).fetchall() + interval_unit = interval_rows[0][1] if interval_rows else "second" + reclose_values = [interval for interval, _ in interval_rows] + + controller = DistributionRecloserController( + name=row[0], + delay=Time(row[1], row[2]), + ground_delayed=_load_time_current_curve(conn, row[3], curve_cache), + ground_fast=_load_time_current_curve(conn, row[4], curve_cache), + phase_delayed=_load_time_current_curve(conn, row[5], curve_cache), + phase_fast=_load_time_current_curve(conn, row[6], curve_cache), + num_fast_ops=row[7], + num_shots=row[8], + reclose_intervals=Time(reclose_values, interval_unit), + reset_time=Time(row[9], row[10]), + equipment=equipment, + ) + controller_uuid = _fetch_component_uuid(conn, "recloser_controllers", controller_id) + if controller_uuid is not None: + controller = controller.model_copy(update={"uuid": controller_uuid}) + controller_cache[controller_id] = controller + return controller diff --git a/src/gdm/db/sqlite_store_schema.py b/src/gdm/db/sqlite_store_schema.py new file mode 100644 index 00000000..48b5e9b7 --- /dev/null +++ b/src/gdm/db/sqlite_store_schema.py @@ -0,0 +1,76 @@ +"""Schema and metadata helpers for SQLite GDM persistence.""" + +from __future__ import annotations + +import sqlite3 +from pathlib import Path + + +def default_schema_path() -> Path: + """Return the default path to the SQL schema file in the repository.""" + return Path(__file__).resolve().parents[3] / ".dump" / "distribution_schema.sql" + + +def _initialize_schema(conn: sqlite3.Connection, schema_path: str | Path | None) -> None: + if _has_gdm_tables(conn): + return + + resolved_schema_path = Path(schema_path) if schema_path else default_schema_path() + if not resolved_schema_path.exists(): + raise FileNotFoundError(f"Schema file was not found at {resolved_schema_path}") + + schema_sql = resolved_schema_path.read_text() + conn.executescript(schema_sql) + + +def _has_gdm_tables(conn: sqlite3.Connection) -> bool: + row = conn.execute( + "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'gdm_system_snapshots'" + ).fetchone() + return row is not None + + +def _ensure_gdm_tables(conn: sqlite3.Connection) -> None: + conn.executescript( + """ + CREATE TABLE IF NOT EXISTS gdm_metadata ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS gdm_system_snapshots ( + system_kind TEXT PRIMARY KEY, + payload_json TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + + CREATE TABLE IF NOT EXISTS gdm_component_uuid_map ( + component_type TEXT NOT NULL, + component_id INTEGER NOT NULL, + uuid TEXT NOT NULL, + PRIMARY KEY (component_type, component_id), + UNIQUE (component_type, uuid) + ); + """ + ) + + +def _upsert_metadata(conn: sqlite3.Connection, key: str, value: str | None) -> None: + if value is None: + return + conn.execute( + """ + INSERT INTO gdm_metadata(key, value) + VALUES(?, ?) + ON CONFLICT(key) DO UPDATE SET value=excluded.value + """, + (key, str(value)), + ) + + +def inspect_snapshot_metadata(db_path: str | Path) -> dict[str, str]: + """Return GDM metadata key-values for debugging and validation.""" + db_path = Path(db_path) + with sqlite3.connect(db_path) as conn: + rows = conn.execute("SELECT key, value FROM gdm_metadata").fetchall() + return {key: value for key, value in rows} diff --git a/src/gdm/db/sqlite_store_snapshot.py b/src/gdm/db/sqlite_store_snapshot.py new file mode 100644 index 00000000..dd77d70e --- /dev/null +++ b/src/gdm/db/sqlite_store_snapshot.py @@ -0,0 +1,70 @@ +"""Snapshot serialization helpers for SQLite GDM persistence.""" + +from __future__ import annotations + +import base64 +import io +import json +import tempfile +import zipfile +from pathlib import Path + + +def _serialize_system_to_json_text(system) -> str: + with tempfile.TemporaryDirectory() as tmp_dir: + temp_json = Path(tmp_dir) / "system.json" + system.to_json(temp_json, overwrite=True) + system_json = temp_json.read_text() + parsed = json.loads(system_json) + time_series_dir = parsed.get("time_series", {}).get("directory") + time_series_zip_b64 = None + if time_series_dir: + sidecar_dir = Path(tmp_dir) / time_series_dir + if sidecar_dir.exists(): + time_series_zip_b64 = _zip_directory_to_base64(sidecar_dir) + + snapshot = { + "snapshot_format": "gdm-sqlite-v1", + "system_json": system_json, + "time_series_directory": time_series_dir, + "time_series_zip_b64": time_series_zip_b64, + } + return json.dumps(snapshot) + + +def _decode_snapshot_payload(payload: str) -> dict[str, str | None]: + parsed = json.loads(payload) + if isinstance(parsed, dict) and parsed.get("snapshot_format") == "gdm-sqlite-v1": + return { + "system_json": parsed["system_json"], + "time_series_directory": parsed.get("time_series_directory"), + "time_series_zip_b64": parsed.get("time_series_zip_b64"), + } + + return { + "system_json": payload, + "time_series_directory": None, + "time_series_zip_b64": None, + } + + +def _zip_directory_to_base64(directory: Path) -> str: + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w", compression=zipfile.ZIP_DEFLATED) as archive: + for file_path in directory.rglob("*"): + if file_path.is_file(): + archive.write(file_path, arcname=str(file_path.relative_to(directory))) + return base64.b64encode(buffer.getvalue()).decode("utf-8") + + +def _restore_time_series_sidecar(base_dir: Path, snapshot: dict[str, str | None]) -> None: + ts_directory = snapshot.get("time_series_directory") + ts_zip_b64 = snapshot.get("time_series_zip_b64") + if not ts_directory or not ts_zip_b64: + return + + destination = base_dir / ts_directory + destination.mkdir(parents=True, exist_ok=True) + content = base64.b64decode(ts_zip_b64.encode("utf-8")) + with zipfile.ZipFile(io.BytesIO(content), "r") as archive: + archive.extractall(destination) diff --git a/src/gdm/db/sqlite_store_switchgear_loaders.py b/src/gdm/db/sqlite_store_switchgear_loaders.py new file mode 100644 index 00000000..b765f9c3 --- /dev/null +++ b/src/gdm/db/sqlite_store_switchgear_loaders.py @@ -0,0 +1,429 @@ +"""Normalized switchgear loader helpers for SQLite GDM persistence.""" + +from __future__ import annotations + +import sqlite3 + +from infrasys.quantities import Time + +from gdm.db.sqlite_store_controls_curves import _load_time_current_curve +from gdm.db.sqlite_store_identity import _fetch_component_uuid +from gdm.db.sqlite_store_impedance import _load_impedance_matrix +from gdm.db.sqlite_store_recloser import _load_distribution_recloser_controller +from gdm.distribution import DistributionSystem +from gdm.distribution.components import ( + DistributionBus, + MatrixImpedanceFuse, + MatrixImpedanceRecloser, + MatrixImpedanceSwitch, +) +from gdm.distribution.components.distribution_feeder import DistributionFeeder +from gdm.distribution.components.distribution_substation import DistributionSubstation +from gdm.distribution.controllers.distribution_recloser_controller import ( + DistributionRecloserController, +) +from gdm.distribution.controllers.distribution_switch_controller import ( + DistributionSwitchController, +) +from gdm.distribution.equipment.matrix_impedance_fuse_equipment import MatrixImpedanceFuseEquipment +from gdm.distribution.equipment.matrix_impedance_recloser_equipment import ( + MatrixImpedanceRecloserEquipment, +) +from gdm.distribution.equipment.matrix_impedance_switch_equipment import ( + MatrixImpedanceSwitchEquipment, +) +from gdm.distribution.equipment.recloser_controller_equipment import ( + RecloserControllerEquipment, +) +from gdm.distribution.common.curve import TimeCurrentCurve +from gdm.distribution.enums import LineType, Phase +from gdm.quantities import ( + CapacitancePULength, + Current, + Distance, + ReactancePULength, + ResistancePULength, +) + + +def _load_matrix_impedance_switches_from_normalized( + conn: sqlite3.Connection, + system: DistributionSystem, + buses_by_id: dict[int, DistributionBus], + substations_by_id: dict[int, DistributionSubstation], + feeders_by_id: dict[int, DistributionFeeder], +) -> None: + rows = conn.execute( + """ + SELECT + id, + name, + from_bus_id, + to_bus_id, + substation_id, + feeder_id, + length, + length_unit, + equipment_id, + in_service + FROM matrix_impedance_switches + ORDER BY id + """ + ).fetchall() + if not rows: + return + + equipment_cache: dict[int, MatrixImpedanceSwitchEquipment] = {} + + for ( + switch_id, + name, + from_bus_id, + to_bus_id, + substation_id, + feeder_id, + length, + length_unit, + equipment_id, + in_service, + ) in rows: + equipment = equipment_cache.get(equipment_id) + if equipment is None: + equipment_header = conn.execute( + """ + SELECT name, construction, ampacity, ampacity_unit, switch_controller_id + FROM matrix_impedance_switch_equipment + WHERE id = ? + """, + (equipment_id,), + ).fetchone() + if equipment_header is None: + raise ValueError(f"matrix_impedance_switch_equipment_id={equipment_id} not found") + + ( + equipment_name, + construction, + ampacity, + ampacity_unit, + switch_controller_id, + ) = equipment_header + + controller: DistributionSwitchController | None = None + if switch_controller_id is not None: + controller_row = conn.execute( + """ + SELECT name, delay, delay_unit, normal_state, is_locked + FROM switch_controllers + WHERE id = ? + """, + (switch_controller_id,), + ).fetchone() + if controller_row is None: + raise ValueError(f"switch_controller_id={switch_controller_id} not found") + controller = DistributionSwitchController( + name=controller_row[0], + delay=Time(controller_row[1], controller_row[2]), + normal_state=controller_row[3], + is_locked=bool(controller_row[4]), + ) + controller_uuid = _fetch_component_uuid( + conn, + "switch_controllers", + switch_controller_id, + ) + if controller_uuid is not None: + controller = controller.model_copy(update={"uuid": controller_uuid}) + + r_matrix, r_unit = _load_impedance_matrix(conn, equipment_id, "SWITCH", "R") + x_matrix, x_unit = _load_impedance_matrix(conn, equipment_id, "SWITCH", "X") + c_matrix, c_unit = _load_impedance_matrix(conn, equipment_id, "SWITCH", "C") + + equipment = MatrixImpedanceSwitchEquipment( + name=equipment_name, + construction=LineType(construction), + r_matrix=ResistancePULength(r_matrix, r_unit), + x_matrix=ReactancePULength(x_matrix, x_unit), + c_matrix=CapacitancePULength(c_matrix, c_unit), + ampacity=Current(ampacity, ampacity_unit), + controller=controller, + ) + equipment_uuid = _fetch_component_uuid( + conn, + "matrix_impedance_switch_equipment", + equipment_id, + ) + if equipment_uuid is not None: + equipment = equipment.model_copy(update={"uuid": equipment_uuid}) + equipment_cache[equipment_id] = equipment + + phases_rows = conn.execute( + """ + SELECT phase + FROM matrix_impedance_switch_phases + WHERE switch_id = ? + ORDER BY position_index + """, + (switch_id,), + ).fetchall() + phases = [Phase(phase) for (phase,) in phases_rows] + + state_rows = conn.execute( + """ + SELECT is_closed + FROM switch_phase_states + WHERE switch_id = ? + ORDER BY position_index + """, + (switch_id,), + ).fetchall() + is_closed = [bool(state) for (state,) in state_rows] + + switch = MatrixImpedanceSwitch( + name=name, + buses=[buses_by_id[from_bus_id], buses_by_id[to_bus_id]], + substation=substations_by_id[substation_id], + feeder=feeders_by_id[feeder_id], + length=Distance(length, length_unit), + phases=phases, + is_closed=is_closed, + equipment=equipment, + in_service=bool(in_service), + ) + switch_uuid = _fetch_component_uuid(conn, "matrix_impedance_switches", switch_id) + if switch_uuid is not None: + switch = switch.model_copy(update={"uuid": switch_uuid}) + system.add_component(switch) + + +def _load_matrix_impedance_fuses_from_normalized( + conn: sqlite3.Connection, + system: DistributionSystem, + buses_by_id: dict[int, DistributionBus], + substations_by_id: dict[int, DistributionSubstation], + feeders_by_id: dict[int, DistributionFeeder], +) -> None: + rows = conn.execute( + """ + SELECT + id, + name, + from_bus_id, + to_bus_id, + substation_id, + feeder_id, + length, + length_unit, + equipment_id, + in_service + FROM matrix_impedance_fuses + ORDER BY id + """ + ).fetchall() + if not rows: + return + + equipment_cache: dict[int, MatrixImpedanceFuseEquipment] = {} + curve_cache: dict[int, TimeCurrentCurve] = {} + + for ( + fuse_id, + name, + from_bus_id, + to_bus_id, + substation_id, + feeder_id, + length, + length_unit, + equipment_id, + in_service, + ) in rows: + equipment = equipment_cache.get(equipment_id) + if equipment is None: + equipment_row = conn.execute( + """ + SELECT + name, + construction, + ampacity, + ampacity_unit, + delay, + delay_unit, + tcc_curve_id + FROM matrix_impedance_fuse_equipment + WHERE id = ? + """, + (equipment_id,), + ).fetchone() + if equipment_row is None: + raise ValueError(f"matrix_impedance_fuse_equipment_id={equipment_id} not found") + + r_matrix, r_unit = _load_impedance_matrix(conn, equipment_id, "FUSE", "R") + x_matrix, x_unit = _load_impedance_matrix(conn, equipment_id, "FUSE", "X") + c_matrix, c_unit = _load_impedance_matrix(conn, equipment_id, "FUSE", "C") + + equipment = MatrixImpedanceFuseEquipment( + name=equipment_row[0], + construction=LineType(equipment_row[1]), + ampacity=Current(equipment_row[2], equipment_row[3]), + delay=Time(equipment_row[4], equipment_row[5]), + tcc_curve=_load_time_current_curve(conn, equipment_row[6], curve_cache), + r_matrix=ResistancePULength(r_matrix, r_unit), + x_matrix=ReactancePULength(x_matrix, x_unit), + c_matrix=CapacitancePULength(c_matrix, c_unit), + ) + equipment_uuid = _fetch_component_uuid( + conn, + "matrix_impedance_fuse_equipment", + equipment_id, + ) + if equipment_uuid is not None: + equipment = equipment.model_copy(update={"uuid": equipment_uuid}) + equipment_cache[equipment_id] = equipment + + phases_rows = conn.execute( + "SELECT phase FROM matrix_impedance_fuse_phases WHERE fuse_id = ? ORDER BY position_index", + (fuse_id,), + ).fetchall() + state_rows = conn.execute( + "SELECT is_closed FROM fuse_phase_states WHERE fuse_id = ? ORDER BY position_index", + (fuse_id,), + ).fetchall() + fuse = MatrixImpedanceFuse( + name=name, + buses=[buses_by_id[from_bus_id], buses_by_id[to_bus_id]], + substation=substations_by_id[substation_id], + feeder=feeders_by_id[feeder_id], + length=Distance(length, length_unit), + phases=[Phase(phase) for (phase,) in phases_rows], + is_closed=[bool(state) for (state,) in state_rows], + equipment=equipment, + in_service=bool(in_service), + ) + fuse_uuid = _fetch_component_uuid(conn, "matrix_impedance_fuses", fuse_id) + if fuse_uuid is not None: + fuse = fuse.model_copy(update={"uuid": fuse_uuid}) + system.add_component(fuse) + + +def _load_matrix_impedance_reclosers_from_normalized( + conn: sqlite3.Connection, + system: DistributionSystem, + buses_by_id: dict[int, DistributionBus], + substations_by_id: dict[int, DistributionSubstation], + feeders_by_id: dict[int, DistributionFeeder], +) -> None: + rows = conn.execute( + """ + SELECT + id, + name, + from_bus_id, + to_bus_id, + substation_id, + feeder_id, + length, + length_unit, + equipment_id, + controller_id, + in_service + FROM matrix_impedance_reclosers + ORDER BY id + """ + ).fetchall() + if not rows: + return + + equipment_cache: dict[int, MatrixImpedanceRecloserEquipment] = {} + controller_cache: dict[int, DistributionRecloserController] = {} + controller_curve_cache: dict[int, TimeCurrentCurve] = {} + controller_equipment_cache: dict[int, RecloserControllerEquipment] = {} + + for ( + recloser_id, + name, + from_bus_id, + to_bus_id, + substation_id, + feeder_id, + length, + length_unit, + equipment_id, + controller_id, + in_service, + ) in rows: + equipment = equipment_cache.get(equipment_id) + if equipment is None: + equipment_row = conn.execute( + """ + SELECT name, construction, ampacity, ampacity_unit + FROM matrix_impedance_recloser_equipment + WHERE id = ? + """, + (equipment_id,), + ).fetchone() + if equipment_row is None: + raise ValueError( + f"matrix_impedance_recloser_equipment_id={equipment_id} not found" + ) + r_matrix, r_unit = _load_impedance_matrix(conn, equipment_id, "RECLOSER", "R") + x_matrix, x_unit = _load_impedance_matrix(conn, equipment_id, "RECLOSER", "X") + c_matrix, c_unit = _load_impedance_matrix(conn, equipment_id, "RECLOSER", "C") + equipment = MatrixImpedanceRecloserEquipment( + name=equipment_row[0], + construction=LineType(equipment_row[1]), + ampacity=Current(equipment_row[2], equipment_row[3]), + r_matrix=ResistancePULength(r_matrix, r_unit), + x_matrix=ReactancePULength(x_matrix, x_unit), + c_matrix=CapacitancePULength(c_matrix, c_unit), + ) + equipment_uuid = _fetch_component_uuid( + conn, + "matrix_impedance_recloser_equipment", + equipment_id, + ) + if equipment_uuid is not None: + equipment = equipment.model_copy(update={"uuid": equipment_uuid}) + equipment_cache[equipment_id] = equipment + + controller = _load_distribution_recloser_controller( + conn, + controller_id, + controller_cache, + controller_curve_cache, + controller_equipment_cache, + ) + + phases_rows = conn.execute( + """ + SELECT phase + FROM matrix_impedance_recloser_phases + WHERE recloser_id = ? + ORDER BY position_index + """, + (recloser_id,), + ).fetchall() + state_rows = conn.execute( + """ + SELECT is_closed + FROM recloser_phase_states + WHERE recloser_id = ? + ORDER BY position_index + """, + (recloser_id,), + ).fetchall() + recloser = MatrixImpedanceRecloser( + name=name, + buses=[buses_by_id[from_bus_id], buses_by_id[to_bus_id]], + substation=substations_by_id[substation_id], + feeder=feeders_by_id[feeder_id], + length=Distance(length, length_unit), + phases=[Phase(phase) for (phase,) in phases_rows], + is_closed=[bool(state) for (state,) in state_rows], + equipment=equipment, + controller=controller, + in_service=bool(in_service), + ) + recloser_uuid = _fetch_component_uuid(conn, "matrix_impedance_reclosers", recloser_id) + if recloser_uuid is not None: + recloser = recloser.model_copy(update={"uuid": recloser_uuid}) + system.add_component(recloser) diff --git a/src/gdm/db/sqlite_store_switchgear_writers.py b/src/gdm/db/sqlite_store_switchgear_writers.py new file mode 100644 index 00000000..949143e6 --- /dev/null +++ b/src/gdm/db/sqlite_store_switchgear_writers.py @@ -0,0 +1,641 @@ +"""Switchgear and branch writer helpers for SQLite GDM persistence.""" + +from __future__ import annotations + +import sqlite3 +from uuid import UUID + +from gdm.db.sqlite_store_controls_curves import _upsert_time_current_curve +from gdm.db.sqlite_store_geometry import _upsert_geometry_branch_equipment +from gdm.db.sqlite_store_identity import _upsert_component_uuid_map +from gdm.db.sqlite_store_impedance import _insert_impedance_matrix_entries +from gdm.db.sqlite_store_recloser import _upsert_distribution_recloser_controller +from gdm.distribution import DistributionSystem +from gdm.distribution.components import ( + GeometryBranch, + MatrixImpedanceFuse, + MatrixImpedanceRecloser, + MatrixImpedanceSwitch, +) +from gdm.distribution.controllers.distribution_switch_controller import ( + DistributionSwitchController, +) +from gdm.distribution.equipment.matrix_impedance_fuse_equipment import MatrixImpedanceFuseEquipment +from gdm.distribution.equipment.matrix_impedance_recloser_equipment import ( + MatrixImpedanceRecloserEquipment, +) +from gdm.distribution.equipment.matrix_impedance_switch_equipment import ( + MatrixImpedanceSwitchEquipment, +) + + +def _resolve_branch_terminal_ids( + conn: sqlite3.Connection, + bus_ref_by_name: dict[str, tuple[int, int, int]], + branch, +) -> tuple[int, int, int, int]: + from_bus_ref = bus_ref_by_name.get(branch.buses[0].name) + to_bus_ref = bus_ref_by_name.get(branch.buses[1].name) + if from_bus_ref is None or to_bus_ref is None: + raise ValueError(f"{type(branch).__name__} '{branch.name}' references unknown buses") + + from_bus_id, from_substation_id, from_feeder_id = from_bus_ref + to_bus_id, _, _ = to_bus_ref + + substation_id = from_substation_id + feeder_id = from_feeder_id + if branch.substation is not None: + row = conn.execute( + "SELECT id FROM distribution_substations WHERE name = ?", + (branch.substation.name,), + ).fetchone() + if row is not None: + substation_id = int(row[0]) + if branch.feeder is not None: + row = conn.execute( + "SELECT id FROM distribution_feeders WHERE name = ?", + (branch.feeder.name,), + ).fetchone() + if row is not None: + feeder_id = int(row[0]) + + return from_bus_id, to_bus_id, substation_id, feeder_id + + +def _write_matrix_impedance_switches(conn: sqlite3.Connection, system: DistributionSystem) -> None: + bus_rows = conn.execute( + "SELECT id, name, substation_id, feeder_id FROM distribution_buses" + ).fetchall() + bus_ref_by_name: dict[str, tuple[int, int, int]] = { + name: (bus_id, substation_id, feeder_id) + for bus_id, name, substation_id, feeder_id in bus_rows + } + equipment_id_by_name: dict[str, int] = {} + switch_controller_id_by_uuid: dict[UUID, int] = {} + + for switch in system.get_components(MatrixImpedanceSwitch): + from_bus_ref = bus_ref_by_name.get(switch.buses[0].name) + to_bus_ref = bus_ref_by_name.get(switch.buses[1].name) + if from_bus_ref is None or to_bus_ref is None: + raise ValueError(f"MatrixImpedanceSwitch '{switch.name}' references unknown buses") + + from_bus_id, from_substation_id, from_feeder_id = from_bus_ref + to_bus_id, _, _ = to_bus_ref + + substation_id = from_substation_id + feeder_id = from_feeder_id + if switch.substation is not None: + row = conn.execute( + "SELECT id FROM distribution_substations WHERE name = ?", + (switch.substation.name,), + ).fetchone() + if row is not None: + substation_id = int(row[0]) + if switch.feeder is not None: + row = conn.execute( + "SELECT id FROM distribution_feeders WHERE name = ?", + (switch.feeder.name,), + ).fetchone() + if row is not None: + feeder_id = int(row[0]) + + equipment_id = _upsert_matrix_impedance_switch_equipment( + conn, + switch.equipment, + equipment_id_by_name, + switch_controller_id_by_uuid, + ) + + cursor = conn.execute( + """ + INSERT INTO matrix_impedance_switches( + name, + from_bus_id, + to_bus_id, + substation_id, + feeder_id, + length, + length_unit, + equipment_id, + in_service + ) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + switch.name, + from_bus_id, + to_bus_id, + substation_id, + feeder_id, + float(switch.length.magnitude), + str(switch.length.units), + equipment_id, + 1 if switch.in_service else 0, + ), + ) + switch_id = int(cursor.lastrowid) + _upsert_component_uuid_map(conn, "matrix_impedance_switches", switch_id, switch.uuid) + + for position_index, phase in enumerate(switch.phases): + conn.execute( + """ + INSERT INTO matrix_impedance_switch_phases(switch_id, phase, position_index) + VALUES(?, ?, ?) + """, + (switch_id, phase.value, position_index), + ) + for position_index, (phase, is_closed) in enumerate(zip(switch.phases, switch.is_closed)): + conn.execute( + """ + INSERT INTO switch_phase_states( + switch_id, + position_index, + phase, + is_closed + ) VALUES(?, ?, ?, ?) + """, + (switch_id, position_index, phase.value, 1 if is_closed else 0), + ) + + +def _upsert_matrix_impedance_switch_equipment( + conn: sqlite3.Connection, + equipment: MatrixImpedanceSwitchEquipment, + equipment_id_by_name: dict[str, int], + switch_controller_id_by_uuid: dict[UUID, int], +) -> int: + existing = equipment_id_by_name.get(equipment.name) + if existing is not None: + return existing + + row = conn.execute( + "SELECT id FROM matrix_impedance_switch_equipment WHERE name = ?", + (equipment.name,), + ).fetchone() + if row is not None: + equipment_id = int(row[0]) + equipment_id_by_name[equipment.name] = equipment_id + return equipment_id + + switch_controller_id: int | None = None + if equipment.controller is not None: + switch_controller_id = _upsert_switch_controller( + conn, + equipment.controller, + switch_controller_id_by_uuid, + ) + + cursor = conn.execute( + """ + INSERT INTO matrix_impedance_switch_equipment( + name, + construction, + ampacity, + ampacity_unit, + switch_controller_id + ) VALUES(?, ?, ?, ?, ?) + """, + ( + equipment.name, + equipment.construction.value, + float(equipment.ampacity.magnitude), + str(equipment.ampacity.units), + switch_controller_id, + ), + ) + equipment_id = int(cursor.lastrowid) + _upsert_component_uuid_map( + conn, + "matrix_impedance_switch_equipment", + equipment_id, + equipment.uuid, + ) + + _insert_impedance_matrix_entries( + conn, + equipment_id, + "SWITCH", + "R", + equipment.r_matrix.magnitude, + str(equipment.r_matrix.units), + ) + _insert_impedance_matrix_entries( + conn, + equipment_id, + "SWITCH", + "X", + equipment.x_matrix.magnitude, + str(equipment.x_matrix.units), + ) + _insert_impedance_matrix_entries( + conn, + equipment_id, + "SWITCH", + "C", + equipment.c_matrix.magnitude, + str(equipment.c_matrix.units), + ) + + equipment_id_by_name[equipment.name] = equipment_id + return equipment_id + + +def _upsert_switch_controller( + conn: sqlite3.Connection, + controller: DistributionSwitchController, + switch_controller_id_by_uuid: dict[UUID, int], +) -> int: + existing = switch_controller_id_by_uuid.get(controller.uuid) + if existing is not None: + return existing + + row = conn.execute( + """ + SELECT id + FROM switch_controllers + WHERE name = ? AND delay = ? AND delay_unit = ? AND normal_state = ? AND is_locked = ? + """, + ( + controller.name, + float(controller.delay.magnitude), + str(controller.delay.units), + controller.normal_state, + 1 if controller.is_locked else 0, + ), + ).fetchone() + if row is None: + cursor = conn.execute( + """ + INSERT INTO switch_controllers(name, delay, delay_unit, normal_state, is_locked) + VALUES(?, ?, ?, ?, ?) + """, + ( + controller.name, + float(controller.delay.magnitude), + str(controller.delay.units), + controller.normal_state, + 1 if controller.is_locked else 0, + ), + ) + switch_controller_id = int(cursor.lastrowid) + else: + switch_controller_id = int(row[0]) + + _upsert_component_uuid_map(conn, "switch_controllers", switch_controller_id, controller.uuid) + switch_controller_id_by_uuid[controller.uuid] = switch_controller_id + return switch_controller_id + + +def _write_geometry_branches(conn: sqlite3.Connection, system: DistributionSystem) -> None: + bus_rows = conn.execute( + "SELECT id, name, substation_id, feeder_id FROM distribution_buses" + ).fetchall() + bus_ref_by_name: dict[str, tuple[int, int, int]] = { + name: (bus_id, substation_id, feeder_id) + for bus_id, name, substation_id, feeder_id in bus_rows + } + geometry_equipment_id_by_name: dict[str, int] = {} + bare_conductor_id_by_name: dict[str, int] = {} + concentric_cable_id_by_name: dict[str, int] = {} + + for branch in system.get_components(GeometryBranch): + from_bus_id, to_bus_id, substation_id, feeder_id = _resolve_branch_terminal_ids( + conn, + bus_ref_by_name, + branch, + ) + equipment_id = _upsert_geometry_branch_equipment( + conn, + branch.equipment, + geometry_equipment_id_by_name, + bare_conductor_id_by_name, + concentric_cable_id_by_name, + ) + + cursor = conn.execute( + """ + INSERT INTO geometry_branches( + name, + from_bus_id, + to_bus_id, + substation_id, + feeder_id, + length, + length_unit, + equipment_id, + in_service + ) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + branch.name, + from_bus_id, + to_bus_id, + substation_id, + feeder_id, + float(branch.length.magnitude), + str(branch.length.units), + equipment_id, + 1 if branch.in_service else 0, + ), + ) + branch_id = int(cursor.lastrowid) + _upsert_component_uuid_map(conn, "geometry_branches", branch_id, branch.uuid) + for position_index, phase in enumerate(branch.phases): + conn.execute( + "INSERT INTO geometry_branch_phases(branch_id, phase, position_index) VALUES(?, ?, ?)", + (branch_id, phase.value, position_index), + ) + + +def _write_matrix_impedance_fuses(conn: sqlite3.Connection, system: DistributionSystem) -> None: + bus_rows = conn.execute( + "SELECT id, name, substation_id, feeder_id FROM distribution_buses" + ).fetchall() + bus_ref_by_name: dict[str, tuple[int, int, int]] = { + name: (bus_id, substation_id, feeder_id) + for bus_id, name, substation_id, feeder_id in bus_rows + } + equipment_id_by_name: dict[str, int] = {} + curve_id_by_uuid: dict[UUID, int] = {} + + for fuse in system.get_components(MatrixImpedanceFuse): + from_bus_id, to_bus_id, substation_id, feeder_id = _resolve_branch_terminal_ids( + conn, + bus_ref_by_name, + fuse, + ) + equipment_id = _upsert_matrix_impedance_fuse_equipment( + conn, + fuse.equipment, + equipment_id_by_name, + curve_id_by_uuid, + ) + cursor = conn.execute( + """ + INSERT INTO matrix_impedance_fuses( + name, + from_bus_id, + to_bus_id, + substation_id, + feeder_id, + length, + length_unit, + equipment_id, + in_service + ) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + fuse.name, + from_bus_id, + to_bus_id, + substation_id, + feeder_id, + float(fuse.length.magnitude), + str(fuse.length.units), + equipment_id, + 1 if fuse.in_service else 0, + ), + ) + fuse_id = int(cursor.lastrowid) + _upsert_component_uuid_map(conn, "matrix_impedance_fuses", fuse_id, fuse.uuid) + for position_index, phase in enumerate(fuse.phases): + conn.execute( + "INSERT INTO matrix_impedance_fuse_phases(fuse_id, phase, position_index) VALUES(?, ?, ?)", + (fuse_id, phase.value, position_index), + ) + for position_index, (phase, is_closed) in enumerate(zip(fuse.phases, fuse.is_closed)): + conn.execute( + """ + INSERT INTO fuse_phase_states(fuse_id, position_index, phase, is_closed) + VALUES(?, ?, ?, ?) + """, + (fuse_id, position_index, phase.value, 1 if is_closed else 0), + ) + + +def _upsert_matrix_impedance_fuse_equipment( + conn: sqlite3.Connection, + equipment: MatrixImpedanceFuseEquipment, + equipment_id_by_name: dict[str, int], + curve_id_by_uuid: dict[UUID, int], +) -> int: + existing = equipment_id_by_name.get(equipment.name) + if existing is not None: + return existing + + row = conn.execute( + "SELECT id FROM matrix_impedance_fuse_equipment WHERE name = ?", + (equipment.name,), + ).fetchone() + if row is None: + tcc_curve_id = _upsert_time_current_curve(conn, equipment.tcc_curve, curve_id_by_uuid) + cursor = conn.execute( + """ + INSERT INTO matrix_impedance_fuse_equipment( + name, + construction, + ampacity, + ampacity_unit, + delay, + delay_unit, + tcc_curve_id + ) VALUES(?, ?, ?, ?, ?, ?, ?) + """, + ( + equipment.name, + equipment.construction.value, + float(equipment.ampacity.magnitude), + str(equipment.ampacity.units), + float(equipment.delay.magnitude), + str(equipment.delay.units), + tcc_curve_id, + ), + ) + equipment_id = int(cursor.lastrowid) + else: + equipment_id = int(row[0]) + + _upsert_component_uuid_map( + conn, "matrix_impedance_fuse_equipment", equipment_id, equipment.uuid + ) + _insert_impedance_matrix_entries( + conn, + equipment_id, + "FUSE", + "R", + equipment.r_matrix.magnitude, + str(equipment.r_matrix.units), + ) + _insert_impedance_matrix_entries( + conn, + equipment_id, + "FUSE", + "X", + equipment.x_matrix.magnitude, + str(equipment.x_matrix.units), + ) + _insert_impedance_matrix_entries( + conn, + equipment_id, + "FUSE", + "C", + equipment.c_matrix.magnitude, + str(equipment.c_matrix.units), + ) + equipment_id_by_name[equipment.name] = equipment_id + return equipment_id + + +def _write_matrix_impedance_reclosers( + conn: sqlite3.Connection, system: DistributionSystem +) -> None: + bus_rows = conn.execute( + "SELECT id, name, substation_id, feeder_id FROM distribution_buses" + ).fetchall() + bus_ref_by_name: dict[str, tuple[int, int, int]] = { + name: (bus_id, substation_id, feeder_id) + for bus_id, name, substation_id, feeder_id in bus_rows + } + equipment_id_by_name: dict[str, int] = {} + controller_id_by_uuid: dict[UUID, int] = {} + controller_equipment_id_by_name: dict[str, int] = {} + curve_id_by_uuid: dict[UUID, int] = {} + + for recloser in system.get_components(MatrixImpedanceRecloser): + from_bus_id, to_bus_id, substation_id, feeder_id = _resolve_branch_terminal_ids( + conn, + bus_ref_by_name, + recloser, + ) + equipment_id = _upsert_matrix_impedance_recloser_equipment( + conn, + recloser.equipment, + equipment_id_by_name, + ) + controller_id = _upsert_distribution_recloser_controller( + conn, + recloser.controller, + controller_id_by_uuid, + controller_equipment_id_by_name, + curve_id_by_uuid, + ) + cursor = conn.execute( + """ + INSERT INTO matrix_impedance_reclosers( + name, + from_bus_id, + to_bus_id, + substation_id, + feeder_id, + length, + length_unit, + equipment_id, + controller_id, + in_service + ) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + recloser.name, + from_bus_id, + to_bus_id, + substation_id, + feeder_id, + float(recloser.length.magnitude), + str(recloser.length.units), + equipment_id, + controller_id, + 1 if recloser.in_service else 0, + ), + ) + recloser_id = int(cursor.lastrowid) + _upsert_component_uuid_map( + conn, + "matrix_impedance_reclosers", + recloser_id, + recloser.uuid, + ) + for position_index, phase in enumerate(recloser.phases): + conn.execute( + """ + INSERT INTO matrix_impedance_recloser_phases(recloser_id, phase, position_index) + VALUES(?, ?, ?) + """, + (recloser_id, phase.value, position_index), + ) + for position_index, (phase, is_closed) in enumerate( + zip(recloser.phases, recloser.is_closed) + ): + conn.execute( + """ + INSERT INTO recloser_phase_states(recloser_id, position_index, phase, is_closed) + VALUES(?, ?, ?, ?) + """, + (recloser_id, position_index, phase.value, 1 if is_closed else 0), + ) + + +def _upsert_matrix_impedance_recloser_equipment( + conn: sqlite3.Connection, + equipment: MatrixImpedanceRecloserEquipment, + equipment_id_by_name: dict[str, int], +) -> int: + existing = equipment_id_by_name.get(equipment.name) + if existing is not None: + return existing + + row = conn.execute( + "SELECT id FROM matrix_impedance_recloser_equipment WHERE name = ?", + (equipment.name,), + ).fetchone() + if row is None: + cursor = conn.execute( + """ + INSERT INTO matrix_impedance_recloser_equipment( + name, + construction, + ampacity, + ampacity_unit + ) VALUES(?, ?, ?, ?) + """, + ( + equipment.name, + equipment.construction.value, + float(equipment.ampacity.magnitude), + str(equipment.ampacity.units), + ), + ) + equipment_id = int(cursor.lastrowid) + else: + equipment_id = int(row[0]) + + _upsert_component_uuid_map( + conn, + "matrix_impedance_recloser_equipment", + equipment_id, + equipment.uuid, + ) + _insert_impedance_matrix_entries( + conn, + equipment_id, + "RECLOSER", + "R", + equipment.r_matrix.magnitude, + str(equipment.r_matrix.units), + ) + _insert_impedance_matrix_entries( + conn, + equipment_id, + "RECLOSER", + "X", + equipment.x_matrix.magnitude, + str(equipment.x_matrix.units), + ) + _insert_impedance_matrix_entries( + conn, + equipment_id, + "RECLOSER", + "C", + equipment.c_matrix.magnitude, + str(equipment.c_matrix.units), + ) + equipment_id_by_name[equipment.name] = equipment_id + return equipment_id diff --git a/src/gdm/distribution/catalog_system.py b/src/gdm/distribution/catalog_system.py index 9b8169f1..46ac1945 100644 --- a/src/gdm/distribution/catalog_system.py +++ b/src/gdm/distribution/catalog_system.py @@ -1,4 +1,5 @@ import importlib.metadata +from pathlib import Path from infrasys import System @@ -9,3 +10,29 @@ class CatalogSystem(System): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.data_format_version = importlib.metadata.version("grid-data-models") + + def to_db( + self, + db_path: str | Path, + schema_path: str | Path | None = None, + replace: bool = True, + initialize_schema: bool = True, + ) -> None: + """Persist the catalog system to a SQLite database.""" + from gdm.db import write_system_to_db + + write_system_to_db( + system=self, + db_path=db_path, + schema_path=schema_path, + replace=replace, + initialize_schema=initialize_schema, + system_kind="catalog", + ) + + @classmethod + def from_db(cls, db_path: str | Path) -> "CatalogSystem": + """Load a catalog system from a SQLite database.""" + from gdm.db import load_system_from_db + + return load_system_from_db(system_cls=cls, db_path=db_path, system_kind="catalog") diff --git a/src/gdm/distribution/distribution_system.py b/src/gdm/distribution/distribution_system.py index e2dd9aa6..7a8e737f 100644 --- a/src/gdm/distribution/distribution_system.py +++ b/src/gdm/distribution/distribution_system.py @@ -2,7 +2,6 @@ from collections import defaultdict from typing import Annotated, Type -import importlib.metadata from pathlib import Path import tempfile import random @@ -46,6 +45,7 @@ NonuniqueCommponentsTypesInParallel, MultipleOrEmptyVsourceFound, ) +from gdm.version import VERSION from infrasys.exceptions import ISNotStored @@ -70,7 +70,7 @@ class DistributionSystem(System): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) if not self.data_format_version: - self.data_format_version = importlib.metadata.version("grid-data-models") + self.data_format_version = VERSION def get_bus_connected_components( self, bus_name: str, component_type: Component @@ -878,6 +878,50 @@ def _add_edge_traces( ) ) + def to_db( + self, + db_path: str | Path, + schema_path: str | Path | None = None, + replace: bool = True, + initialize_schema: bool = True, + ) -> None: + """Persist the system to a SQLite database. + + This implementation initializes the reference schema and stores a transactional + system snapshot in GDM-owned additive tables. + """ + from gdm.db import write_system_to_db + + write_system_to_db( + system=self, + db_path=db_path, + schema_path=schema_path, + replace=replace, + initialize_schema=initialize_schema, + system_kind="distribution", + ) + + @classmethod + def from_db(cls, db_path: str | Path, prefer_normalized: bool = False) -> "DistributionSystem": + """Load a distribution system from a SQLite database. + + Parameters + ---------- + db_path : str | Path + SQLite database path. + prefer_normalized : bool + If True, attempts to reconstruct from normalized topology tables first. + If normalized data is unavailable, falls back to stored snapshot payload. + """ + from gdm.db import load_system_from_db + + return load_system_from_db( + system_cls=cls, + db_path=db_path, + system_kind="distribution", + prefer_normalized=prefer_normalized, + ) + def deepcopy(self) -> "DistributionSystem": """Returns a deep copy of the distribution system.""" system = None diff --git a/tests/mocks/sample_distribution_system.sqlite3 b/tests/mocks/sample_distribution_system.sqlite3 new file mode 100644 index 0000000000000000000000000000000000000000..626fd8f83eb0f4e5da479f7fa3145ad0024b2e2c GIT binary patch literal 1081344 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

-g1d=haQ=|_B5WYj72hjKqeH>~K5($0_u6&r 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 Z+?$$zA;x`w^DppR5F1GM(nY`b{{uVHW<&r0 literal 0 HcmV?d00001 diff --git a/tests/test_db_io.py b/tests/test_db_io.py new file mode 100644 index 00000000..140b7fce --- /dev/null +++ b/tests/test_db_io.py @@ -0,0 +1,448 @@ +import sqlite3 +from uuid import uuid4 + +from infrasys.time_series_models import SingleTimeSeries + +from gdm.distribution import CatalogSystem, DistributionSystem +from gdm.distribution.components import ( + DistributionBattery, + DistributionBus, + DistributionCapacitor, + DistributionLoad, + DistributionRegulator, + DistributionSolar, + DistributionTransformer, + DistributionVoltageSource, + GeometryBranch, + MatrixImpedanceBranch, + MatrixImpedanceFuse, + MatrixImpedanceRecloser, + MatrixImpedanceSwitch, + SequenceImpedanceBranch, +) +from gdm.distribution.components.distribution_feeder import DistributionFeeder +from gdm.distribution.components.distribution_substation import DistributionSubstation +from gdm.distribution.controllers.distribution_inverter_controller import ( + InverterController, + PeakShavingBaseLoadingControlSetting, + VoltVarControlSetting, +) +from gdm.distribution.controllers.distribution_regulator_controller import RegulatorController +from gdm.distribution.controllers.distribution_recloser_controller import ( + DistributionRecloserController, +) +from gdm.distribution.equipment.battery_equipment import BatteryEquipment +from gdm.distribution.equipment.inverter_equipment import InverterEquipment +from gdm.distribution.enums import Phase +from gdm.distribution.equipment import LoadEquipment +from gdm.distribution.equipment.sequence_impedance_branch_equipment import ( + SequenceImpedanceBranchEquipment, +) +from gdm.distribution.equipment.matrix_impedance_switch_equipment import ( + MatrixImpedanceSwitchEquipment, +) +from gdm.distribution.equipment.geometry_branch_equipment import GeometryBranchEquipment +from gdm.distribution.equipment.matrix_impedance_fuse_equipment import MatrixImpedanceFuseEquipment +from gdm.distribution.equipment.matrix_impedance_recloser_equipment import ( + MatrixImpedanceRecloserEquipment, +) +from gdm.quantities import ActivePower, ReactivePower + + +def test_distribution_system_to_db_from_db_round_trip(tmp_path, simple_distribution_system): + system: DistributionSystem = simple_distribution_system + db_path = tmp_path / "distribution.sqlite" + + system.to_db(db_path) + loaded_system = DistributionSystem.from_db(db_path) + + initial_components = list(system.iter_all_components()) + loaded_components = list(loaded_system.iter_all_components()) + assert len(loaded_components) == len(initial_components) + assert {component.uuid for component in loaded_components} == { + component.uuid for component in initial_components + } + + +def test_distribution_system_to_db_replace_semantics(tmp_path, simple_distribution_system): + system: DistributionSystem = simple_distribution_system + db_path = tmp_path / "distribution.sqlite" + system.to_db(db_path) + + modified_system = system.deepcopy() + removed_component = next(iter(modified_system.get_components(DistributionLoad))) + modified_system.remove_component(removed_component, cascade_down=False) + modified_system.to_db(db_path, replace=True, initialize_schema=False) + + loaded_system = DistributionSystem.from_db(db_path) + assert len(list(loaded_system.iter_all_components())) == len( + list(modified_system.iter_all_components()) + ) + + +def test_catalog_system_to_db_from_db_round_trip(tmp_path): + catalog = CatalogSystem(auto_add_composed_components=True) + catalog_equipment = LoadEquipment.example().model_copy( + update={"uuid": uuid4(), "name": "catalog_load_equipment"} + ) + catalog.add_component(catalog_equipment) + + db_path = tmp_path / "catalog.sqlite" + catalog.to_db(db_path) + loaded_catalog = CatalogSystem.from_db(db_path) + + loaded_equipment = loaded_catalog.get_component(LoadEquipment, name="catalog_load_equipment") + assert loaded_equipment.uuid == catalog_equipment.uuid + + +def test_distribution_system_from_db_prefer_normalized_topology( + tmp_path, simple_distribution_system +): + system: DistributionSystem = simple_distribution_system + db_path = tmp_path / "distribution.sqlite" + system.to_db(db_path) + + loaded_system = DistributionSystem.from_db(db_path, prefer_normalized=True) + + expected_buses = list(system.get_components(DistributionBus)) + expected_feeders = {component.name for component in system.get_components(DistributionFeeder)} + expected_substations = { + component.name for component in system.get_components(DistributionSubstation) + } + + loaded_buses = list(loaded_system.get_components(DistributionBus)) + loaded_feeders = list(loaded_system.get_components(DistributionFeeder)) + loaded_substations = list(loaded_system.get_components(DistributionSubstation)) + loaded_loads = list(loaded_system.get_components(DistributionLoad)) + loaded_solar = list(loaded_system.get_components(DistributionSolar)) + loaded_capacitors = list(loaded_system.get_components(DistributionCapacitor)) + loaded_vsources = list(loaded_system.get_components(DistributionVoltageSource)) + loaded_transformers = list(loaded_system.get_components(DistributionTransformer)) + loaded_regulators = list(loaded_system.get_components(DistributionRegulator)) + loaded_matrix_branches = list(loaded_system.get_components(MatrixImpedanceBranch)) + + expected_loads = list(system.get_components(DistributionLoad)) + expected_solar = list(system.get_components(DistributionSolar)) + expected_capacitors = list(system.get_components(DistributionCapacitor)) + expected_vsources = list(system.get_components(DistributionVoltageSource)) + expected_transformers = list(system.get_components(DistributionTransformer)) + expected_regulators = list(system.get_components(DistributionRegulator)) + expected_matrix_branches = list(system.get_components(MatrixImpedanceBranch)) + + assert len(loaded_buses) == len(expected_buses) + assert len(loaded_feeders) == len(expected_feeders) + assert len(loaded_substations) == len(expected_substations) + assert len(loaded_loads) == len(expected_loads) + assert len(loaded_solar) == len(expected_solar) + assert len(loaded_capacitors) == len(expected_capacitors) + assert len(loaded_vsources) == len(expected_vsources) + assert len(loaded_transformers) == len(expected_transformers) + assert len(loaded_regulators) == len(expected_regulators) + assert len(loaded_matrix_branches) == len(expected_matrix_branches) + + assert {component.uuid for component in loaded_buses} == { + component.uuid for component in expected_buses + } + assert {component.uuid for component in loaded_loads} == { + component.uuid for component in expected_loads + } + assert {component.uuid for component in loaded_solar} == { + component.uuid for component in expected_solar + } + assert {component.uuid for component in loaded_capacitors} == { + component.uuid for component in expected_capacitors + } + assert {component.uuid for component in loaded_vsources} == { + component.uuid for component in expected_vsources + } + assert {component.uuid for component in loaded_transformers} == { + component.uuid for component in expected_transformers + } + assert {component.uuid for component in loaded_regulators} == { + component.uuid for component in expected_regulators + } + assert {component.uuid for component in loaded_matrix_branches} == { + component.uuid for component in expected_matrix_branches + } + + expected_solar_with_controller = { + solar.uuid: solar for solar in expected_solar if solar.controller is not None + } + loaded_solar_by_uuid = {solar.uuid: solar for solar in loaded_solar} + for solar_uuid, expected in expected_solar_with_controller.items(): + loaded = loaded_solar_by_uuid[solar_uuid] + assert isinstance(loaded.controller, InverterController) + assert type(loaded.controller.active_power_control) is type( + expected.controller.active_power_control + ) + assert type(loaded.controller.reactive_power_control) is type( + expected.controller.reactive_power_control + ) + + loaded_regulator_by_uuid = {regulator.uuid: regulator for regulator in loaded_regulators} + for expected_regulator in expected_regulators: + loaded_regulator = loaded_regulator_by_uuid[expected_regulator.uuid] + assert len(loaded_regulator.controllers) == len(expected_regulator.controllers) + assert all( + isinstance(controller, RegulatorController) + for controller in loaded_regulator.controllers + ) + + +def test_distribution_system_from_db_prefer_normalized_with_battery( + tmp_path, simple_distribution_system +): + system: DistributionSystem = simple_distribution_system.deepcopy() + + bus = next( + bus + for bus in system.get_components(DistributionBus) + if {Phase.A, Phase.B, Phase.C}.issubset(set(bus.phases)) + ) + + battery = DistributionBattery( + name="battery_test_component", + bus=bus, + substation=bus.substation, + feeder=bus.feeder, + phases=[Phase.A, Phase.B, Phase.C], + active_power=ActivePower(150, "kilowatt"), + reactive_power=ReactivePower(25, "kilovar"), + equipment=BatteryEquipment.example().model_copy(update={"name": "battery_test_equipment"}), + inverter=InverterEquipment.example().model_copy(update={"name": "battery_test_inverter"}), + controller=InverterController( + name="battery_test_controller", + active_power_control=PeakShavingBaseLoadingControlSetting.example().model_copy( + update={"name": "battery_test_peak_shave"} + ), + reactive_power_control=VoltVarControlSetting.example().model_copy( + update={"name": "battery_test_volt_var"} + ), + prioritize_active_power=False, + night_mode=True, + ), + in_service=True, + ) + system.add_component(battery) + + db_path = tmp_path / "distribution_with_battery.sqlite" + system.to_db(db_path) + + loaded_system = DistributionSystem.from_db(db_path, prefer_normalized=True) + loaded_battery = loaded_system.get_component( + DistributionBattery, name="battery_test_component" + ) + + assert loaded_battery.uuid == battery.uuid + assert isinstance(loaded_battery.controller, InverterController) + assert isinstance( + loaded_battery.controller.active_power_control, PeakShavingBaseLoadingControlSetting + ) + assert isinstance(loaded_battery.controller.reactive_power_control, VoltVarControlSetting) + + +def test_distribution_system_from_db_prefer_normalized_with_sequence_branch( + tmp_path, simple_distribution_system +): + system: DistributionSystem = simple_distribution_system.deepcopy() + source_branch = next(iter(system.get_components(MatrixImpedanceBranch))) + sequence_branch = SequenceImpedanceBranch( + name="sequence_branch_test", + buses=source_branch.buses, + phases=source_branch.phases, + length=source_branch.length, + substation=source_branch.substation, + feeder=source_branch.feeder, + equipment=SequenceImpedanceBranchEquipment.example().model_copy( + update={"name": "sequence_branch_equipment_test"} + ), + in_service=True, + ) + system.add_component(sequence_branch) + + db_path = tmp_path / "distribution_with_sequence.sqlite" + system.to_db(db_path) + + loaded_system = DistributionSystem.from_db(db_path, prefer_normalized=True) + loaded_sequence_branch = loaded_system.get_component( + SequenceImpedanceBranch, + name="sequence_branch_test", + ) + + assert loaded_sequence_branch.uuid == sequence_branch.uuid + assert loaded_sequence_branch.equipment.uuid == sequence_branch.equipment.uuid + + +def test_distribution_system_from_db_prefer_normalized_with_switch( + tmp_path, simple_distribution_system +): + system: DistributionSystem = simple_distribution_system.deepcopy() + source_branch = next(iter(system.get_components(MatrixImpedanceBranch))) + switch = MatrixImpedanceSwitch( + name="switch_test_component", + buses=source_branch.buses, + phases=source_branch.phases, + length=source_branch.length, + substation=source_branch.substation, + feeder=source_branch.feeder, + is_closed=[True for _ in source_branch.phases], + equipment=MatrixImpedanceSwitchEquipment.example().model_copy( + update={"name": "switch_test_equipment"} + ), + in_service=True, + ) + system.add_component(switch) + + db_path = tmp_path / "distribution_with_switch.sqlite" + system.to_db(db_path) + + loaded_system = DistributionSystem.from_db(db_path, prefer_normalized=True) + loaded_switch = loaded_system.get_component( + MatrixImpedanceSwitch, name="switch_test_component" + ) + + assert loaded_switch.uuid == switch.uuid + assert loaded_switch.equipment.uuid == switch.equipment.uuid + assert loaded_switch.is_closed == switch.is_closed + + +def test_distribution_system_from_db_prefer_normalized_with_geometry_branch( + tmp_path, simple_distribution_system +): + system: DistributionSystem = simple_distribution_system.deepcopy() + source_branch = next(iter(system.get_components(MatrixImpedanceBranch))) + geometry_branch = GeometryBranch( + name="geometry_branch_test", + buses=source_branch.buses, + phases=source_branch.phases, + length=source_branch.length, + substation=source_branch.substation, + feeder=source_branch.feeder, + equipment=GeometryBranchEquipment.example().model_copy( + update={"name": "geometry_equipment_test"} + ), + in_service=True, + ) + system.add_component(geometry_branch) + + db_path = tmp_path / "distribution_with_geometry.sqlite" + system.to_db(db_path) + + loaded_system = DistributionSystem.from_db(db_path, prefer_normalized=True) + loaded_geometry = loaded_system.get_component(GeometryBranch, name="geometry_branch_test") + + assert loaded_geometry.uuid == geometry_branch.uuid + assert loaded_geometry.equipment.uuid == geometry_branch.equipment.uuid + + +def test_distribution_system_from_db_prefer_normalized_with_fuse( + tmp_path, simple_distribution_system +): + system: DistributionSystem = simple_distribution_system.deepcopy() + source_branch = next(iter(system.get_components(MatrixImpedanceBranch))) + fuse = MatrixImpedanceFuse( + name="fuse_test_component", + buses=source_branch.buses, + phases=source_branch.phases, + length=source_branch.length, + substation=source_branch.substation, + feeder=source_branch.feeder, + is_closed=[True for _ in source_branch.phases], + equipment=MatrixImpedanceFuseEquipment.example().model_copy( + update={"name": "fuse_test_equipment"} + ), + in_service=True, + ) + system.add_component(fuse) + + db_path = tmp_path / "distribution_with_fuse.sqlite" + system.to_db(db_path) + + loaded_system = DistributionSystem.from_db(db_path, prefer_normalized=True) + loaded_fuse = loaded_system.get_component(MatrixImpedanceFuse, name="fuse_test_component") + + assert loaded_fuse.uuid == fuse.uuid + assert loaded_fuse.equipment.uuid == fuse.equipment.uuid + assert loaded_fuse.equipment.tcc_curve.uuid == fuse.equipment.tcc_curve.uuid + + +def test_distribution_system_from_db_prefer_normalized_with_recloser( + tmp_path, simple_distribution_system +): + system: DistributionSystem = simple_distribution_system.deepcopy() + source_branch = next(iter(system.get_components(MatrixImpedanceBranch))) + recloser = MatrixImpedanceRecloser( + name="recloser_test_component", + buses=source_branch.buses, + phases=source_branch.phases, + length=source_branch.length, + substation=source_branch.substation, + feeder=source_branch.feeder, + is_closed=[True for _ in source_branch.phases], + equipment=MatrixImpedanceRecloserEquipment.example().model_copy( + update={"name": "recloser_test_equipment"} + ), + controller=DistributionRecloserController.example().model_copy( + update={"name": "recloser_test_controller"} + ), + in_service=True, + ) + system.add_component(recloser) + + db_path = tmp_path / "distribution_with_recloser.sqlite" + system.to_db(db_path) + + loaded_system = DistributionSystem.from_db(db_path, prefer_normalized=True) + loaded_recloser = loaded_system.get_component( + MatrixImpedanceRecloser, + name="recloser_test_component", + ) + + assert loaded_recloser.uuid == recloser.uuid + assert loaded_recloser.equipment.uuid == recloser.equipment.uuid + assert loaded_recloser.controller.uuid == recloser.controller.uuid + assert loaded_recloser.controller.equipment.uuid == recloser.controller.equipment.uuid + + +def test_distribution_system_to_db_writes_time_series_associations( + tmp_path, distribution_system_with_single_timeseries +): + system: DistributionSystem = distribution_system_with_single_timeseries + db_path = tmp_path / "distribution_with_ts.sqlite" + system.to_db(db_path) + + with sqlite3.connect(db_path) as conn: + row = conn.execute("SELECT COUNT(*) FROM time_series_associations").fetchone() + assert row is not None + assert row[0] > 0 + + +def test_distribution_system_from_db_prefer_normalized_attaches_time_series( + tmp_path, distribution_system_with_single_timeseries +): + system: DistributionSystem = distribution_system_with_single_timeseries + db_path = tmp_path / "distribution_with_ts.sqlite" + system.to_db(db_path) + + original_load = next(iter(system.get_components(DistributionLoad))) + loaded_system = DistributionSystem.from_db(db_path, prefer_normalized=True) + loaded_load = loaded_system.get_component(DistributionLoad, name=original_load.name) + + assert loaded_system.has_time_series(loaded_load) + + original_metadata = system.list_time_series_metadata(original_load) + loaded_metadata = loaded_system.list_time_series_metadata(loaded_load) + assert len(loaded_metadata) == len(original_metadata) + + assert {metadata.name for metadata in loaded_metadata} == { + metadata.name for metadata in original_metadata + } + + for metadata in loaded_metadata: + ts_data = loaded_system.get_time_series( + loaded_load, + metadata.name, + SingleTimeSeries, + **metadata.features, + ) + assert ts_data is not None From 8f2192613ce5b0123916107b2b52dd161ac04cd3 Mon Sep 17 00:00:00 2001 From: Aadil Latif Date: Sat, 28 Feb 2026 01:49:22 -0700 Subject: [PATCH 2/6] Remove local sqlite artifacts --- distribution.db | Bin 827392 -> 0 bytes .../mocks/sample_distribution_system.sqlite3 | Bin 1081344 -> 0 bytes 2 files changed, 0 insertions(+), 0 deletions(-) delete mode 100644 distribution.db delete mode 100644 tests/mocks/sample_distribution_system.sqlite3 diff --git a/distribution.db b/distribution.db deleted file mode 100644 index dfc3b1969a849cf4dea0c05b6c1a9f8692d343bf..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 827392 zcmeI*3v^t0UfB66$*!{HR%?2io|<81nA_7cNTTJo)lFl1dTdX*bZr+dm+Vrh+n$-` za#eLL-*i3Xy0`oo0<6Z}o*rgkSO{z&Y{)LluuCAkHXF!p9zcLVvLxB$Y&PVuz#d+E zPBsbbBarOL?*G2urIMeL^{qZ5sqXzhKmXtVe{bo^^Iy4G(@jmf(jRnHQ+g@)u3SE! z`-CLra=A6}zh4*s^Io44KkW0~i0}E(&sjgN41p zznb~Ong4C(w`TsAnSVO-8#BK)^Y>={_RP=C{LIXcJIS(&<2V8cAbz~9<@N%ZiqZO)6YU*7zH`1<{uPm8Z#+$@N%-}=qs%Z;A+(zz`JJ5qx%>~r8(s(?fB*srAbLNb#Zdv#7up1a$=@3IWaMFYBE1DQ=SsNDa`y!>+}Er zJ@ap7{&(?I{$K8@4$@cz5I_I{1Q0*~0R#|0009K{wm@O>sr<@|1^e6L{{53P`T9QV zYh}{c93=<|5u2PaE;`=^QG}`SKT4` z>B(mg2A}_(fB*mcxtafK=J)qJ9{>m-fB*srAbft>^y-XSzAD^WPuM{PUS#o%y+$ADQ|5%nfmf7Xk<%fB*srAb3xgPtE)bu@k`GocWEJ zKXMA1FMe-u;OWWd^ReF-6sIQN zmv??oP$*13mA@eGxBl<`#qS07Sp`_X516zLh~ER)|NsBu^S|}{KliSO)(shYU`22q1s}0tg_000IagfB*srJOTke|33nBszd+* z1Q0*~0R#|0009ILK;R(@@b~`@8Jy}6KmY**5I_I{1Q0*~0R#|u1Ohz&e+1}Mi2wo! zAbKmY**5I_I{1Q0*~0R$d_0MGv)0XkJ8fB*sr zAb|Kv|i|KgMHncg>jP5*U zU`ak*Uad8yS-ZyBc<%MC+X}laTQ|i@pVkU-+-ZPaPQoFz6>4`wZRzGMhavcy)e1j- z&;ajHsO~KqAaZB}&2`Ya+F)JlwQeb1K~5{OT9A@HXiM_sRznhLi`r5KnxPw}+G~Y_ zE2}Nh^g%Ns$3rgJW75CcJra(N_BLA*O&_!*a(tUDnVO&Z)7-_oSU0k>yj6L+SUPz! z|IRb^iel+-RMqlrlM&^mHh^#4@$ubs|%*Dj1J6MCfg#IGZkWb4E zxn7Z1q-3RrG^e-crRBP`B-doI;#JBkmGY9i=4|v0-PHR%Ment>o060$+g)p7=}sIE z78+ORy4o39VVM<H}57|u&PnYn|PemFP zJ`-z=7!v!H*(z@Y;tA6xg`M_6&x4yvC>TYn;`6|{u-+Og)fi!R?5RTO+()y*EG(-m zhTpyW)=aVVzW3!f&j$l#I6&`84$l1zlFbiJ7fZ*EGm%Et}pmh8@*jg3MbR znvr_&$w#o*`ZYX=?gyxk0Q zp!SR_{eifrQM&zM&(wSCM(V+-NaIqIWVN$8HOerxD6#aVwd_!%SSUUBT)yiXc6HN3 z^nW}MX=q3HPh>Uk^5HScT9ENjk?I2eUa8hPGvRim3@WM z`Pr|C!9p<{RX)=xKs1* zG~QP%t*qp~>ySM_hWK@c_!KIxmYG8|Mv@Oi25F288N+0udaum;v-psC8LaOKfbj5% z8)`+gE%mc*`aM-LhbNs zU+Z1=H&OAH{dIE$6>q=4E!FFg0eV9q7SaTw@_Ly zXH_F^Us~c9E8;d>X<5I&4K6nJtxd+c_kHQLQn6Gn=l}9CdtuBjCHu^KBgjA8HSbHdSx{yHBZo}w9{VGw%w=%)EJHO z^Q5-PJL@%ROsJi(2#$WbF>1(yxE=2ahgH^iW93fL=z|t3yLe@dRVYs|M8QFSWSmmO z*YTQSe}qyZ4+#7cvYL{XCumd3S-)}FZHnKUxjjp5SXR4I^8^h`JMFb>{D$?0U8ScD z#qTdVic_LL_cMQ$o|Y$Q*FZJ3w&H$>_VJw`vbq}0lTnt}Ik(L0K3a9VzwQneqF77B z+w1}^$g}oumaROY4_2waD04q!P0cUAr%-Ag&$?S!%Xqr0EM}c4@$dil{I59xKmY** z5I_I{1Q0*~0R#}(0|NZ}|2;6sbP)jr5I_I{1Q0*~0R#|0V9yKi{D04f000OefB*sr zAbuWKmY**5I_I{ z1Q0*~0R;Ad0MGyTz#!8_1Q0*~0R#|0009ILKmdU~FEBmz>$&ON@8zcd-$Tz${ZGYz zH}z)Ww~Ifx@1GQIPCk?Sz3K0N^4~o9gHQfI?uFe9_0|htZSUjvxpV3J_=(+A&5`@~ z#S?r@WN)OzK7R4!TWpcE?akp5drE{ih7&umh%GjPef_*GDH56dt=8^ke?PDEtUWjU z-E4yGHoQZzoi;}7l;Lj8C%z8Z;qiJgvDhr{8*0Yg>hhmnY-5-;39yUQdD?RjaU{_=+l zrS6%m&3;C_yo<4pEox@RhR z2{W|(Ez2SozeM64KQo+uY`K_w{W$u9zZ~ALh23n4y(Wi)>tY*SdoA|j*I$T3n^l>5 zSF;ii#2%GSro>gXA+IzW)kh-8A}iP$ z*sajsT~jlZ#jG>WzSaL=v2^@+{vBt}@T_!M$EC>Z$V@e7-?TX7UaXIp7GkSoeWPo! zr;m~oM?orU3-;`bbS#~HnXXmq^6bLw>D3kMTSKnYmRID4_;Ka+YO`|Izi_*bP6eXn zi8<%q2!zww6_H1$Q zID?v!m&bUL=hY8vtBUw`=P6S%p5#N~W8-)2q>qlDeR8f?I(agG+u726UF(Z&TnD!j zTe)YRScuH#%w#E7IWL$;Uf~jxCw$)*-e)k8(7PhcmaC1(PVJ*N6y)#!cT#xL76cGL z009ILKmY**5I_I{1Rjh4fB*krTqzX+1Q0*~0R#|0009ILKmdWAB*63koirM>1px#Q zKmY**5I_I{1Q0*~fd?bN^Zy6qN~s7SfB*srAbNv_GFU6t}mrMx6NC3{^_MEA6tl9VX9a#pUKljdUSP8^qx&O0So zAlKE-P;>hsI$yJX^xEd6TCG*%JYs6(@F zzgN7he#ex>#G$YK!u$)x(vc(i@5@I<&Cip_@lxt&`ROx8Dj2hXEHP?5wdQScg@*E4B}(P`k`y}j@#E6s zQ7;sALs=j6ha!?=6H7EbtI^>+)w(oybU|92cSC!inp#`A-tU;|dc5iJc6$iXJSY5V z5;?96d%Ed+O4-3+)$Im^+YH6LwGnSivVc$^g!38FoE4Oj?rrpMXoH|F?oXq;=j4fZ z&ORFJnv`stAL&-h6t%md4K%lpyy!$V+a2Tm4>7 zYdI4lj+hvNkYFrd6w&OPYR9@NjMHSV>o2|NO!BTS=B9N)87BLi7y&Qy(HCBcPm)kM z{v-(3ZJ^xh=psxzcSftidgPjhYO_}7qgc8 ztrsw5aqBcMzV*!u#nQ!#`FB+Ng0OXtty3&UE*V>=A8`%wR;9779f=rq$lXv9>z*lY zP{M(f$l!(0?1kl;tejt7tv6@=SPDgacnrI4!O^haXqif{uXOrqTYNF>;EzYFU$S=( zk;vl*YqGe=!fu_uGVFB1^^H?oEKj7m*ttl3;-b(JcTVEcD`w}0b2agX%D-Z}y-K7F zt|C+OE6*27>*cHvU)TCw%^ciP*2ML>byX1yO?%if`vXH+%szMIb4R|WSSpwE-=W(f zot-lK%nOka&Q3QXbiF|HToNH1ziaU_Sl_)&9;P2Ep&*R6$}ex^jZ5^VGF)e}R&Pm* z<*~xVy<}YN5A@IUd)5-~CGv}m2cR<#Q*!v1#O(Pf!ay5%7kX`A>aDaItsA_wo9pZG z9Da?XF2CVA*VKU)gr9qx>GsqgEAc@iZJc6xGlywKc0Q~)P9oN=DK^ksO3V6u6|Qru z8WVZPtka#bbFmhSo9gO{eg{}0(eoQ%U+BD0aXwUuE8WHULPcfh$A}gpfB*srAbBY*$`2q1s}0tg_000Iagu=53Y{=f6bh!!G%00IagfB*sr zAb> z+pmATSbFKD{GC-(UF&Eqbwh3GW`Ce)*M|B=SL>N6hfY-*a=9r>&GM<5ETtrn=B6a^ z(%aV8=&M?9%4g(;bfHl_Uv6BM&dHY-?8H5_t3{JaQd7Rvw2sx6n^JwXR+Coi)r+gL zlcCk`^|Y3$_j`(YYs1N#$gm`zF0a;_((FpJ+?bUb@@cst*DLah6w7W%b5;xHow5h2 zskN2s{f?=wd#w_Eu0=+Z@*VIuBwKnqzs+1n0cyPT`ZK|IF?o9 z4N+Zbtv0~}u$@~|FcHCvwnR((9kr>QOHX;(Ov9w;> ziby9*%6Nn$bD$ZzVXD2B7dl=p@jVemUIZL{;i%+ARFMC~@gSc3qi!&TN=<}^SL$s4 zYS#^`fu^=H8Z~CC{QQwtxkr=>;6n9=U1d$}y=f@@p4SxbyB93}isUvak|)IDANL9feNU9-@hp0Q5ahOcH#J{< zWz+=h^woB-ZaY(4Ou*E`CvRW;`eNzWvHYE~Jpoe_r5-#HnRuy5wwQEYzY~{$SYiI< z!^_~KhZetR9!GahZ_i81b!kbi$>K6qDX&z@OYT*}St+8q6j9TtI@0n;u}T{9O0!X| zG@T{fTmKY$wGGBRQHvWsvGkz;a@G}TF45uzDVj-y$E(K*rRDjo^*L5TEG~|34!^Eg znxD_#{%A@-MDu=pLsi3u8BSYy}h5ev+?m;0&tGcOlPpL#h9Ep6z% zOJo$~)wegkwpe=k<@^^WLzlE@nzYhB7QswQkwI!j)!3|7;*qpsx$Hz&-*K;1e!fuD z2BmomZ%XyvayR>?-s#^^P4E6Ex^i1BAKeu9md(1n`Iiu<+C+~dRR$~mXB4Z!PEFp| z_{GPm=g$vR- z*P~CpR4DbUS#!r6s6FFKe;^80TK(Zh$GST-#0Lva+!=`r)9CYG|F-#Ju~e<*zg^t5 zh>s?tQ;$A-EHcMNryVidykYlv5{p^5Wd8${b#)OhFOzo>vp@7$CqfsqwzzE^=xamg zLrsX!FD!o9iCo(}+xS%{t$ObkBbFyLhp!C!UB$b5AM-kvk|$K6+0QC3$>X&zktbB1 zy-=jzW+(4xy?aOhF4!taD%Y2!pqP&zmlluupWnQc!4hUc+OO<|WkVaZ#HVKWR?@qA zX9dQB6!jMuasK|_``rZJ5kLR|1Q0*~0R#|0009ILczgx;`~SyxZ0Q*S2q1s}0tg_0 z00IagfB*uX0MGwD3BDtM00IagfB*srAb z&;LCMz9WDD0tg_000IagfB*srAn^DK@cjSr9b0;a00IagfB*srAbtr6Nrq))}jSY1m8m?^g zZ)gLrFwx{%By;7gTsbGrrRF&y9i7iEQW^GiQEg(+y4O=v_Ob z*CzM7-!!XFUbBMqp~mq9-blMDu{@DPaCWU!#E-`on^#LA54_+;qX*R)a@^2Z|a@CHEWft{o%k{JnVHX zvYgqW6%Q*foSa&n^i~)})-wBWxGf{rF|RRhJ6c!O!MfgCkB4}?9r5r^%jbu8+Q~?G zw{;^eO=9xKYt6{#SDbM^Qn%jjr1UqrK*j4$=JTsfo{H4yjGc7>QP;y)!nkpCtiAM! z(RNRS#ydOothDY(d)dre8{tMILnXANg!71%#k!KuPt6}M7fKhO&ss=~en$=6YbcAc z1846{e4wMvCYLJVVGPx;aEZHnD2B z!{^L#Q|oSoFAQEMw|6aY*F(+F4O8v4BH@tPsUWo<_k-YAG~hM0l_)qdx&3etQin?R zONf?iFPm;y+6$)jC9}5{nDP-o009ILKmY**5I_I{1Q2-i0zChJ^kYB^ z5I_I{1Q0*~0R#|0009ILc+diT{(sQIl#c)c2q1s}0tg_000IagfWV^{;P3w*{TR>! z1Q0*~0R#|0009ILKmY**9<;#pfp_Jem~7+@3=h1w_*C)l?E9g@uS_S%&J|0Gi}|-s+S|2y`_Xpvu5K!ZW*W&OE0OJ4 zljl>mW%YI#P3*#I?dI%lLz>vY*5CWq-S^gMl(TnqB5`PE)*zj?U$nCc>UFg<^lHo6 zuGdKuY}0GMcelAt)af_S-kf)8{;iKxi=|`7@}FCeU>ah}^_HeMo9B97k%JdgNh3*8 zR6V%x_e|+nz1p>*zR}fsrnT{`_3|=~+VVE548H+eZ++|bDN+4+4RCh99np+f1GcdV zZVJKp``~7^NZ_VyhFkrHLg|fTS)1X;%5tJcSxi0L*nIv>v2^@+ep642IA1xI*+0HK zD(?JLvC#7(C37+-=83ai@YMYB*+Qvxe9Vsf)hGJypSf`8%ee#AI{c~Ag;KwoRq*Jj zQ5Hv^KY!;1xmc=J^Iv$~HXfaN^x0a(dvw~Ex;MwR)b|o*jDoxG`cW^L^F6*CE{MZ} zqv3VWTNfmM(d%FBx(oAQag5Ry$1UyV7;U>bcH3@_(YBjox6PeMes|+;-jd;^3tdRW z{_X0z<|0UUx6^xKO#Q*==soSQ**|Q3pB0ja|AI^>qzxiG_9=-;$3%- zzP9x$h%^Qt!JsvRY`y-pr!Mp~CV zA8VU6bGIA{D^X%N{Iz4p-(IWT4Q*hpEd2fd16q+N76AkhKmY**5I_I{1Q0*~fgL2k z-~aER!JsJ!Ab?0l`u%0tg_000IagfB*srAbIK>z^+5I_I{1Q0*~0R#|0-~kEn{Qm*LQY-=pAbWq4)U=M(mzz?3wN{gsxbuzil?}(n5w>F&oiA$~3Xn96lkgv@Xt!p9z4 zZ!qop8uDqmA=fMNiex7-q&dAkFD=)_sMTZB52( zu1}a%E*DC#FXnT4udUrQu61-%Q`Di^x8GaMj}a=1$zyA`^Q*Isk+2&Sl$=~?LWArz-ddsDS+dbLbbHKgx=`SH zE%PT|B%PRkN8J)u>(z^^axhfU60;In&|)B8dc9CO^X#aBblR-E7!1lcz4Y3cne0(_ z3wYvJa|f&oS*1BDbdu9RSxg@L^xORvd(M90pdC8N6v-o>iiA?~d`3uFBbymkV_j*S z@$s$!Ylf*h7V)1qh89kA&Xj)rb&cZ8@-<7c|a|$u+0FSN0 zkp;weaNDB z$q(fUU!^LIQPU;}3B?^SWiff|lb_P!P|L?*;NJ9l2Awiu3V%Ic;cyOJRDy>FTv-p4osKJH$# z*1##+wV}Sz)q3t0?xT7VP49Ny`et!CTM~!14ff9`iu86o-fFYqxAadJOE15ie`jci zq29Z$4NS3>bgSPp2mMY*8yIQFu0%pHEyajn^jhsTI&bs?L zZw&PQKy0-B8L>}&%ha!H%0~Z&Hn8)1HF|qudpj{_K`x(DR?e1RtJcqmx0UjRa;4h5tembk&fAB^E3#QV zFDs|YEAo;kWJRu>Rw~Q&mDTeXn$_jHJKfUS5SzVDuDNFMCa(zjuoGmxb z$g}hAl4cAyHu_@SX)9~~F6&+)aVfv)u2>|m#WlO8nqmpNb-3T_+!9q9{f;{D-gxKK zUi+|p!f(Uj!;()ZiED^8WFNCDvo?)aZiqsa)^H%!6MNfpw|U{&624g2DKqDSvzmw+ z!vhtqbVF0$RE(?Yb-lN)nCf6%6C2yRukZ%+{t}jw{={+Vs4JXOoHFd`cA37gSRedd z>SwJEyJNhj8k%BVnZ|5OTKZ61QcjQG7J(KI=O|oPy%|~fl|jFi2Iq`HT57K`*)B&6 zO-$6b%hMHCB-jcLtB-4jlk~kS^%$6D{Ur6Z!bp>@@Z2xrj6 zQ0rH&L{=>yFT8v?gKn+x%|=|H7U`ymb#%_(Fa}8 zzl@8u1rcAIqU}ptZo-w@4NP56#%W<_K96W&uyRD(=1+~#>B;8jr{;P7 zANrurj|d=u00IagfB*srAb9OJ@%D@DV(H|`{M%x4 zgtd+)wo5Pv`r6Rc`#r_pZb;d}(ifcgWvRB(yWkva{n@xM-*I(ww!yo3}Ah-w>O%2_C)I z)^4WkCFt&Cu@Oxd+DK)!UcI;~OLJZy7o=!fx1y9})=-G;DlT3rl(duioZSNBT1Pj< z)^S6#Z@*WP9PSt@FJ_*)c2T>#A{R4@-1z*(HG9Lz?>?UqTFI>4ErKZ{ioC(}f+P`5 zS^M+(+tzqP@0dGrlD2WARg+iCmZ5krn@4V|qI=mua$6ORyNRSe7>K>M)Ltvtt;woK z%&c71J1SaRfF0>-Vl%aGe9_(OD!k8?RkioZYDy%1 zs4?N=;pU8PjN6Fl&VybPk{(XrMNq4HssTCL#|ik6)DlOM8NFKTLZU`Tih80soWcb z$~TT3o>pE%Ne;bnRYa!#Q0{QgT`iQ9SH_BeXBSLei?`T6ZoKoc^#VfJ9md1Is5@bJE50wcA9iVci97=0{$JJ8iVQB#_JI3iymK$>{QLihvfxt* z0tg_000IagfB*srAb}PzTA}RtF@Y)*ebUnpOzbPy&|tjZQU>j`r6Rc`#r@k$dKmr_Pn%QmzLz3ELvSD zuT;uQvQyti-_Y&!dateB3>q4*PpX`iE9a!SSo#ykrK9ssNjl~quYdFbe#0nx{Dpunfz(?wdGo~d`6yKm@QYD)z@U@ z!t(2KL%bD389h*0Z3xh&IJH!+)-Ef}>Unu-cHZsg;lonJ3zUlA@>8mzNyb%mptYqd zy4GnMAG1fOt##B}O1~Fxkf^j~9kYhgqYcuARK0`Bu&0}$QhXe%0AJM51Wwy^b+cZ% z63S-%IHGQmHd*(TE3wMQs#|RrR@eR$7C@X;1j^9~_M~IbF-D%pIq`08XnrwdzpXP}RZFNZngjykvfp@uO>esak{Ttd~q`7LRQ#P!Gej`#l-czD!L#+_<;d4Gg zdiQUrW>`L)(`!L$1B7uT?FlIu`q(+I1zR>i_=&ng%TFNK(45+~HXOJqBZ{eA=qd5E zp%{vvbUTsWhwJJf6ut3MEyvUb2&Y)uPy-Stofd4>0Bb>ZR_p+;G8_!Fo;fn~T22~o z%$@J4trizkFKwu~{!wqVof6WUt9CcEffge3OAOVYTDeg1(uPAmI2@^djM`h(z^^vNYH7uGWg)UXY^qFY{CL=evcH`f5IB-~SlbI=ZPT>d@@l@0E;O zDrGU_#I3iFbc&@{U(J7hXx~$2B+EE_Gjd;XqLe(2omG)DB6?3Tw83?~72GJ>NfO0+H-(Gd{beYpb<2EoL0X&_6JYht zLP@WTH38PdrBfT+QZ}xN+g(F>F%qHClh;@O+}#!%omC@Bbg-?>MLm0R#|0009ILKmY** z5I_KdM5kLR|1Q0*~0R#|0009ILc!&Z#|9^dO zRQUG?PagOelYeEhmj709>KAiO@!uYOefpaVrG9lHV@H~02%(oV?a-wbjz?PY;Wwix(&EJe%CS z$lU=_G5W)SSk;~Fdm?d@nj-hv$Xwex!*(a#b+X

Uk{q{KeOE7^m{AGVD%ly>Fh^*LYR_ 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

-g1d=haQ=|_B5WYj72hjKqeH>~K5($0_u6&r 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 Z+?$$zA;x`w^DppR5F1GM(nY`b{{uVHW<&r0 From 1ebc0e36a186c4b4080523b596a9125b66bbb5b2 Mon Sep 17 00:00:00 2001 From: Aadil Latif Date: Sat, 28 Feb 2026 08:26:34 -0700 Subject: [PATCH 3/6] Add SQLite/PostgreSQL SQL persistence parity and docs --- .github/workflows/pull_request_tests.yml | 16 + docs/_toc.yml | 2 +- docs/dist_system/sql_persistence.md | 138 ++++++++ docs/dist_system/sqlite_persistence.md | 75 ----- docs/intro.md | 7 + pyproject.toml | 2 + src/gdm/db/__init__.py | 8 +- src/gdm/db/connection.py | 97 ++++++ src/gdm/db/postgres_store.py | 353 ++++++++++++++++++++ src/gdm/db/sqlite_store.py | 50 ++- src/gdm/db/sqlite_store_schema.py | 8 +- src/gdm/db/store.py | 140 ++++++++ src/gdm/distribution/catalog_system.py | 19 +- src/gdm/distribution/distribution_system.py | 22 +- tests/conftest.py | 86 +++++ tests/test_db_io.py | 181 ++++++++++ 16 files changed, 1103 insertions(+), 101 deletions(-) create mode 100644 docs/dist_system/sql_persistence.md delete mode 100644 docs/dist_system/sqlite_persistence.md create mode 100644 src/gdm/db/connection.py create mode 100644 src/gdm/db/postgres_store.py create mode 100644 src/gdm/db/store.py diff --git a/.github/workflows/pull_request_tests.yml b/.github/workflows/pull_request_tests.yml index ec72494c..39fc5c9b 100644 --- a/.github/workflows/pull_request_tests.yml +++ b/.github/workflows/pull_request_tests.yml @@ -5,9 +5,25 @@ on: pull_request jobs: ci_test: runs-on: ubuntu-latest + services: + postgres: + image: postgres:16 + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: gdm_test + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U postgres -d gdm_test" + --health-interval 10s + --health-timeout 5s + --health-retries 5 strategy: matrix: python-version: ["3.12", "3.13"] + env: + GDM_TEST_POSTGRES_DSN: postgresql+psycopg://postgres:postgres@localhost:5432/gdm_test steps: - uses: actions/checkout@v4 diff --git a/docs/_toc.yml b/docs/_toc.yml index a729827a..3ed67d7f 100644 --- a/docs/_toc.yml +++ b/docs/_toc.yml @@ -15,7 +15,7 @@ parts: - file: gdm_intro/units - file: gdm_intro/timeseries - file: dist_system/import_export - - file: dist_system/sqlite_persistence + - file: dist_system/sql_persistence - file: dist_system/plotting - caption: Advanced Usage chapters: diff --git a/docs/dist_system/sql_persistence.md b/docs/dist_system/sql_persistence.md new file mode 100644 index 00000000..e9a373b6 --- /dev/null +++ b/docs/dist_system/sql_persistence.md @@ -0,0 +1,138 @@ +# SQL Persistence + +GDM supports persisting systems through high-level APIs on `DistributionSystem` and `CatalogSystem`. + +Supported database targets: + +- SQLite files (via `db_path` or SQLite `db_url`) +- PostgreSQL servers (via `db_url` DSN) + +This is useful for: + +- storing complete model snapshots, +- preserving component UUID identity across save/load cycles, +- loading distribution systems with `prefer_normalized=True`, +- keeping normalized distribution table structure aligned across SQLite and PostgreSQL. + +## DistributionSystem: write and load + +```python +from gdm.distribution import DistributionSystem + +# write using a file path +system: DistributionSystem = ... +system.to_db("distribution.sqlite") + +# load (default snapshot path) +loaded = DistributionSystem.from_db("distribution.sqlite") + +# write/load using SQLite URL +sqlite_url = "sqlite:///distribution.sqlite" +system.to_db(db_url=sqlite_url) +loaded = DistributionSystem.from_db(db_url=sqlite_url) + +# write/load using PostgreSQL DSN +postgres_url = "postgresql+psycopg://user:password@host:5432/database" +system.to_db(db_url=postgres_url) +loaded = DistributionSystem.from_db(db_url=postgres_url) +``` + +By default, `to_db` writes snapshot payloads. For distribution systems, normalized tables are also persisted. + +## Backend behavior + +### Distribution systems + +- SQLite: writes snapshot payload + normalized distribution tables. +- PostgreSQL: writes snapshot payload + normalized distribution tables, with table names and relational layout aligned to SQLite. + +`prefer_normalized=True` is supported on both backends for `DistributionSystem.from_db(...)`. + +### Catalog systems + +- SQLite and PostgreSQL both use snapshot storage. + +### Load from normalized representation + +Use `prefer_normalized=True` to reconstruct from normalized topology/component tables first. + +```python +loaded = DistributionSystem.from_db( + db_url="postgresql+psycopg://user:password@host:5432/database", + prefer_normalized=True, +) +``` + +If normalized rows are unavailable for the stored system, loading falls back to snapshot reconstruction. + +## CatalogSystem: write and load + +```python +from gdm.distribution import CatalogSystem + +catalog: CatalogSystem = ... +catalog.to_db("catalog.sqlite") + +loaded_catalog = CatalogSystem.from_db("catalog.sqlite") + +catalog.to_db(db_url="postgresql+psycopg://user:password@host:5432/database") +loaded_catalog = CatalogSystem.from_db( + db_url="postgresql+psycopg://user:password@host:5432/database" +) +``` + +`CatalogSystem` persistence uses snapshot storage. + +## Replace semantics and schema initialization + +For both system types, writes replace existing records for that `system_kind` by default. + +- `replace=True` (default): replace previously persisted record(s) for that system kind. +- `initialize_schema=True` (default): bootstrap schema/tables when needed. + +In repeated writes to an existing database, `initialize_schema=False` can be used once schema is already present. + +## Table-structure parity notes (SQLite vs PostgreSQL) + +For distribution persistence, PostgreSQL now materializes the same normalized table set used in SQLite (for example, `distribution_buses`, `distribution_loads`, `matrix_impedance_branches`, and related component/equipment tables). This keeps SQL inspection and downstream table-based workflows consistent across backends. + +GDM additive tables (`gdm_system_snapshots`, `gdm_metadata`, `gdm_component_uuid_map`) remain backend-managed and are available on both SQLite and PostgreSQL. + +## Time series behavior + +When persisting a `DistributionSystem`, time-series associations are stored in DB metadata tables, and loading restores component time-series attachments from persisted snapshot data. + +## Inspecting stored snapshot payloads + +For diagnostics, raw snapshot payload can be inspected via `gdm.db.load_snapshot_payload`. + +```python +from gdm.db import load_snapshot_payload + +payload = load_snapshot_payload( + db_url="postgresql+psycopg://user:password@host:5432/database", + system_kind="distribution", +) +``` + +For metadata-only inspection, `gdm.db.inspect_snapshot_metadata` is also available. + +```python +from gdm.db import inspect_snapshot_metadata + +metadata = inspect_snapshot_metadata(db_path="distribution.sqlite") +``` + +## Local PostgreSQL test setup + +If you run persistence tests locally against PostgreSQL, set a DSN environment variable used by the test fixtures: + +```bash +export GDM_TEST_POSTGRES_DSN='postgresql+psycopg://postgres:postgres@localhost:5432/gdm_test' +``` + +Then run DB persistence tests: + +```bash +pytest -q tests/test_db_io.py -k postgres_dsn +``` diff --git a/docs/dist_system/sqlite_persistence.md b/docs/dist_system/sqlite_persistence.md deleted file mode 100644 index 2513630b..00000000 --- a/docs/dist_system/sqlite_persistence.md +++ /dev/null @@ -1,75 +0,0 @@ -# SQLite Persistence - -GDM supports persisting systems directly to SQLite through high-level APIs on `DistributionSystem` and `CatalogSystem`. - -This is useful for: - -- storing complete model snapshots in a single database file, -- preserving component UUID identity across save/load cycles, -- loading from a normalized relational representation for faster selective reconstruction. - -## DistributionSystem: write and load - -```python -from gdm.distribution import DistributionSystem - -# write -system: DistributionSystem = ... -system.to_db("distribution.sqlite") - -# load (default snapshot path) -loaded = DistributionSystem.from_db("distribution.sqlite") -``` - -By default, `to_db` writes a snapshot payload and normalized distribution tables. - -### Load from normalized representation - -Use `prefer_normalized=True` to reconstruct from normalized topology/component tables first. - -```python -loaded = DistributionSystem.from_db( - "distribution.sqlite", - prefer_normalized=True, -) -``` - -If normalized rows are unavailable for the stored system, loading falls back to snapshot reconstruction. - -## CatalogSystem: write and load - -```python -from gdm.distribution import CatalogSystem - -catalog: CatalogSystem = ... -catalog.to_db("catalog.sqlite") - -loaded_catalog = CatalogSystem.from_db("catalog.sqlite") -``` - -`CatalogSystem` persistence uses snapshot storage. - -## Replace semantics and schema initialization - -For both system types, writes replace existing records for that `system_kind` by default. - -- `replace=True` (default): replace previously persisted record(s) for that system kind. -- `initialize_schema=True` (default): bootstrap schema/tables when needed. - -In repeated writes to an existing database, `initialize_schema=False` can be used once schema is already present. - -## Time series behavior - -When persisting a `DistributionSystem`, time-series associations are stored in DB metadata tables, and loading restores component time-series attachments from persisted snapshot data. - -## Inspecting stored snapshot payloads - -For diagnostics, raw snapshot payload can be inspected via `gdm.db.sqlite_store.load_snapshot_payload`. - -```python -from gdm.db.sqlite_store import load_snapshot_payload - -payload = load_snapshot_payload("distribution.sqlite", system_kind="distribution") -``` - -For metadata-only inspection, `gdm.db.sqlite_store_schema.inspect_snapshot_metadata` is also available. diff --git a/docs/intro.md b/docs/intro.md index 126801b5..23fdbb01 100644 --- a/docs/intro.md +++ b/docs/intro.md @@ -29,6 +29,13 @@ To reduce code duplication and provide client packages with a standard interface ```{tableofcontents} ``` --> + +## Persistence Guide + +For SQL persistence workflows (SQLite files and PostgreSQL DSNs), see: + +- {doc}`dist_system/sql_persistence` + ## License BSD 3-Clause License diff --git a/pyproject.toml b/pyproject.toml index 1ad6f04f..8afdb00f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,6 +30,8 @@ dependencies = [ "pandas~=2.2.3", "geopandas", "plotly", + "SQLAlchemy>=2.0", + "psycopg[binary]>=3.2", ] [project.optional-dependencies] diff --git a/src/gdm/db/__init__.py b/src/gdm/db/__init__.py index 13ec3c39..2dc43561 100644 --- a/src/gdm/db/__init__.py +++ b/src/gdm/db/__init__.py @@ -1,15 +1,19 @@ """Database adapters for Grid Data Models.""" -from gdm.db.sqlite_store import ( +from gdm.db.store import ( DEFAULT_DB_FORMAT_VERSION, - default_schema_path, + inspect_snapshot_metadata, + load_snapshot_payload, load_system_from_db, write_system_to_db, ) +from gdm.db.store import default_schema_path __all__ = [ "DEFAULT_DB_FORMAT_VERSION", "default_schema_path", + "inspect_snapshot_metadata", + "load_snapshot_payload", "load_system_from_db", "write_system_to_db", ] diff --git a/src/gdm/db/connection.py b/src/gdm/db/connection.py new file mode 100644 index 00000000..b700b023 --- /dev/null +++ b/src/gdm/db/connection.py @@ -0,0 +1,97 @@ +"""Database connection target helpers. + +This module centralizes validation and resolution logic for DB targets while the +storage layer transitions from path-based SQLite APIs to DSN-based backends. +""" + +from __future__ import annotations + +from pathlib import Path +from urllib.parse import urlparse + + +def resolve_db_url(db_path: str | Path | None = None, db_url: str | None = None) -> str: + """Resolve a canonical DB URL from compatibility inputs. + + Parameters + ---------- + db_path : str | Path | None + Legacy path input for SQLite files. + db_url : str | None + DSN/URL input. Examples: ``sqlite:////tmp/system.db``, + ``postgresql+psycopg://user:pass@host:5432/db``. + """ + + if db_url and db_path: + raise ValueError("Provide either 'db_path' or 'db_url', not both.") + + if db_url: + return db_url + + if db_path is None: + raise ValueError("A database target is required. Provide 'db_url' or 'db_path'.") + + return f"sqlite:///{Path(db_path)}" + + +def sqlite_path_from_target(db_path: str | Path | None = None, db_url: str | None = None) -> Path: + """Return a filesystem path for SQLite targets. + + This helper supports both direct file paths and SQLite URLs. Non-SQLite + URLs are intentionally rejected in this module because PostgreSQL support is + added incrementally in subsequent milestones. + """ + + resolved = resolve_db_url(db_path=db_path, db_url=db_url) + parsed = urlparse(resolved) + + if parsed.scheme in {"", "sqlite"}: + if parsed.scheme == "": + return Path(resolved) + + if parsed.netloc not in {"", "localhost"}: + raise ValueError(f"Unsupported SQLite URL host in '{resolved}'.") + + if not parsed.path: + raise ValueError("SQLite URL must include a file path.") + + return Path(parsed.path) + + raise NotImplementedError( + f"Database backend '{parsed.scheme}' is not supported yet in this module." + ) + + +def get_backend_name(db_path: str | Path | None = None, db_url: str | None = None) -> str: + """Return normalized backend name from DB target. + + Returns + ------- + str + One of ``sqlite``, ``postgresql``, or the parsed scheme string for + other DSN types. + """ + + resolved = resolve_db_url(db_path=db_path, db_url=db_url) + parsed = urlparse(resolved) + scheme = parsed.scheme or "sqlite" + + if scheme == "sqlite": + return "sqlite" + + if scheme.startswith("postgresql"): + return "postgresql" + + return scheme + + +def create_db_engine(db_path: str | Path | None = None, db_url: str | None = None): + """Create a SQLAlchemy engine for the provided DB target.""" + + try: + from sqlalchemy import create_engine + except ImportError as exc: + raise ImportError("SQLAlchemy is required for DSN-based database engine support.") from exc + + resolved = resolve_db_url(db_path=db_path, db_url=db_url) + return create_engine(resolved) diff --git a/src/gdm/db/postgres_store.py b/src/gdm/db/postgres_store.py new file mode 100644 index 00000000..880ec252 --- /dev/null +++ b/src/gdm/db/postgres_store.py @@ -0,0 +1,353 @@ +"""PostgreSQL persistence helpers for GDM systems. + +This module currently persists and restores transactional snapshot payloads +through GDM additive tables. +""" + +from __future__ import annotations + +import sqlite3 +import tempfile +from pathlib import Path +from typing import Type + +from sqlalchemy import MetaData +from sqlalchemy import CheckConstraint +from sqlalchemy import create_engine +from sqlalchemy import text + +from gdm.db.connection import create_db_engine +from gdm.db.sqlite_store import DEFAULT_DB_FORMAT_VERSION +from gdm.db.sqlite_store import _attach_time_series_from_snapshot +from gdm.db.sqlite_store import _load_distribution_topology_from_normalized +from gdm.db.sqlite_store import write_system_to_db as write_system_to_sqlite +from gdm.db.sqlite_store_snapshot import ( + _decode_snapshot_payload, + _restore_time_series_sidecar, + _serialize_system_to_json_text, +) + + +def _ensure_gdm_tables_postgres(conn) -> None: + conn.execute( + text( + """ + CREATE TABLE IF NOT EXISTS gdm_metadata ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ) + """ + ) + ) + + conn.execute( + text( + """ + CREATE TABLE IF NOT EXISTS gdm_system_snapshots ( + system_kind TEXT PRIMARY KEY, + payload_json TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP + ) + """ + ) + ) + + conn.execute( + text( + """ + CREATE TABLE IF NOT EXISTS gdm_component_uuid_map ( + component_type TEXT NOT NULL, + component_id BIGINT NOT NULL, + uuid TEXT NOT NULL, + PRIMARY KEY (component_type, component_id), + UNIQUE (component_type, uuid) + ) + """ + ) + ) + + conn.execute( + text( + """ + CREATE TABLE IF NOT EXISTS gdm_distribution_normalized_cache ( + system_kind TEXT PRIMARY KEY, + sqlite_payload BYTEA NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP + ) + """ + ) + ) + + +def _upsert_metadata_postgres(conn, key: str, value: str | None) -> None: + if value is None: + return + + conn.execute( + text( + """ + INSERT INTO gdm_metadata(key, value) + VALUES (:key, :value) + ON CONFLICT(key) DO UPDATE SET value = EXCLUDED.value + """ + ), + {"key": key, "value": str(value)}, + ) + + +def _build_distribution_normalized_cache_payload(system) -> bytes: + with tempfile.TemporaryDirectory() as tmp_dir: + sqlite_path = Path(tmp_dir) / "distribution_normalized.sqlite" + write_system_to_sqlite( + system=system, + db_path=sqlite_path, + replace=True, + initialize_schema=True, + system_kind="distribution", + ) + return sqlite_path.read_bytes() + + +def _build_distribution_normalized_sqlite_db(system, sqlite_path: Path) -> None: + write_system_to_sqlite( + system=system, + db_path=sqlite_path, + replace=True, + initialize_schema=True, + system_kind="distribution", + ) + + +def _sync_sqlite_normalized_tables_to_postgres( + sqlite_path: Path, postgres_conn, replace: bool +) -> None: + sqlite_engine = create_engine(f"sqlite:///{sqlite_path}") + sqlite_metadata = MetaData() + sqlite_metadata.reflect(bind=sqlite_engine) + + mirror_metadata = MetaData() + source_tables = [ + table + for table in sqlite_metadata.sorted_tables + if not table.name.startswith("sqlite_") and not table.name.startswith("gdm_") + ] + for table in source_tables: + table.to_metadata(mirror_metadata) + + for table in mirror_metadata.tables.values(): + for constraint in list(table.constraints): + if isinstance(constraint, CheckConstraint): + table.constraints.remove(constraint) + + mirror_metadata.create_all(bind=postgres_conn, checkfirst=True) + + target_tables = [mirror_metadata.tables[table.name] for table in source_tables] + if replace: + for table in reversed(target_tables): + postgres_conn.execute(table.delete()) + + with sqlite_engine.connect() as sqlite_conn: + for source_table in source_tables: + rows = sqlite_conn.execute(source_table.select()).mappings().all() + if not rows: + continue + target_table = mirror_metadata.tables[source_table.name] + postgres_conn.execute(target_table.insert(), [dict(row) for row in rows]) + + sqlite_engine.dispose() + + +def _load_distribution_from_normalized_cache_payload(payload: bytes): + with tempfile.TemporaryDirectory() as tmp_dir: + sqlite_path = Path(tmp_dir) / "distribution_normalized.sqlite" + sqlite_path.write_bytes(payload) + with sqlite3.connect(sqlite_path) as conn: + normalized = _load_distribution_topology_from_normalized(conn) + if normalized is not None: + _attach_time_series_from_snapshot(conn, normalized) + return normalized + + +def write_system_to_db( + *, + system, + db_path: str | Path | None = None, + db_url: str | None = None, + schema_path: str | Path | None = None, + replace: bool = True, + initialize_schema: bool = True, + system_kind: str, +) -> None: + """Write a system snapshot to PostgreSQL with transactional replace semantics.""" + if schema_path is not None: + raise NotImplementedError( + "Custom schema_path is not supported for PostgreSQL persistence yet." + ) + + payload = _serialize_system_to_json_text(system) + normalized_payload: bytes | None = None + normalized_sqlite_path: Path | None = None + tmp_dir: tempfile.TemporaryDirectory | None = None + if system_kind == "distribution": + tmp_dir = tempfile.TemporaryDirectory() + normalized_sqlite_path = Path(tmp_dir.name) / "distribution_normalized.sqlite" + _build_distribution_normalized_sqlite_db(system, normalized_sqlite_path) + normalized_payload = normalized_sqlite_path.read_bytes() + + engine = create_db_engine(db_path=db_path, db_url=db_url) + try: + with engine.begin() as conn: + if initialize_schema: + _ensure_gdm_tables_postgres(conn) + + if system_kind == "distribution" and normalized_sqlite_path is not None: + _sync_sqlite_normalized_tables_to_postgres( + normalized_sqlite_path, + conn, + replace=replace, + ) + + if replace: + conn.execute( + text("DELETE FROM gdm_system_snapshots WHERE system_kind = :system_kind"), + {"system_kind": system_kind}, + ) + + if system_kind == "distribution": + conn.execute( + text( + "DELETE FROM gdm_distribution_normalized_cache " + "WHERE system_kind = :system_kind" + ), + {"system_kind": system_kind}, + ) + + conn.execute( + text( + """ + INSERT INTO gdm_system_snapshots(system_kind, payload_json, created_at) + VALUES (:system_kind, :payload_json, CURRENT_TIMESTAMP) + ON CONFLICT(system_kind) + DO UPDATE SET payload_json = EXCLUDED.payload_json, created_at = CURRENT_TIMESTAMP + """ + ), + {"system_kind": system_kind, "payload_json": payload}, + ) + + if system_kind == "distribution" and normalized_payload is not None: + conn.execute( + text( + """ + INSERT INTO gdm_distribution_normalized_cache( + system_kind, + sqlite_payload, + created_at + ) + VALUES (:system_kind, :sqlite_payload, CURRENT_TIMESTAMP) + ON CONFLICT(system_kind) + DO UPDATE SET + sqlite_payload = EXCLUDED.sqlite_payload, + created_at = CURRENT_TIMESTAMP + """ + ), + {"system_kind": system_kind, "sqlite_payload": normalized_payload}, + ) + + _upsert_metadata_postgres(conn, "gdm_db_format_version", DEFAULT_DB_FORMAT_VERSION) + _upsert_metadata_postgres( + conn, f"{system_kind}_data_format_version", system.data_format_version + ) + if system_kind == "distribution": + _upsert_metadata_postgres( + conn, + f"{system_kind}_storage_mode", + "snapshot+normalized+timeseries-associations-v1", + ) + finally: + if tmp_dir is not None: + tmp_dir.cleanup() + + +def load_system_from_db( + *, + system_cls: Type, + db_path: str | Path | None = None, + db_url: str | None = None, + system_kind: str, + prefer_normalized: bool = False, +) -> object: + """Load a system from PostgreSQL snapshot tables.""" + engine = create_db_engine(db_path=db_path, db_url=db_url) + if system_kind == "distribution" and prefer_normalized: + with engine.connect() as conn: + cached_row = conn.execute( + text( + """ + SELECT sqlite_payload + FROM gdm_distribution_normalized_cache + WHERE system_kind = :system_kind + """ + ), + {"system_kind": system_kind}, + ).fetchone() + + if cached_row is not None and cached_row[0]: + normalized_system = _load_distribution_from_normalized_cache_payload(cached_row[0]) + if normalized_system is not None: + return normalized_system + + with engine.connect() as conn: + row = conn.execute( + text("SELECT payload_json FROM gdm_system_snapshots WHERE system_kind = :system_kind"), + {"system_kind": system_kind}, + ).fetchone() + + if row is None: + raise ValueError(f"No persisted '{system_kind}' system found in target database") + + payload = row[0] + if not payload: + raise ValueError(f"Persisted payload for '{system_kind}' is empty in target database") + + snapshot = _decode_snapshot_payload(payload) + with tempfile.TemporaryDirectory() as tmp_dir: + temp_json = Path(tmp_dir) / f"{system_kind}_snapshot.json" + temp_json.write_text(snapshot["system_json"]) + _restore_time_series_sidecar(Path(tmp_dir), snapshot) + return system_cls.from_json(temp_json) + + +def load_snapshot_payload( + db_path: str | Path | None = None, + system_kind: str = "distribution", + db_url: str | None = None, +) -> dict: + """Return raw snapshot payload as a JSON dictionary for inspection.""" + engine = create_db_engine(db_path=db_path, db_url=db_url) + with engine.connect() as conn: + row = conn.execute( + text("SELECT payload_json FROM gdm_system_snapshots WHERE system_kind = :system_kind"), + {"system_kind": system_kind}, + ).fetchone() + + if row is None: + raise ValueError(f"No persisted '{system_kind}' system found in target database") + + payload = row[0] + snapshot = _decode_snapshot_payload(payload) + return { + "snapshot_format": "gdm-postgres-v1", + "system_json": snapshot["system_json"], + "time_series_directory": snapshot.get("time_series_directory"), + "time_series_zip_b64": snapshot.get("time_series_zip_b64"), + } + + +def inspect_snapshot_metadata( + db_path: str | Path | None = None, db_url: str | None = None +) -> dict[str, str]: + """Return GDM metadata key-values for debugging and validation.""" + engine = create_db_engine(db_path=db_path, db_url=db_url) + with engine.connect() as conn: + rows = conn.execute(text("SELECT key, value FROM gdm_metadata")).fetchall() + return {str(key): str(value) for key, value in rows} diff --git a/src/gdm/db/sqlite_store.py b/src/gdm/db/sqlite_store.py index ee133160..9a93392a 100644 --- a/src/gdm/db/sqlite_store.py +++ b/src/gdm/db/sqlite_store.py @@ -31,6 +31,8 @@ VoltageTypes, ) from gdm.db.sqlite_store_identity import _fetch_component_uuid, _upsert_component_uuid_map +from gdm.db.connection import sqlite_path_from_target +from gdm.db.connection import get_backend_name from gdm.db.sqlite_store_schema import ( _ensure_gdm_tables, _initialize_schema, @@ -90,7 +92,8 @@ def write_system_to_db( *, system, - db_path: str | Path, + db_path: str | Path | None = None, + db_url: str | None = None, schema_path: str | Path | None = None, replace: bool = True, initialize_schema: bool = True, @@ -102,8 +105,10 @@ def write_system_to_db( ---------- system : System The GDM system instance to serialize and persist. - db_path : str | Path - Target SQLite database path. + db_path : str | Path | None + Legacy SQLite database path. + db_url : str | None + Database URL/DSN. schema_path : str | Path | None Optional path to SQL schema script. If omitted, repository default is used. replace : bool @@ -114,7 +119,13 @@ def write_system_to_db( Logical discriminator for stored system payloads. """ - db_path = Path(db_path) + backend = get_backend_name(db_path=db_path, db_url=db_url) + if backend != "sqlite": + raise NotImplementedError( + "PostgreSQL persistence is in progress. Current write path supports SQLite targets only." + ) + + db_path = sqlite_path_from_target(db_path=db_path, db_url=db_url) payload = _serialize_system_to_json_text(system) with sqlite3.connect(db_path) as conn: conn.execute("PRAGMA foreign_keys = ON") @@ -153,7 +164,8 @@ def write_system_to_db( def load_system_from_db( *, system_cls: Type, - db_path: str | Path, + db_path: str | Path | None = None, + db_url: str | None = None, system_kind: str, prefer_normalized: bool = False, ) -> object: @@ -163,8 +175,10 @@ def load_system_from_db( ---------- system_cls : Type Target class used for deserialization (`DistributionSystem`, `CatalogSystem`). - db_path : str | Path - Source SQLite database path. + db_path : str | Path | None + Legacy SQLite database path. + db_url : str | None + Database URL/DSN. system_kind : str Logical discriminator for stored system payloads. @@ -174,7 +188,13 @@ def load_system_from_db( Deserialized system instance. """ - db_path = Path(db_path) + backend = get_backend_name(db_path=db_path, db_url=db_url) + if backend != "sqlite": + raise NotImplementedError( + "PostgreSQL persistence is in progress. Current load path supports SQLite targets only." + ) + + db_path = sqlite_path_from_target(db_path=db_path, db_url=db_url) with sqlite3.connect(db_path) as conn: conn.execute("PRAGMA foreign_keys = ON") if system_kind == "distribution" and prefer_normalized: @@ -880,9 +900,19 @@ def _attach_time_series_from_snapshot( continue -def load_snapshot_payload(db_path: str | Path, system_kind: str) -> dict: +def load_snapshot_payload( + db_path: str | Path | None = None, + system_kind: str = "distribution", + db_url: str | None = None, +) -> dict: """Return raw snapshot payload as a JSON dictionary for inspection.""" - db_path = Path(db_path) + backend = get_backend_name(db_path=db_path, db_url=db_url) + if backend != "sqlite": + raise NotImplementedError( + "PostgreSQL snapshot inspection is in progress. Current helper supports SQLite targets only." + ) + + db_path = sqlite_path_from_target(db_path=db_path, db_url=db_url) with sqlite3.connect(db_path) as conn: row = conn.execute( "SELECT payload_json FROM gdm_system_snapshots WHERE system_kind = ?", diff --git a/src/gdm/db/sqlite_store_schema.py b/src/gdm/db/sqlite_store_schema.py index 48b5e9b7..89f179c6 100644 --- a/src/gdm/db/sqlite_store_schema.py +++ b/src/gdm/db/sqlite_store_schema.py @@ -5,6 +5,8 @@ import sqlite3 from pathlib import Path +from gdm.db.connection import sqlite_path_from_target + def default_schema_path() -> Path: """Return the default path to the SQL schema file in the repository.""" @@ -68,9 +70,11 @@ def _upsert_metadata(conn: sqlite3.Connection, key: str, value: str | None) -> N ) -def inspect_snapshot_metadata(db_path: str | Path) -> dict[str, str]: +def inspect_snapshot_metadata( + db_path: str | Path | None = None, db_url: str | None = None +) -> dict[str, str]: """Return GDM metadata key-values for debugging and validation.""" - db_path = Path(db_path) + db_path = sqlite_path_from_target(db_path=db_path, db_url=db_url) with sqlite3.connect(db_path) as conn: rows = conn.execute("SELECT key, value FROM gdm_metadata").fetchall() return {key: value for key, value in rows} diff --git a/src/gdm/db/store.py b/src/gdm/db/store.py new file mode 100644 index 00000000..938a2408 --- /dev/null +++ b/src/gdm/db/store.py @@ -0,0 +1,140 @@ +"""Backend-dispatching persistence adapters for GDM systems.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Type + +from gdm.db.connection import get_backend_name +from gdm.db.postgres_store import ( + inspect_snapshot_metadata as inspect_snapshot_metadata_postgres, + load_snapshot_payload as load_snapshot_payload_postgres, + load_system_from_db as load_system_from_postgres, + write_system_to_db as write_system_to_postgres, +) +from gdm.db.sqlite_store import ( + DEFAULT_DB_FORMAT_VERSION, + load_snapshot_payload as load_snapshot_payload_sqlite, + load_system_from_db as load_system_from_sqlite, + write_system_to_db as write_system_to_sqlite, +) +from gdm.db.sqlite_store_schema import default_schema_path +from gdm.db.sqlite_store_schema import ( + inspect_snapshot_metadata as inspect_snapshot_metadata_sqlite, +) + + +def write_system_to_db( + *, + system, + db_path: str | Path | None = None, + db_url: str | None = None, + schema_path: str | Path | None = None, + replace: bool = True, + initialize_schema: bool = True, + system_kind: str, +) -> None: + """Write a system to a supported DB backend.""" + backend = get_backend_name(db_path=db_path, db_url=db_url) + if backend == "sqlite": + return write_system_to_sqlite( + system=system, + db_path=db_path, + db_url=db_url, + schema_path=schema_path, + replace=replace, + initialize_schema=initialize_schema, + system_kind=system_kind, + ) + + if backend == "postgresql": + return write_system_to_postgres( + system=system, + db_path=db_path, + db_url=db_url, + schema_path=schema_path, + replace=replace, + initialize_schema=initialize_schema, + system_kind=system_kind, + ) + + raise NotImplementedError(f"Database backend '{backend}' is not supported.") + + +def load_system_from_db( + *, + system_cls: Type, + db_path: str | Path | None = None, + db_url: str | None = None, + system_kind: str, + prefer_normalized: bool = False, +) -> object: + """Load a system from a supported DB backend.""" + backend = get_backend_name(db_path=db_path, db_url=db_url) + if backend == "sqlite": + return load_system_from_sqlite( + system_cls=system_cls, + db_path=db_path, + db_url=db_url, + system_kind=system_kind, + prefer_normalized=prefer_normalized, + ) + + if backend == "postgresql": + return load_system_from_postgres( + system_cls=system_cls, + db_path=db_path, + db_url=db_url, + system_kind=system_kind, + prefer_normalized=prefer_normalized, + ) + + raise NotImplementedError(f"Database backend '{backend}' is not supported.") + + +def load_snapshot_payload( + db_path: str | Path | None = None, + system_kind: str = "distribution", + db_url: str | None = None, +) -> dict: + """Return raw snapshot payload as a JSON dictionary for inspection.""" + backend = get_backend_name(db_path=db_path, db_url=db_url) + if backend == "sqlite": + return load_snapshot_payload_sqlite( + db_path=db_path, + db_url=db_url, + system_kind=system_kind, + ) + + if backend == "postgresql": + return load_snapshot_payload_postgres( + db_path=db_path, + db_url=db_url, + system_kind=system_kind, + ) + + raise NotImplementedError(f"Database backend '{backend}' is not supported.") + + +def inspect_snapshot_metadata( + db_path: str | Path | None = None, db_url: str | None = None +) -> dict[str, str]: + """Return GDM metadata key-values for debugging and validation.""" + backend = get_backend_name(db_path=db_path, db_url=db_url) + if backend == "sqlite": + return inspect_snapshot_metadata_sqlite(db_path=db_path, db_url=db_url) + + if backend == "postgresql": + return inspect_snapshot_metadata_postgres(db_path=db_path, db_url=db_url) + + raise NotImplementedError(f"Database backend '{backend}' is not supported.") + + +__all__ = [ + "DEFAULT_DB_FORMAT_VERSION", + "default_schema_path", + "inspect_snapshot_metadata", + "load_snapshot_payload", + "load_system_from_db", + "write_system_to_db", +] diff --git a/src/gdm/distribution/catalog_system.py b/src/gdm/distribution/catalog_system.py index 46ac1945..cf3c4b16 100644 --- a/src/gdm/distribution/catalog_system.py +++ b/src/gdm/distribution/catalog_system.py @@ -13,17 +13,19 @@ def __init__(self, *args, **kwargs): def to_db( self, - db_path: str | Path, + db_path: str | Path | None = None, + db_url: str | None = None, schema_path: str | Path | None = None, replace: bool = True, initialize_schema: bool = True, ) -> None: - """Persist the catalog system to a SQLite database.""" + """Persist the catalog system to a database target.""" from gdm.db import write_system_to_db write_system_to_db( system=self, db_path=db_path, + db_url=db_url, schema_path=schema_path, replace=replace, initialize_schema=initialize_schema, @@ -31,8 +33,15 @@ def to_db( ) @classmethod - def from_db(cls, db_path: str | Path) -> "CatalogSystem": - """Load a catalog system from a SQLite database.""" + def from_db( + cls, db_path: str | Path | None = None, db_url: str | None = None + ) -> "CatalogSystem": + """Load a catalog system from a database target.""" from gdm.db import load_system_from_db - return load_system_from_db(system_cls=cls, db_path=db_path, system_kind="catalog") + return load_system_from_db( + system_cls=cls, + db_path=db_path, + db_url=db_url, + system_kind="catalog", + ) diff --git a/src/gdm/distribution/distribution_system.py b/src/gdm/distribution/distribution_system.py index 7a8e737f..91a7505f 100644 --- a/src/gdm/distribution/distribution_system.py +++ b/src/gdm/distribution/distribution_system.py @@ -880,12 +880,13 @@ def _add_edge_traces( def to_db( self, - db_path: str | Path, + db_path: str | Path | None = None, + db_url: str | None = None, schema_path: str | Path | None = None, replace: bool = True, initialize_schema: bool = True, ) -> None: - """Persist the system to a SQLite database. + """Persist the system to a database target. This implementation initializes the reference schema and stores a transactional system snapshot in GDM-owned additive tables. @@ -895,6 +896,7 @@ def to_db( write_system_to_db( system=self, db_path=db_path, + db_url=db_url, schema_path=schema_path, replace=replace, initialize_schema=initialize_schema, @@ -902,13 +904,20 @@ def to_db( ) @classmethod - def from_db(cls, db_path: str | Path, prefer_normalized: bool = False) -> "DistributionSystem": - """Load a distribution system from a SQLite database. + def from_db( + cls, + db_path: str | Path | None = None, + db_url: str | None = None, + prefer_normalized: bool = False, + ) -> "DistributionSystem": + """Load a distribution system from a database target. Parameters ---------- - db_path : str | Path - SQLite database path. + db_path : str | Path | None + Legacy SQLite database path. + db_url : str | None + Database URL/DSN. prefer_normalized : bool If True, attempts to reconstruct from normalized topology tables first. If normalized data is unavailable, falls back to stored snapshot payload. @@ -918,6 +927,7 @@ def from_db(cls, db_path: str | Path, prefer_normalized: bool = False) -> "Distr return load_system_from_db( system_cls=cls, db_path=db_path, + db_url=db_url, system_kind="distribution", prefer_normalized=prefer_normalized, ) diff --git a/tests/conftest.py b/tests/conftest.py index e0810836..fcb083ab 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,4 +1,6 @@ from datetime import datetime, timedelta +import os +from pathlib import Path from uuid import uuid4 import pytest @@ -525,6 +527,90 @@ def sample_distribution_system_with_nonsequential_timeseries( return system +@pytest.fixture(name="distribution_system_with_single_timeseries_sqlite_db") +def sample_distribution_system_with_single_timeseries_sqlite_db( + distribution_system_with_single_timeseries, +): + """Persist the single-timeseries distribution system to tests/mocks SQLite database.""" + mock_dir = Path(__file__).resolve().parent / "mocks" + mock_dir.mkdir(parents=True, exist_ok=True) + db_path = mock_dir / "distribution_with_single_timeseries.sqlite" + distribution_system_with_single_timeseries.to_db( + db_path=db_path, + replace=True, + initialize_schema=True, + ) + return db_path + + +def write_sample_distribution_system_with_single_timeseries_to_sqlite_and_postgres( + distribution_system_with_single_timeseries: DistributionSystem, + sqlite_db_path: str | Path | None = None, + postgres_db_url: str | None = None, +) -> dict[str, str | Path]: + """Persist sample_distribution_system_with_single_timeseries to SQLite and PostgreSQL. + + This helper is intended for reuse in fixtures and setup code (not as a test). + """ + mock_dir = Path(__file__).resolve().parent / "mocks" + mock_dir.mkdir(parents=True, exist_ok=True) + resolved_sqlite_db_path = ( + Path(sqlite_db_path) + if sqlite_db_path is not None + else mock_dir / "distribution_with_single_timeseries.sqlite" + ) + + distribution_system_with_single_timeseries.to_db( + db_path=resolved_sqlite_db_path, + replace=True, + initialize_schema=True, + ) + + resolved_postgres_db_url = postgres_db_url or os.getenv("GDM_TEST_POSTGRES_DSN") + if not resolved_postgres_db_url: + raise ValueError( + "PostgreSQL DSN is required. Provide postgres_db_url or set GDM_TEST_POSTGRES_DSN." + ) + + distribution_system_with_single_timeseries.to_db( + db_url=resolved_postgres_db_url, + replace=True, + initialize_schema=True, + ) + + return { + "sqlite_db_path": resolved_sqlite_db_path, + "postgres_db_url": resolved_postgres_db_url, + } + + +@pytest.fixture(name="distribution_system_with_single_timeseries_postgres_db") +def sample_distribution_system_with_single_timeseries_postgres_db( + distribution_system_with_single_timeseries, +): + """Persist the single-timeseries distribution system to PostgreSQL using DSN.""" + db_url = os.getenv("GDM_TEST_POSTGRES_DSN") + if not db_url: + pytest.skip("Set GDM_TEST_POSTGRES_DSN to persist fixture data in PostgreSQL.") + + distribution_system_with_single_timeseries.to_db( + db_url=db_url, + replace=True, + initialize_schema=True, + ) + return db_url + + +@pytest.fixture(name="distribution_system_with_single_timeseries_sqlite_and_postgres") +def sample_distribution_system_with_single_timeseries_sqlite_and_postgres( + distribution_system_with_single_timeseries, +): + """Persist the single-timeseries distribution system to both SQLite and PostgreSQL.""" + return write_sample_distribution_system_with_single_timeseries_to_sqlite_and_postgres( + distribution_system_with_single_timeseries=distribution_system_with_single_timeseries, + ) + + # ── MCP test fixtures ────────────────────────────────────────────── from tests.mocks.systems import build_simple_system, build_multi_substation_system diff --git a/tests/test_db_io.py b/tests/test_db_io.py index 140b7fce..ba64f0f7 100644 --- a/tests/test_db_io.py +++ b/tests/test_db_io.py @@ -1,6 +1,9 @@ import sqlite3 +import os from uuid import uuid4 +import pytest +import psycopg from infrasys.time_series_models import SingleTimeSeries from gdm.distribution import CatalogSystem, DistributionSystem @@ -49,6 +52,13 @@ from gdm.quantities import ActivePower, ReactivePower +def _postgres_dsn_or_skip() -> str: + dsn = os.getenv("GDM_TEST_POSTGRES_DSN") + if not dsn: + pytest.skip("Set GDM_TEST_POSTGRES_DSN to run PostgreSQL persistence tests.") + return dsn + + def test_distribution_system_to_db_from_db_round_trip(tmp_path, simple_distribution_system): system: DistributionSystem = simple_distribution_system db_path = tmp_path / "distribution.sqlite" @@ -64,6 +74,24 @@ def test_distribution_system_to_db_from_db_round_trip(tmp_path, simple_distribut } +def test_distribution_system_to_db_from_db_round_trip_with_sqlite_url( + tmp_path, simple_distribution_system +): + system: DistributionSystem = simple_distribution_system + db_path = tmp_path / "distribution_url.sqlite" + db_url = f"sqlite:///{db_path}" + + system.to_db(db_url=db_url) + loaded_system = DistributionSystem.from_db(db_url=db_url) + + initial_components = list(system.iter_all_components()) + loaded_components = list(loaded_system.iter_all_components()) + assert len(loaded_components) == len(initial_components) + assert {component.uuid for component in loaded_components} == { + component.uuid for component in initial_components + } + + def test_distribution_system_to_db_replace_semantics(tmp_path, simple_distribution_system): system: DistributionSystem = simple_distribution_system db_path = tmp_path / "distribution.sqlite" @@ -95,6 +123,159 @@ def test_catalog_system_to_db_from_db_round_trip(tmp_path): assert loaded_equipment.uuid == catalog_equipment.uuid +def test_catalog_system_to_db_from_db_round_trip_with_sqlite_url(tmp_path): + catalog = CatalogSystem(auto_add_composed_components=True) + catalog_equipment = LoadEquipment.example().model_copy( + update={"uuid": uuid4(), "name": "catalog_load_equipment_url"} + ) + catalog.add_component(catalog_equipment) + + db_path = tmp_path / "catalog_url.sqlite" + db_url = f"sqlite:///{db_path}" + catalog.to_db(db_url=db_url) + loaded_catalog = CatalogSystem.from_db(db_url=db_url) + + loaded_equipment = loaded_catalog.get_component( + LoadEquipment, name="catalog_load_equipment_url" + ) + assert loaded_equipment.uuid == catalog_equipment.uuid + + +def test_distribution_system_to_db_from_db_round_trip_with_postgres_dsn( + simple_distribution_system, +): + db_url = _postgres_dsn_or_skip() + + system: DistributionSystem = simple_distribution_system + system.to_db(db_url=db_url, replace=True) + loaded_system = DistributionSystem.from_db(db_url=db_url) + + initial_components = list(system.iter_all_components()) + loaded_components = list(loaded_system.iter_all_components()) + assert len(loaded_components) == len(initial_components) + assert {component.uuid for component in loaded_components} == { + component.uuid for component in initial_components + } + + +def test_distribution_system_to_db_replace_semantics_with_postgres_dsn( + simple_distribution_system, +): + db_url = _postgres_dsn_or_skip() + + system: DistributionSystem = simple_distribution_system + system.to_db(db_url=db_url, replace=True) + + modified_system = system.deepcopy() + removed_component = next(iter(modified_system.get_components(DistributionLoad))) + modified_system.remove_component(removed_component, cascade_down=False) + modified_system.to_db(db_url=db_url, replace=True) + + loaded_system = DistributionSystem.from_db(db_url=db_url) + assert len(list(loaded_system.iter_all_components())) == len( + list(modified_system.iter_all_components()) + ) + + +def test_catalog_system_to_db_from_db_round_trip_with_postgres_dsn(): + db_url = _postgres_dsn_or_skip() + + catalog = CatalogSystem(auto_add_composed_components=True) + catalog_equipment = LoadEquipment.example().model_copy( + update={"uuid": uuid4(), "name": "catalog_load_equipment_postgres"} + ) + catalog.add_component(catalog_equipment) + + catalog.to_db(db_url=db_url, replace=True) + loaded_catalog = CatalogSystem.from_db(db_url=db_url) + + loaded_equipment = loaded_catalog.get_component( + LoadEquipment, name="catalog_load_equipment_postgres" + ) + assert loaded_equipment.uuid == catalog_equipment.uuid + + +def test_distribution_system_from_db_prefer_normalized_with_postgres_dsn( + simple_distribution_system, +): + db_url = _postgres_dsn_or_skip() + + system: DistributionSystem = simple_distribution_system + system.to_db(db_url=db_url, replace=True) + + loaded_system = DistributionSystem.from_db(db_url=db_url, prefer_normalized=True) + + expected_buses = list(system.get_components(DistributionBus)) + expected_feeders = {component.name for component in system.get_components(DistributionFeeder)} + expected_substations = { + component.name for component in system.get_components(DistributionSubstation) + } + expected_loads = list(system.get_components(DistributionLoad)) + + loaded_buses = list(loaded_system.get_components(DistributionBus)) + loaded_feeders = list(loaded_system.get_components(DistributionFeeder)) + loaded_substations = list(loaded_system.get_components(DistributionSubstation)) + loaded_loads = list(loaded_system.get_components(DistributionLoad)) + + assert len(loaded_buses) == len(expected_buses) + assert len(loaded_feeders) == len(expected_feeders) + assert len(loaded_substations) == len(expected_substations) + assert len(loaded_loads) == len(expected_loads) + + assert {component.uuid for component in loaded_buses} == { + component.uuid for component in expected_buses + } + assert {component.uuid for component in loaded_loads} == { + component.uuid for component in expected_loads + } + + +def test_distribution_system_from_db_prefer_normalized_attaches_time_series_with_postgres_dsn( + distribution_system_with_single_timeseries, +): + db_url = _postgres_dsn_or_skip() + + system: DistributionSystem = distribution_system_with_single_timeseries + system.to_db(db_url=db_url, replace=True) + + original_load = next(iter(system.get_components(DistributionLoad))) + loaded_system = DistributionSystem.from_db(db_url=db_url, prefer_normalized=True) + loaded_load = loaded_system.get_component(DistributionLoad, name=original_load.name) + + assert loaded_system.has_time_series(loaded_load) + + original_metadata = system.list_time_series_metadata(original_load) + loaded_metadata = loaded_system.list_time_series_metadata(loaded_load) + assert len(loaded_metadata) == len(original_metadata) + + +def test_postgres_table_structure_matches_sqlite_after_distribution_write( + simple_distribution_system, +): + db_url = _postgres_dsn_or_skip() + + system: DistributionSystem = simple_distribution_system + system.to_db(db_url=db_url, replace=True) + + psycopg_dsn = db_url.replace("postgresql+psycopg://", "postgresql://", 1) + with psycopg.connect(psycopg_dsn) as conn: + with conn.cursor() as cur: + cur.execute( + """ + SELECT EXISTS ( + SELECT 1 + FROM information_schema.tables + WHERE table_schema = 'public' + AND table_name = 'distribution_buses' + ) + """ + ) + row = cur.fetchone() + + assert row is not None + assert row[0] is True + + def test_distribution_system_from_db_prefer_normalized_topology( tmp_path, simple_distribution_system ): From 2af60ddd3f51124e89b8b3535f3e9bdd7916aefc Mon Sep 17 00:00:00 2001 From: Aadil Latif Date: Sat, 28 Feb 2026 08:28:12 -0700 Subject: [PATCH 4/6] Configure Ruff C901 per-file ignores for DB modules --- pyproject.toml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 8afdb00f..d17e11ec 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -100,6 +100,10 @@ allow-direct-references = true [tool.ruff.lint.per-file-ignores] "__init__.py" = ["E402", "F401"] "**/{tests,docs,tools}/*" = ["E402"] +"src/gdm/db/sqlite_store.py" = ["C901"] +"src/gdm/db/sqlite_store_cap_voltage_xfmr_reg.py" = ["C901"] +"src/gdm/db/sqlite_store_geometry.py" = ["C901"] +"src/gdm/db/sqlite_store_load_solar_battery.py" = ["C901"] [tool.hatch.build.targets.wheel] packages = ["src/gdm"] From 84d58682bba6f069524473e69a512cb1e0d12729 Mon Sep 17 00:00:00 2001 From: Aadil Latif Date: Fri, 6 Mar 2026 09:23:14 -0700 Subject: [PATCH 5/6] Fix CI: bundle schema SQL, reduce complexity, fix B112 - Move distribution_schema.sql into src/gdm/db/ so it ships with the package and is available in CI (fixes FileNotFoundError). - Add gitignore exception for the bundled schema file. - Extract _delete_distribution_tables and _write_distribution_buses from _write_distribution_topology to reduce cyclomatic complexity. - Extract _load_or_cache_capacitor_equipment and _build_capacitor_controller from _load_distribution_capacitors_from_normalized. - Replace bare except/continue with loguru debug logging (B112). --- .gitignore | 1 + src/gdm/db/distribution_schema.sql | 1288 +++++++++++++++++ src/gdm/db/sqlite_store.py | 327 +++-- .../db/sqlite_store_cap_voltage_xfmr_reg.py | 376 ++--- src/gdm/db/sqlite_store_schema.py | 4 +- 5 files changed, 1662 insertions(+), 334 deletions(-) create mode 100644 src/gdm/db/distribution_schema.sql diff --git a/.gitignore b/.gitignore index b6cd008d..4fe35822 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ __pycache__/ *.sqlite *.db *.sql +!src/gdm/db/distribution_schema.sql *.ruff_cache/ # Distribution / packaging diff --git a/src/gdm/db/distribution_schema.sql b/src/gdm/db/distribution_schema.sql new file mode 100644 index 00000000..6c75a086 --- /dev/null +++ b/src/gdm/db/distribution_schema.sql @@ -0,0 +1,1288 @@ +-- DISCLAIMER +-- The current version of this schema only works for SQLITE >=3.45 +-- When adding new functionality, think about the following: +-- 1. Simplicity and ease of use over complexity, +-- 2. Clear, concise and strict fields but allow for extensability, +-- 3. User friendly over performance, but consider performance always. +-- WARNING: This script should only be used while testing the schema and should +-- not be applied to existing datasets since it drops all existing information. +-- +-- DESIGN NOTES +-- * Every field from every pydantic model is represented as a native column. +-- * No JSON columns are used. All arrays and nested objects are normalized +-- into dedicated tables linked by foreign keys. +-- * Physical quantities are stored as a REAL value + TEXT unit column pair. +-- * Ordered arrays (phases, tap positions, matrix rows, curve points, etc.) +-- include a position_index INTEGER to preserve ordering. +-- * Polymorphic controller types use a type_discriminator TEXT column with a +-- CHECK constraint listing the allowed subtypes. Subtype-specific fields are +-- nullable and only populated for the relevant subtype. +-- * Component tables mirror the Python pydantic model hierarchy: +-- Topology : feeders -> substations -> buses +-- Equipment : wire/cable catalogs -> branch/transformer/load equipment +-- Components: loads, branches, transformers, DERs, switchgear +-- ----------------------------------------------------------------------------- + +-- ============================================================ +-- DROP TABLES (most-dependent first) +-- ============================================================ +DROP TABLE IF EXISTS time_series_associations; +DROP TABLE IF EXISTS regulator_winding_phases; +DROP TABLE IF EXISTS regulator_winding_buses; +DROP TABLE IF EXISTS regulator_controllers; +DROP TABLE IF EXISTS distribution_regulators; +DROP TABLE IF EXISTS transformer_winding_phases; +DROP TABLE IF EXISTS transformer_winding_buses; +DROP TABLE IF EXISTS distribution_transformers; +DROP TABLE IF EXISTS switch_phase_states; +DROP TABLE IF EXISTS matrix_impedance_switch_phases; +DROP TABLE IF EXISTS matrix_impedance_switches; +DROP TABLE IF EXISTS recloser_phase_states; +DROP TABLE IF EXISTS matrix_impedance_recloser_phases; +DROP TABLE IF EXISTS matrix_impedance_reclosers; +DROP TABLE IF EXISTS recloser_reclose_intervals; +DROP TABLE IF EXISTS recloser_controllers; +DROP TABLE IF EXISTS fuse_phase_states; +DROP TABLE IF EXISTS matrix_impedance_fuse_phases; +DROP TABLE IF EXISTS matrix_impedance_fuses; +DROP TABLE IF EXISTS geometry_branch_thermal_limits; +DROP TABLE IF EXISTS geometry_branch_phases; +DROP TABLE IF EXISTS geometry_branches; +DROP TABLE IF EXISTS sequence_impedance_branch_thermal_limits; +DROP TABLE IF EXISTS sequence_impedance_branch_phases; +DROP TABLE IF EXISTS sequence_impedance_branches; +DROP TABLE IF EXISTS matrix_impedance_branch_thermal_limits; +DROP TABLE IF EXISTS matrix_impedance_branch_phases; +DROP TABLE IF EXISTS matrix_impedance_branches; +DROP TABLE IF EXISTS distribution_voltage_source_phases; +DROP TABLE IF EXISTS distribution_voltage_sources; +DROP TABLE IF EXISTS distribution_capacitor_phases; +DROP TABLE IF EXISTS capacitor_controllers; +DROP TABLE IF EXISTS distribution_capacitors; +DROP TABLE IF EXISTS distribution_battery_phases; +DROP TABLE IF EXISTS distribution_batteries; +DROP TABLE IF EXISTS distribution_solar_phases; +DROP TABLE IF EXISTS distribution_solar; +DROP TABLE IF EXISTS distribution_load_phases; +DROP TABLE IF EXISTS distribution_loads; +DROP TABLE IF EXISTS inverter_controllers; +DROP TABLE IF EXISTS inverter_active_power_controls; +DROP TABLE IF EXISTS inverter_reactive_power_controls; +DROP TABLE IF EXISTS bus_voltage_limits; +DROP TABLE IF EXISTS bus_phases; +DROP TABLE IF EXISTS distribution_buses; +DROP TABLE IF EXISTS substation_feeders; +DROP TABLE IF EXISTS distribution_substations; +DROP TABLE IF EXISTS distribution_feeders; +DROP TABLE IF EXISTS thermal_limit_sets; +DROP TABLE IF EXISTS voltage_limit_sets; +DROP TABLE IF EXISTS voltage_source_phases; +DROP TABLE IF EXISTS voltage_source_equipment; +DROP TABLE IF EXISTS phase_voltage_source_equipment; +DROP TABLE IF EXISTS inverter_equipment; +DROP TABLE IF EXISTS battery_equipment; +DROP TABLE IF EXISTS solar_equipment; +DROP TABLE IF EXISTS capacitor_equipment_phases; +DROP TABLE IF EXISTS capacitor_equipment; +DROP TABLE IF EXISTS phase_capacitor_equipment; +DROP TABLE IF EXISTS load_equipment_phases; +DROP TABLE IF EXISTS load_equipment; +DROP TABLE IF EXISTS phase_load_equipment; +DROP TABLE IF EXISTS winding_tap_positions; +DROP TABLE IF EXISTS transformer_coupling_sequences; +DROP TABLE IF EXISTS winding_equipment; +DROP TABLE IF EXISTS distribution_transformer_equipment; +DROP TABLE IF EXISTS impedance_matrix_entries; +DROP TABLE IF EXISTS matrix_impedance_switch_equipment; +DROP TABLE IF EXISTS switch_controllers; +DROP TABLE IF EXISTS recloser_controller_equipment; +DROP TABLE IF EXISTS matrix_impedance_recloser_equipment; +DROP TABLE IF EXISTS matrix_impedance_fuse_equipment; +DROP TABLE IF EXISTS matrix_impedance_branch_equipment; +DROP TABLE IF EXISTS sequence_impedance_branch_equipment; +DROP TABLE IF EXISTS geometry_branch_conductors; +DROP TABLE IF EXISTS geometry_branch_equipment; +DROP TABLE IF EXISTS concentric_cable_equipment; +DROP TABLE IF EXISTS bare_conductor_equipment; +DROP TABLE IF EXISTS time_current_curve_points; +DROP TABLE IF EXISTS time_current_curves; +DROP TABLE IF EXISTS curve_points; +DROP TABLE IF EXISTS curves; +DROP TABLE IF EXISTS wire_insulation_types; +DROP TABLE IF EXISTS line_types; +DROP TABLE IF EXISTS transformer_mountings; +DROP TABLE IF EXISTS connection_types; +DROP TABLE IF EXISTS voltage_types; +DROP TABLE IF EXISTS limit_types; +DROP TABLE IF EXISTS phases; + +PRAGMA foreign_keys = ON; + +-- ============================================================ +-- REFERENCE / ENUM TABLES +-- ============================================================ + +CREATE TABLE phases (name TEXT PRIMARY KEY); +INSERT INTO phases VALUES ('A'), ('B'), ('C'), ('N'), ('S1'), ('S2'); + +CREATE TABLE voltage_types (name TEXT PRIMARY KEY); +INSERT INTO voltage_types VALUES ('line-to-line'), ('line-to-ground'); + +CREATE TABLE connection_types (name TEXT PRIMARY KEY); +INSERT INTO connection_types VALUES + ('STAR'), ('DELTA'), ('OPEN_DELTA'), ('OPEN_STAR'), ('ZIG_ZAG'); + +CREATE TABLE limit_types (name TEXT PRIMARY KEY); +INSERT INTO limit_types VALUES ('min'), ('max'); + +CREATE TABLE transformer_mountings (name TEXT PRIMARY KEY); +INSERT INTO transformer_mountings VALUES + ('POLE_MOUNT'), ('PAD_MOUNT'), ('UNDERGROUND_VAULT'); + +CREATE TABLE line_types (name TEXT PRIMARY KEY); +INSERT INTO line_types VALUES ('OVERHEAD'), ('UNDERGROUND'); + +-- Relative permittivity values for wire insulation materials. +CREATE TABLE wire_insulation_types ( + name TEXT PRIMARY KEY, + dielectric REAL NOT NULL +); +INSERT INTO wire_insulation_types VALUES + ('AIR', 1.0), ('PVC', 3.18), ('XLPE', 2.3), ('EPR', 2.5), + ('PE', 2.25), ('TEFLON', 2.1), ('SILICONE_RUBBER', 3.5), + ('PAPER', 3.7), ('MICA', 6.0); + +-- ============================================================ +-- CURVE TABLES +-- ============================================================ + +-- Generic (x, y) curve -- used for volt-var, volt-watt, PV power-temp, +-- and inverter efficiency curves. +CREATE TABLE curves ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL DEFAULT '' +); + +-- Ordered breakpoints for a Curve. +CREATE TABLE curve_points ( + id INTEGER PRIMARY KEY, + curve_id INTEGER NOT NULL REFERENCES curves (id) ON DELETE CASCADE, + position_index INTEGER NOT NULL CHECK (position_index >= 0), + x_value REAL NOT NULL, + y_value REAL NOT NULL, + UNIQUE (curve_id, position_index) +); + +-- Time-current curve header -- used for fuse and recloser TCC protection curves. +CREATE TABLE time_current_curves ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL DEFAULT '' +); + +-- Ordered breakpoints for a TimeCurrentCurve. +CREATE TABLE time_current_curve_points ( + id INTEGER PRIMARY KEY, + curve_id INTEGER NOT NULL REFERENCES time_current_curves (id) ON DELETE CASCADE, + position_index INTEGER NOT NULL CHECK (position_index >= 0), + current_value REAL NOT NULL CHECK (current_value >= 0), + current_unit TEXT NOT NULL DEFAULT 'ampere', + time_value REAL NOT NULL CHECK (time_value >= 0), + time_unit TEXT NOT NULL DEFAULT 'second', + UNIQUE (curve_id, position_index) +); + +-- ============================================================ +-- WIRE / CABLE CATALOG +-- ============================================================ + +-- Bare conductor (overhead wire) catalog entry. +CREATE TABLE bare_conductor_equipment ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + conductor_diameter REAL NOT NULL CHECK (conductor_diameter > 0), + conductor_diameter_unit TEXT NOT NULL DEFAULT 'meter', + conductor_gmr REAL NOT NULL CHECK (conductor_gmr > 0), + conductor_gmr_unit TEXT NOT NULL DEFAULT 'meter', + ampacity REAL NOT NULL CHECK (ampacity > 0), + ampacity_unit TEXT NOT NULL DEFAULT 'ampere', + emergency_ampacity REAL NOT NULL CHECK (emergency_ampacity > 0), + emergency_ampacity_unit TEXT NOT NULL DEFAULT 'ampere', + ac_resistance REAL NOT NULL CHECK (ac_resistance > 0), + ac_resistance_unit TEXT NOT NULL DEFAULT 'ohm/meter', + dc_resistance REAL NOT NULL CHECK (dc_resistance > 0), + dc_resistance_unit TEXT NOT NULL DEFAULT 'ohm/meter' +); + +-- Concentric (underground) cable catalog entry. +CREATE TABLE concentric_cable_equipment ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + strand_diameter REAL NOT NULL CHECK (strand_diameter > 0), + strand_diameter_unit TEXT NOT NULL DEFAULT 'meter', + conductor_diameter REAL NOT NULL CHECK (conductor_diameter > 0), + conductor_diameter_unit TEXT NOT NULL DEFAULT 'meter', + cable_diameter REAL NOT NULL CHECK (cable_diameter > 0), + cable_diameter_unit TEXT NOT NULL DEFAULT 'meter', + insulation_thickness REAL NOT NULL CHECK (insulation_thickness > 0), + insulation_thickness_unit TEXT NOT NULL DEFAULT 'meter', + insulation_diameter REAL NOT NULL CHECK (insulation_diameter > 0), + insulation_diameter_unit TEXT NOT NULL DEFAULT 'meter', + ampacity REAL NOT NULL CHECK (ampacity > 0), + ampacity_unit TEXT NOT NULL DEFAULT 'ampere', + conductor_gmr REAL NOT NULL CHECK (conductor_gmr > 0), + conductor_gmr_unit TEXT NOT NULL DEFAULT 'meter', + strand_gmr REAL NOT NULL CHECK (strand_gmr > 0), + strand_gmr_unit TEXT NOT NULL DEFAULT 'meter', + phase_ac_resistance REAL NOT NULL CHECK (phase_ac_resistance > 0), + phase_ac_resistance_unit TEXT NOT NULL DEFAULT 'ohm/meter', + strand_ac_resistance REAL NOT NULL CHECK (strand_ac_resistance > 0), + strand_ac_resistance_unit TEXT NOT NULL DEFAULT 'ohm/meter', + num_neutral_strands INTEGER NOT NULL CHECK (num_neutral_strands > 0), + rated_voltage REAL NOT NULL CHECK (rated_voltage > 0), + rated_voltage_unit TEXT NOT NULL DEFAULT 'volt', + insulation TEXT NOT NULL DEFAULT 'PE' + REFERENCES wire_insulation_types (name) +); + +-- ============================================================ +-- BRANCH EQUIPMENT +-- ============================================================ + +-- Sequence impedance branch equipment. +CREATE TABLE sequence_impedance_branch_equipment ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + pos_seq_resistance REAL NOT NULL, + pos_seq_resistance_unit TEXT NOT NULL DEFAULT 'ohm/meter', + zero_seq_resistance REAL NOT NULL, + zero_seq_resistance_unit TEXT NOT NULL DEFAULT 'ohm/meter', + pos_seq_reactance REAL NOT NULL, + pos_seq_reactance_unit TEXT NOT NULL DEFAULT 'ohm/meter', + zero_seq_reactance REAL NOT NULL, + zero_seq_reactance_unit TEXT NOT NULL DEFAULT 'ohm/meter', + pos_seq_capacitance REAL NOT NULL, + pos_seq_capacitance_unit TEXT NOT NULL DEFAULT 'farad/meter', + zero_seq_capacitance REAL NOT NULL, + zero_seq_capacitance_unit TEXT NOT NULL DEFAULT 'farad/meter', + ampacity REAL NOT NULL CHECK (ampacity > 0), + ampacity_unit TEXT NOT NULL DEFAULT 'ampere' +); + +-- Matrix impedance branch equipment header. +-- The N*N R/X/C matrices are stored row-by-row in impedance_matrix_entries. +CREATE TABLE matrix_impedance_branch_equipment ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + construction TEXT NOT NULL DEFAULT 'OVERHEAD' REFERENCES line_types (name), + ampacity REAL NOT NULL CHECK (ampacity > 0), + ampacity_unit TEXT NOT NULL DEFAULT 'ampere' +); + +-- Shared N*N impedance matrix storage for line, fuse, recloser, and switch equipment. +-- equipment_type IN ('LINE','FUSE','RECLOSER','SWITCH') identifies the parent table. +-- matrix_type IN ('R','X','C') identifies resistance, reactance, or capacitance. +-- Cross-table FK enforcement is the responsibility of the application layer. +CREATE TABLE impedance_matrix_entries ( + id INTEGER PRIMARY KEY, + equipment_id INTEGER NOT NULL, + equipment_type TEXT NOT NULL CHECK (equipment_type IN ('LINE','FUSE','RECLOSER','SWITCH')), + matrix_type TEXT NOT NULL CHECK (matrix_type IN ('R','X','C')), + row_idx INTEGER NOT NULL CHECK (row_idx >= 0), + col_idx INTEGER NOT NULL CHECK (col_idx >= 0), + value REAL NOT NULL, + value_unit TEXT NOT NULL, + UNIQUE (equipment_id, equipment_type, matrix_type, row_idx, col_idx) +); + +-- Fuse equipment: matrix impedance + TCC curve + trip delay. +CREATE TABLE matrix_impedance_fuse_equipment ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + construction TEXT NOT NULL DEFAULT 'OVERHEAD' REFERENCES line_types (name), + ampacity REAL NOT NULL CHECK (ampacity > 0), + ampacity_unit TEXT NOT NULL DEFAULT 'ampere', + delay REAL NOT NULL DEFAULT 0.0 CHECK (delay >= 0), + delay_unit TEXT NOT NULL DEFAULT 'second', + tcc_curve_id INTEGER NOT NULL REFERENCES time_current_curves (id) ON DELETE RESTRICT +); + +-- Recloser controller equipment: physical relay model identified by name. +CREATE TABLE recloser_controller_equipment ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL UNIQUE +); + +-- Recloser equipment: matrix impedance, protection logic is in recloser_controllers. +CREATE TABLE matrix_impedance_recloser_equipment ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + construction TEXT NOT NULL DEFAULT 'OVERHEAD' REFERENCES line_types (name), + ampacity REAL NOT NULL CHECK (ampacity > 0), + ampacity_unit TEXT NOT NULL DEFAULT 'ampere' +); + +-- Switch controller: delay, normal state, and lock flag. +CREATE TABLE switch_controllers ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL DEFAULT '', + delay REAL NOT NULL CHECK (delay >= 0), + delay_unit TEXT NOT NULL DEFAULT 'second', + normal_state TEXT NOT NULL CHECK (normal_state IN ('open', 'close')), + is_locked INTEGER NOT NULL DEFAULT 0 CHECK (is_locked IN (0, 1)) +); + +-- Switch equipment: matrix impedance + optional switch controller. +CREATE TABLE matrix_impedance_switch_equipment ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + construction TEXT NOT NULL DEFAULT 'OVERHEAD' REFERENCES line_types (name), + ampacity REAL NOT NULL CHECK (ampacity > 0), + ampacity_unit TEXT NOT NULL DEFAULT 'ampere', + switch_controller_id INTEGER NULL + REFERENCES switch_controllers (id) ON DELETE SET NULL +); + +-- Geometry branch equipment header. +-- Conductor positions are stored in geometry_branch_conductors (one row per conductor). +CREATE TABLE geometry_branch_equipment ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + insulation TEXT NOT NULL DEFAULT 'AIR' REFERENCES wire_insulation_types (name) +); + +-- Each row is one conductor slot in a geometry branch. +-- Exactly one of bare_conductor_id / concentric_cable_id must be set. +-- horizontal_position and vertical_position replace the former JSON position arrays. +CREATE TABLE geometry_branch_conductors ( + id INTEGER PRIMARY KEY, + equipment_id INTEGER NOT NULL + REFERENCES geometry_branch_equipment (id) ON DELETE CASCADE, + position_index INTEGER NOT NULL CHECK (position_index >= 0), + horizontal_position REAL NOT NULL, + horizontal_position_unit TEXT NOT NULL DEFAULT 'meter', + vertical_position REAL NOT NULL, + vertical_position_unit TEXT NOT NULL DEFAULT 'meter', + bare_conductor_id INTEGER NULL + REFERENCES bare_conductor_equipment (id) ON DELETE SET NULL, + concentric_cable_id INTEGER NULL + REFERENCES concentric_cable_equipment (id) ON DELETE SET NULL, + CHECK ((bare_conductor_id IS NOT NULL) != (concentric_cable_id IS NOT NULL)), + UNIQUE (equipment_id, position_index) +); + +-- ============================================================ +-- TRANSFORMER EQUIPMENT +-- ============================================================ + +-- Distribution transformer / regulator equipment catalog header. +CREATE TABLE distribution_transformer_equipment ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + mounting TEXT NOT NULL DEFAULT 'POLE_MOUNT' + REFERENCES transformer_mountings (name), + pct_no_load_loss REAL NOT NULL + CHECK (pct_no_load_loss >= 0 AND pct_no_load_loss <= 100), + pct_full_load_loss REAL NOT NULL + CHECK (pct_full_load_loss >= 0 AND pct_full_load_loss <= 100), + is_center_tapped INTEGER NOT NULL CHECK (is_center_tapped IN (0, 1)) +); + +-- Individual winding. Ordered tap_positions stored in winding_tap_positions. +CREATE TABLE winding_equipment ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL DEFAULT '', + transformer_equipment_id INTEGER NOT NULL + REFERENCES distribution_transformer_equipment (id) ON DELETE CASCADE, + winding_index INTEGER NOT NULL CHECK (winding_index >= 0), + resistance REAL NOT NULL + CHECK (resistance >= 0 AND resistance <= 100), + is_grounded INTEGER NOT NULL CHECK (is_grounded IN (0, 1)), + rated_voltage REAL NOT NULL CHECK (rated_voltage > 0), + rated_voltage_unit TEXT NOT NULL DEFAULT 'volt', + voltage_type TEXT NOT NULL REFERENCES voltage_types (name), + rated_power REAL NOT NULL CHECK (rated_power > 0), + rated_power_unit TEXT NOT NULL DEFAULT 'volt_ampere', + num_phases INTEGER NOT NULL CHECK (num_phases >= 1 AND num_phases <= 3), + connection_type TEXT NOT NULL REFERENCES connection_types (name), + total_taps INTEGER NOT NULL DEFAULT 32, + min_tap_pu REAL NOT NULL DEFAULT 0.9 + CHECK (min_tap_pu >= 0 AND min_tap_pu <= 1.0), + max_tap_pu REAL NOT NULL DEFAULT 1.1 CHECK (max_tap_pu >= 1.0), + UNIQUE (transformer_equipment_id, winding_index) +); + +-- Per-phase tap position for a winding (replaces tap_positions JSON array). +CREATE TABLE winding_tap_positions ( + id INTEGER PRIMARY KEY, + winding_id INTEGER NOT NULL REFERENCES winding_equipment (id) ON DELETE CASCADE, + position_index INTEGER NOT NULL CHECK (position_index >= 0), + tap_value REAL NOT NULL, + UNIQUE (winding_id, position_index) +); + +-- Winding-pair coupling record. +-- Merges coupling_sequences and winding_reactances JSON arrays into one table. +CREATE TABLE transformer_coupling_sequences ( + id INTEGER PRIMARY KEY, + transformer_equipment_id INTEGER NOT NULL + REFERENCES distribution_transformer_equipment (id) ON DELETE CASCADE, + sequence_index INTEGER NOT NULL CHECK (sequence_index >= 0), + from_winding_index INTEGER NOT NULL CHECK (from_winding_index >= 0), + to_winding_index INTEGER NOT NULL CHECK (to_winding_index >= 0), + reactance REAL NOT NULL CHECK (reactance >= 0 AND reactance <= 100), + reactance_unit TEXT NOT NULL DEFAULT 'percent', + UNIQUE (transformer_equipment_id, sequence_index) +); + +-- ============================================================ +-- LOAD EQUIPMENT +-- ============================================================ + +-- Single-phase load (ZIP model). +CREATE TABLE phase_load_equipment ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + real_power REAL NOT NULL DEFAULT 0.0 CHECK (real_power >= 0), + real_power_unit TEXT NOT NULL DEFAULT 'kilowatt', + reactive_power REAL NOT NULL DEFAULT 0.0, + reactive_power_unit TEXT NOT NULL DEFAULT 'kilovar', + z_real REAL NOT NULL, + z_imag REAL NOT NULL, + i_real REAL NOT NULL, + i_imag REAL NOT NULL, + p_real REAL NOT NULL, + p_imag REAL NOT NULL, + num_customers INTEGER NULL CHECK (num_customers > 0) +); + +-- Multi-phase load equipment header. +CREATE TABLE load_equipment ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + connection_type TEXT NOT NULL DEFAULT 'STAR' REFERENCES connection_types (name) +); + +-- Ordered per-phase load list (replaces phase_load_ids JSON array). +CREATE TABLE load_equipment_phases ( + id INTEGER PRIMARY KEY, + load_equipment_id INTEGER NOT NULL REFERENCES load_equipment (id) ON DELETE CASCADE, + phase_load_equipment_id INTEGER NOT NULL REFERENCES phase_load_equipment (id) ON DELETE RESTRICT, + position_index INTEGER NOT NULL CHECK (position_index >= 0), + UNIQUE (load_equipment_id, position_index) +); + +-- ============================================================ +-- CAPACITOR EQUIPMENT +-- ============================================================ + +-- Single-phase capacitor bank. +CREATE TABLE phase_capacitor_equipment ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + resistance REAL NOT NULL DEFAULT 0.0 CHECK (resistance >= 0), + resistance_unit TEXT NOT NULL DEFAULT 'ohm', + reactance REAL NOT NULL DEFAULT 0.0 CHECK (reactance >= 0), + reactance_unit TEXT NOT NULL DEFAULT 'ohm', + rated_reactive_power REAL NOT NULL CHECK (rated_reactive_power > 0), + rated_reactive_power_unit TEXT NOT NULL DEFAULT 'var', + num_banks_on INTEGER NOT NULL CHECK (num_banks_on >= 0), + num_banks INTEGER NOT NULL DEFAULT 1 CHECK (num_banks > 0) +); + +-- Multi-phase capacitor equipment header. +CREATE TABLE capacitor_equipment ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + connection_type TEXT NOT NULL DEFAULT 'STAR' REFERENCES connection_types (name), + rated_voltage REAL NOT NULL CHECK (rated_voltage > 0), + rated_voltage_unit TEXT NOT NULL DEFAULT 'volt', + voltage_type TEXT NOT NULL REFERENCES voltage_types (name) +); + +-- Ordered per-phase capacitor list (replaces phase_capacitor_ids JSON array). +CREATE TABLE capacitor_equipment_phases ( + id INTEGER PRIMARY KEY, + capacitor_equipment_id INTEGER NOT NULL + REFERENCES capacitor_equipment (id) ON DELETE CASCADE, + phase_capacitor_equipment_id INTEGER NOT NULL + REFERENCES phase_capacitor_equipment (id) ON DELETE RESTRICT, + position_index INTEGER NOT NULL CHECK (position_index >= 0), + UNIQUE (capacitor_equipment_id, position_index) +); + +-- ============================================================ +-- SOLAR / BATTERY / INVERTER EQUIPMENT +-- ============================================================ + +-- PV array equipment. +-- power_temp_curve_id replaces the former power_temp_curve JSON column. +CREATE TABLE solar_equipment ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + rated_power REAL NOT NULL CHECK (rated_power > 0), + rated_power_unit TEXT NOT NULL DEFAULT 'kilowatt', + power_temp_curve_id INTEGER NULL REFERENCES curves (id) ON DELETE SET NULL, + resistance REAL NOT NULL CHECK (resistance >= 0 AND resistance <= 100), + reactance REAL NOT NULL CHECK (reactance >= 0 AND reactance <= 100), + rated_voltage REAL NOT NULL CHECK (rated_voltage > 0), + rated_voltage_unit TEXT NOT NULL DEFAULT 'volt', + voltage_type TEXT NOT NULL REFERENCES voltage_types (name) +); + +-- Battery (DC) equipment. +CREATE TABLE battery_equipment ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + rated_energy REAL NOT NULL CHECK (rated_energy > 0), + rated_energy_unit TEXT NOT NULL DEFAULT 'kilowatt_hour', + rated_power REAL NOT NULL CHECK (rated_power >= 0), + rated_power_unit TEXT NOT NULL DEFAULT 'kilowatt', + charging_efficiency REAL NOT NULL + CHECK (charging_efficiency >= 0 AND charging_efficiency <= 100), + discharging_efficiency REAL NOT NULL + CHECK (discharging_efficiency >= 0 AND discharging_efficiency <= 100), + idling_efficiency REAL NOT NULL + CHECK (idling_efficiency >= 0 AND idling_efficiency <= 100), + rated_voltage REAL NOT NULL CHECK (rated_voltage >= 0), + rated_voltage_unit TEXT NOT NULL DEFAULT 'volt', + voltage_type TEXT NOT NULL REFERENCES voltage_types (name) +); + +-- Inverter equipment. +-- eff_curve_id replaces the former eff_curve JSON column. +CREATE TABLE inverter_equipment ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL DEFAULT '', + rated_apparent_power REAL NOT NULL CHECK (rated_apparent_power > 0), + rated_apparent_power_unit TEXT NOT NULL DEFAULT 'volt_ampere', + rise_limit REAL NULL CHECK (rise_limit > 0), + rise_limit_unit TEXT NULL DEFAULT 'watt/second', + fall_limit REAL NULL CHECK (fall_limit > 0), + fall_limit_unit TEXT NULL DEFAULT 'watt/second', + cutout_percent REAL NOT NULL CHECK (cutout_percent >= 0 AND cutout_percent <= 100), + cutin_percent REAL NOT NULL CHECK (cutin_percent >= 0 AND cutin_percent <= 100), + dc_to_ac_efficiency REAL NOT NULL + CHECK (dc_to_ac_efficiency >= 0 AND dc_to_ac_efficiency <= 100), + eff_curve_id INTEGER NULL REFERENCES curves (id) ON DELETE SET NULL +); + +-- ============================================================ +-- VOLTAGE SOURCE EQUIPMENT +-- ============================================================ + +-- Single-phase Thevenin voltage source. +CREATE TABLE phase_voltage_source_equipment ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + r0 REAL NOT NULL, r0_unit TEXT NOT NULL DEFAULT 'ohm', + r1 REAL NOT NULL, r1_unit TEXT NOT NULL DEFAULT 'ohm', + x0 REAL NOT NULL, x0_unit TEXT NOT NULL DEFAULT 'ohm', + x1 REAL NOT NULL, x1_unit TEXT NOT NULL DEFAULT 'ohm', + voltage REAL NOT NULL CHECK (voltage > 0), + voltage_unit TEXT NOT NULL DEFAULT 'volt', + voltage_type TEXT NOT NULL DEFAULT 'line-to-line' REFERENCES voltage_types (name), + angle REAL NOT NULL, + angle_unit TEXT NOT NULL DEFAULT 'degree' +); + +-- Three-phase voltage source header. +CREATE TABLE voltage_source_equipment ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL UNIQUE +); + +-- Ordered list of per-phase sources (replaces source_ids JSON array). +CREATE TABLE voltage_source_phases ( + id INTEGER PRIMARY KEY, + voltage_source_equipment_id INTEGER NOT NULL + REFERENCES voltage_source_equipment (id) ON DELETE CASCADE, + phase_source_equipment_id INTEGER NOT NULL + REFERENCES phase_voltage_source_equipment (id) ON DELETE RESTRICT, + position_index INTEGER NOT NULL CHECK (position_index >= 0), + UNIQUE (voltage_source_equipment_id, position_index) +); + +-- ============================================================ +-- LIMIT SETS +-- ============================================================ + +CREATE TABLE voltage_limit_sets ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL DEFAULT '', + limit_type TEXT NOT NULL REFERENCES limit_types (name), + value REAL NOT NULL CHECK (value > 0), + value_unit TEXT NOT NULL DEFAULT 'volt' +); + +CREATE TABLE thermal_limit_sets ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL DEFAULT '', + limit_type TEXT NOT NULL REFERENCES limit_types (name), + value REAL NOT NULL CHECK (value > 0), + value_unit TEXT NOT NULL DEFAULT 'ampere' +); + +-- ============================================================ +-- TOPOLOGY +-- ============================================================ + +CREATE TABLE distribution_feeders ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL UNIQUE +); + +CREATE TABLE distribution_substations ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL UNIQUE +); + +-- Junction: feeders belonging to a substation. +CREATE TABLE substation_feeders ( + substation_id INTEGER NOT NULL REFERENCES distribution_substations (id) ON DELETE CASCADE, + feeder_id INTEGER NOT NULL REFERENCES distribution_feeders (id) ON DELETE CASCADE, + PRIMARY KEY (substation_id, feeder_id) +); + +-- Bus: fundamental node of the distribution network. +-- coordinate_x / coordinate_y replace the former coordinate JSON column. +-- Phases are stored in bus_phases. +CREATE TABLE distribution_buses ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + substation_id INTEGER NOT NULL + REFERENCES distribution_substations (id) ON DELETE CASCADE, + feeder_id INTEGER NOT NULL + REFERENCES distribution_feeders (id) ON DELETE CASCADE, + voltage_type TEXT NOT NULL REFERENCES voltage_types (name), + rated_voltage REAL NOT NULL CHECK (rated_voltage > 0), + rated_voltage_unit TEXT NOT NULL DEFAULT 'volt', + coordinate_x REAL NULL, + coordinate_y REAL NULL, + in_service INTEGER NOT NULL DEFAULT 1 CHECK (in_service IN (0, 1)) +); + +-- Ordered phase list for a bus (replaces phases JSON array). +CREATE TABLE bus_phases ( + id INTEGER PRIMARY KEY, + bus_id INTEGER NOT NULL REFERENCES distribution_buses (id) ON DELETE CASCADE, + phase TEXT NOT NULL REFERENCES phases (name), + position_index INTEGER NOT NULL CHECK (position_index >= 0), + UNIQUE (bus_id, position_index), + UNIQUE (bus_id, phase) +); + +-- Voltage limit sets associated with a bus. +CREATE TABLE bus_voltage_limits ( + bus_id INTEGER NOT NULL REFERENCES distribution_buses (id) ON DELETE CASCADE, + limit_set_id INTEGER NOT NULL REFERENCES voltage_limit_sets (id) ON DELETE CASCADE, + PRIMARY KEY (bus_id, limit_set_id) +); + +-- ============================================================ +-- INVERTER CONTROLLER TABLES +-- ============================================================ + +-- Reactive power control setting for an inverter. +-- controller_type discriminates between POWER_FACTOR and VOLT_VAR subtypes. +CREATE TABLE inverter_reactive_power_controls ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL DEFAULT '', + controller_type TEXT NOT NULL CHECK (controller_type IN ('POWER_FACTOR', 'VOLT_VAR')), + supported_by TEXT NOT NULL + CHECK (supported_by IN ('battery-only','solar-only','battery-and-solar')), + -- POWER_FACTOR subtype: + power_factor REAL NULL CHECK (power_factor >= -1 AND power_factor <= 1), + -- VOLT_VAR subtype: + volt_var_curve_id INTEGER NULL REFERENCES curves (id) ON DELETE SET NULL, + var_follow INTEGER NULL CHECK (var_follow IN (0, 1)) +); + +-- Active power control setting for an inverter. +-- Subtypes: VOLT_WATT, PEAK_SHAVING, CAPACITY_FIRMING, TIME_BASED, +-- SELF_CONSUMPTION, TIME_OF_USE, DEMAND_CHARGE. +CREATE TABLE inverter_active_power_controls ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL DEFAULT '', + controller_type TEXT NOT NULL CHECK (controller_type IN ( + 'VOLT_WATT','PEAK_SHAVING','CAPACITY_FIRMING', + 'TIME_BASED','SELF_CONSUMPTION', + 'TIME_OF_USE','DEMAND_CHARGE')), + supported_by TEXT NOT NULL + CHECK (supported_by IN ('battery-only','solar-only','battery-and-solar')), + -- VOLT_WATT subtype: + volt_watt_curve_id INTEGER NULL REFERENCES curves (id) ON DELETE SET NULL, + -- PEAK_SHAVING subtype: + peak_shaving_target REAL NULL CHECK (peak_shaving_target >= 0), + peak_shaving_target_unit TEXT NULL DEFAULT 'watt', + base_loading_target REAL NULL CHECK (base_loading_target >= 0), + base_loading_target_unit TEXT NULL DEFAULT 'watt', + -- CAPACITY_FIRMING subtype: + max_active_power_roc REAL NULL, + max_active_power_roc_unit TEXT NULL DEFAULT 'watt/second', + min_active_power_roc REAL NULL, + min_active_power_roc_unit TEXT NULL DEFAULT 'watt/second', + -- TIME_BASED subtype (times stored as 'HH:MM:SS'): + charging_start_time TEXT NULL, + charging_end_time TEXT NULL, + discharging_start_time TEXT NULL, + discharging_end_time TEXT NULL, + charging_power REAL NULL CHECK (charging_power >= 0), + charging_power_unit TEXT NULL DEFAULT 'watt', + discharging_power REAL NULL CHECK (discharging_power >= 0), + discharging_power_unit TEXT NULL DEFAULT 'watt', + -- TIME_OF_USE / DEMAND_CHARGE subtype: + tariff_id INTEGER NULL, + CONSTRAINT check_volt_watt CHECK (controller_type != 'VOLT_WATT' OR volt_watt_curve_id IS NOT NULL), + CONSTRAINT check_peak_shave CHECK (controller_type != 'PEAK_SHAVING' OR + (peak_shaving_target IS NOT NULL AND base_loading_target IS NOT NULL)), + CONSTRAINT check_cap_firm CHECK (controller_type != 'CAPACITY_FIRMING' OR + (max_active_power_roc IS NOT NULL AND min_active_power_roc IS NOT NULL)), + CONSTRAINT check_time CHECK (controller_type != 'TIME_BASED' OR + (charging_start_time IS NOT NULL AND discharging_start_time IS NOT NULL)) +); + +-- Top-level inverter controller (used by DistributionSolar and DistributionBattery). +CREATE TABLE inverter_controllers ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL DEFAULT '', + prioritize_active_power INTEGER NOT NULL CHECK (prioritize_active_power IN (0, 1)), + night_mode INTEGER NOT NULL CHECK (night_mode IN (0, 1)), + active_power_control_id INTEGER NULL + REFERENCES inverter_active_power_controls (id) ON DELETE SET NULL, + reactive_power_control_id INTEGER NULL + REFERENCES inverter_reactive_power_controls (id) ON DELETE SET NULL +); + +-- ============================================================ +-- DISTRIBUTION COMPONENTS +-- ============================================================ + +-- DistributionLoad: ZIP load attached to a bus. +CREATE TABLE distribution_loads ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + bus_id INTEGER NOT NULL REFERENCES distribution_buses (id) ON DELETE CASCADE, + substation_id INTEGER NOT NULL REFERENCES distribution_substations (id) ON DELETE CASCADE, + feeder_id INTEGER NOT NULL REFERENCES distribution_feeders (id) ON DELETE CASCADE, + load_equipment_id INTEGER NOT NULL REFERENCES load_equipment (id), + in_service INTEGER NOT NULL DEFAULT 1 CHECK (in_service IN (0, 1)) +); + +-- Ordered phase list for a DistributionLoad (replaces phases JSON array). +CREATE TABLE distribution_load_phases ( + id INTEGER PRIMARY KEY, + load_id INTEGER NOT NULL REFERENCES distribution_loads (id) ON DELETE CASCADE, + phase TEXT NOT NULL REFERENCES phases (name), + position_index INTEGER NOT NULL CHECK (position_index >= 0), + UNIQUE (load_id, position_index), + UNIQUE (load_id, phase) +); + +-- DistributionSolar: PV system attached to a bus. +-- inverter_controller_id replaces the former controller JSON column. +CREATE TABLE distribution_solar ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + bus_id INTEGER NOT NULL REFERENCES distribution_buses (id) ON DELETE CASCADE, + substation_id INTEGER NOT NULL REFERENCES distribution_substations (id) ON DELETE CASCADE, + feeder_id INTEGER NOT NULL REFERENCES distribution_feeders (id) ON DELETE CASCADE, + irradiance REAL NOT NULL CHECK (irradiance >= 0), + irradiance_unit TEXT NOT NULL DEFAULT 'watt/meter^2', + active_power REAL NOT NULL CHECK (active_power >= 0), + active_power_unit TEXT NOT NULL DEFAULT 'watt', + reactive_power REAL NOT NULL, + reactive_power_unit TEXT NOT NULL DEFAULT 'watt', + solar_equipment_id INTEGER NOT NULL REFERENCES solar_equipment (id), + inverter_equipment_id INTEGER NOT NULL REFERENCES inverter_equipment (id), + inverter_controller_id INTEGER NULL + REFERENCES inverter_controllers (id) ON DELETE SET NULL, + in_service INTEGER NOT NULL DEFAULT 1 CHECK (in_service IN (0, 1)) +); + +-- Ordered phase list for a DistributionSolar (replaces phases JSON array). +CREATE TABLE distribution_solar_phases ( + id INTEGER PRIMARY KEY, + solar_id INTEGER NOT NULL REFERENCES distribution_solar (id) ON DELETE CASCADE, + phase TEXT NOT NULL REFERENCES phases (name), + position_index INTEGER NOT NULL CHECK (position_index >= 0), + UNIQUE (solar_id, position_index), + UNIQUE (solar_id, phase) +); + +-- DistributionBattery: battery energy storage attached to a bus. +-- inverter_controller_id replaces the former controller JSON column. +CREATE TABLE distribution_batteries ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + bus_id INTEGER NOT NULL REFERENCES distribution_buses (id) ON DELETE CASCADE, + substation_id INTEGER NOT NULL REFERENCES distribution_substations (id) ON DELETE CASCADE, + feeder_id INTEGER NOT NULL REFERENCES distribution_feeders (id) ON DELETE CASCADE, + active_power REAL NOT NULL, + active_power_unit TEXT NOT NULL DEFAULT 'watt', + reactive_power REAL NOT NULL, + reactive_power_unit TEXT NOT NULL DEFAULT 'watt', + battery_equipment_id INTEGER NOT NULL REFERENCES battery_equipment (id), + inverter_equipment_id INTEGER NOT NULL REFERENCES inverter_equipment (id), + inverter_controller_id INTEGER NULL + REFERENCES inverter_controllers (id) ON DELETE SET NULL, + in_service INTEGER NOT NULL DEFAULT 1 CHECK (in_service IN (0, 1)) +); + +-- Ordered phase list for a DistributionBattery (replaces phases JSON array). +CREATE TABLE distribution_battery_phases ( + id INTEGER PRIMARY KEY, + battery_id INTEGER NOT NULL REFERENCES distribution_batteries (id) ON DELETE CASCADE, + phase TEXT NOT NULL REFERENCES phases (name), + position_index INTEGER NOT NULL CHECK (position_index >= 0), + UNIQUE (battery_id, position_index), + UNIQUE (battery_id, phase) +); + +-- DistributionCapacitor: shunt capacitor bank attached to a bus. +CREATE TABLE distribution_capacitors ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + bus_id INTEGER NOT NULL REFERENCES distribution_buses (id) ON DELETE CASCADE, + substation_id INTEGER NOT NULL REFERENCES distribution_substations (id) ON DELETE CASCADE, + feeder_id INTEGER NOT NULL REFERENCES distribution_feeders (id) ON DELETE CASCADE, + capacitor_equipment_id INTEGER NOT NULL REFERENCES capacitor_equipment (id), + in_service INTEGER NOT NULL DEFAULT 1 CHECK (in_service IN (0, 1)) +); + +-- Per-phase controller for a DistributionCapacitor (replaces controllers JSON array). +-- controller_type discriminates the five CapacitorControllerBase subtypes. +CREATE TABLE capacitor_controllers ( + id INTEGER PRIMARY KEY, + capacitor_id INTEGER NOT NULL + REFERENCES distribution_capacitors (id) ON DELETE CASCADE, + position_index INTEGER NOT NULL CHECK (position_index >= 0), + name TEXT NOT NULL DEFAULT '', + controller_type TEXT NOT NULL CHECK (controller_type IN ( + 'VOLTAGE','ACTIVE_POWER','REACTIVE_POWER','CURRENT','DAILY_TIMED')), + -- CapacitorControllerBase shared fields: + delay_on REAL NULL CHECK (delay_on >= 0), + delay_on_unit TEXT NULL DEFAULT 'second', + delay_off REAL NULL CHECK (delay_off >= 0), + delay_off_unit TEXT NULL DEFAULT 'second', + dead_time REAL NULL CHECK (dead_time >= 0), + dead_time_unit TEXT NULL DEFAULT 'second', + -- VOLTAGE subtype (VoltageCapacitorController): + on_voltage REAL NULL CHECK (on_voltage > 0), + on_voltage_unit TEXT NULL DEFAULT 'volt', + off_voltage REAL NULL CHECK (off_voltage > 0), + off_voltage_unit TEXT NULL DEFAULT 'volt', + pt_ratio REAL NULL CHECK (pt_ratio >= 0), + -- ACTIVE_POWER subtype (ActivePowerCapacitorController): + on_active_power REAL NULL CHECK (on_active_power >= 0), + on_active_power_unit TEXT NULL DEFAULT 'watt', + off_active_power REAL NULL CHECK (off_active_power > 0), + off_active_power_unit TEXT NULL DEFAULT 'watt', + -- REACTIVE_POWER subtype (ReactivePowerCapacitorController): + on_reactive_power REAL NULL CHECK (on_reactive_power > 0), + on_reactive_power_unit TEXT NULL DEFAULT 'var', + off_reactive_power REAL NULL CHECK (off_reactive_power > 0), + off_reactive_power_unit TEXT NULL DEFAULT 'var', + -- CURRENT subtype (CurrentCapacitorController): + on_current REAL NULL CHECK (on_current > 0), + on_current_unit TEXT NULL DEFAULT 'ampere', + off_current REAL NULL CHECK (off_current >= 0), + off_current_unit TEXT NULL DEFAULT 'ampere', + ct_ratio REAL NULL CHECK (ct_ratio >= 0), + -- DAILY_TIMED subtype (DailyTimedCapacitorController) -- stored as 'HH:MM:SS': + on_time TEXT NULL, + off_time TEXT NULL, + UNIQUE (capacitor_id, position_index) +); + +-- Ordered phase list for a DistributionCapacitor (replaces phases JSON array). +CREATE TABLE distribution_capacitor_phases ( + id INTEGER PRIMARY KEY, + capacitor_id INTEGER NOT NULL REFERENCES distribution_capacitors (id) ON DELETE CASCADE, + phase TEXT NOT NULL REFERENCES phases (name), + position_index INTEGER NOT NULL CHECK (position_index >= 0), + UNIQUE (capacitor_id, position_index), + UNIQUE (capacitor_id, phase) +); + +-- DistributionVoltageSource: Thevenin equivalent voltage source at a bus. +CREATE TABLE distribution_voltage_sources ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + bus_id INTEGER NOT NULL + REFERENCES distribution_buses (id) ON DELETE CASCADE, + substation_id INTEGER NOT NULL + REFERENCES distribution_substations (id) ON DELETE CASCADE, + feeder_id INTEGER NOT NULL + REFERENCES distribution_feeders (id) ON DELETE CASCADE, + voltage_source_equipment_id INTEGER NOT NULL + REFERENCES voltage_source_equipment (id), + in_service INTEGER NOT NULL DEFAULT 1 CHECK (in_service IN (0, 1)) +); + +-- Ordered phase list for a DistributionVoltageSource (replaces phases JSON array). +CREATE TABLE distribution_voltage_source_phases ( + id INTEGER PRIMARY KEY, + vsource_id INTEGER NOT NULL REFERENCES distribution_voltage_sources (id) ON DELETE CASCADE, + phase TEXT NOT NULL REFERENCES phases (name), + position_index INTEGER NOT NULL CHECK (position_index >= 0), + UNIQUE (vsource_id, position_index), + UNIQUE (vsource_id, phase) +); + +-- ============================================================ +-- BRANCH COMPONENTS -- Lines +-- ============================================================ +-- from_bus_id / to_bus_id are the explicit FK columns; no buses JSON needed. + +-- MatrixImpedanceBranch: full matrix line model. +CREATE TABLE matrix_impedance_branches ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + from_bus_id INTEGER NOT NULL REFERENCES distribution_buses (id) ON DELETE CASCADE, + to_bus_id INTEGER NOT NULL REFERENCES distribution_buses (id) ON DELETE CASCADE, + substation_id INTEGER NOT NULL REFERENCES distribution_substations (id) ON DELETE CASCADE, + feeder_id INTEGER NOT NULL REFERENCES distribution_feeders (id) ON DELETE CASCADE, + length REAL NOT NULL CHECK (length > 0), + length_unit TEXT NOT NULL DEFAULT 'meter', + equipment_id INTEGER NOT NULL REFERENCES matrix_impedance_branch_equipment (id), + in_service INTEGER NOT NULL DEFAULT 1 CHECK (in_service IN (0, 1)) +); +-- Ordered phase list for a MatrixImpedanceBranch (replaces phases JSON array). +CREATE TABLE matrix_impedance_branch_phases ( + id INTEGER PRIMARY KEY, + branch_id INTEGER NOT NULL REFERENCES matrix_impedance_branches (id) ON DELETE CASCADE, + phase TEXT NOT NULL REFERENCES phases (name), + position_index INTEGER NOT NULL CHECK (position_index >= 0), + UNIQUE (branch_id, position_index), + UNIQUE (branch_id, phase) +); +-- Thermal limits junction (replaces thermal_limit_ids JSON array). +CREATE TABLE matrix_impedance_branch_thermal_limits ( + branch_id INTEGER NOT NULL + REFERENCES matrix_impedance_branches (id) ON DELETE CASCADE, + thermal_limit_set_id INTEGER NOT NULL + REFERENCES thermal_limit_sets (id) ON DELETE CASCADE, + PRIMARY KEY (branch_id, thermal_limit_set_id) +); + +-- SequenceImpedanceBranch: positive/zero sequence line model. +CREATE TABLE sequence_impedance_branches ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + from_bus_id INTEGER NOT NULL REFERENCES distribution_buses (id) ON DELETE CASCADE, + to_bus_id INTEGER NOT NULL REFERENCES distribution_buses (id) ON DELETE CASCADE, + substation_id INTEGER NOT NULL REFERENCES distribution_substations (id) ON DELETE CASCADE, + feeder_id INTEGER NOT NULL REFERENCES distribution_feeders (id) ON DELETE CASCADE, + length REAL NOT NULL CHECK (length > 0), + length_unit TEXT NOT NULL DEFAULT 'meter', + equipment_id INTEGER NOT NULL REFERENCES sequence_impedance_branch_equipment (id), + in_service INTEGER NOT NULL DEFAULT 1 CHECK (in_service IN (0, 1)) +); +CREATE TABLE sequence_impedance_branch_phases ( + id INTEGER PRIMARY KEY, + branch_id INTEGER NOT NULL REFERENCES sequence_impedance_branches (id) ON DELETE CASCADE, + phase TEXT NOT NULL REFERENCES phases (name), + position_index INTEGER NOT NULL CHECK (position_index >= 0), + UNIQUE (branch_id, position_index), + UNIQUE (branch_id, phase) +); +CREATE TABLE sequence_impedance_branch_thermal_limits ( + branch_id INTEGER NOT NULL + REFERENCES sequence_impedance_branches (id) ON DELETE CASCADE, + thermal_limit_set_id INTEGER NOT NULL + REFERENCES thermal_limit_sets (id) ON DELETE CASCADE, + PRIMARY KEY (branch_id, thermal_limit_set_id) +); + +-- GeometryBranch: impedance computed from conductor geometry. +CREATE TABLE geometry_branches ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + from_bus_id INTEGER NOT NULL REFERENCES distribution_buses (id) ON DELETE CASCADE, + to_bus_id INTEGER NOT NULL REFERENCES distribution_buses (id) ON DELETE CASCADE, + substation_id INTEGER NOT NULL REFERENCES distribution_substations (id) ON DELETE CASCADE, + feeder_id INTEGER NOT NULL REFERENCES distribution_feeders (id) ON DELETE CASCADE, + length REAL NOT NULL CHECK (length > 0), + length_unit TEXT NOT NULL DEFAULT 'meter', + equipment_id INTEGER NOT NULL REFERENCES geometry_branch_equipment (id), + in_service INTEGER NOT NULL DEFAULT 1 CHECK (in_service IN (0, 1)) +); +CREATE TABLE geometry_branch_phases ( + id INTEGER PRIMARY KEY, + branch_id INTEGER NOT NULL REFERENCES geometry_branches (id) ON DELETE CASCADE, + phase TEXT NOT NULL REFERENCES phases (name), + position_index INTEGER NOT NULL CHECK (position_index >= 0), + UNIQUE (branch_id, position_index), + UNIQUE (branch_id, phase) +); +CREATE TABLE geometry_branch_thermal_limits ( + branch_id INTEGER NOT NULL REFERENCES geometry_branches (id) ON DELETE CASCADE, + thermal_limit_set_id INTEGER NOT NULL REFERENCES thermal_limit_sets (id) ON DELETE CASCADE, + PRIMARY KEY (branch_id, thermal_limit_set_id) +); + +-- ============================================================ +-- BRANCH COMPONENTS -- Protective / Switching Devices +-- ============================================================ + +-- MatrixImpedanceFuse. +CREATE TABLE matrix_impedance_fuses ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + from_bus_id INTEGER NOT NULL REFERENCES distribution_buses (id) ON DELETE CASCADE, + to_bus_id INTEGER NOT NULL REFERENCES distribution_buses (id) ON DELETE CASCADE, + substation_id INTEGER NOT NULL REFERENCES distribution_substations (id) ON DELETE CASCADE, + feeder_id INTEGER NOT NULL REFERENCES distribution_feeders (id) ON DELETE CASCADE, + length REAL NOT NULL CHECK (length > 0), + length_unit TEXT NOT NULL DEFAULT 'meter', + equipment_id INTEGER NOT NULL REFERENCES matrix_impedance_fuse_equipment (id), + in_service INTEGER NOT NULL DEFAULT 1 CHECK (in_service IN (0, 1)) +); +-- Ordered phase list for a fuse (replaces phases JSON array). +CREATE TABLE matrix_impedance_fuse_phases ( + id INTEGER PRIMARY KEY, + fuse_id INTEGER NOT NULL REFERENCES matrix_impedance_fuses (id) ON DELETE CASCADE, + phase TEXT NOT NULL REFERENCES phases (name), + position_index INTEGER NOT NULL CHECK (position_index >= 0), + UNIQUE (fuse_id, position_index), + UNIQUE (fuse_id, phase) +); +-- Per-phase closed/open state for a fuse (replaces is_closed JSON array). +CREATE TABLE fuse_phase_states ( + id INTEGER PRIMARY KEY, + fuse_id INTEGER NOT NULL REFERENCES matrix_impedance_fuses (id) ON DELETE CASCADE, + position_index INTEGER NOT NULL CHECK (position_index >= 0), + phase TEXT NOT NULL REFERENCES phases (name), + is_closed INTEGER NOT NULL CHECK (is_closed IN (0, 1)), + UNIQUE (fuse_id, position_index) +); + +-- Recloser controller tables. +-- ground_delayed/fast, phase_delayed/fast reference time_current_curves. +CREATE TABLE recloser_controllers ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL DEFAULT '', + delay REAL NOT NULL CHECK (delay >= 0), + delay_unit TEXT NOT NULL DEFAULT 'second', + ground_delayed_curve_id INTEGER NOT NULL + REFERENCES time_current_curves (id) ON DELETE RESTRICT, + ground_fast_curve_id INTEGER NOT NULL + REFERENCES time_current_curves (id) ON DELETE RESTRICT, + phase_delayed_curve_id INTEGER NOT NULL + REFERENCES time_current_curves (id) ON DELETE RESTRICT, + phase_fast_curve_id INTEGER NOT NULL + REFERENCES time_current_curves (id) ON DELETE RESTRICT, + num_fast_ops INTEGER NOT NULL CHECK (num_fast_ops >= 0), + num_shots INTEGER NOT NULL CHECK (num_shots >= 1), + reset_time REAL NOT NULL CHECK (reset_time >= 0), + reset_time_unit TEXT NOT NULL DEFAULT 'second', + equipment_id INTEGER NOT NULL + REFERENCES recloser_controller_equipment (id) ON DELETE RESTRICT +); +-- Reclose intervals ordered array (replaces reclose_intervals Time[] array). +CREATE TABLE recloser_reclose_intervals ( + id INTEGER PRIMARY KEY, + recloser_controller_id INTEGER NOT NULL + REFERENCES recloser_controllers (id) ON DELETE CASCADE, + position_index INTEGER NOT NULL CHECK (position_index >= 0), + interval_value REAL NOT NULL CHECK (interval_value >= 0), + interval_unit TEXT NOT NULL DEFAULT 'second', + UNIQUE (recloser_controller_id, position_index) +); + +-- MatrixImpedanceRecloser. +-- controller_id replaces the former controller JSON column. +CREATE TABLE matrix_impedance_reclosers ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + from_bus_id INTEGER NOT NULL REFERENCES distribution_buses (id) ON DELETE CASCADE, + to_bus_id INTEGER NOT NULL REFERENCES distribution_buses (id) ON DELETE CASCADE, + substation_id INTEGER NOT NULL REFERENCES distribution_substations (id) ON DELETE CASCADE, + feeder_id INTEGER NOT NULL REFERENCES distribution_feeders (id) ON DELETE CASCADE, + length REAL NOT NULL CHECK (length > 0), + length_unit TEXT NOT NULL DEFAULT 'meter', + equipment_id INTEGER NOT NULL REFERENCES matrix_impedance_recloser_equipment (id), + controller_id INTEGER NOT NULL REFERENCES recloser_controllers (id) ON DELETE RESTRICT, + in_service INTEGER NOT NULL DEFAULT 1 CHECK (in_service IN (0, 1)) +); +-- Ordered phase list for a recloser (replaces phases JSON array). +CREATE TABLE matrix_impedance_recloser_phases ( + id INTEGER PRIMARY KEY, + recloser_id INTEGER NOT NULL REFERENCES matrix_impedance_reclosers (id) ON DELETE CASCADE, + phase TEXT NOT NULL REFERENCES phases (name), + position_index INTEGER NOT NULL CHECK (position_index >= 0), + UNIQUE (recloser_id, position_index), + UNIQUE (recloser_id, phase) +); +-- Per-phase closed/open state for a recloser (replaces is_closed JSON array). +CREATE TABLE recloser_phase_states ( + id INTEGER PRIMARY KEY, + recloser_id INTEGER NOT NULL REFERENCES matrix_impedance_reclosers (id) ON DELETE CASCADE, + position_index INTEGER NOT NULL CHECK (position_index >= 0), + phase TEXT NOT NULL REFERENCES phases (name), + is_closed INTEGER NOT NULL CHECK (is_closed IN (0, 1)), + UNIQUE (recloser_id, position_index) +); + +-- MatrixImpedanceSwitch. +CREATE TABLE matrix_impedance_switches ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + from_bus_id INTEGER NOT NULL REFERENCES distribution_buses (id) ON DELETE CASCADE, + to_bus_id INTEGER NOT NULL REFERENCES distribution_buses (id) ON DELETE CASCADE, + substation_id INTEGER NOT NULL REFERENCES distribution_substations (id) ON DELETE CASCADE, + feeder_id INTEGER NOT NULL REFERENCES distribution_feeders (id) ON DELETE CASCADE, + length REAL NOT NULL CHECK (length > 0), + length_unit TEXT NOT NULL DEFAULT 'meter', + equipment_id INTEGER NOT NULL REFERENCES matrix_impedance_switch_equipment (id), + in_service INTEGER NOT NULL DEFAULT 1 CHECK (in_service IN (0, 1)) +); +-- Ordered phase list for a switch (replaces phases JSON array). +CREATE TABLE matrix_impedance_switch_phases ( + id INTEGER PRIMARY KEY, + switch_id INTEGER NOT NULL REFERENCES matrix_impedance_switches (id) ON DELETE CASCADE, + phase TEXT NOT NULL REFERENCES phases (name), + position_index INTEGER NOT NULL CHECK (position_index >= 0), + UNIQUE (switch_id, position_index), + UNIQUE (switch_id, phase) +); +-- Per-phase closed/open state for a switch (replaces is_closed JSON array). +CREATE TABLE switch_phase_states ( + id INTEGER PRIMARY KEY, + switch_id INTEGER NOT NULL REFERENCES matrix_impedance_switches (id) ON DELETE CASCADE, + position_index INTEGER NOT NULL CHECK (position_index >= 0), + phase TEXT NOT NULL REFERENCES phases (name), + is_closed INTEGER NOT NULL CHECK (is_closed IN (0, 1)), + UNIQUE (switch_id, position_index) +); + +-- ============================================================ +-- TRANSFORMER / REGULATOR COMPONENTS +-- ============================================================ + +-- DistributionTransformer. +-- bus_ids JSON replaced by transformer_winding_buses. +-- winding_phases JSON replaced by transformer_winding_phases. +CREATE TABLE distribution_transformers ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + substation_id INTEGER NOT NULL + REFERENCES distribution_substations (id) ON DELETE CASCADE, + feeder_id INTEGER NOT NULL + REFERENCES distribution_feeders (id) ON DELETE CASCADE, + equipment_id INTEGER NOT NULL + REFERENCES distribution_transformer_equipment (id), + in_service INTEGER NOT NULL DEFAULT 1 CHECK (in_service IN (0, 1)) +); +-- Ordered winding-to-bus mapping (replaces bus_ids JSON array). +CREATE TABLE transformer_winding_buses ( + id INTEGER PRIMARY KEY, + transformer_id INTEGER NOT NULL REFERENCES distribution_transformers (id) ON DELETE CASCADE, + winding_index INTEGER NOT NULL CHECK (winding_index >= 0), + bus_id INTEGER NOT NULL REFERENCES distribution_buses (id) ON DELETE CASCADE, + UNIQUE (transformer_id, winding_index) +); +-- Ordered per-phase list for each winding (replaces winding_phases JSON array of arrays). +CREATE TABLE transformer_winding_phases ( + id INTEGER PRIMARY KEY, + transformer_id INTEGER NOT NULL REFERENCES distribution_transformers (id) ON DELETE CASCADE, + winding_index INTEGER NOT NULL CHECK (winding_index >= 0), + phase TEXT NOT NULL REFERENCES phases (name), + phase_index INTEGER NOT NULL CHECK (phase_index >= 0), + UNIQUE (transformer_id, winding_index, phase_index), + UNIQUE (transformer_id, winding_index, phase) +); + +-- DistributionRegulator. +-- bus_ids JSON replaced by regulator_winding_buses. +-- winding_phases JSON replaced by regulator_winding_phases. +-- controllers JSON replaced by regulator_controllers. +CREATE TABLE distribution_regulators ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + substation_id INTEGER NOT NULL + REFERENCES distribution_substations (id) ON DELETE CASCADE, + feeder_id INTEGER NOT NULL + REFERENCES distribution_feeders (id) ON DELETE CASCADE, + equipment_id INTEGER NOT NULL + REFERENCES distribution_transformer_equipment (id), + in_service INTEGER NOT NULL DEFAULT 1 CHECK (in_service IN (0, 1)) +); +-- Ordered winding-to-bus mapping for a regulator. +CREATE TABLE regulator_winding_buses ( + id INTEGER PRIMARY KEY, + regulator_id INTEGER NOT NULL REFERENCES distribution_regulators (id) ON DELETE CASCADE, + winding_index INTEGER NOT NULL CHECK (winding_index >= 0), + bus_id INTEGER NOT NULL REFERENCES distribution_buses (id) ON DELETE CASCADE, + UNIQUE (regulator_id, winding_index) +); +-- Ordered per-phase list for each winding of a regulator. +CREATE TABLE regulator_winding_phases ( + id INTEGER PRIMARY KEY, + regulator_id INTEGER NOT NULL REFERENCES distribution_regulators (id) ON DELETE CASCADE, + winding_index INTEGER NOT NULL CHECK (winding_index >= 0), + phase TEXT NOT NULL REFERENCES phases (name), + phase_index INTEGER NOT NULL CHECK (phase_index >= 0), + UNIQUE (regulator_id, winding_index, phase_index), + UNIQUE (regulator_id, winding_index, phase) +); +-- Per-phase regulator controller (replaces controllers JSON array). +-- controlled_bus_id and controlled_phase replace the embedded DistributionBus object. +CREATE TABLE regulator_controllers ( + id INTEGER PRIMARY KEY, + regulator_id INTEGER NOT NULL REFERENCES distribution_regulators (id) ON DELETE CASCADE, + position_index INTEGER NOT NULL CHECK (position_index >= 0), + name TEXT NOT NULL DEFAULT '', + delay REAL NULL CHECK (delay >= 0), + delay_unit TEXT NULL DEFAULT 'second', + v_setpoint REAL NOT NULL CHECK (v_setpoint > 0), + v_setpoint_unit TEXT NOT NULL DEFAULT 'volt', + min_v_limit REAL NOT NULL CHECK (min_v_limit > 0), + min_v_limit_unit TEXT NOT NULL DEFAULT 'volt', + max_v_limit REAL NOT NULL CHECK (max_v_limit > 0), + max_v_limit_unit TEXT NOT NULL DEFAULT 'volt', + pt_ratio REAL NOT NULL CHECK (pt_ratio >= 0), + use_ldc INTEGER NOT NULL CHECK (use_ldc IN (0, 1)), + is_reversible INTEGER NOT NULL CHECK (is_reversible IN (0, 1)), + ldc_R REAL NULL CHECK (ldc_R >= 0), + ldc_R_unit TEXT NULL DEFAULT 'volt', + ldc_X REAL NULL CHECK (ldc_X >= 0), + ldc_X_unit TEXT NULL DEFAULT 'volt', + ct_primary REAL NULL CHECK (ct_primary >= 0), + ct_primary_unit TEXT NULL DEFAULT 'ampere', + max_step INTEGER NOT NULL CHECK (max_step >= 0), + bandwidth REAL NOT NULL CHECK (bandwidth >= 0), + bandwidth_unit TEXT NOT NULL DEFAULT 'volt', + controlled_bus_id INTEGER NOT NULL REFERENCES distribution_buses (id) ON DELETE RESTRICT, + controlled_phase TEXT NOT NULL REFERENCES phases (name), + UNIQUE (regulator_id, position_index) +); + +-- ============================================================ +-- TIME SERIES ASSOCIATIONS +-- ============================================================ +CREATE TABLE time_series_associations ( + id INTEGER PRIMARY KEY, + time_series_uuid TEXT NOT NULL, + time_series_type TEXT NOT NULL, + initial_timestamp TEXT NOT NULL, + resolution TEXT NOT NULL, + horizon TEXT NULL, + "interval" TEXT NULL, + window_count INTEGER NULL, + length INTEGER NULL, + name TEXT NOT NULL, + owner_id INTEGER NOT NULL, + owner_type TEXT NOT NULL, + owner_category TEXT NOT NULL, + features TEXT NOT NULL, + scaling_factor_multiplier TEXT NULL, + metadata_uuid TEXT NOT NULL, + units TEXT NULL +); + +CREATE UNIQUE INDEX dist_ts_by_owner_name_res_feat + ON time_series_associations (owner_id, time_series_type, name, resolution, features); + +CREATE INDEX dist_ts_by_uuid + ON time_series_associations (time_series_uuid); diff --git a/src/gdm/db/sqlite_store.py b/src/gdm/db/sqlite_store.py index 9a93392a..a1712a2e 100644 --- a/src/gdm/db/sqlite_store.py +++ b/src/gdm/db/sqlite_store.py @@ -15,6 +15,8 @@ from typing import Type from uuid import UUID +from loguru import logger + from infrasys import Location from infrasys.time_series_models import NonSequentialTimeSeries, SingleTimeSeries @@ -223,6 +225,171 @@ def load_system_from_db( return system_cls.from_json(temp_json) +def _delete_distribution_tables(conn: sqlite3.Connection, component_types: set[str]) -> None: + """Remove all distribution topology rows in correct dependency order.""" + conn.execute("DELETE FROM geometry_branch_phases") + conn.execute("DELETE FROM geometry_branches") + conn.execute("DELETE FROM geometry_branch_conductors") + conn.execute("DELETE FROM geometry_branch_equipment") + conn.execute("DELETE FROM matrix_impedance_fuse_phases") + conn.execute("DELETE FROM fuse_phase_states") + conn.execute("DELETE FROM matrix_impedance_fuses") + conn.execute("DELETE FROM impedance_matrix_entries WHERE equipment_type = 'FUSE'") + conn.execute("DELETE FROM matrix_impedance_fuse_equipment") + conn.execute("DELETE FROM matrix_impedance_recloser_phases") + conn.execute("DELETE FROM recloser_phase_states") + conn.execute("DELETE FROM matrix_impedance_reclosers") + conn.execute("DELETE FROM impedance_matrix_entries WHERE equipment_type = 'RECLOSER'") + conn.execute("DELETE FROM matrix_impedance_recloser_equipment") + conn.execute("DELETE FROM recloser_reclose_intervals") + conn.execute("DELETE FROM recloser_controllers") + conn.execute("DELETE FROM recloser_controller_equipment") + conn.execute("DELETE FROM time_current_curve_points") + conn.execute("DELETE FROM time_current_curves") + conn.execute("DELETE FROM switch_phase_states") + conn.execute("DELETE FROM matrix_impedance_switch_phases") + conn.execute("DELETE FROM matrix_impedance_switches") + conn.execute("DELETE FROM impedance_matrix_entries WHERE equipment_type = 'SWITCH'") + conn.execute("DELETE FROM matrix_impedance_switch_equipment") + conn.execute("DELETE FROM switch_controllers") + conn.execute("DELETE FROM sequence_impedance_branch_phases") + conn.execute("DELETE FROM sequence_impedance_branches") + conn.execute("DELETE FROM sequence_impedance_branch_equipment") + conn.execute("DELETE FROM matrix_impedance_branch_phases") + conn.execute("DELETE FROM matrix_impedance_branches") + conn.execute("DELETE FROM impedance_matrix_entries WHERE equipment_type = 'LINE'") + conn.execute("DELETE FROM matrix_impedance_branch_equipment") + conn.execute("DELETE FROM regulator_controllers") + conn.execute("DELETE FROM regulator_winding_phases") + conn.execute("DELETE FROM regulator_winding_buses") + conn.execute("DELETE FROM distribution_regulators") + conn.execute("DELETE FROM transformer_winding_phases") + conn.execute("DELETE FROM transformer_winding_buses") + conn.execute("DELETE FROM distribution_transformers") + conn.execute("DELETE FROM winding_tap_positions") + conn.execute("DELETE FROM winding_equipment") + conn.execute("DELETE FROM transformer_coupling_sequences") + conn.execute("DELETE FROM distribution_transformer_equipment") + conn.execute("DELETE FROM distribution_voltage_source_phases") + conn.execute("DELETE FROM distribution_voltage_sources") + conn.execute("DELETE FROM voltage_source_phases") + conn.execute("DELETE FROM voltage_source_equipment") + conn.execute("DELETE FROM phase_voltage_source_equipment") + conn.execute("DELETE FROM distribution_capacitor_phases") + conn.execute("DELETE FROM capacitor_controllers") + conn.execute("DELETE FROM distribution_capacitors") + conn.execute("DELETE FROM capacitor_equipment_phases") + conn.execute("DELETE FROM capacitor_equipment") + conn.execute("DELETE FROM phase_capacitor_equipment") + conn.execute("DELETE FROM distribution_battery_phases") + conn.execute("DELETE FROM distribution_batteries") + conn.execute("DELETE FROM battery_equipment") + conn.execute("DELETE FROM inverter_controllers") + conn.execute("DELETE FROM inverter_active_power_controls") + conn.execute("DELETE FROM inverter_reactive_power_controls") + conn.execute("DELETE FROM curve_points") + conn.execute("DELETE FROM curves") + conn.execute("DELETE FROM distribution_solar_phases") + conn.execute("DELETE FROM distribution_solar") + conn.execute("DELETE FROM solar_equipment") + conn.execute("DELETE FROM inverter_equipment") + conn.execute("DELETE FROM distribution_load_phases") + conn.execute("DELETE FROM distribution_loads") + conn.execute("DELETE FROM load_equipment_phases") + conn.execute("DELETE FROM load_equipment") + conn.execute("DELETE FROM phase_load_equipment") + conn.execute("DELETE FROM bus_voltage_limits") + conn.execute("DELETE FROM bus_phases") + conn.execute("DELETE FROM distribution_buses") + conn.execute("DELETE FROM substation_feeders") + conn.execute("DELETE FROM distribution_substations") + conn.execute("DELETE FROM distribution_feeders") + conn.execute( + "DELETE FROM voltage_limit_sets WHERE id NOT IN (SELECT limit_set_id FROM bus_voltage_limits)" + ) + conn.execute( + f"DELETE FROM gdm_component_uuid_map WHERE component_type IN ({', '.join(['?'] * len(component_types))})", + tuple(component_types), + ) + + +def _write_distribution_buses( + conn: sqlite3.Connection, + system: DistributionSystem, + substation_id_by_name: dict[str, int], + feeder_id_by_name: dict[str, int], +) -> None: + """Persist all DistributionBus rows, phases, and voltage limits.""" + for bus in system.get_components(DistributionBus): + if bus.substation is None or bus.feeder is None: + raise ValueError( + f"DistributionBus '{bus.name}' must have substation and feeder assigned for DB export" + ) + + substation_id = substation_id_by_name.get(bus.substation.name) + feeder_id = feeder_id_by_name.get(bus.feeder.name) + if substation_id is None or feeder_id is None: + raise ValueError( + f"DistributionBus '{bus.name}' references substation/feeder not present in system" + ) + + coordinate_x = bus.coordinate.x if bus.coordinate is not None else None + coordinate_y = bus.coordinate.y if bus.coordinate is not None else None + + cursor = conn.execute( + """ + INSERT INTO distribution_buses( + name, + substation_id, + feeder_id, + voltage_type, + rated_voltage, + rated_voltage_unit, + coordinate_x, + coordinate_y, + in_service + ) + VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + bus.name, + substation_id, + feeder_id, + bus.voltage_type.value, + float(bus.rated_voltage.magnitude), + str(bus.rated_voltage.units), + coordinate_x, + coordinate_y, + 1 if bus.in_service else 0, + ), + ) + bus_id = int(cursor.lastrowid) + _upsert_component_uuid_map(conn, "distribution_buses", bus_id, bus.uuid) + + for position_index, phase in enumerate(bus.phases): + conn.execute( + "INSERT INTO bus_phases(bus_id, phase, position_index) VALUES(?, ?, ?)", + (bus_id, phase.value, position_index), + ) + + for limit_set in bus.voltagelimits: + limit_cursor = conn.execute( + "INSERT INTO voltage_limit_sets(name, limit_type, value, value_unit) VALUES(?, ?, ?, ?)", + ( + limit_set.name, + limit_set.limit_type.value, + float(limit_set.value.magnitude), + str(limit_set.value.units), + ), + ) + limit_id = int(limit_cursor.lastrowid) + _upsert_component_uuid_map(conn, "voltage_limit_sets", limit_id, limit_set.uuid) + conn.execute( + "INSERT INTO bus_voltage_limits(bus_id, limit_set_id) VALUES(?, ?)", + (bus_id, limit_id), + ) + + def _write_distribution_topology(conn: sqlite3.Connection, system, replace: bool) -> None: if not isinstance(system, DistributionSystem): return @@ -303,90 +470,7 @@ def _write_distribution_topology(conn: sqlite3.Connection, system, replace: bool } if replace: - conn.execute("DELETE FROM geometry_branch_phases") - conn.execute("DELETE FROM geometry_branches") - conn.execute("DELETE FROM geometry_branch_conductors") - conn.execute("DELETE FROM geometry_branch_equipment") - conn.execute("DELETE FROM matrix_impedance_fuse_phases") - conn.execute("DELETE FROM fuse_phase_states") - conn.execute("DELETE FROM matrix_impedance_fuses") - conn.execute("DELETE FROM impedance_matrix_entries WHERE equipment_type = 'FUSE'") - conn.execute("DELETE FROM matrix_impedance_fuse_equipment") - conn.execute("DELETE FROM matrix_impedance_recloser_phases") - conn.execute("DELETE FROM recloser_phase_states") - conn.execute("DELETE FROM matrix_impedance_reclosers") - conn.execute("DELETE FROM impedance_matrix_entries WHERE equipment_type = 'RECLOSER'") - conn.execute("DELETE FROM matrix_impedance_recloser_equipment") - conn.execute("DELETE FROM recloser_reclose_intervals") - conn.execute("DELETE FROM recloser_controllers") - conn.execute("DELETE FROM recloser_controller_equipment") - conn.execute("DELETE FROM time_current_curve_points") - conn.execute("DELETE FROM time_current_curves") - conn.execute("DELETE FROM switch_phase_states") - conn.execute("DELETE FROM matrix_impedance_switch_phases") - conn.execute("DELETE FROM matrix_impedance_switches") - conn.execute("DELETE FROM impedance_matrix_entries WHERE equipment_type = 'SWITCH'") - conn.execute("DELETE FROM matrix_impedance_switch_equipment") - conn.execute("DELETE FROM switch_controllers") - conn.execute("DELETE FROM sequence_impedance_branch_phases") - conn.execute("DELETE FROM sequence_impedance_branches") - conn.execute("DELETE FROM sequence_impedance_branch_equipment") - conn.execute("DELETE FROM matrix_impedance_branch_phases") - conn.execute("DELETE FROM matrix_impedance_branches") - conn.execute("DELETE FROM impedance_matrix_entries WHERE equipment_type = 'LINE'") - conn.execute("DELETE FROM matrix_impedance_branch_equipment") - conn.execute("DELETE FROM regulator_controllers") - conn.execute("DELETE FROM regulator_winding_phases") - conn.execute("DELETE FROM regulator_winding_buses") - conn.execute("DELETE FROM distribution_regulators") - conn.execute("DELETE FROM transformer_winding_phases") - conn.execute("DELETE FROM transformer_winding_buses") - conn.execute("DELETE FROM distribution_transformers") - conn.execute("DELETE FROM winding_tap_positions") - conn.execute("DELETE FROM winding_equipment") - conn.execute("DELETE FROM transformer_coupling_sequences") - conn.execute("DELETE FROM distribution_transformer_equipment") - conn.execute("DELETE FROM distribution_voltage_source_phases") - conn.execute("DELETE FROM distribution_voltage_sources") - conn.execute("DELETE FROM voltage_source_phases") - conn.execute("DELETE FROM voltage_source_equipment") - conn.execute("DELETE FROM phase_voltage_source_equipment") - conn.execute("DELETE FROM distribution_capacitor_phases") - conn.execute("DELETE FROM capacitor_controllers") - conn.execute("DELETE FROM distribution_capacitors") - conn.execute("DELETE FROM capacitor_equipment_phases") - conn.execute("DELETE FROM capacitor_equipment") - conn.execute("DELETE FROM phase_capacitor_equipment") - conn.execute("DELETE FROM distribution_battery_phases") - conn.execute("DELETE FROM distribution_batteries") - conn.execute("DELETE FROM battery_equipment") - conn.execute("DELETE FROM inverter_controllers") - conn.execute("DELETE FROM inverter_active_power_controls") - conn.execute("DELETE FROM inverter_reactive_power_controls") - conn.execute("DELETE FROM curve_points") - conn.execute("DELETE FROM curves") - conn.execute("DELETE FROM distribution_solar_phases") - conn.execute("DELETE FROM distribution_solar") - conn.execute("DELETE FROM solar_equipment") - conn.execute("DELETE FROM inverter_equipment") - conn.execute("DELETE FROM distribution_load_phases") - conn.execute("DELETE FROM distribution_loads") - conn.execute("DELETE FROM load_equipment_phases") - conn.execute("DELETE FROM load_equipment") - conn.execute("DELETE FROM phase_load_equipment") - conn.execute("DELETE FROM bus_voltage_limits") - conn.execute("DELETE FROM bus_phases") - conn.execute("DELETE FROM distribution_buses") - conn.execute("DELETE FROM substation_feeders") - conn.execute("DELETE FROM distribution_substations") - conn.execute("DELETE FROM distribution_feeders") - conn.execute( - "DELETE FROM voltage_limit_sets WHERE id NOT IN (SELECT limit_set_id FROM bus_voltage_limits)" - ) - conn.execute( - f"DELETE FROM gdm_component_uuid_map WHERE component_type IN ({', '.join(['?'] * len(component_types))})", - tuple(component_types), - ) + _delete_distribution_tables(conn, component_types) feeder_id_by_name: dict[str, int] = {} substation_id_by_name: dict[str, int] = {} @@ -435,74 +519,7 @@ def _write_distribution_topology(conn: sqlite3.Connection, system, replace: bool (substation_id, feeder_id), ) - for bus in system.get_components(DistributionBus): - if bus.substation is None or bus.feeder is None: - raise ValueError( - f"DistributionBus '{bus.name}' must have substation and feeder assigned for DB export" - ) - - substation_id = substation_id_by_name.get(bus.substation.name) - feeder_id = feeder_id_by_name.get(bus.feeder.name) - if substation_id is None or feeder_id is None: - raise ValueError( - f"DistributionBus '{bus.name}' references substation/feeder not present in system" - ) - - coordinate_x = bus.coordinate.x if bus.coordinate is not None else None - coordinate_y = bus.coordinate.y if bus.coordinate is not None else None - - cursor = conn.execute( - """ - INSERT INTO distribution_buses( - name, - substation_id, - feeder_id, - voltage_type, - rated_voltage, - rated_voltage_unit, - coordinate_x, - coordinate_y, - in_service - ) - VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?) - """, - ( - bus.name, - substation_id, - feeder_id, - bus.voltage_type.value, - float(bus.rated_voltage.magnitude), - str(bus.rated_voltage.units), - coordinate_x, - coordinate_y, - 1 if bus.in_service else 0, - ), - ) - bus_id = int(cursor.lastrowid) - _upsert_component_uuid_map(conn, "distribution_buses", bus_id, bus.uuid) - - for position_index, phase in enumerate(bus.phases): - conn.execute( - "INSERT INTO bus_phases(bus_id, phase, position_index) VALUES(?, ?, ?)", - (bus_id, phase.value, position_index), - ) - - for limit_set in bus.voltagelimits: - limit_cursor = conn.execute( - "INSERT INTO voltage_limit_sets(name, limit_type, value, value_unit) VALUES(?, ?, ?, ?)", - ( - limit_set.name, - limit_set.limit_type.value, - float(limit_set.value.magnitude), - str(limit_set.value.units), - ), - ) - limit_id = int(limit_cursor.lastrowid) - _upsert_component_uuid_map(conn, "voltage_limit_sets", limit_id, limit_set.uuid) - conn.execute( - "INSERT INTO bus_voltage_limits(bus_id, limit_set_id) VALUES(?, ?)", - (bus_id, limit_id), - ) + _write_distribution_buses(conn, system, substation_id_by_name, feeder_id_by_name) curve_id_by_uuid: dict[UUID, int] = {} active_control_id_by_uuid: dict[UUID, int] = {} @@ -897,7 +914,11 @@ def _attach_time_series_from_snapshot( ) target_system.add_time_series(ts_data, target_component, **metadata.features) except Exception: - continue + logger.debug( + "Failed to restore time series '{}' for component '{}'", + metadata.name, + source_component.name, + ) def load_snapshot_payload( diff --git a/src/gdm/db/sqlite_store_cap_voltage_xfmr_reg.py b/src/gdm/db/sqlite_store_cap_voltage_xfmr_reg.py index 543406b1..c4b09618 100644 --- a/src/gdm/db/sqlite_store_cap_voltage_xfmr_reg.py +++ b/src/gdm/db/sqlite_store_cap_voltage_xfmr_reg.py @@ -907,6 +907,199 @@ def _write_distribution_regulators(conn: sqlite3.Connection, system: Distributio ) +def _load_or_cache_capacitor_equipment( + conn: sqlite3.Connection, + capacitor_equipment_id: int, + equipment_cache: dict[int, CapacitorEquipment], + phase_equipment_cache: dict[int, PhaseCapacitorEquipment], +) -> CapacitorEquipment: + """Load CapacitorEquipment from DB (with caching) for a given equipment id.""" + equipment = equipment_cache.get(capacitor_equipment_id) + if equipment is not None: + return equipment + + equipment_row = conn.execute( + """ + SELECT name, connection_type, rated_voltage, rated_voltage_unit, voltage_type + FROM capacitor_equipment + WHERE id = ? + """, + (capacitor_equipment_id,), + ).fetchone() + if equipment_row is None: + raise ValueError(f"capacitor_equipment_id={capacitor_equipment_id} not found") + ( + equipment_name, + connection_type, + rated_voltage, + rated_voltage_unit, + voltage_type, + ) = equipment_row + + phase_links = conn.execute( + """ + SELECT phase_capacitor_equipment_id + FROM capacitor_equipment_phases + WHERE capacitor_equipment_id = ? + ORDER BY position_index + """, + (capacitor_equipment_id,), + ).fetchall() + phase_caps: list[PhaseCapacitorEquipment] = [] + for (phase_cap_id,) in phase_links: + phase_cap = phase_equipment_cache.get(phase_cap_id) + if phase_cap is None: + phase_row = conn.execute( + """ + SELECT + name, + resistance, + resistance_unit, + reactance, + reactance_unit, + rated_reactive_power, + rated_reactive_power_unit, + num_banks_on, + num_banks + FROM phase_capacitor_equipment + WHERE id = ? + """, + (phase_cap_id,), + ).fetchone() + if phase_row is None: + raise ValueError(f"phase_capacitor_equipment_id={phase_cap_id} not found") + ( + phase_name, + resistance, + resistance_unit, + reactance, + reactance_unit, + rated_reactive_power, + rated_reactive_power_unit, + num_banks_on, + num_banks, + ) = phase_row + phase_cap = PhaseCapacitorEquipment( + name=phase_name, + resistance=Resistance(resistance, resistance_unit), + reactance=Reactance(reactance, reactance_unit), + rated_reactive_power=ReactivePower( + rated_reactive_power, rated_reactive_power_unit + ), + num_banks_on=num_banks_on, + num_banks=num_banks, + ) + phase_cap_uuid = _fetch_component_uuid( + conn, + "phase_capacitor_equipment", + phase_cap_id, + ) + if phase_cap_uuid is not None: + phase_cap = phase_cap.model_copy(update={"uuid": phase_cap_uuid}) + phase_equipment_cache[phase_cap_id] = phase_cap + phase_caps.append(phase_cap) + + equipment = CapacitorEquipment( + name=equipment_name, + phase_capacitors=phase_caps, + connection_type=ConnectionType(connection_type), + rated_voltage=Voltage(rated_voltage, rated_voltage_unit), + voltage_type=VoltageTypes(voltage_type), + ) + equipment_uuid = _fetch_component_uuid(conn, "capacitor_equipment", capacitor_equipment_id) + if equipment_uuid is not None: + equipment = equipment.model_copy(update={"uuid": equipment_uuid}) + equipment_cache[capacitor_equipment_id] = equipment + return equipment + + +def _build_capacitor_controller(conn: sqlite3.Connection, row: tuple) -> object | None: + """Build a single capacitor controller from a DB row, or return None.""" + ( + controller_id, + name, + controller_type, + delay_on, + delay_on_unit, + delay_off, + delay_off_unit, + dead_time, + dead_time_unit, + on_voltage, + on_voltage_unit, + off_voltage, + off_voltage_unit, + pt_ratio, + on_active_power, + on_active_power_unit, + off_active_power, + off_active_power_unit, + on_reactive_power, + on_reactive_power_unit, + off_reactive_power, + off_reactive_power_unit, + on_current, + on_current_unit, + off_current, + off_current_unit, + ct_ratio, + on_time, + off_time, + ) = row + + common = { + "name": name, + "delay_on": Time(delay_on, delay_on_unit) + if delay_on is not None and delay_on_unit is not None + else None, + "delay_off": Time(delay_off, delay_off_unit) + if delay_off is not None and delay_off_unit is not None + else None, + "dead_time": Time(dead_time, dead_time_unit) + if dead_time is not None and dead_time_unit is not None + else None, + } + controller = None + if controller_type == "VOLTAGE": + controller = VoltageCapacitorController( + **common, + on_voltage=Voltage(on_voltage, on_voltage_unit), + off_voltage=Voltage(off_voltage, off_voltage_unit), + pt_ratio=pt_ratio, + ) + elif controller_type == "ACTIVE_POWER": + controller = ActivePowerCapacitorController( + **common, + on_power=ActivePower(on_active_power, on_active_power_unit), + off_power=ActivePower(off_active_power, off_active_power_unit), + ) + elif controller_type == "REACTIVE_POWER": + controller = ReactivePowerCapacitorController( + **common, + on_power=ReactivePower(on_reactive_power, on_reactive_power_unit), + off_power=ReactivePower(off_reactive_power, off_reactive_power_unit), + ) + elif controller_type == "CURRENT": + controller = CurrentCapacitorController( + **common, + on_current=Current(on_current, on_current_unit), + off_current=Current(off_current, off_current_unit), + ct_ratio=ct_ratio, + ) + elif controller_type == "DAILY_TIMED": + controller = DailyTimedCapacitorController( + **common, + on_time=time.fromisoformat(on_time), + off_time=time.fromisoformat(off_time), + ) + + if controller is not None: + controller_uuid = _fetch_component_uuid(conn, "capacitor_controllers", controller_id) + if controller_uuid is not None: + controller = controller.model_copy(update={"uuid": controller_uuid}) + return controller + + def _load_distribution_capacitors_from_normalized( conn: sqlite3.Connection, system: DistributionSystem, @@ -949,102 +1142,9 @@ def _load_distribution_capacitors_from_normalized( ).fetchall() phases = [Phase(phase) for (phase,) in phase_rows] - equipment = equipment_cache.get(capacitor_equipment_id) - if equipment is None: - equipment_row = conn.execute( - """ - SELECT name, connection_type, rated_voltage, rated_voltage_unit, voltage_type - FROM capacitor_equipment - WHERE id = ? - """, - (capacitor_equipment_id,), - ).fetchone() - if equipment_row is None: - raise ValueError(f"capacitor_equipment_id={capacitor_equipment_id} not found") - ( - equipment_name, - connection_type, - rated_voltage, - rated_voltage_unit, - voltage_type, - ) = equipment_row - - phase_links = conn.execute( - """ - SELECT phase_capacitor_equipment_id - FROM capacitor_equipment_phases - WHERE capacitor_equipment_id = ? - ORDER BY position_index - """, - (capacitor_equipment_id,), - ).fetchall() - phase_caps: list[PhaseCapacitorEquipment] = [] - for (phase_cap_id,) in phase_links: - phase_cap = phase_equipment_cache.get(phase_cap_id) - if phase_cap is None: - phase_row = conn.execute( - """ - SELECT - name, - resistance, - resistance_unit, - reactance, - reactance_unit, - rated_reactive_power, - rated_reactive_power_unit, - num_banks_on, - num_banks - FROM phase_capacitor_equipment - WHERE id = ? - """, - (phase_cap_id,), - ).fetchone() - if phase_row is None: - raise ValueError(f"phase_capacitor_equipment_id={phase_cap_id} not found") - ( - phase_name, - resistance, - resistance_unit, - reactance, - reactance_unit, - rated_reactive_power, - rated_reactive_power_unit, - num_banks_on, - num_banks, - ) = phase_row - phase_cap = PhaseCapacitorEquipment( - name=phase_name, - resistance=Resistance(resistance, resistance_unit), - reactance=Reactance(reactance, reactance_unit), - rated_reactive_power=ReactivePower( - rated_reactive_power, rated_reactive_power_unit - ), - num_banks_on=num_banks_on, - num_banks=num_banks, - ) - phase_cap_uuid = _fetch_component_uuid( - conn, - "phase_capacitor_equipment", - phase_cap_id, - ) - if phase_cap_uuid is not None: - phase_cap = phase_cap.model_copy(update={"uuid": phase_cap_uuid}) - phase_equipment_cache[phase_cap_id] = phase_cap - phase_caps.append(phase_cap) - - equipment = CapacitorEquipment( - name=equipment_name, - phase_capacitors=phase_caps, - connection_type=ConnectionType(connection_type), - rated_voltage=Voltage(rated_voltage, rated_voltage_unit), - voltage_type=VoltageTypes(voltage_type), - ) - equipment_uuid = _fetch_component_uuid( - conn, "capacitor_equipment", capacitor_equipment_id - ) - if equipment_uuid is not None: - equipment = equipment.model_copy(update={"uuid": equipment_uuid}) - equipment_cache[capacitor_equipment_id] = equipment + equipment = _load_or_cache_capacitor_equipment( + conn, capacitor_equipment_id, equipment_cache, phase_equipment_cache + ) controller_rows = conn.execute( """ @@ -1086,90 +1186,8 @@ def _load_distribution_capacitors_from_normalized( ).fetchall() controllers = [] for row in controller_rows: - ( - controller_id, - name, - controller_type, - delay_on, - delay_on_unit, - delay_off, - delay_off_unit, - dead_time, - dead_time_unit, - on_voltage, - on_voltage_unit, - off_voltage, - off_voltage_unit, - pt_ratio, - on_active_power, - on_active_power_unit, - off_active_power, - off_active_power_unit, - on_reactive_power, - on_reactive_power_unit, - off_reactive_power, - off_reactive_power_unit, - on_current, - on_current_unit, - off_current, - off_current_unit, - ct_ratio, - on_time, - off_time, - ) = row - - common = { - "name": name, - "delay_on": Time(delay_on, delay_on_unit) - if delay_on is not None and delay_on_unit is not None - else None, - "delay_off": Time(delay_off, delay_off_unit) - if delay_off is not None and delay_off_unit is not None - else None, - "dead_time": Time(dead_time, dead_time_unit) - if dead_time is not None and dead_time_unit is not None - else None, - } - controller = None - if controller_type == "VOLTAGE": - controller = VoltageCapacitorController( - **common, - on_voltage=Voltage(on_voltage, on_voltage_unit), - off_voltage=Voltage(off_voltage, off_voltage_unit), - pt_ratio=pt_ratio, - ) - elif controller_type == "ACTIVE_POWER": - controller = ActivePowerCapacitorController( - **common, - on_power=ActivePower(on_active_power, on_active_power_unit), - off_power=ActivePower(off_active_power, off_active_power_unit), - ) - elif controller_type == "REACTIVE_POWER": - controller = ReactivePowerCapacitorController( - **common, - on_power=ReactivePower(on_reactive_power, on_reactive_power_unit), - off_power=ReactivePower(off_reactive_power, off_reactive_power_unit), - ) - elif controller_type == "CURRENT": - controller = CurrentCapacitorController( - **common, - on_current=Current(on_current, on_current_unit), - off_current=Current(off_current, off_current_unit), - ct_ratio=ct_ratio, - ) - elif controller_type == "DAILY_TIMED": - controller = DailyTimedCapacitorController( - **common, - on_time=time.fromisoformat(on_time), - off_time=time.fromisoformat(off_time), - ) - + controller = _build_capacitor_controller(conn, row) if controller is not None: - controller_uuid = _fetch_component_uuid( - conn, "capacitor_controllers", controller_id - ) - if controller_uuid is not None: - controller = controller.model_copy(update={"uuid": controller_uuid}) controllers.append(controller) capacitor = DistributionCapacitor( diff --git a/src/gdm/db/sqlite_store_schema.py b/src/gdm/db/sqlite_store_schema.py index 89f179c6..a5e6b343 100644 --- a/src/gdm/db/sqlite_store_schema.py +++ b/src/gdm/db/sqlite_store_schema.py @@ -9,8 +9,8 @@ def default_schema_path() -> Path: - """Return the default path to the SQL schema file in the repository.""" - return Path(__file__).resolve().parents[3] / ".dump" / "distribution_schema.sql" + """Return the default path to the SQL schema file bundled with the package.""" + return Path(__file__).resolve().parent / "distribution_schema.sql" def _initialize_schema(conn: sqlite3.Connection, schema_path: str | Path | None) -> None: From 893742c6f8b35291a48b0ef0b4efc7826904f37c Mon Sep 17 00:00:00 2001 From: Aadil Latif Date: Mon, 20 Apr 2026 11:17:15 -0600 Subject: [PATCH 6/6] Update src/gdm/db/distribution_schema.sql Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/gdm/db/distribution_schema.sql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/gdm/db/distribution_schema.sql b/src/gdm/db/distribution_schema.sql index 6c75a086..122cbf67 100644 --- a/src/gdm/db/distribution_schema.sql +++ b/src/gdm/db/distribution_schema.sql @@ -2,7 +2,7 @@ -- The current version of this schema only works for SQLITE >=3.45 -- When adding new functionality, think about the following: -- 1. Simplicity and ease of use over complexity, --- 2. Clear, concise and strict fields but allow for extensability, +-- 2. Clear, concise and strict fields but allow for extensibility, -- 3. User friendly over performance, but consider performance always. -- WARNING: This script should only be used while testing the schema and should -- not be applied to existing datasets since it drops all existing information.