diff --git a/.github/workflows/pull_request_tests.yml b/.github/workflows/pull_request_tests.yml index 3dea7fb6..0093d018 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@v6 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/docs/_toc.yml b/docs/_toc.yml index bdec4917..3ed67d7f 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/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/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..d17e11ec 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] @@ -98,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"] diff --git a/src/gdm/db/__init__.py b/src/gdm/db/__init__.py new file mode 100644 index 00000000..2dc43561 --- /dev/null +++ b/src/gdm/db/__init__.py @@ -0,0 +1,19 @@ +"""Database adapters for Grid Data Models.""" + +from gdm.db.store import ( + DEFAULT_DB_FORMAT_VERSION, + 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/distribution_schema.sql b/src/gdm/db/distribution_schema.sql new file mode 100644 index 00000000..122cbf67 --- /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 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. +-- +-- 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/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 new file mode 100644 index 00000000..a1712a2e --- /dev/null +++ b/src/gdm/db/sqlite_store.py @@ -0,0 +1,944 @@ +"""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 loguru import logger + +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.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, + _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 | 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 SQLite with transactional replace semantics. + + Parameters + ---------- + system : System + The GDM system instance to serialize and persist. + 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 + 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. + """ + + 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") + 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 | None = None, + db_url: str | None = None, + 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 | None + Legacy SQLite database path. + db_url : str | None + Database URL/DSN. + system_kind : str + Logical discriminator for stored system payloads. + + Returns + ------- + object + Deserialized system instance. + """ + + 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: + 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 _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 + + 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: + _delete_distribution_tables(conn, 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), + ) + + _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] = {} + 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: + logger.debug( + "Failed to restore time series '{}' for component '{}'", + metadata.name, + source_component.name, + ) + + +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": + 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 = ?", + (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..c4b09618 --- /dev/null +++ b/src/gdm/db/sqlite_store_cap_voltage_xfmr_reg.py @@ -0,0 +1,1736 @@ +"""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_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, + 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 = _load_or_cache_capacitor_equipment( + conn, capacitor_equipment_id, equipment_cache, phase_equipment_cache + ) + + 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 = _build_capacitor_controller(conn, row) + if controller is not None: + 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..a5e6b343 --- /dev/null +++ b/src/gdm/db/sqlite_store_schema.py @@ -0,0 +1,80 @@ +"""Schema and metadata helpers for SQLite GDM persistence.""" + +from __future__ import annotations + +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 bundled with the package.""" + return Path(__file__).resolve().parent / "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 | None = None, db_url: str | None = None +) -> dict[str, str]: + """Return GDM metadata key-values for debugging and validation.""" + 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/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/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 9b8169f1..cf3c4b16 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,38 @@ 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 | 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 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, + system_kind="catalog", + ) + + @classmethod + 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, + db_url=db_url, + system_kind="catalog", + ) diff --git a/src/gdm/distribution/distribution_system.py b/src/gdm/distribution/distribution_system.py index e2dd9aa6..91a7505f 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,60 @@ def _add_edge_traces( ) ) + def to_db( + self, + 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 database target. + + 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, + db_url=db_url, + schema_path=schema_path, + replace=replace, + initialize_schema=initialize_schema, + system_kind="distribution", + ) + + @classmethod + 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 | 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. + """ + from gdm.db import load_system_from_db + + return load_system_from_db( + system_cls=cls, + db_path=db_path, + db_url=db_url, + 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/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 new file mode 100644 index 00000000..ba64f0f7 --- /dev/null +++ b/tests/test_db_io.py @@ -0,0 +1,629 @@ +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 +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 _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" + + 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_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" + 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_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 +): + 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