diff --git a/docs/api/cim_reader.md b/docs/api/cim_reader.md index 8144bfe..fa215e9 100644 --- a/docs/api/cim_reader.md +++ b/docs/api/cim_reader.md @@ -4,6 +4,23 @@ The CIM reader parses an IEC 61968-13 CIM/XML file into a GDM `DistributionSystem`. It uses [rdflib](https://rdflib.readthedocs.io/) to query the RDF graph embedded in the XML and maps CIM classes to GDM components. +## Parsed Component Coverage + +The reader currently maps CIM data into these primary GDM components: + +- `DistributionBus` +- `DistributionLoad` +- `DistributionCapacitor` +- `DistributionVoltageSource` +- `DistributionBattery` +- `MatrixImpedanceBranch` +- `DistributionTransformer` +- `DistributionRegulator` +- `MatrixImpedanceSwitch` + +Battery support is provided through CIM `BatteryUnit` and +`PowerElectronicsConnection` data. + ## Reader Interface ```{eval-rst} diff --git a/docs/api/cim_writer.md b/docs/api/cim_writer.md new file mode 100644 index 0000000..50079a1 --- /dev/null +++ b/docs/api/cim_writer.md @@ -0,0 +1,40 @@ +# CIM Writer + +The CIM writer exports a GDM `DistributionSystem` to CIM IEC 61968-13 XML. + +## Supported GDM Components + +The writer currently serialises the following component types: + +- `DistributionBus` +- `DistributionVoltageSource` +- `DistributionLoad` +- `MatrixImpedanceBranch` +- `DistributionTransformer` +- `DistributionRegulator` +- `DistributionCapacitor` +- `MatrixImpedanceSwitch` +- `DistributionSolar` +- `DistributionBattery` +- `MatrixImpedanceFuse` + +## Output Modes + +The writer supports two output modes: + +- `single`: write one RDF/XML file named `model.xml` +- `package`: write grouped files plus a package `manifest.xml` + +In `package` mode, outputs can be separated by: + +- substation (`separate_substations=True`) +- feeder (`separate_feeders=True`) +- equipment type (`separate_equipment_types=True`) + +## Writer Interface + +```{eval-rst} +.. automodule:: ditto.writers.cim_iec_61968_13.write + :members: + :show-inheritance: +``` diff --git a/docs/index.md b/docs/index.md index 693c163..f7e45ab 100644 --- a/docs/index.md +++ b/docs/index.md @@ -20,7 +20,7 @@ The intermediate GDM representation can also be serialised to JSON for inspectio | Format | Reader | Writer | |--------|:------:|:------:| | OpenDSS | ✅ | ✅ | -| CIM IEC 61968-13 | ✅ | — | +| CIM IEC 61968-13 | ✅ | ✅ | ## Quick Start diff --git a/docs/reference.md b/docs/reference.md index 4401520..9bd10ee 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -27,6 +27,7 @@ Writers export a GDM `DistributionSystem` to a target format on disk. :hidden: true api/opendss_writer +api/cim_writer ``` ## MCP Server diff --git a/docs/usage.md b/docs/usage.md index 7e136ef..b595fc6 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -58,6 +58,30 @@ writer = Writer(system) writer.write(output_path=Path("output_model")) ``` +### Writing CIM IEC 61968-13 Output + +```python +from pathlib import Path +from ditto.writers.cim_iec_61968_13.write import Writer as CimWriter + +writer = CimWriter(system) + +# Single RDF/XML file: output/model.xml +writer.write(output_path=Path("cim_output"), output_mode="single") + +# Package output with per-substation, per-feeder, per-equipment-type files +writer.write( + output_path=Path("cim_package"), + output_mode="package", + separate_substations=True, + separate_feeders=True, + separate_equipment_types=True, +) +``` + +Current CIM writer coverage includes buses, loads, sources, lines, transformers, +regulators, capacitors, switches, fuses, solar, and battery components. + ## End-to-End Conversion Combining reader and writer for a full format conversion: diff --git a/pyproject.toml b/pyproject.toml index 38afa24..692236e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "hatchling.build" [project] name = "NREL-ditto" dynamic = ["version"] -description = 'Many to one to many distrbution system model converter ' +description = 'Many to one to many distribution system model converter' requires-python = ">=3.11" license = "MIT" keywords = [] @@ -15,7 +15,6 @@ authors = [ classifiers = [ "Development Status :: 4 - Beta", "Programming Language :: Python", - "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: Implementation :: CPython", @@ -50,7 +49,8 @@ dev = [ "pytest", "pytest-cov", "ruff", - "typer" + "typer", + "defusedxml" ] mcp = [ "mcp[cli]", @@ -66,6 +66,7 @@ filterwarnings = [ "ignore:Accessing the 'model_fields' attribute on the instance is deprecated:DeprecationWarning", "ignore::pydantic.warnings.PydanticDeprecatedSince211", "ignore:Pydantic serializer warnings:UserWarning", + "ignore::DeprecationWarning:pyparsing.*", ] [tool.ruff] diff --git a/src/ditto/mcp/docs.py b/src/ditto/mcp/docs.py index ea3c7d4..9384294 100644 --- a/src/ditto/mcp/docs.py +++ b/src/ditto/mcp/docs.py @@ -29,6 +29,8 @@ "api/opendss_reader": "api/opendss_reader.md", "api/cim_reader": "api/cim_reader.md", "api/opendss_writer": "api/opendss_writer.md", + "api/cim_writer": "api/cim_writer.md", + "api/mcp_server": "api/mcp_server.md", } diff --git a/src/ditto/mcp/server.py b/src/ditto/mcp/server.py index 3a7d0c6..817674a 100644 --- a/src/ditto/mcp/server.py +++ b/src/ditto/mcp/server.py @@ -1,8 +1,8 @@ """DiTTo MCP Server — Model Context Protocol interface for the Distribution Transformation Tool. Exposes DiTTo's reader/writer pipeline, model inspection, and documentation -as MCP tools, resources, and prompts. Uses FastMCP with a lifespan-managed -``AppState`` to hold loaded ``DistributionSystem`` instances across calls. +as MCP tools, resources, and prompts. Uses a module-level ``AppState`` +singleton to hold loaded ``DistributionSystem`` instances across calls. Run directly:: @@ -19,28 +19,15 @@ import importlib import json import pkgutil -from collections.abc import AsyncIterator -from contextlib import asynccontextmanager from pathlib import Path from typing import Any -from mcp.server.fastmcp import Context, FastMCP -from mcp.server.session import ServerSession +from mcp.server.fastmcp import FastMCP +from loguru import logger from ditto.mcp.docs import list_doc_pages, read_doc_page from ditto.mcp.state import AppState -# --------------------------------------------------------------------------- -# Lifespan -# --------------------------------------------------------------------------- - - -@asynccontextmanager -async def app_lifespan(server: FastMCP) -> AsyncIterator[AppState]: - """Initialise shared application state that persists across tool calls.""" - yield AppState() - - # --------------------------------------------------------------------------- # Server instance # --------------------------------------------------------------------------- @@ -55,7 +42,6 @@ async def app_lifespan(server: FastMCP) -> AsyncIterator[AppState]: "list readers/writers, load models, inspect components, and " "write output files. Documentation is available as resources." ), - lifespan=app_lifespan, ) # --------------------------------------------------------------------------- @@ -69,6 +55,7 @@ def _list_subpackages(package_name: str) -> list[str]: pkg = importlib.import_module(package_name) return [name for _, name, ispkg in pkgutil.iter_modules(pkg.__path__) if ispkg] except Exception: + logger.warning(f"Failed to import package '{package_name}' for listing subpackages") return [] @@ -108,11 +95,6 @@ def _resolve_component_type(type_name: str): ) -def _get_state(ctx: Context[ServerSession, AppState]) -> AppState: - """Extract the ``AppState`` from the request context.""" - return ctx.request_context.lifespan_context - - def _safe_json(obj: Any) -> Any: """Best-effort JSON-safe conversion of a pydantic/infrasys model.""" try: @@ -424,9 +406,9 @@ def convert_model( available_writers = _list_subpackages("ditto.writers") if reader_type not in available_readers: - return f"Unknown reader '{reader_type}'. Available: {available_readers}" + raise ValueError(f"Unknown reader '{reader_type}'. Available: {available_readers}") if writer_type not in available_writers: - return f"Unknown writer '{writer_type}'. Available: {available_writers}" + raise ValueError(f"Unknown writer '{writer_type}'. Available: {available_writers}") ReaderClass = _import_reader(reader_type) reader_instance = ReaderClass(Path(input_path).resolve()) @@ -469,7 +451,7 @@ def docs_page(page: str) -> str: """Read a specific DiTTo documentation page by slug. Available slugs: index, install, usage, reference, - api/opendss_reader, api/cim_reader, api/opendss_writer. + api/opendss_reader, api/cim_reader, api/opendss_writer, api/cim_writer. """ return read_doc_page(page) @@ -518,7 +500,7 @@ def inspect_model(name: str = "default") -> str: # Module-level sync state # --------------------------------------------------------------------------- # FastMCP sync tool functions cannot receive Context, so we use a -# module-level AppState that's also shared with the lifespan context. +# module-level AppState singleton to persist loaded systems across calls. _SYNC_STATE = AppState() diff --git a/src/ditto/readers/cim_iec_61968_13/__init__.py b/src/ditto/readers/cim_iec_61968_13/__init__.py index eb5fc1d..2c9beb7 100644 --- a/src/ditto/readers/cim_iec_61968_13/__init__.py +++ b/src/ditto/readers/cim_iec_61968_13/__init__.py @@ -25,6 +25,9 @@ from ditto.readers.cim_iec_61968_13.components.distribution_regulator import ( DistributionRegulatorMapper, ) +from ditto.readers.cim_iec_61968_13.components.distribution_battery import ( + DistributionBatteryMapper, +) from ditto.readers.cim_iec_61968_13.components.matrix_impedance_switch import ( MatrixImpedanceSwitchMapper, diff --git a/src/ditto/readers/cim_iec_61968_13/cim_mapper.py b/src/ditto/readers/cim_iec_61968_13/cim_mapper.py index c190279..e6bf656 100644 --- a/src/ditto/readers/cim_iec_61968_13/cim_mapper.py +++ b/src/ditto/readers/cim_iec_61968_13/cim_mapper.py @@ -1,4 +1,5 @@ from abc import ABC +from typing import Any from gdm.distribution import DistributionSystem @@ -6,3 +7,26 @@ class CimMapper(ABC): def __init__(self, system: DistributionSystem): self.system = system + + def _required_component(self, component_type: type, name: str, context: str): + try: + component = self.system.get_component(component_type=component_type, name=name) + except Exception as error: + raise LookupError( + f"Failed to resolve {component_type.__name__} '{name}' while mapping {context}" + ) from error + + if component is None: + raise LookupError( + f"Missing {component_type.__name__} '{name}' while mapping {context}" + ) + + return component + + def _required_field(self, row: Any, field_name: str, context: str): + if field_name not in row: + raise ValueError(f"Missing required field '{field_name}' while mapping {context}") + value = row[field_name] + if value is None: + raise ValueError(f"Null required field '{field_name}' while mapping {context}") + return value diff --git a/src/ditto/readers/cim_iec_61968_13/components/distribution_battery.py b/src/ditto/readers/cim_iec_61968_13/components/distribution_battery.py new file mode 100644 index 0000000..f97fc25 --- /dev/null +++ b/src/ditto/readers/cim_iec_61968_13/components/distribution_battery.py @@ -0,0 +1,58 @@ +from gdm.distribution.components import DistributionBattery, DistributionBus +from gdm.quantities import ActivePower, ReactivePower + +from ditto.readers.cim_iec_61968_13.equipment.battery_equipment import ( + BatteryEquipmentMapper, + InverterEquipmentMapper, +) +from ditto.readers.cim_iec_61968_13.cim_mapper import CimMapper +from ditto.readers.cim_iec_61968_13.common import phase_mapper + + +class DistributionBatteryMapper(CimMapper): + def __init__(self, system): + super().__init__(system) + + def parse(self, row): + return DistributionBattery( + name=self.map_name(row), + bus=self.map_bus(row), + phases=self.map_phases(row), + active_power=self.map_active_power(row), + reactive_power=self.map_reactive_power(row), + controller=None, + inverter=self.map_inverter(row), + equipment=self.map_equipment(row), + ) + + def map_name(self, row): + return self._required_field(row, "battery", "DistributionBattery") + + def map_bus(self, row): + battery_name = self.map_name(row) + bus_name = self._required_field(row, "bus", f"DistributionBattery '{battery_name}'") + return self._required_component( + DistributionBus, + bus_name, + f"DistributionBattery '{battery_name}'", + ) + + def map_phases(self, row): + phases = row["phase"] + if phases is None: + phases = ["A", "B", "C"] + else: + phases = phases.split(",") + return [phase_mapper[phase] for phase in phases if phase in phase_mapper] + + def map_active_power(self, row): + return ActivePower(float(row["p"]), "watt") + + def map_reactive_power(self, row): + return ReactivePower(float(row["q"]), "var") + + def map_equipment(self, row): + return BatteryEquipmentMapper(self.system).parse(row) + + def map_inverter(self, row): + return InverterEquipmentMapper(self.system).parse(row) diff --git a/src/ditto/readers/cim_iec_61968_13/components/distribution_capacitor.py b/src/ditto/readers/cim_iec_61968_13/components/distribution_capacitor.py index 873bcde..8708ff9 100644 --- a/src/ditto/readers/cim_iec_61968_13/components/distribution_capacitor.py +++ b/src/ditto/readers/cim_iec_61968_13/components/distribution_capacitor.py @@ -19,12 +19,19 @@ def parse(self, row): ) def map_name(self, row): - return row["capacitor"] + return self._required_field(row, "capacitor", "DistributionCapacitor") def map_bus(self, row): - bus_name = row["bus"] - bus = self.system.get_component(component_type=DistributionBus, name=bus_name) - return bus + bus_name = self._required_field( + row, + "bus", + f"DistributionCapacitor '{self.map_name(row)}'", + ) + return self._required_component( + DistributionBus, + bus_name, + f"DistributionCapacitor '{self.map_name(row)}'", + ) def map_phases(self, row): phases = row["phase"] diff --git a/src/ditto/readers/cim_iec_61968_13/components/distribution_load.py b/src/ditto/readers/cim_iec_61968_13/components/distribution_load.py index e3bbe79..f276fb5 100644 --- a/src/ditto/readers/cim_iec_61968_13/components/distribution_load.py +++ b/src/ditto/readers/cim_iec_61968_13/components/distribution_load.py @@ -18,16 +18,23 @@ def parse(self, row): ) def map_name(self, row): - return row["load"] + return self._required_field(row, "load", "DistributionLoad") def map_bus(self, row): - bus_name = row["bus"] - bus = self.system.get_component(component_type=DistributionBus, name=bus_name) - return bus + bus_name = self._required_field(row, "bus", f"DistributionLoad '{self.map_name(row)}'") + return self._required_component( + DistributionBus, + bus_name, + f"DistributionLoad '{self.map_name(row)}'", + ) def map_phases(self, row): - bus_name = row["bus"] - bus = self.system.get_component(component_type=DistributionBus, name=bus_name) + bus_name = self._required_field(row, "bus", f"DistributionLoad '{self.map_name(row)}'") + bus = self._required_component( + DistributionBus, + bus_name, + f"DistributionLoad '{self.map_name(row)}'", + ) phases = row["phase"] if phases is None: diff --git a/src/ditto/readers/cim_iec_61968_13/components/distribution_regulator.py b/src/ditto/readers/cim_iec_61968_13/components/distribution_regulator.py index cbca273..741aa96 100644 --- a/src/ditto/readers/cim_iec_61968_13/components/distribution_regulator.py +++ b/src/ditto/readers/cim_iec_61968_13/components/distribution_regulator.py @@ -24,7 +24,7 @@ def parse(self, row): ) def map_name(self, row): - return row["xfmr"] + return self._required_field(row, "xfmr", "DistributionRegulator") def map_winding_phases(self, row): if "wdg_1_phase" in row: @@ -40,10 +40,27 @@ def map_winding_phases(self, row): return [phase_1, phase_2] def map_bus(self, row): - bus_1_name = row["bus_1"] - bus_2_name = row["bus_2"] - bus_1 = self.system.get_component(DistributionBus, bus_1_name) - bus_2 = self.system.get_component(DistributionBus, bus_2_name) + regulator_name = self.map_name(row) + bus_1_name = self._required_field( + row, + "bus_1", + f"DistributionRegulator '{regulator_name}'", + ) + bus_2_name = self._required_field( + row, + "bus_2", + f"DistributionRegulator '{regulator_name}'", + ) + bus_1 = self._required_component( + DistributionBus, + bus_1_name, + f"DistributionRegulator '{regulator_name}'", + ) + bus_2 = self._required_component( + DistributionBus, + bus_2_name, + f"DistributionRegulator '{regulator_name}'", + ) return [bus_1, bus_2] def map_equipment(self, row): @@ -52,5 +69,10 @@ def map_equipment(self, row): return xfmr_equip def map_controllers(self, row): - reg_controllers = self.system.get_component(RegulatorController, row["xfmr"]) + reg_name = self.map_name(row) + reg_controllers = self._required_component( + RegulatorController, + reg_name, + f"DistributionRegulator '{reg_name}'", + ) return [reg_controllers] diff --git a/src/ditto/readers/cim_iec_61968_13/components/distribution_transformer.py b/src/ditto/readers/cim_iec_61968_13/components/distribution_transformer.py index 6fee2ce..575a44f 100644 --- a/src/ditto/readers/cim_iec_61968_13/components/distribution_transformer.py +++ b/src/ditto/readers/cim_iec_61968_13/components/distribution_transformer.py @@ -20,16 +20,60 @@ def parse(self, row): ) def map_name(self, row): - return row["xfmr"] + return self._required_field(row, "xfmr", "DistributionTransformer") + + def _infer_winding_phases(self, row, winding_index: int): + phase_key = f"wdg_{winding_index}_phase" + if phase_key in row and row[phase_key] is not None: + phase_text = str(row[phase_key]).replace("N", "") + return [Phase[phase] for phase in phase_text if phase in {"A", "B", "C"}] + + bus_name = row.get(f"bus_{winding_index}") if hasattr(row, "get") else None + if bus_name is None: + return [Phase.A, Phase.B, Phase.C] + + try: + bus = self.system.get_component(DistributionBus, bus_name) + except Exception: + bus = None + if bus is None: + return [Phase.A, Phase.B, Phase.C] + + bus_voltage = bus.rated_voltage.to("volt").magnitude + winding_voltage = float(row.get(f"wdg_{winding_index}_rated_voltage", 0.0)) + if bus_voltage <= 0.0 or winding_voltage <= 0.0: + return [Phase.A, Phase.B, Phase.C] + + ratio = winding_voltage / bus_voltage + if 1.45 <= ratio <= 2.05: + return [Phase.A, Phase.B, Phase.C] + return [Phase.A] def map_winding_phases(self, row): - return [[Phase.A, Phase.B, Phase.C], [Phase.A, Phase.B, Phase.C]] + return [self._infer_winding_phases(row, 1), self._infer_winding_phases(row, 2)] def map_bus(self, row): - bus_1_name = row["bus_1"] - bus_2_name = row["bus_2"] - bus_1 = self.system.get_component(DistributionBus, bus_1_name) - bus_2 = self.system.get_component(DistributionBus, bus_2_name) + transformer_name = self.map_name(row) + bus_1_name = self._required_field( + row, + "bus_1", + f"DistributionTransformer '{transformer_name}'", + ) + bus_2_name = self._required_field( + row, + "bus_2", + f"DistributionTransformer '{transformer_name}'", + ) + bus_1 = self._required_component( + DistributionBus, + bus_1_name, + f"DistributionTransformer '{transformer_name}'", + ) + bus_2 = self._required_component( + DistributionBus, + bus_2_name, + f"DistributionTransformer '{transformer_name}'", + ) return [bus_1, bus_2] def map_equipment(self, row): diff --git a/src/ditto/readers/cim_iec_61968_13/components/distribution_voltage_source.py b/src/ditto/readers/cim_iec_61968_13/components/distribution_voltage_source.py index a08d989..7ebf6a7 100644 --- a/src/ditto/readers/cim_iec_61968_13/components/distribution_voltage_source.py +++ b/src/ditto/readers/cim_iec_61968_13/components/distribution_voltage_source.py @@ -20,12 +20,19 @@ def parse(self, row): ) def map_name(self, row): - return row["source"] + return self._required_field(row, "source", "DistributionVoltageSource") def map_bus(self, row): - bus_name = row["bus"] - bus = self.system.get_component(component_type=DistributionBus, name=bus_name) - return bus + bus_name = self._required_field( + row, + "bus", + f"DistributionVoltageSource '{self.map_name(row)}'", + ) + return self._required_component( + DistributionBus, + bus_name, + f"DistributionVoltageSource '{self.map_name(row)}'", + ) def map_phases(self): return [phase_mapper[phase] for phase in ["A", "B", "C"]] diff --git a/src/ditto/readers/cim_iec_61968_13/components/matrix_branch.py b/src/ditto/readers/cim_iec_61968_13/components/matrix_branch.py index 6ce38da..bbaf574 100644 --- a/src/ditto/readers/cim_iec_61968_13/components/matrix_branch.py +++ b/src/ditto/readers/cim_iec_61968_13/components/matrix_branch.py @@ -20,13 +20,22 @@ def parse(self, row): ) def map_name(self, row): - return row["line"] + return self._required_field(row, "line", "MatrixImpedanceBranch") def map_buses(self, row): - bus_1_name = row["bus_1"] - bus_1 = self.system.get_component(DistributionBus, bus_1_name) - bus_2_name = row["bus_2"] - bus_2 = self.system.get_component(DistributionBus, bus_2_name) + line_name = self.map_name(row) + bus_1_name = self._required_field(row, "bus_1", f"MatrixImpedanceBranch '{line_name}'") + bus_2_name = self._required_field(row, "bus_2", f"MatrixImpedanceBranch '{line_name}'") + bus_1 = self._required_component( + DistributionBus, + bus_1_name, + f"MatrixImpedanceBranch '{line_name}'", + ) + bus_2 = self._required_component( + DistributionBus, + bus_2_name, + f"MatrixImpedanceBranch '{line_name}'", + ) return [bus_1, bus_2] def map_length(self, row): @@ -38,5 +47,14 @@ def map_phases(self, row): return [phase_mapper[phase] for phase in phases] def map_equipment(self, row): - equipment = self.system.get_component(MatrixImpedanceBranchEquipment, row["line_code"]) - return equipment + line_name = self.map_name(row) + line_code = self._required_field( + row, + "line_code", + f"MatrixImpedanceBranch '{line_name}'", + ) + return self._required_component( + MatrixImpedanceBranchEquipment, + line_code, + f"MatrixImpedanceBranch '{line_name}'", + ) diff --git a/src/ditto/readers/cim_iec_61968_13/components/matrix_impedance_switch.py b/src/ditto/readers/cim_iec_61968_13/components/matrix_impedance_switch.py index a5165f1..5cee916 100644 --- a/src/ditto/readers/cim_iec_61968_13/components/matrix_impedance_switch.py +++ b/src/ditto/readers/cim_iec_61968_13/components/matrix_impedance_switch.py @@ -13,7 +13,13 @@ def __init__(self, system): super().__init__(system) def parse(self, row): - self.bus_2 = self.system.get_component(DistributionBus, row["bus_2"]) + switch_name = self.map_name(row) + bus_2_name = self._required_field(row, "bus_2", f"MatrixImpedanceSwitch '{switch_name}'") + self.bus_2 = self._required_component( + DistributionBus, + bus_2_name, + f"MatrixImpedanceSwitch '{switch_name}'", + ) self.n_phases = len(self.bus_2.phases) return MatrixImpedanceSwitch( @@ -27,16 +33,33 @@ def parse(self, row): def map_is_closed(self, row): state = True if row["is_open"] == "false" else False - return [state] * 3 + return [state] * max(1, len(self.bus_2.phases)) def map_name(self, row): - return row["switch_name"] + return self._required_field(row, "switch_name", "MatrixImpedanceSwitch") def map_buses(self, row): - bus_1_name = row["bus_1"] - bus_1 = self.system.get_component(DistributionBus, bus_1_name) - bus_2_name = row["bus_2"] - bus_2 = self.system.get_component(DistributionBus, bus_2_name) + switch_name = self.map_name(row) + bus_1_name = self._required_field( + row, + "bus_1", + f"MatrixImpedanceSwitch '{switch_name}'", + ) + bus_2_name = self._required_field( + row, + "bus_2", + f"MatrixImpedanceSwitch '{switch_name}'", + ) + bus_1 = self._required_component( + DistributionBus, + bus_1_name, + f"MatrixImpedanceSwitch '{switch_name}'", + ) + bus_2 = self._required_component( + DistributionBus, + bus_2_name, + f"MatrixImpedanceSwitch '{switch_name}'", + ) return [bus_1, bus_2] def map_length(self, row): @@ -58,6 +81,7 @@ def map_equipment(self, row): equipment = MatrixImpedanceSwitchEquipment(**model_dict) return equipment else: - raise Exception( - "No Matrix Impedance Branch Equipment found with {} phases".format(self.n_phases) + raise ValueError( + "No MatrixImpedanceBranchEquipment found for switch " + f"'{row['switch_name']}' with {self.n_phases} phases" ) diff --git a/src/ditto/readers/cim_iec_61968_13/equipment/battery_equipment.py b/src/ditto/readers/cim_iec_61968_13/equipment/battery_equipment.py new file mode 100644 index 0000000..438829c --- /dev/null +++ b/src/ditto/readers/cim_iec_61968_13/equipment/battery_equipment.py @@ -0,0 +1,60 @@ +from gdm.distribution.equipment import BatteryEquipment, InverterEquipment +from gdm.distribution.enums import VoltageTypes +from gdm.quantities import ActivePower, ApparentPower, EnergyDC, Voltage + +from ditto.readers.cim_iec_61968_13.cim_mapper import CimMapper + + +class BatteryEquipmentMapper(CimMapper): + def __init__(self, system): + super().__init__(system) + + def parse(self, row): + return BatteryEquipment( + name=self.map_name(row), + rated_energy=self.map_rated_energy(row), + rated_power=self.map_rated_power(row), + charging_efficiency=1.0, + discharging_efficiency=1.0, + idling_efficiency=1.0, + rated_voltage=self.map_rated_voltage(row), + voltage_type=VoltageTypes.LINE_TO_LINE, + ) + + def map_name(self, row): + return row["battery"] + "_equipment" + + def map_rated_energy(self, row): + return EnergyDC(float(row["rated_energy"]), "watthour") + + def map_rated_power(self, row): + return ActivePower(float(row["max_p"]), "watt") + + def map_rated_voltage(self, row): + return Voltage(float(row["rated_voltage"]), "volt") + + +class InverterEquipmentMapper(CimMapper): + def __init__(self, system): + super().__init__(system) + + def parse(self, row): + return InverterEquipment( + name=self.map_name(row), + rated_apparent_power=self.map_rated_apparent_power(row), + rise_limit=None, + fall_limit=None, + cutout_percent=0.0, + cutin_percent=0.0, + dc_to_ac_efficiency=1.0, + eff_curve=None, + ) + + def map_name(self, row): + return row["battery"] + "_inverter" + + def map_rated_apparent_power(self, row): + rated_s = row["rated_s"] + if rated_s is None: + rated_s = row["max_p"] + return ApparentPower(float(rated_s), "VA") diff --git a/src/ditto/readers/cim_iec_61968_13/equipment/distribution_transformer_equipment.py b/src/ditto/readers/cim_iec_61968_13/equipment/distribution_transformer_equipment.py index a90f92a..21f7056 100644 --- a/src/ditto/readers/cim_iec_61968_13/equipment/distribution_transformer_equipment.py +++ b/src/ditto/readers/cim_iec_61968_13/equipment/distribution_transformer_equipment.py @@ -57,8 +57,23 @@ def map_winding_reactances(self, row): return [per_x] - else: - return [1] + if "wdg_1_z_1_leakage" in row and "wdg_1_z_0_leakage" in row: + z1 = row["wdg_1_z_1_leakage"] + z0 = row["wdg_1_z_0_leakage"] + if z1 is not None and z0 is not None: + x_hl = (2 * float(z1) + float(z0)) / 3 + per_x = x_hl / (self.v_h**2 / self.s) * 100 + return [per_x] + + if "wdg_2_z_1_leakage" in row and "wdg_2_z_0_leakage" in row: + z1 = row["wdg_2_z_1_leakage"] + z0 = row["wdg_2_z_0_leakage"] + if z1 is not None and z0 is not None: + x_hl = (2 * float(z1) + float(z0)) / 3 + per_x = x_hl / (self.v_h**2 / self.s) * 100 + return [per_x] + + return [0.01] def map_is_center_tapped(self, row): return False diff --git a/src/ditto/readers/cim_iec_61968_13/equipment/voltage_source_equipment.py b/src/ditto/readers/cim_iec_61968_13/equipment/voltage_source_equipment.py index dae4067..a8619e6 100644 --- a/src/ditto/readers/cim_iec_61968_13/equipment/voltage_source_equipment.py +++ b/src/ditto/readers/cim_iec_61968_13/equipment/voltage_source_equipment.py @@ -62,4 +62,4 @@ def map_voltage(self, row): return Voltage(float(row["src_voltage"]) / 1.732, "volt") def map_angle(self, row): - return Angle(float(row["src_angle"]) * 180 * pi, "degree") + return Angle(float(row["src_angle"]) * 180 / pi, "degree") diff --git a/src/ditto/readers/cim_iec_61968_13/equipment/winding_equipment.py b/src/ditto/readers/cim_iec_61968_13/equipment/winding_equipment.py index 1a978eb..05ccf52 100644 --- a/src/ditto/readers/cim_iec_61968_13/equipment/winding_equipment.py +++ b/src/ditto/readers/cim_iec_61968_13/equipment/winding_equipment.py @@ -1,6 +1,7 @@ from gdm.distribution.enums import VoltageTypes, ConnectionType, Phase from gdm.quantities import Voltage, ApparentPower from gdm.distribution.equipment import WindingEquipment +from gdm.distribution.components import DistributionBus from ditto.readers.cim_iec_61968_13.cim_mapper import CimMapper from ditto.readers.cim_iec_61968_13.common import phase_mapper @@ -15,15 +16,37 @@ def parse(self, row): windings = [] for i in range(1, 3): new_indices = [j for j in indices if j.startswith(f"wdg_{i}_")] - windings.append(self._build_winding(row[new_indices], i, row["xfmr"])) + windings.append(self._build_winding(row[new_indices], row, i, row["xfmr"])) return windings - def _build_winding(self, row, index, xfmr_name): + def _infer_phases_from_voltage(self, full_row, index): + bus_name = full_row.get(f"bus_{index}") if hasattr(full_row, "get") else None + if bus_name is None: + return [Phase.A, Phase.B, Phase.C] + + try: + bus = self.system.get_component(DistributionBus, bus_name) + except Exception: + bus = None + if bus is None: + return [Phase.A, Phase.B, Phase.C] + + bus_voltage = bus.rated_voltage.to("volt").magnitude + winding_voltage = float(full_row.get(f"wdg_{index}_rated_voltage", 0.0)) + if bus_voltage <= 0.0 or winding_voltage <= 0.0: + return [Phase.A, Phase.B, Phase.C] + + ratio = winding_voltage / bus_voltage + if 1.45 <= ratio <= 2.05: + return [Phase.A, Phase.B, Phase.C] + return [Phase.A] + + def _build_winding(self, row, full_row, index, xfmr_name): if f"wdg_{index}_phase" in row: self.phases = [phase_mapper[phs] for phs in row[f"wdg_{index}_phase"].replace("N", "")] else: - self.phases = [Phase.A, Phase.B, Phase.C] + self.phases = self._infer_phases_from_voltage(full_row, index) self.n_phases = len(self.phases) mapping = { diff --git a/src/ditto/readers/cim_iec_61968_13/length_units.py b/src/ditto/readers/cim_iec_61968_13/length_units.py deleted file mode 100644 index 19adb09..0000000 --- a/src/ditto/readers/cim_iec_61968_13/length_units.py +++ /dev/null @@ -1,26 +0,0 @@ -length_units = { - "English2": { - "SUL": "inch", - "MUL": "feet", - "LUL": "mile", - "PerLUL": "ohm/mile", - }, - "English1": { - "SUL": "inch", - "MUL": "feet", - "LUL": "kft", - "PerLUL": "ohm/kft", - }, - "English": { - "SUL": "inch", - "MUL": "feet", - "LUL": "kft", - "PerLUL": "ohm/kft", - }, - "Metric": { - "SUL": "mm", - "MUL": "m", - "LUL": "km", - "PerLUL": "ohm/km", - }, -} diff --git a/src/ditto/readers/cim_iec_61968_13/queries.py b/src/ditto/readers/cim_iec_61968_13/queries.py index 465f7f2..a3010f4 100644 --- a/src/ditto/readers/cim_iec_61968_13/queries.py +++ b/src/ditto/readers/cim_iec_61968_13/queries.py @@ -1,19 +1,73 @@ -from functools import reduce +from functools import lru_cache from rdflib.query import Result from loguru import logger from rdflib import Graph +from rdflib.term import BNode, Literal, URIRef import pandas as pd import numpy as np +def _namespace_key(graph: Graph) -> tuple[tuple[str, str], ...]: + return tuple((str(prefix_name), str(url)) for prefix_name, url in graph.namespaces()) + + +@lru_cache(maxsize=32) +def _prefix_block(namespace_key: tuple[tuple[str, str], ...]) -> str: + return "".join(f"PREFIX {prefix_name}: <{url}>\n" for prefix_name, url in namespace_key) + + +def _shorten_uri(value: str) -> str: + token = value.rstrip("/") + for delimiter in ("#", "/", "."): + if delimiter in token: + token = token.rsplit(delimiter, 1)[-1] + return token + + +def _normalize_rdf_value(value): + if value is None: + return None + + if isinstance(value, URIRef): + return _shorten_uri(str(value)) + if isinstance(value, Literal): + return value.value + if isinstance(value, BNode): + return str(value) + + raw_value = getattr(value, "value", value) + if isinstance(raw_value, str): + if "," in raw_value and "http" in raw_value: + parts = [part.strip() for part in raw_value.split(",")] + return ",".join(_shorten_uri(part) if "http" in part else part for part in parts) + if raw_value.startswith("http://") or raw_value.startswith("https://"): + return _shorten_uri(raw_value) + + return raw_value + + +def _query_dataframe(graph: Graph, query: str, columns: list[str]) -> pd.DataFrame: + return query_to_df(graph.query(add_prefixes(query, graph)), columns) + + +def _sorted_phase_string(phase_values: list) -> str | None: + phase_order = ["A", "B", "C", "N"] + phase_set = set() + + for raw_phase in phase_values: + if raw_phase is None: + continue + phase_text = str(raw_phase).replace(",", "").upper() + phase_set.update({phase for phase in phase_text if phase in phase_order}) + + ordered_phases = [phase for phase in phase_order if phase in phase_set] + return ",".join(ordered_phases) if ordered_phases else None + + def add_prefixes(query: str, graph: Graph) -> str: - prefixes = "" - for prefix_name, url in graph.namespaces(): - prefix = f"PREFIX {prefix_name}: <{str(url)}>\n" - prefixes += prefix - return prefixes + query + return _prefix_block(_namespace_key(graph)) + query def query_to_df(results: Result, columns: list[str]): @@ -21,41 +75,50 @@ def query_to_df(results: Result, columns: list[str]): for row in results: row_data = {} for column, value in zip(columns, row): - try: - new_value = value.value - except Exception as _: - new_value = value - - if isinstance(new_value, str) and "http" in new_value: - if "," in new_value: - new_value = new_value.split(",") - if "." in new_value[0]: - new_value = [v.split(".")[-1] for v in new_value] - elif "." in new_value: - new_value = new_value.split(".")[-1] - else: - new_value = new_value - new_value = ",".join(new_value) if isinstance(new_value, list) else new_value - - row_data[column] = new_value + row_data[column] = _normalize_rdf_value(value) data.append(row_data) - data = pd.DataFrame(data) + data = pd.DataFrame(data, columns=columns) data = data.drop_duplicates() return data -def query_line_codes(graph: Graph) -> pd.DataFrame: - columns = ["line_code", "phase_count", "r", "x", "b", "row", "column", "ampacity"] +def _empty_df(columns: list[str]) -> pd.DataFrame: + return pd.DataFrame(columns=columns) + + +def _line_code_empty_df() -> pd.DataFrame: + return _empty_df( + ["line_code", "phase_count", "r", "x", "b", "ampacity_normal", "ampacity_emergency"] + ) + + +def _line_code_lower_triangle( + values_df: pd.DataFrame, value_column: str, matrix_size: int +) -> list[float]: + matrix = np.zeros((matrix_size, matrix_size), dtype=float) + row_indices = pd.to_numeric(values_df["row"], errors="coerce").fillna(0).astype(int) - 1 + column_indices = pd.to_numeric(values_df["column"], errors="coerce").fillna(0).astype(int) - 1 + values = pd.to_numeric(values_df[value_column], errors="coerce").fillna(0.0).to_numpy() + + valid_mask = ( + (row_indices >= 0) + & (column_indices >= 0) + & (row_indices < matrix_size) + & (column_indices < matrix_size) + ) + matrix[row_indices[valid_mask], column_indices[valid_mask]] = values[valid_mask] - sparql_query_ac_line_segment = """ - SELECT ?line_code ?phase_count ?r ?x ?b ?row ?column ?ampacity + lower_row_indices, lower_col_indices = np.tril_indices(matrix_size) + return matrix[lower_row_indices, lower_col_indices].tolist() + + +def query_line_codes(graph: Graph) -> pd.DataFrame: + impedance_columns = ["line_code", "phase_count", "r", "x", "b", "row", "column"] + impedance_query = """ + SELECT ?line_code ?phase_count ?r ?x ?b ?row ?column WHERE { - ?term rdf:type cim:Terminal . - ?term cim:Terminal.ConductingEquipment ?line . - ?term cim:ACDCTerminal.OperationalLimitSet ?oplimset . ?line cim:ACLineSegment.PerLengthImpedance ?pu_phs_imp . - # ?pu_phs_imp rdf:type cim:PerLengthPhaseImpedance . ?pu_phs_imp cim:IdentifiedObject.name ?line_code . ?pu_phs_imp cim:PerLengthPhaseImpedance.conductorCount ?phase_count . ?phase_imp_data rdf:type cim:PhaseImpedanceData . @@ -65,45 +128,55 @@ def query_line_codes(graph: Graph) -> pd.DataFrame: ?phase_imp_data cim:PhaseImpedanceData.b ?b . ?phase_imp_data cim:PhaseImpedanceData.row ?row . ?phase_imp_data cim:PhaseImpedanceData.column ?column . + } + """ + + ampacity_columns = ["line_code", "ampacity"] + ampacity_query = """ + SELECT ?line_code ?ampacity + WHERE { + ?line cim:ACLineSegment.PerLengthImpedance ?pu_phs_imp . + ?pu_phs_imp cim:IdentifiedObject.name ?line_code . + ?term rdf:type cim:Terminal . + ?term cim:Terminal.ConductingEquipment ?line . + ?term cim:ACDCTerminal.OperationalLimitSet ?oplimset . ?curr_lim_set rdf:type cim:CurrentLimit . ?curr_lim_set cim:OperationalLimit.OperationalLimitSet ?oplimset . ?curr_lim_set cim:CurrentLimit.value ?ampacity . } """ - data = query_to_df(graph.query(add_prefixes(sparql_query_ac_line_segment, graph)), columns) - - data_set = {} - for line_code in data["line_code"].unique(): - filt_data = data[data["line_code"] == line_code] - line_code_filt = filt_data["line_code"].unique()[0] - phase_count_filt = filt_data["phase_count"].unique()[0] - ampacities = filt_data["ampacity"].unique() - filt_data = filt_data[filt_data["ampacity"] == ampacities[0]] - r = pd.pivot_table( - filt_data, values="r", index=["row"], columns=["column"], aggfunc="sum" - ).values - x = pd.pivot_table( - filt_data, values="x", index=["row"], columns=["column"], aggfunc="sum" - ).values - b = pd.pivot_table( - filt_data, values="b", index=["row"], columns=["column"], aggfunc="sum" - ).values - row_indices, col_indices = np.tril_indices(r.shape[0]) - r_lower = r[row_indices, col_indices].tolist() - x_lower = x[row_indices, col_indices].tolist() - b_lower = b[row_indices, col_indices].tolist() - - ampacities = [float(ampacity) for ampacity in ampacities] - data_set[line_code_filt] = { - "line_code": line_code_filt, - "phase_count": phase_count_filt, - "r": r_lower, - "x": x_lower, - "b": b_lower, - "ampacity_normal": min(ampacities), - "ampacity_emergency": max(ampacities), - } - return pd.DataFrame(data_set).T + impedance_data = _query_dataframe(graph, impedance_query, impedance_columns) + if impedance_data.empty: + return _line_code_empty_df() + + ampacity_data = _query_dataframe(graph, ampacity_query, ampacity_columns) + ampacity_map: dict[str, list[float]] = {} + if not ampacity_data.empty: + ampacity_data = ampacity_data.copy() + ampacity_data["ampacity"] = pd.to_numeric( + ampacity_data["ampacity"], errors="coerce" + ).fillna(0.0) + ampacity_map = ( + ampacity_data.groupby("line_code", dropna=False)["ampacity"].apply(list).to_dict() + ) + + output_rows = [] + for line_code, line_data in impedance_data.groupby("line_code", dropna=False, sort=False): + phase_count = int(pd.to_numeric(line_data["phase_count"], errors="coerce").iloc[0]) + ampacities = ampacity_map.get(line_code, [0.0]) + output_rows.append( + { + "line_code": line_code, + "phase_count": phase_count, + "r": _line_code_lower_triangle(line_data, "r", phase_count), + "x": _line_code_lower_triangle(line_data, "x", phase_count), + "b": _line_code_lower_triangle(line_data, "b", phase_count), + "ampacity_normal": min(ampacities), + "ampacity_emergency": max(ampacities), + } + ) + + return pd.DataFrame(output_rows) def query_load_break_switches(graph: Graph) -> pd.DataFrame: @@ -134,15 +207,44 @@ def query_load_break_switches(graph: Graph) -> pd.DataFrame: } """ - data = query_to_df(graph.query(add_prefixes(query, graph)), columns) + data = _query_dataframe(graph, query, columns) + if data.empty or "switch_name" not in data.columns: + return _empty_df( + [ + "switch_name", + "capacity", + "ratedCurrent", + "normally_open", + "is_open", + "voltage", + "bus_1", + "bus_2", + ] + ) data_set = [] for line_name in data["switch_name"].unique(): filt_data = data[data["switch_name"] == line_name] buses = filt_data["bus"].unique() - reduced_data = filt_data.drop_duplicates() + if len(buses) < 2: + logger.warning(f"Switch '{line_name}' has fewer than 2 buses ({len(buses)}), skipping") + continue + reduced_data = filt_data.drop_duplicates().copy() reduced_data["bus_1"] = buses[0] reduced_data["bus_2"] = buses[1] data_set.append(reduced_data) + if not data_set: + return _empty_df( + [ + "switch_name", + "capacity", + "ratedCurrent", + "normally_open", + "is_open", + "voltage", + "bus_1", + "bus_2", + ] + ) data = pd.concat(data_set) data.drop("bus", axis=1, inplace=True) data = data.drop_duplicates() @@ -172,12 +274,29 @@ def query_line_segments(graph: Graph) -> pd.DataFrame: } """ - data = query_to_df(graph.query(add_prefixes(query, graph)), columns) + data = _query_dataframe(graph, query, columns) + if data.empty or "line" not in data.columns: + return _empty_df( + [ + "line", + "voltage", + "length", + "phase_count", + "line_code", + "bus_1", + "phases_1", + "bus_2", + "phases_2", + ] + ) data_set = [] for line_name in data["line"].unique(): filt_data = data[data["line"] == line_name] buses = filt_data["bus"].unique() + if len(buses) < 2: + logger.warning(f"Line '{line_name}' has fewer than 2 buses ({len(buses)}), skipping") + continue bus_phases = {} for bus in buses: filt_data_bus = filt_data[filt_data["bus"] == bus] @@ -186,11 +305,26 @@ def query_line_segments(graph: Graph) -> pd.DataFrame: reduced_data = filt_data[["line", "voltage", "length", "phase_count", "line_code"]] reduced_data = reduced_data.drop_duplicates() reduced_data["bus_1"] = buses[0] - reduced_data["phases_1"] = ",".join(bus_phases[buses[0]]) + reduced_data["phases_1"] = _sorted_phase_string(bus_phases[buses[0]]) reduced_data["bus_2"] = buses[1] - reduced_data["phases_2"] = ",".join(bus_phases[buses[1]]) + reduced_data["phases_2"] = _sorted_phase_string(bus_phases[buses[1]]) data_set.append(reduced_data) + if not data_set: + return _empty_df( + [ + "line", + "voltage", + "length", + "phase_count", + "line_code", + "bus_1", + "phases_1", + "bus_2", + "phases_2", + ] + ) + data_set = pd.concat(data_set) return data_set @@ -208,7 +342,7 @@ def query_distribution_buses(graph: Graph) -> pd.DataFrame: ?location cim:IdentifiedObject.mRID ?location_id . } """ - locations = query_to_df(graph.query(add_prefixes(query_locations, graph)), locations_columns) + locations = _query_dataframe(graph, query_locations, locations_columns) location_dict = {} for location in locations["location_id"].unique(): loc = locations[locations["location_id"] == location] @@ -242,6 +376,9 @@ def query_distribution_buses(graph: Graph) -> pd.DataFrame: ?xfmr cim:IdentifiedObject.name ?TransformerEnd. ?xfmr cim:TransformerEnd.BaseVoltage ?xfmr_base_voltage . ?xfmr_base_voltage cim:BaseVoltage.nominalVoltage ?xfmr_voltage . + } . + + OPTIONAL { ?xfmr_tank cim:TransformerTank.PowerTransformer ?equip . ?xfmr_tank cim:PowerSystemResource.Location ?xfmr_location . ?xfmr_location cim:IdentifiedObject.mRID ?xfmr_loc_id. @@ -258,12 +395,15 @@ def query_distribution_buses(graph: Graph) -> pd.DataFrame: ?line_location cim:IdentifiedObject.mRID ?line_loc_id . ?equip cim:ConductingEquipment.BaseVoltage ?line_base_voltage . ?line_base_voltage cim:BaseVoltage.nominalVoltage ?line_voltage . - }. + FILTER NOT EXISTS { ?_te cim:TransformerEnd.Terminal ?term } + } . } # GROUP BY ?equip_name ?TransformerEnd """ - data = query_to_df(graph.query(add_prefixes(query, graph)), columns) + data = _query_dataframe(graph, query, columns) + if data.empty: + return _empty_df(["x", "y", "rated_voltage", "bus"]) node_voltage_df = _get_bus_base_voltages(data) node_coordinates = _get_bus_coordinates(data, location_dict) final_data = pd.concat([node_coordinates, node_voltage_df], axis=1) @@ -271,61 +411,100 @@ def query_distribution_buses(graph: Graph) -> pd.DataFrame: return final_data -def _get_bus_coordinates(loc_df: pd.DataFrame, location_dict: dict) -> pd.DataFrame: - filt_data = loc_df[["node", "reg_loc_id", "xfmr_loc_id", "line_loc_id"]] - df_grouped = filt_data.groupby("node", as_index=False).agg( +def _group_node_location_ids(loc_df: pd.DataFrame) -> pd.DataFrame: + grouped_data = loc_df[["node", "reg_loc_id", "xfmr_loc_id", "line_loc_id"]].groupby( + "node", as_index=False + ) + grouped_data = grouped_data.agg( { - "reg_loc_id": lambda x: list(filter(None, x)), - "xfmr_loc_id": lambda x: list(filter(None, x)), - "line_loc_id": lambda x: list(filter(None, x)), + "reg_loc_id": lambda values: list(filter(None, values)), + "xfmr_loc_id": lambda values: list(filter(None, values)), + "line_loc_id": lambda values: list(filter(None, values)), } ) - df_grouped["merged_ids"] = df_grouped.apply( - lambda row: row["reg_loc_id"] + row["xfmr_loc_id"] + row["line_loc_id"], axis=1 + grouped_data["merged_ids"] = grouped_data.apply( + lambda row: row["reg_loc_id"] + row["xfmr_loc_id"] + row["line_loc_id"], + axis=1, ) - df_grouped = df_grouped[["node", "merged_ids"]] - coordinate_map = {} - coordinates_to_be_corrected = {} - for _, row in df_grouped.iterrows(): - node = row["node"] - ids = row["merged_ids"] - coordinates = [] - - for cood_id in ids: - coordinates.append(set(location_dict[cood_id])) - result = reduce(lambda x, y: x & y, coordinates) - if len(result) == 1: - coordinate_map[node] = list(result)[0] - else: - coordinates_to_be_corrected[node] = result - - coordinate_map_fix = {} - for node_unknown, coordinates in coordinates_to_be_corrected.items(): - for _, coordinate in coordinate_map.items(): - if coordinate in coordinates: - coordinates.remove(coordinate) - if len(coordinates) == 1: - coordinate_map_fix[node_unknown] = list(coordinates)[0] - else: - logger.warning( - f"Node {node_unknown} has more than 1 location. Please correct this manually" - ) - - coordinate_map = {**coordinate_map, **coordinate_map_fix} - final_coordinate_map = {} - for node, coordinate in coordinate_map.items(): - final_coordinate_map[node] = {"x": coordinate[0], "y": coordinate[1]} + return grouped_data[["node", "merged_ids"]] + + +def _intersect_node_coordinates( + grouped_ids: pd.DataFrame, location_dict: dict +) -> tuple[dict[str, tuple], dict[str, set]]: + coordinate_map: dict[str, tuple] = {} + ambiguous_map: dict[str, set] = {} + for node, ids in grouped_ids.itertuples(index=False): + coordinate_sets = [] + for coord_id in ids: + location_coordinates = location_dict.get(coord_id) + if location_coordinates: + coordinate_sets.append(set(location_coordinates)) + + if not coordinate_sets: + continue + + overlap = set.intersection(*coordinate_sets) + if len(overlap) == 1: + coordinate_map[node] = list(overlap)[0] + continue + + if overlap: + ambiguous_map[node] = overlap + + return coordinate_map, ambiguous_map + + +def _resolve_ambiguous_coordinates( + coordinate_map: dict[str, tuple], ambiguous_map: dict[str, set] +) -> dict[str, tuple]: + resolved: dict[str, tuple] = {} + used_coordinates = set(coordinate_map.values()) + + for node_unknown, coordinates in ambiguous_map.items(): + remaining_coordinates = set(coordinates).difference(used_coordinates) + if len(remaining_coordinates) == 1: + selected_coordinate = list(remaining_coordinates)[0] + resolved[node_unknown] = selected_coordinate + used_coordinates.add(selected_coordinate) + continue + + if len(remaining_coordinates) > 1: + logger.warning( + f"Node {node_unknown} has more than 1 location. Please correct this manually" + ) + + return resolved + + +def _coordinate_dataframe(coordinate_map: dict[str, tuple]) -> pd.DataFrame: + final_coordinate_map = { + node: {"x": coordinate[0], "y": coordinate[1]} + for node, coordinate in coordinate_map.items() + } return pd.DataFrame(final_coordinate_map).T +def _get_bus_coordinates(loc_df: pd.DataFrame, location_dict: dict) -> pd.DataFrame: + if loc_df.empty: + return _empty_df(["x", "y"]) + + grouped_ids = _group_node_location_ids(loc_df) + coordinate_map, ambiguous_map = _intersect_node_coordinates(grouped_ids, location_dict) + coordinate_map.update(_resolve_ambiguous_coordinates(coordinate_map, ambiguous_map)) + return _coordinate_dataframe(coordinate_map) + + def _get_bus_base_voltages(data: pd.DataFrame) -> pd.DataFrame: + if data.empty: + return _empty_df(["rated_voltage"]) + filt_data = data[["node", "xfmr_voltage", "line_voltage"]] - filt_data_arr = filt_data.values - filt_data_arr = np.where(filt_data_arr is None, 0.0, filt_data_arr) - filt_data = pd.DataFrame(filt_data_arr, columns=filt_data.columns) - dtype_mapping = {"node": str, "xfmr_voltage": float, "line_voltage": float} - filt_data = filt_data.astype(dtype_mapping) + filt_data = filt_data.copy() + filt_data["node"] = filt_data["node"].astype(str) + for numeric_col in ["xfmr_voltage", "line_voltage"]: + filt_data[numeric_col] = pd.to_numeric(filt_data[numeric_col], errors="coerce").fillna(0.0) filt_data["rated_voltage"] = filt_data[["xfmr_voltage", "line_voltage"]].max(axis=1) filt_data = filt_data[["node", "rated_voltage"]] filt_data.drop_duplicates(inplace=True) @@ -414,7 +593,7 @@ def query_distribution_regulators(graph: Graph) -> pd.DataFrame: } """ - return query_to_df(graph.query(add_prefixes(query, graph)), columns) + return _query_dataframe(graph, query, columns) def query_power_transformers(graph: Graph) -> pd.DataFrame: @@ -446,12 +625,17 @@ def query_power_transformers(graph: Graph) -> pd.DataFrame: ?xfmr_end cim:PowerTransformerEnd.phaseAngleClock ?angle . ?xfmr_end cim:TransformerEnd.endNumber ?winding . ?xfmr_end cim:IdentifiedObject.name ?xfmr_end_name . + ?xfmr_end cim:TransformerEnd.Terminal ?term . ?term cim:Terminal.ConductingEquipment ?xfmr . ?term cim:Terminal.ConnectivityNode ?node . ?node cim:IdentifiedObject.name ?node_name . + + FILTER NOT EXISTS { + ?_tank cim:TransformerTank.PowerTransformer ?xfmr . + } } """ - return query_to_df(graph.query(add_prefixes(query, graph)), columns) + return _query_dataframe(graph, query, columns) def query_transformer_windings(graph: Graph) -> pd.DataFrame: @@ -475,7 +659,7 @@ def query_transformer_windings(graph: Graph) -> pd.DataFrame: ?xfmr_end_2 cim:IdentifiedObject.name ?xfmr_end_name_2 . } """ - return query_to_df(graph.query(add_prefixes(query, graph)), columns) + return _query_dataframe(graph, query, columns) def query_capacitors(graph: Graph) -> pd.DataFrame: @@ -519,7 +703,7 @@ def query_capacitors(graph: Graph) -> pd.DataFrame: } """ - return query_to_df(graph.query(add_prefixes(query, graph)), columns) + return _query_dataframe(graph, query, columns) def query_source(graph: Graph) -> pd.DataFrame: @@ -557,7 +741,7 @@ def query_source(graph: Graph) -> pd.DataFrame: } """ - return query_to_df(graph.query(add_prefixes(query, graph)), columns) + return _query_dataframe(graph, query, columns) def query_loads(graph: Graph) -> pd.DataFrame: @@ -613,7 +797,67 @@ def query_loads(graph: Graph) -> pd.DataFrame: ?zip_params cim:LoadResponseCharacteristic.qVoltageExponent ?q_exp . } """ - return query_to_df(graph.query(add_prefixes(query, graph)), columns) + return _query_dataframe(graph, query, columns) + + +def query_batteries(graph: Graph) -> pd.DataFrame: + columns = [ + "battery", + "rated_energy", + "stored_energy", + "max_p", + "p", + "q", + "rated_s", + "rated_voltage", + "phase", + "bus", + ] + + query = """ + SELECT ?battery_name ?rated_energy ?stored_energy ?max_p ?p ?q ?rated_s ?rated_voltage ?phase ?node_name + WHERE { + ?unit rdf:type cim:BatteryUnit . + ?unit cim:IdentifiedObject.name ?battery_name . + ?unit cim:BatteryUnit.ratedE ?rated_energy . + ?unit cim:BatteryUnit.storedE ?stored_energy . + + ?pec rdf:type cim:PowerElectronicsConnection . + ?pec cim:PowerElectronicsConnection.PowerElectronicsUnit ?unit . + ?pec cim:PowerElectronicsConnection.maxP ?max_p . + ?pec cim:PowerElectronicsConnection.p ?p . + ?pec cim:PowerElectronicsConnection.q ?q . + OPTIONAL { ?pec cim:PowerElectronicsConnection.ratedS ?rated_s . } . + + ?pec cim:ConductingEquipment.BaseVoltage ?base_voltage . + ?base_voltage cim:BaseVoltage.nominalVoltage ?rated_voltage . + + ?term rdf:type cim:Terminal . + ?term cim:Terminal.ConductingEquipment ?pec . + ?term cim:Terminal.ConnectivityNode ?node . + ?node cim:IdentifiedObject.name ?node_name . + + OPTIONAL { + ?pec_phase rdf:type cim:PowerElectronicsConnectionPhase . + ?pec_phase cim:PowerElectronicsConnectionPhase.PowerElectronicsConnection ?pec . + ?pec_phase cim:PowerElectronicsConnectionPhase.phase ?phase . + } . + } + """ + data = _query_dataframe(graph, query, columns) + if data.empty or "battery" not in data.columns: + return _empty_df(columns) + + data_set = [] + for battery_name in data["battery"].unique(): + battery_rows = data[data["battery"] == battery_name].copy() + first_row = battery_rows.iloc[0].copy() + + first_row["phase"] = _sorted_phase_string(battery_rows["phase"].dropna().tolist()) + + data_set.append(first_row) + + return pd.DataFrame(data_set) def query_regulator_controllers(graph: Graph) -> pd.DataFrame: @@ -671,4 +915,4 @@ def query_regulator_controllers(graph: Graph) -> pd.DataFrame: ?controller cim:TapChangerControl.minLimitVoltage ?min_voltage . } """ - return query_to_df(graph.query(add_prefixes(query, graph)), columns) + return _query_dataframe(graph, query, columns) diff --git a/src/ditto/readers/cim_iec_61968_13/reader.py b/src/ditto/readers/cim_iec_61968_13/reader.py index 79dc4c1..3655e40 100644 --- a/src/ditto/readers/cim_iec_61968_13/reader.py +++ b/src/ditto/readers/cim_iec_61968_13/reader.py @@ -5,6 +5,7 @@ from gdm.distribution import DistributionSystem from gdm.distribution.components import ( DistributionComponentBase, + DistributionBattery, DistributionVoltageSource, DistributionTransformer, MatrixImpedanceBranch, @@ -29,6 +30,7 @@ query_line_segments, query_line_codes, query_capacitors, + query_batteries, query_source, query_loads, ) @@ -38,9 +40,10 @@ class Reader(AbstractReader): # NOTE: Do not change sequnce of the component types below. - component_types: DistributionComponentBase = [ + component_types: list[type[DistributionComponentBase]] = [ DistributionBus, DistributionLoad, + DistributionBattery, DistributionCapacitor, DistributionVoltageSource, RegulatorController, @@ -53,7 +56,8 @@ class Reader(AbstractReader): def __init__(self, cim_file: str | Path): cim_file = Path(cim_file) - assert cim_file.exists(), f"{cim_file} does not exist" + if not cim_file.exists(): + raise FileNotFoundError(f"{cim_file} does not exist") self.system = DistributionSystem(auto_add_composed_components=True) self.graph = Graph() self.graph.parse(cim_file, format="xml") @@ -75,6 +79,9 @@ def read(self): logger.debug("Querying for capacitors...") datasets[DistributionCapacitor] = query_capacitors(self.graph) + logger.debug("Querying for batteries...") + datasets[DistributionBattery] = query_batteries(self.graph) + logger.debug("Querying for transformers...") xfmr_data = query_power_transformers(self.graph) logger.debug("Querying for transformer windings...") @@ -96,100 +103,164 @@ def read(self): datasets[DistributionBus] = self._set_bus_phases(datasets) + query_summary = { + component_type.__name__: int(len(dataframe)) + for component_type, dataframe in datasets.items() + } + logger.info(f"CIM query row counts: {query_summary}") + + parse_summary: dict[str, dict[str, int]] = {} + for component_type in self.component_types: mapper_name = component_type.__name__ + "Mapper" components = [] + row_count = 0 if component_type in datasets: try: mapper = getattr(cim_mapper, mapper_name)(self.system) logger.debug(f"Buliding components for {component_type.__name__}") except AttributeError as _: logger.warning(f"Mapper for {mapper_name} not found. Skipping") + parse_summary[component_type.__name__] = { + "rows": int(len(datasets[component_type])), + "parsed": 0, + } + continue if datasets[component_type].empty: logger.warning( f"Dataframe for {component_type.__name__} is empty. Check query." ) - for _, row in datasets[component_type].iterrows(): - model_entry = mapper.parse(row) + for row_index, row in datasets[component_type].iterrows(): + row_count += 1 + try: + model_entry = mapper.parse(row) + except Exception as error: + component_name = row.get("name") if hasattr(row, "get") else None + if component_name is None and hasattr(row, "get"): + component_name = ( + row.get("xfmr") or row.get("line") or row.get("switch_name") + ) + raise ValueError( + f"Failed parsing {component_type.__name__} row {row_index}" + + (f" (name={component_name})" if component_name is not None else "") + ) from error components.append(model_entry) else: logger.warning(f"Dataframe for {component_type.__name__} not found. Skipping") self.system.add_components(*components) + parse_summary[component_type.__name__] = { + "rows": row_count, + "parsed": int(len(components)), + } + + logger.info(f"CIM parse summary: {parse_summary}") logger.info("System summary: ", self.system.info()) + def _select_transformer_winding_rows( + self, xfmr_df: pd.DataFrame, windings: list + ) -> tuple[list[str], list[pd.Series]]: + selected_buses: list[str] = [] + winding_rows: list[pd.Series] = [] + + for winding in windings: + winding_rows_df = xfmr_df[xfmr_df["winding"] == winding] + if winding_rows_df.empty: + continue + + bus_candidates = winding_rows_df["bus"].drop_duplicates().to_list() + selected_bus = next( + (candidate for candidate in bus_candidates if candidate not in selected_buses), + bus_candidates[0], + ) + selected_buses.append(selected_bus) + + selected_row = winding_rows_df[winding_rows_df["bus"] == selected_bus] + winding_rows.append(selected_row.iloc[0]) + + return selected_buses, winding_rows + + def _build_winding_series(self, winding_rows: list[pd.Series], windings: list) -> pd.Series: + winding_df = pd.DataFrame(winding_rows).drop(columns=["winding", "bus"], errors="ignore") + + winding_series = [] + for winding, (_, winding_data) in zip(windings, winding_df.iterrows()): + winding_data.index = [ + f"wdg_{winding}_" + column_name for column_name in winding_data.index + ] + winding_series.append(winding_data) + + return pd.concat(winding_series) + + def _attach_winding_coupling_data(self, wdgs: pd.Series, winding_df: pd.DataFrame) -> None: + for _, wdg_coupling_data in winding_df.iterrows(): + xfmr_ends = {wdg_coupling_data["xfmr_end_1"], wdg_coupling_data["xfmr_end_2"]} + if not xfmr_ends.intersection(wdgs.to_list()): + continue + + wdgs["r0"] = wdg_coupling_data["r0"] + wdgs["r1"] = wdg_coupling_data["r1"] + wdgs["x0"] = wdg_coupling_data["x0"] + wdgs["x1"] = wdg_coupling_data["x1"] + wdgs["winding"] = wdg_coupling_data["winding"] + def _build_xfmr_dataset( self, xfmr_data: pd.DataFrame, winding_df: pd.DataFrame = pd.DataFrame() ) -> pd.DataFrame: - xfmrs = xfmr_data["xfmr"].unique() + if xfmr_data.empty or "xfmr" not in xfmr_data.columns: + return pd.DataFrame() + xfms = [] - for xfmr in xfmrs: - xfmr_df = xfmr_data[xfmr_data["xfmr"] == xfmr] - xfmr_df.pop("xfmr") - windings = xfmr_df.pop("winding").unique() - buses = xfmr_df.pop("bus").unique() - xfmr_df = xfmr_df.drop_duplicates() - wdgs = [] - for winding, (_, winding_data) in zip(windings, xfmr_df.iterrows()): - winding_data.index = [f"wdg_{winding}_" + c for c in winding_data.index] - wdgs.append(winding_data) - wdgs = pd.concat(wdgs) - wdgs["bus_1"] = buses[0] - wdgs["bus_2"] = buses[1] + for xfmr in xfmr_data["xfmr"].unique(): + xfmr_df = xfmr_data[xfmr_data["xfmr"] == xfmr].copy() + xfmr_df.drop(columns=["xfmr"], inplace=True, errors="ignore") + windings = xfmr_df["winding"].drop_duplicates().to_list() + selected_buses, winding_rows = self._select_transformer_winding_rows(xfmr_df, windings) + + if not winding_rows: + continue + + wdgs = self._build_winding_series(winding_rows, windings) + if selected_buses: + wdgs["bus_1"] = selected_buses[0] + wdgs["bus_2"] = selected_buses[1] if len(selected_buses) > 1 else selected_buses[0] wdgs["xfmr"] = xfmr - for _, wdg_coupling_data in winding_df.iterrows(): - xfmr_ends = {wdg_coupling_data["xfmr_end_1"], wdg_coupling_data["xfmr_end_2"]} - intersection = xfmr_ends.intersection(wdgs.to_list()) - if intersection: - wdgs["r0"] = wdg_coupling_data["r0"] - wdgs["r1"] = wdg_coupling_data["r1"] - wdgs["x0"] = wdg_coupling_data["x0"] - wdgs["x1"] = wdg_coupling_data["x1"] - wdgs["winding"] = wdg_coupling_data["winding"] + self._attach_winding_coupling_data(wdgs, winding_df) xfms.append(wdgs) - xfmr_dataset = pd.DataFrame(xfms) - return xfmr_dataset + return pd.DataFrame(xfms) def _set_bus_phases( self, df_dict: dict[DistributionComponentBase, pd.DataFrame] ) -> pd.DataFrame: all_phases = [] - bus_df = df_dict.pop(DistributionBus) + bus_df = df_dict[DistributionBus].copy() + phase_columns = ["phase", "phases_1", "phases_2"] + bus_columns = ["bus", "bus_1", "bus_2"] + + bus_phases_lookup: dict[str, list[str]] = {} + for df_type, df in df_dict.items(): + if df_type == DistributionBus or not isinstance(df, pd.DataFrame) or df.empty: + continue + + available_bus_columns = [column for column in bus_columns if column in df.columns] + available_phase_columns = [column for column in phase_columns if column in df.columns] + if not available_bus_columns or not available_phase_columns: + continue + + for bus_column in available_bus_columns: + for phase_column in available_phase_columns: + subset = df[[bus_column, phase_column]].dropna(subset=[bus_column]) + for bus_name, phase_value in subset.itertuples(index=False): + phase_text = "" if phase_value is None else str(phase_value) + cleaned = phase_text.replace(",", "").replace("N", "") + phase_list = [phase for phase in cleaned if len(phase) == 1] + if not phase_list: + continue + bus_phases_lookup.setdefault(str(bus_name), []).extend(phase_list) + for _, bus_data in bus_df.iterrows(): bus_name = bus_data["bus"] - phases = [] - - for df_type in df_dict: - df = df_dict[df_type] - bus_subset = {"bus", "bus_1", "bus_2"} - phase_subset = {"phase", "phases_1", "phases_2"} - - bus_cols = bus_subset.intersection(df.columns) - phase_cols = phase_subset.intersection(df.columns) - - if isinstance(df, pd.DataFrame) and len(bus_cols) and len(phase_cols): - for bus_col in bus_subset: - if bus_col in df.columns: - filt_data = df[df[bus_col] == bus_name] - if not filt_data.empty: - for phase_col in phase_subset: - phase_list = filt_data.get( - phase_col, default=pd.Series() - ).to_list() - phase_list = [ - phase.replace(",", "").replace("N", "") - for phase in phase_list - if phase is not None - ] - phase_list = [ - list(phase) for phase in phase_list if len(phase) >= 1 - ] - for ph_lst in phase_list: - phases.extend(ph_lst) - - phases = set(phases) - phases = phases.difference({None}) - phases = [phase for phase in list(phases) if len(phase) == 1] + phases = sorted(set(bus_phases_lookup.get(bus_name, []))) phases = sorted(phases) if not phases: phases = ["A", "B", "C"] diff --git a/src/ditto/readers/opendss/common.py b/src/ditto/readers/opendss/common.py index 0d0fa35..1fe2b4d 100644 --- a/src/ditto/readers/opendss/common.py +++ b/src/ditto/readers/opendss/common.py @@ -71,12 +71,12 @@ def remove_keys_from_dict(model_dict: dict, key_names: list[str] = ["name", "uui model_dict.pop(key_name) for k, v in model_dict.items(): if isinstance(v, dict): - model_dict[k] = remove_keys_from_dict(v) + model_dict[k] = remove_keys_from_dict(v, key_names) elif isinstance(v, list): values = [] for value in v: if isinstance(value, dict): - value = remove_keys_from_dict(value) + value = remove_keys_from_dict(value, key_names) values.append(value) model_dict[k] = values return model_dict @@ -104,7 +104,8 @@ def get_equipment_from_catalog( catalog[model_hash] = model return model else: - assert sub_catalog in catalog and isinstance(catalog[sub_catalog], dict) + if sub_catalog not in catalog or not isinstance(catalog[sub_catalog], dict): + raise ValueError(f"Sub-catalog '{sub_catalog}' not found or not a dict in catalog") if model_hash in catalog[sub_catalog]: return catalog[sub_catalog][model_hash] else: diff --git a/src/ditto/readers/opendss/components/loads.py b/src/ditto/readers/opendss/components/loads.py index 92846e3..21eb809 100644 --- a/src/ditto/readers/opendss/components/loads.py +++ b/src/ditto/readers/opendss/components/loads.py @@ -99,7 +99,7 @@ def get_loads(system: System) -> list[DistributionLoad]: flag = odd.Loads.First() while flag > 0: load_name = odd.Loads.Name().lower() - LoadEquipment, buses, nodes = _build_load_equipment() + load_equipment, buses, nodes = _build_load_equipment() bus1 = buses[0].split(".")[0] profile_names = [odd.Loads.Daily(), odd.Loads.Yearly(), odd.Loads.Duty()] profiles = build_profiles(profile_names, ObjectsWithProfile.LOAD, profile_catalog) @@ -107,7 +107,7 @@ def get_loads(system: System) -> list[DistributionLoad]: name=load_name, bus=system.get_component(DistributionBus, bus1), phases=[PHASE_MAPPER[el] for el in nodes], - equipment=LoadEquipment, + equipment=load_equipment, ) for profile_name in profile_names: if profile_name in profiles: diff --git a/src/ditto/readers/opendss/graph_utils.py b/src/ditto/readers/opendss/graph_utils.py index 5f5f686..4bea64a 100644 --- a/src/ditto/readers/opendss/graph_utils.py +++ b/src/ditto/readers/opendss/graph_utils.py @@ -1,3 +1,5 @@ +from loguru import logger + from gdm.distribution import DistributionSystem from gdm.distribution.enums import Phase from gdm.distribution.components import ( @@ -34,7 +36,10 @@ def update_split_phase_nodes(graph: Graph, system: DistributionSystem) -> Distri """ source_buses = get_source_bus(system) - assert len(set(source_buses)) == 1, "Source bus should be singular" + if len(set(source_buses)) != 1: + raise ValueError( + f"Expected exactly one source bus, but found {len(set(source_buses))}: {set(source_buses)}" + ) tree = dfs_multidigraph(graph, source=source_buses[0]) split_phase_transformers = _get_split_phase_transformers(system) for transformer in split_phase_transformers: @@ -77,13 +82,12 @@ def _get_split_phase_sub_graph( ) hv_xfmr_bus = max(xfmr_buses, key=lambda x: x[1])[0] lv_xfmr_bus = min(xfmr_buses, key=lambda x: x[1])[0] - xfmr.pprint() + logger.debug(f"Processing split-phase transformer: {xfmr.name}") xfmr_info = graph[hv_xfmr_bus][lv_xfmr_bus] for k in xfmr_info: - assert ( - xfmr_info[k]["type"] == DistributionTransformer - ), f"Unsupported model type {xfmr_info[k]['type']}" + if xfmr_info[k]["type"] != DistributionTransformer: + raise TypeError(f"Unsupported model type {xfmr_info[k]['type']}") model_type = xfmr_info[k]["type"] xfmr_model = system.get_component(model_type, xfmr_info[k]["name"]) @@ -144,9 +148,8 @@ def _fix_bus_phases( for _, _, data in subgraph.edges(data=True): model: DistributionBranchBase = system.get_component(data["type"], data["name"]) - assert issubclass( - model.__class__, DistributionBranchBase - ), f"Unsupported model type {model.__class__.__name__}" + if not issubclass(model.__class__, DistributionBranchBase): + raise TypeError(f"Unsupported model type {model.__class__.__name__}") model.phases = _mapped_phases(mapped_split_phases, model.phases) diff --git a/src/ditto/readers/opendss/reader.py b/src/ditto/readers/opendss/reader.py index 92e62e3..d40be26 100644 --- a/src/ditto/readers/opendss/reader.py +++ b/src/ditto/readers/opendss/reader.py @@ -1,6 +1,5 @@ from pathlib import Path -from gdm.distribution.common import SequencePair from gdm.distribution import DistributionSystem from pydantic import ValidationError @@ -31,14 +30,9 @@ from ditto.readers.reader import AbstractReader -SEQUENCE_PAIRS = [SequencePair(1, 2), SequencePair(1, 3), SequencePair(2, 3)] - - class Reader(AbstractReader): """Class interface for Opendss case file reader""" - validation_errors = [] - def __init__( self, Opendss_master_file: Path, @@ -53,6 +47,7 @@ def __init__( """ self.system = DistributionSystem(auto_add_composed_components=True) + self.validation_errors: list[list[str]] = [] self.Opendss_master_file = Path(Opendss_master_file) self.crs = crs self._read(use_split_phase_representation) @@ -167,7 +162,7 @@ def _validate_model(self): console = Console() console.print(error_table) raise Exception( - "Validations errors occured when running the script. See the table above" + "Validation errors occurred when running the script. See the table above" ) ... diff --git a/src/ditto/writers/cim_iec_61968_13/__init__.py b/src/ditto/writers/cim_iec_61968_13/__init__.py new file mode 100644 index 0000000..24c8324 --- /dev/null +++ b/src/ditto/writers/cim_iec_61968_13/__init__.py @@ -0,0 +1,3 @@ +from ditto.writers.cim_iec_61968_13.write import Writer + +__all__ = ["Writer"] diff --git a/src/ditto/writers/cim_iec_61968_13/equipment_emitters/__init__.py b/src/ditto/writers/cim_iec_61968_13/equipment_emitters/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/ditto/writers/cim_iec_61968_13/equipment_emitters/battery.py b/src/ditto/writers/cim_iec_61968_13/equipment_emitters/battery.py new file mode 100644 index 0000000..1c98c55 --- /dev/null +++ b/src/ditto/writers/cim_iec_61968_13/equipment_emitters/battery.py @@ -0,0 +1,93 @@ +from __future__ import annotations + +import xml.etree.ElementTree as ET + + +def emit_battery( + writer, + root: ET.Element, + battery, + bus_node_ids: dict[str, str], + bus_location_ids: dict[str, str], + base_voltage_cache: dict[str, str], +) -> None: + bus = getattr(battery, "bus", None) + if bus is None or bus.name not in bus_node_ids: + return + + unit_id = writer._deterministic_id("battery_unit", battery.name) + unit = writer._create_identified_object(root, "BatteryUnit", unit_id, battery.name) + writer._add_literal( + unit, + "BatteryUnit.ratedE", + writer._quantity(getattr(battery.equipment, "rated_energy", 0.0), "watthour"), + ) + writer._add_literal( + unit, + "BatteryUnit.storedE", + writer._quantity(getattr(battery.equipment, "rated_energy", 0.0), "watthour"), + ) + + connection_id = writer._deterministic_id( + "power_electronics_connection", f"battery:{battery.name}" + ) + connection = writer._create_identified_object( + root, + "PowerElectronicsConnection", + connection_id, + battery.name, + ) + + nominal_voltage = writer._bus_nominal_voltage(bus) + base_voltage_id = writer._create_base_voltage(root, nominal_voltage, base_voltage_cache) + writer._add_ref(connection, "ConductingEquipment.BaseVoltage", base_voltage_id) + writer._add_ref(connection, "PowerSystemResource.Location", bus_location_ids[bus.name]) + writer._add_ref(connection, "PowerElectronicsConnection.PowerElectronicsUnit", unit_id) + + writer._add_literal( + connection, + "PowerElectronicsConnection.maxP", + writer._quantity(getattr(battery.equipment, "rated_power", 0.0), "watt"), + ) + writer._add_literal( + connection, + "PowerElectronicsConnection.p", + writer._quantity(getattr(battery, "active_power", 0.0), "watt"), + ) + writer._add_literal( + connection, + "PowerElectronicsConnection.q", + writer._quantity(getattr(battery, "reactive_power", 0.0), "var"), + ) + + inverter = getattr(battery, "inverter", None) + if inverter is not None: + writer._add_literal( + connection, + "PowerElectronicsConnection.ratedS", + writer._quantity(getattr(inverter, "rated_apparent_power", 0.0), "VA"), + ) + + for index, phase in enumerate(getattr(battery, "phases", []), start=1): + phase_id = writer._deterministic_id( + "power_electronics_connection_phase", + f"battery:{battery.name}:{index}", + ) + phase_element = writer._create_identified_object( + root, + "PowerElectronicsConnectionPhase", + phase_id, + f"{battery.name}_phase_{index}", + ) + writer._add_ref( + phase_element, + "PowerElectronicsConnectionPhase.PowerElectronicsConnection", + connection_id, + ) + writer._add_literal( + phase_element, + "PowerElectronicsConnectionPhase.phase", + writer._phase_text(phase), + ) + + writer._create_terminal(root, connection_id, bus_node_ids[bus.name], f"{battery.name}:1") diff --git a/src/ditto/writers/cim_iec_61968_13/equipment_emitters/capacitor.py b/src/ditto/writers/cim_iec_61968_13/equipment_emitters/capacitor.py new file mode 100644 index 0000000..6159ded --- /dev/null +++ b/src/ditto/writers/cim_iec_61968_13/equipment_emitters/capacitor.py @@ -0,0 +1,69 @@ +from __future__ import annotations + +import xml.etree.ElementTree as ET + + +def emit_capacitor( + writer, + root: ET.Element, + capacitor, + bus_node_ids: dict[str, str], + bus_location_ids: dict[str, str], + base_voltage_cache: dict[str, str], +) -> None: + bus = getattr(capacitor, "bus", None) + if bus is None or bus.name not in bus_node_ids: + return + + capacitor_id = writer._deterministic_id("linear_shunt_compensator", capacitor.name) + capacitor_element = writer._create_identified_object( + root, + "LinearShuntCompensator", + capacitor_id, + capacitor.name, + ) + + rated_voltage = writer._quantity(getattr(capacitor.equipment, "rated_voltage", 0.0), "volt") + if rated_voltage <= 0: + rated_voltage = writer._bus_nominal_voltage(bus) + + base_voltage_id = writer._create_base_voltage(root, rated_voltage, base_voltage_cache) + writer._add_ref(capacitor_element, "ConductingEquipment.BaseVoltage", base_voltage_id) + writer._add_ref(capacitor_element, "PowerSystemResource.Location", bus_location_ids[bus.name]) + + conn_text = writer._safe_text(getattr(capacitor.equipment, "connection_type", "STAR")) + conn_code = "D" if "DELTA" in conn_text else "Y" + writer._add_literal(capacitor_element, "ShuntCompensator.phaseConnection", conn_code) + + phase_caps = list(getattr(capacitor.equipment, "phase_capacitors", [])) + total_var = 0.0 + for phase_cap in phase_caps: + total_var += writer._quantity(getattr(phase_cap, "rated_reactive_power", 0.0), "var") + b1 = total_var / (rated_voltage**2) if rated_voltage > 0 else 0.0 + + steps = 1 + if phase_caps: + steps = int(getattr(phase_caps[0], "num_banks", 1) or 1) + + writer._add_literal(capacitor_element, "LinearShuntCompensator.bPerSection", b1) + writer._add_literal(capacitor_element, "LinearShuntCompensator.gPerSection", 0.0) + writer._add_literal(capacitor_element, "LinearShuntCompensator.b0PerSection", 0.0) + writer._add_literal(capacitor_element, "LinearShuntCompensator.g0PerSection", 0.0) + writer._add_literal(capacitor_element, "ShuntCompensator.sections", steps) + + for index, phase in enumerate(getattr(capacitor, "phases", []), start=1): + phase_id = writer._deterministic_id( + "linear_shunt_compensator_phase", f"{capacitor.name}:{index}" + ) + phase_element = writer._create_identified_object( + root, + "LinearShuntCompensatorPhase", + phase_id, + f"{capacitor.name}_phase_{index}", + ) + writer._add_ref(phase_element, "ShuntCompensatorPhase.ShuntCompensator", capacitor_id) + writer._add_literal( + phase_element, "ShuntCompensatorPhase.phase", writer._phase_text(phase) + ) + + writer._create_terminal(root, capacitor_id, bus_node_ids[bus.name], f"{capacitor.name}:1") diff --git a/src/ditto/writers/cim_iec_61968_13/equipment_emitters/fuse.py b/src/ditto/writers/cim_iec_61968_13/equipment_emitters/fuse.py new file mode 100644 index 0000000..b34eb01 --- /dev/null +++ b/src/ditto/writers/cim_iec_61968_13/equipment_emitters/fuse.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +import xml.etree.ElementTree as ET + + +def emit_fuse( + writer, + root: ET.Element, + fuse, + bus_node_ids: dict[str, str], + bus_location_ids: dict[str, str], + base_voltage_cache: dict[str, str], +) -> None: + buses = list(getattr(fuse, "buses", [])) + if len(buses) < 2: + return + if buses[0].name not in bus_node_ids or buses[1].name not in bus_node_ids: + return + + fuse_id = writer._deterministic_id("fuse", fuse.name) + fuse_element = writer._create_identified_object(root, "Fuse", fuse_id, fuse.name) + + rated_current = writer._quantity(getattr(fuse.equipment, "ampacity", 0.0), "ampere") + writer._add_literal(fuse_element, "Switch.ratedCurrent", rated_current) + + states = list(getattr(fuse, "is_closed", [])) + is_open = not all(states) if states else False + state_text = "true" if is_open else "false" + writer._add_literal(fuse_element, "Switch.normalOpen", state_text) + writer._add_literal(fuse_element, "Switch.open", state_text) + + base_voltage_id = writer._create_base_voltage( + root, + writer._bus_nominal_voltage(buses[0]), + base_voltage_cache, + ) + writer._add_ref(fuse_element, "ConductingEquipment.BaseVoltage", base_voltage_id) + writer._add_ref(fuse_element, "PowerSystemResource.Location", bus_location_ids[buses[0].name]) + + writer._create_terminal(root, fuse_id, bus_node_ids[buses[0].name], f"{fuse.name}:1") + writer._create_terminal(root, fuse_id, bus_node_ids[buses[1].name], f"{fuse.name}:2") diff --git a/src/ditto/writers/cim_iec_61968_13/equipment_emitters/line.py b/src/ditto/writers/cim_iec_61968_13/equipment_emitters/line.py new file mode 100644 index 0000000..d556ad7 --- /dev/null +++ b/src/ditto/writers/cim_iec_61968_13/equipment_emitters/line.py @@ -0,0 +1,104 @@ +from __future__ import annotations + +import math +import xml.etree.ElementTree as ET + + +def emit_line_code_equipment( + writer, root: ET.Element, branch_equipment, emitted_ids: set[str] +) -> str: + line_code_id = writer._deterministic_id("per_length_impedance", branch_equipment.name) + if line_code_id in emitted_ids: + return line_code_id + + emitted_ids.add(line_code_id) + line_code = writer._create_identified_object( + root, + "PerLengthPhaseImpedance", + line_code_id, + branch_equipment.name, + ) + + r_matrix = branch_equipment.r_matrix.magnitude + x_matrix = branch_equipment.x_matrix.magnitude + c_matrix = branch_equipment.c_matrix.magnitude + conductor_count = int(r_matrix.shape[0]) + writer._add_literal(line_code, "PerLengthPhaseImpedance.conductorCount", conductor_count) + + for row in range(conductor_count): + for col in range(row + 1): + phase_data_id = writer._deterministic_id( + "phase_impedance_data", + f"{branch_equipment.name}:{row + 1}:{col + 1}", + ) + phase_data = writer._create_identified_object( + root, + "PhaseImpedanceData", + phase_data_id, + f"{branch_equipment.name}_{row + 1}_{col + 1}", + ) + writer._add_ref(phase_data, "PhaseImpedanceData.PhaseImpedance", line_code_id) + writer._add_literal(phase_data, "PhaseImpedanceData.r", float(r_matrix[row, col])) + writer._add_literal(phase_data, "PhaseImpedanceData.x", float(x_matrix[row, col])) + susceptance = float(c_matrix[row, col]) * 2 * math.pi * 60 + writer._add_literal(phase_data, "PhaseImpedanceData.b", susceptance) + writer._add_literal(phase_data, "PhaseImpedanceData.row", row + 1) + writer._add_literal(phase_data, "PhaseImpedanceData.column", col + 1) + + return line_code_id + + +def emit_line_segment( + writer, + root: ET.Element, + branch, + bus_node_ids: dict[str, str], + bus_location_ids: dict[str, str], + base_voltage_cache: dict[str, str], + emitted_line_code_ids: set[str], +) -> None: + if not getattr(branch, "buses", None) or len(branch.buses) < 2: + return + if not getattr(branch, "equipment", None): + return + + line_id = writer._deterministic_id("line_segment", branch.name) + line = writer._create_identified_object(root, "ACLineSegment", line_id, branch.name) + + nominal_voltage = writer._bus_nominal_voltage(branch.buses[0]) + base_voltage_id = writer._create_base_voltage(root, nominal_voltage, base_voltage_cache) + writer._add_ref(line, "ConductingEquipment.BaseVoltage", base_voltage_id) + writer._add_ref(line, "PowerSystemResource.Location", bus_location_ids[branch.buses[0].name]) + writer._add_literal(line, "Conductor.length", writer._quantity(branch.length, "meter")) + + line_code_id = emit_line_code_equipment(writer, root, branch.equipment, emitted_line_code_ids) + writer._add_ref(line, "ACLineSegment.PerLengthImpedance", line_code_id) + + for index, phase in enumerate(getattr(branch, "phases", []), start=1): + phase_id = writer._deterministic_id("line_segment_phase", f"{branch.name}:{index}") + line_phase = writer._create_identified_object( + root, + "ACLineSegmentPhase", + phase_id, + f"{branch.name}_phase_{index}", + ) + writer._add_ref(line_phase, "ACLineSegmentPhase.ACLineSegment", line_id) + writer._add_literal(line_phase, "ACLineSegmentPhase.phase", writer._phase_text(phase)) + + ampacity = writer._quantity(getattr(branch.equipment, "ampacity", 0.0), "ampere") + writer._create_terminal( + root, + line_id, + bus_node_ids[branch.buses[0].name], + f"{branch.name}:1", + with_limits=True, + ampacity=ampacity, + ) + writer._create_terminal( + root, + line_id, + bus_node_ids[branch.buses[1].name], + f"{branch.name}:2", + with_limits=True, + ampacity=ampacity, + ) diff --git a/src/ditto/writers/cim_iec_61968_13/equipment_emitters/load.py b/src/ditto/writers/cim_iec_61968_13/equipment_emitters/load.py new file mode 100644 index 0000000..16a2856 --- /dev/null +++ b/src/ditto/writers/cim_iec_61968_13/equipment_emitters/load.py @@ -0,0 +1,81 @@ +from __future__ import annotations + +import xml.etree.ElementTree as ET + + +def emit_energy_consumer( + writer, + root: ET.Element, + load, + bus_node_ids: dict[str, str], + bus_location_ids: dict[str, str], + base_voltage_cache: dict[str, str], +) -> None: + bus = load.bus + load_id = writer._deterministic_id("energy_consumer", load.name) + load_element = writer._create_identified_object(root, "EnergyConsumer", load_id, load.name) + + nominal_voltage = writer._bus_nominal_voltage(bus) + base_voltage_id = writer._create_base_voltage(root, nominal_voltage, base_voltage_cache) + writer._add_ref(load_element, "ConductingEquipment.BaseVoltage", base_voltage_id) + writer._add_ref(load_element, "PowerSystemResource.Location", bus_location_ids[bus.name]) + + total_p = 0.0 + total_q = 0.0 + z_p = 0.0 + i_p = 0.0 + p_p = 0.0 + z_q = 0.0 + i_q = 0.0 + p_q = 0.0 + phase_loads = getattr(load.equipment, "phase_loads", []) + if phase_loads: + for phase_load in phase_loads: + total_p += writer._quantity(getattr(phase_load, "real_power", 0.0), "watt") + total_q += writer._quantity(getattr(phase_load, "reactive_power", 0.0), "var") + lead = phase_loads[0] + z_p = float(getattr(lead, "z_real", 0.0)) * 100.0 + i_p = float(getattr(lead, "i_real", 0.0)) * 100.0 + p_p = float(getattr(lead, "p_real", 0.0)) * 100.0 + z_q = float(getattr(lead, "z_imag", 0.0)) * 100.0 + i_q = float(getattr(lead, "i_imag", 0.0)) * 100.0 + p_q = float(getattr(lead, "p_imag", 0.0)) * 100.0 + + conn_type = writer._safe_text(getattr(load.equipment, "connection_type", "STAR")) + conn_code = "D" if "DELTA" in conn_type else "Y" + grounded = "false" if conn_code == "D" else "true" + + writer._add_literal(load_element, "EnergyConsumer.p", total_p) + writer._add_literal(load_element, "EnergyConsumer.q", total_q) + writer._add_literal(load_element, "EnergyConsumer.phaseConnection", conn_code) + writer._add_literal(load_element, "EnergyConsumer.grounded", grounded) + + zip_id = writer._deterministic_id("load_response", load.name) + zip_element = writer._create_identified_object( + root, + "LoadResponseCharacteristic", + zip_id, + f"ZIP_{load.name}", + ) + writer._add_literal(zip_element, "LoadResponseCharacteristic.pConstantImpedance", z_p) + writer._add_literal(zip_element, "LoadResponseCharacteristic.pConstantCurrent", i_p) + writer._add_literal(zip_element, "LoadResponseCharacteristic.pConstantPower", p_p) + writer._add_literal(zip_element, "LoadResponseCharacteristic.qConstantImpedance", z_q) + writer._add_literal(zip_element, "LoadResponseCharacteristic.qConstantCurrent", i_q) + writer._add_literal(zip_element, "LoadResponseCharacteristic.qConstantPower", p_q) + writer._add_literal(zip_element, "LoadResponseCharacteristic.pVoltageExponent", 1.0) + writer._add_literal(zip_element, "LoadResponseCharacteristic.qVoltageExponent", 2.0) + writer._add_ref(load_element, "EnergyConsumer.LoadResponse", zip_id) + + for index, phase in enumerate(getattr(load, "phases", []), start=1): + phase_id = writer._deterministic_id("energy_consumer_phase", f"{load.name}:{index}") + phase_element = writer._create_identified_object( + root, + "EnergyConsumerPhase", + phase_id, + f"{load.name}_phase_{index}", + ) + writer._add_ref(phase_element, "EnergyConsumerPhase.EnergyConsumer", load_id) + writer._add_literal(phase_element, "EnergyConsumerPhase.phase", writer._phase_text(phase)) + + writer._create_terminal(root, load_id, bus_node_ids[bus.name], f"{load.name}:1") diff --git a/src/ditto/writers/cim_iec_61968_13/equipment_emitters/solar.py b/src/ditto/writers/cim_iec_61968_13/equipment_emitters/solar.py new file mode 100644 index 0000000..ab4425d --- /dev/null +++ b/src/ditto/writers/cim_iec_61968_13/equipment_emitters/solar.py @@ -0,0 +1,80 @@ +from __future__ import annotations + +import xml.etree.ElementTree as ET + + +def emit_solar( + writer, + root: ET.Element, + solar, + bus_node_ids: dict[str, str], + bus_location_ids: dict[str, str], + base_voltage_cache: dict[str, str], +) -> None: + bus = getattr(solar, "bus", None) + if bus is None or bus.name not in bus_node_ids: + return + + unit_id = writer._deterministic_id("photovoltaic_unit", solar.name) + writer._create_identified_object(root, "PhotoVoltaicUnit", unit_id, solar.name) + + connection_id = writer._deterministic_id("power_electronics_connection", solar.name) + connection = writer._create_identified_object( + root, + "PowerElectronicsConnection", + connection_id, + solar.name, + ) + + nominal_voltage = writer._bus_nominal_voltage(bus) + base_voltage_id = writer._create_base_voltage(root, nominal_voltage, base_voltage_cache) + writer._add_ref(connection, "ConductingEquipment.BaseVoltage", base_voltage_id) + writer._add_ref(connection, "PowerSystemResource.Location", bus_location_ids[bus.name]) + writer._add_ref(connection, "PowerElectronicsConnection.PowerElectronicsUnit", unit_id) + + writer._add_literal( + connection, + "PowerElectronicsConnection.maxP", + writer._quantity(getattr(solar.equipment, "rated_power", 0.0), "watt"), + ) + writer._add_literal( + connection, + "PowerElectronicsConnection.p", + writer._quantity(getattr(solar, "active_power", 0.0), "watt"), + ) + writer._add_literal( + connection, + "PowerElectronicsConnection.q", + writer._quantity(getattr(solar, "reactive_power", 0.0), "var"), + ) + + inverter = getattr(solar, "inverter", None) + if inverter is not None: + writer._add_literal( + connection, + "PowerElectronicsConnection.ratedS", + writer._quantity(getattr(inverter, "rated_apparent_power", 0.0), "VA"), + ) + + for index, phase in enumerate(getattr(solar, "phases", []), start=1): + phase_id = writer._deterministic_id( + "power_electronics_connection_phase", f"{solar.name}:{index}" + ) + phase_element = writer._create_identified_object( + root, + "PowerElectronicsConnectionPhase", + phase_id, + f"{solar.name}_phase_{index}", + ) + writer._add_ref( + phase_element, + "PowerElectronicsConnectionPhase.PowerElectronicsConnection", + connection_id, + ) + writer._add_literal( + phase_element, + "PowerElectronicsConnectionPhase.phase", + writer._phase_text(phase), + ) + + writer._create_terminal(root, connection_id, bus_node_ids[bus.name], f"{solar.name}:1") diff --git a/src/ditto/writers/cim_iec_61968_13/equipment_emitters/source.py b/src/ditto/writers/cim_iec_61968_13/equipment_emitters/source.py new file mode 100644 index 0000000..2378b37 --- /dev/null +++ b/src/ditto/writers/cim_iec_61968_13/equipment_emitters/source.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +import math +import xml.etree.ElementTree as ET + + +def emit_energy_source( + writer, + root: ET.Element, + source, + bus_node_ids: dict[str, str], + bus_location_ids: dict[str, str], + base_voltage_cache: dict[str, str], +) -> None: + bus = source.bus + source_id = writer._deterministic_id("energy_source", source.name) + source_element = writer._create_identified_object(root, "EnergySource", source_id, source.name) + + nominal_voltage = writer._bus_nominal_voltage(bus) + base_voltage_id = writer._create_base_voltage(root, nominal_voltage, base_voltage_cache) + writer._add_ref(source_element, "ConductingEquipment.BaseVoltage", base_voltage_id) + writer._add_ref(source_element, "PowerSystemResource.Location", bus_location_ids[bus.name]) + + source_phase = source.equipment.sources[0] if source.equipment.sources else None + r1 = writer._quantity(getattr(source_phase, "r1", 0.0), "ohm") + x1 = writer._quantity(getattr(source_phase, "x1", 0.0), "ohm") + r0 = writer._quantity(getattr(source_phase, "r0", 0.0), "ohm") + x0 = writer._quantity(getattr(source_phase, "x0", 0.0), "ohm") + phase_voltage = writer._quantity(getattr(source_phase, "voltage", 0.0), "volt") + angle_deg = writer._quantity(getattr(source_phase, "angle", 0.0), "degree") + + writer._add_literal(source_element, "EnergySource.nominalVoltage", nominal_voltage) + writer._add_literal(source_element, "EnergySource.voltageMagnitude", phase_voltage * 1.732) + writer._add_literal(source_element, "EnergySource.voltageAngle", angle_deg * math.pi / 180.0) + writer._add_literal(source_element, "EnergySource.r", r1) + writer._add_literal(source_element, "EnergySource.x", x1) + writer._add_literal(source_element, "EnergySource.r0", r0) + writer._add_literal(source_element, "EnergySource.x0", x0) + + writer._create_terminal(root, source_id, bus_node_ids[bus.name], f"{source.name}:1") diff --git a/src/ditto/writers/cim_iec_61968_13/equipment_emitters/switch.py b/src/ditto/writers/cim_iec_61968_13/equipment_emitters/switch.py new file mode 100644 index 0000000..cb3705e --- /dev/null +++ b/src/ditto/writers/cim_iec_61968_13/equipment_emitters/switch.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +import xml.etree.ElementTree as ET + + +def emit_switch( + writer, + root: ET.Element, + switch, + bus_node_ids: dict[str, str], + bus_location_ids: dict[str, str], + base_voltage_cache: dict[str, str], +) -> None: + buses = list(getattr(switch, "buses", [])) + if len(buses) < 2: + return + if buses[0].name not in bus_node_ids or buses[1].name not in bus_node_ids: + return + + switch_id = writer._deterministic_id("load_break_switch", switch.name) + switch_element = writer._create_identified_object( + root, "LoadBreakSwitch", switch_id, switch.name + ) + + rated_current = writer._quantity(getattr(switch.equipment, "ampacity", 0.0), "ampere") + writer._add_literal(switch_element, "ProtectedSwitch.breakingCapacity", rated_current) + writer._add_literal(switch_element, "Switch.ratedCurrent", rated_current) + + states = list(getattr(switch, "is_closed", [])) + is_open = not all(states) if states else False + state_text = "true" if is_open else "false" + writer._add_literal(switch_element, "Switch.normalOpen", state_text) + writer._add_literal(switch_element, "Switch.open", state_text) + + base_voltage_id = writer._create_base_voltage( + root, + writer._bus_nominal_voltage(buses[0]), + base_voltage_cache, + ) + writer._add_ref(switch_element, "ConductingEquipment.BaseVoltage", base_voltage_id) + writer._add_ref( + switch_element, "PowerSystemResource.Location", bus_location_ids[buses[0].name] + ) + + writer._create_terminal(root, switch_id, bus_node_ids[buses[0].name], f"{switch.name}:1") + writer._create_terminal(root, switch_id, bus_node_ids[buses[1].name], f"{switch.name}:2") diff --git a/src/ditto/writers/cim_iec_61968_13/equipment_emitters/transformer.py b/src/ditto/writers/cim_iec_61968_13/equipment_emitters/transformer.py new file mode 100644 index 0000000..2f98494 --- /dev/null +++ b/src/ditto/writers/cim_iec_61968_13/equipment_emitters/transformer.py @@ -0,0 +1,343 @@ +from __future__ import annotations + +import xml.etree.ElementTree as ET + + +def emit_transformer_mesh_impedance( + writer, + root: ET.Element, + xfmr_name: str, + end_1_id: str, + end_2_id: str, + winding_1, + winding_reactances: list, +) -> None: + mesh_id = writer._deterministic_id("transformer_mesh_impedance", xfmr_name) + mesh = writer._create_identified_object( + root, + "TransformerMeshImpedance", + mesh_id, + f"{xfmr_name}_mesh", + ) + per_x = winding_reactances[0] if winding_reactances else 1.0 + x1 = writer._winding_reactance_ohm(winding_1, per_x) + x0 = x1 + r1 = writer._winding_resistance_ohm(winding_1) + r0 = r1 + writer._add_literal(mesh, "TransformerMeshImpedance.r", r1) + writer._add_literal(mesh, "TransformerMeshImpedance.x", x1) + writer._add_literal(mesh, "TransformerMeshImpedance.r0", r0) + writer._add_literal(mesh, "TransformerMeshImpedance.x0", x0) + writer._add_ref(mesh, "TransformerMeshImpedance.FromTransformerEnd", end_1_id) + writer._add_ref(mesh, "TransformerMeshImpedance.ToTransformerEnd", end_2_id) + + +def emit_power_transformer( + writer, + root: ET.Element, + xfmr_name: str, + buses: list, + winding_phases: list, + equipment, + bus_node_ids: dict[str, str], + bus_location_ids: dict[str, str], + base_voltage_cache: dict[str, str], +) -> tuple[str, list[str], list[str], list[str]]: + power_id = writer._deterministic_id("power_transformer", xfmr_name) + power = writer._create_identified_object(root, "PowerTransformer", power_id, xfmr_name) + + if buses: + writer._add_ref(power, "PowerSystemResource.Location", bus_location_ids[buses[0].name]) + nominal_voltage = writer._bus_nominal_voltage(buses[0]) + base_voltage_id = writer._create_base_voltage(root, nominal_voltage, base_voltage_cache) + writer._add_ref(power, "ConductingEquipment.BaseVoltage", base_voltage_id) + + windings = list(getattr(equipment, "windings", [])) + if len(windings) < 2: + return power_id, [], [], [] + + vector_group = "".join(writer._connection_kind(winding) for winding in windings[:2]) + writer._add_literal(power, "PowerTransformer.vectorGroup", vector_group) + + end_ids: list[str] = [] + tank_end_ids: list[str] = [] + terminal_ids: list[str] = [] + + for index, (winding, bus) in enumerate(zip(windings[:2], buses[:2]), start=1): + end_id = writer._deterministic_id("power_transformer_end", f"{xfmr_name}:{index}") + end_ids.append(end_id) + end = writer._create_identified_object( + root, + "PowerTransformerEnd", + end_id, + f"{xfmr_name}_end_{index}", + ) + writer._add_ref(end, "PowerTransformerEnd.PowerTransformer", power_id) + writer._add_literal( + end, + "PowerTransformerEnd.ratedS", + writer._quantity(getattr(winding, "rated_power", 0.0), "VA"), + ) + writer._add_literal( + end, "PowerTransformerEnd.ratedU", writer._line_to_line_winding_voltage(winding) + ) + writer._add_literal(end, "PowerTransformerEnd.r", writer._winding_resistance_ohm(winding)) + writer._add_literal( + end, "PowerTransformerEnd.connectionKind", writer._connection_kind(winding) + ) + writer._add_literal(end, "PowerTransformerEnd.phaseAngleClock", index - 1) + writer._add_literal(end, "TransformerEnd.endNumber", index) + + terminal_id = writer._create_terminal( + root, + power_id, + bus_node_ids[bus.name], + f"{xfmr_name}:terminal:{index}", + ) + terminal_ids.append(terminal_id) + writer._add_ref(end, "TransformerEnd.Terminal", terminal_id) + winding_base_voltage_id = writer._create_base_voltage( + root, + writer._line_to_line_winding_voltage(winding), + base_voltage_cache, + ) + writer._add_ref(end, "TransformerEnd.BaseVoltage", winding_base_voltage_id) + + tank_end_id = writer._deterministic_id("transformer_tank_end", f"{xfmr_name}:{index}") + tank_end_ids.append(tank_end_id) + + emit_transformer_mesh_impedance( + writer, + root, + xfmr_name, + end_ids[0], + end_ids[1], + windings[0], + getattr(equipment, "winding_reactances", []), + ) + return power_id, end_ids, tank_end_ids, terminal_ids + + +def emit_distribution_transformer( + writer, + root: ET.Element, + transformer, + bus_node_ids: dict[str, str], + bus_location_ids: dict[str, str], + base_voltage_cache: dict[str, str], +) -> None: + if len(getattr(transformer, "buses", [])) < 2: + return + emit_power_transformer( + writer, + root, + transformer.name, + transformer.buses, + getattr(transformer, "winding_phases", []), + transformer.equipment, + bus_node_ids, + bus_location_ids, + base_voltage_cache, + ) + + +def emit_regulator( + writer, + root: ET.Element, + regulator, + bus_node_ids: dict[str, str], + bus_location_ids: dict[str, str], + base_voltage_cache: dict[str, str], +) -> None: + buses = list(getattr(regulator, "buses", [])) + if len(buses) < 2: + return + + equipment = regulator.equipment + power_id, _, tank_end_ids, power_terminal_ids = emit_power_transformer( + writer, + root, + f"{regulator.name}_power", + buses, + getattr(regulator, "winding_phases", []), + equipment, + bus_node_ids, + bus_location_ids, + base_voltage_cache, + ) + + tank_info_id = writer._deterministic_id("transformer_tank_info", regulator.name) + writer._create_identified_object( + root, + "TransformerTankInfo", + tank_info_id, + f"{regulator.name}_tank_info", + ) + + tank_id = writer._deterministic_id("transformer_tank", regulator.name) + tank = writer._create_identified_object(root, "TransformerTank", tank_id, regulator.name) + writer._add_ref(tank, "TransformerTank.TransformerTankInfo", tank_info_id) + writer._add_ref(tank, "TransformerTank.PowerTransformer", power_id) + writer._add_ref(tank, "PowerSystemResource.Location", bus_location_ids[buses[0].name]) + + windings = list(getattr(equipment, "windings", [])) + for index, winding in enumerate(windings[:2], start=1): + end_info_id = writer._deterministic_id("transformer_end_info", f"{regulator.name}:{index}") + end_info = writer._create_identified_object( + root, + "TransformerEndInfo", + end_info_id, + f"{regulator.name}_end_{index}", + ) + writer._add_ref(end_info, "TransformerEndInfo.TransformerTankInfo", tank_info_id) + writer._add_literal( + end_info, + "TransformerEndInfo.ratedS", + writer._quantity(getattr(winding, "rated_power", 0.0), "VA"), + ) + writer._add_literal( + end_info, "TransformerEndInfo.ratedU", writer._line_to_line_winding_voltage(winding) + ) + writer._add_literal( + end_info, "TransformerEndInfo.r", writer._winding_resistance_ohm(winding) + ) + writer._add_literal( + end_info, "TransformerEndInfo.connectionKind", writer._connection_kind(winding) + ) + writer._add_literal(end_info, "TransformerEndInfo.phaseAngleClock", index - 1) + writer._add_literal(end_info, "TransformerEndInfo.endNumber", index) + + tank_end = writer._create_identified_object( + root, + "TransformerTankEnd", + tank_end_ids[index - 1], + f"{regulator.name}_tank_end_{index}", + ) + writer._add_ref(tank_end, "TransformerTankEnd.TransformerTank", tank_id) + phases = getattr(regulator, "winding_phases", []) + phase_text = ( + writer._winding_phases_text(phases[index - 1]) if len(phases) >= index else "ABC" + ) + writer._add_literal(tank_end, "TransformerTankEnd.orderedPhases", phase_text) + + controllers = list(getattr(regulator, "controllers", [])) + controller = controllers[0] if controllers else None + if controller is None: + return + + tap_changer_id = writer._deterministic_id("ratio_tap_changer", regulator.name) + tap_changer = writer._create_identified_object( + root, + "RatioTapChanger", + tap_changer_id, + regulator.name, + ) + writer._add_ref(tap_changer, "RatioTapChanger.TransformerEnd", tank_end_ids[0]) + + primary_winding = windings[0] if windings else None + ( + dv_percent, + high_step, + low_step, + neutral_step, + normal_step, + current_step, + ) = writer._tap_step_values(primary_winding) + + writer._add_literal(tap_changer, "TapChanger.highStep", high_step) + writer._add_literal(tap_changer, "TapChanger.lowStep", low_step) + writer._add_literal(tap_changer, "TapChanger.neutralStep", neutral_step) + writer._add_literal(tap_changer, "TapChanger.normalStep", normal_step) + writer._add_literal(tap_changer, "RatioTapChanger.stepVoltageIncrement", dv_percent) + writer._add_literal(tap_changer, "TapChanger.step", current_step) + writer._add_literal( + tap_changer, "TapChanger.neutralU", writer._quantity(controller.v_setpoint, "volt") + ) + writer._add_literal( + tap_changer, "TapChanger.initialDelay", writer._quantity(controller.delay, "second") + ) + writer._add_literal( + tap_changer, "TapChanger.subsequentDelay", writer._quantity(controller.delay, "second") + ) + writer._add_literal(tap_changer, "TapChanger.ltcFlag", "true") + writer._add_literal(tap_changer, "TapChanger.controlEnabled", "true") + writer._add_literal( + tap_changer, "TapChanger.ptRatio", float(getattr(controller, "pt_ratio", 1.0)) + ) + writer._add_literal(tap_changer, "TapChanger.ctRatio", 1.0) + writer._add_literal( + tap_changer, "TapChanger.ctRating", writer._quantity(controller.ct_primary, "ampere") + ) + + control_id = writer._deterministic_id("tap_changer_control", regulator.name) + control = writer._create_identified_object( + root, + "TapChangerControl", + control_id, + f"{regulator.name}_control", + ) + writer._add_ref(tap_changer, "TapChanger.TapChangerControl", control_id) + + writer._add_literal(control, "RegulatingControl.mode", "voltage") + writer._add_ref(control, "RegulatingControl.Terminal", power_terminal_ids[0]) + writer._add_ref(control, "PowerSystemResource.Location", bus_location_ids[buses[0].name]) + writer._add_literal( + control, + "RegulatingControl.monitoredPhase", + writer._phase_text(controller.controlled_phase), + ) + writer._add_literal( + control, "RegulatingControl.targetValue", writer._quantity(controller.v_setpoint, "volt") + ) + writer._add_literal( + control, "RegulatingControl.targetDeadband", writer._quantity(controller.bandwidth, "volt") + ) + writer._add_literal( + control, + "TapChangerControl.lineDropCompensation", + str(getattr(controller, "use_ldc", False)).lower(), + ) + writer._add_literal( + control, "TapChangerControl.lineDropR", writer._quantity(controller.ldc_R, "volt") + ) + writer._add_literal( + control, "TapChangerControl.lineDropX", writer._quantity(controller.ldc_X, "volt") + ) + writer._add_literal( + control, + "TapChangerControl.reversible", + str(getattr(controller, "is_reversible", False)).lower(), + ) + writer._add_literal( + control, + "TapChangerControl.maxLimitVoltage", + writer._quantity(controller.max_v_limit, "volt"), + ) + writer._add_literal( + control, + "TapChangerControl.minLimitVoltage", + writer._quantity(controller.min_v_limit, "volt"), + ) + + short_test_id = writer._deterministic_id("short_circuit_test", regulator.name) + short_test = writer._create_identified_object( + root, + "ShortCircuitTest", + short_test_id, + f"{regulator.name}_short_test", + ) + x_test = ( + writer._winding_reactance_ohm(primary_winding, equipment.winding_reactances[0]) + if getattr(equipment, "winding_reactances", []) and primary_winding + else 0.0 + ) + r_test = writer._winding_resistance_ohm(primary_winding) if primary_winding else 0.0 + writer._add_ref( + short_test, + "ShortCircuitTest.EnergisedEnd", + writer._deterministic_id("transformer_end_info", f"{regulator.name}:1"), + ) + writer._add_literal(short_test, "ShortCircuitTest.leakageImpedance", x_test) + writer._add_literal(short_test, "ShortCircuitTest.leakageImpedanceZero", x_test) + writer._add_literal(short_test, "ShortCircuitTest.loss", r_test) + writer._add_literal(short_test, "ShortCircuitTest.lossZero", r_test) diff --git a/src/ditto/writers/cim_iec_61968_13/write.py b/src/ditto/writers/cim_iec_61968_13/write.py new file mode 100644 index 0000000..00b96af --- /dev/null +++ b/src/ditto/writers/cim_iec_61968_13/write.py @@ -0,0 +1,583 @@ +from __future__ import annotations + +import re +from uuid import NAMESPACE_URL, uuid5 +from collections import defaultdict +from pathlib import Path +import xml.etree.ElementTree as ET + +from ditto.writers.abstract_writer import AbstractWriter +from ditto.writers.cim_iec_61968_13.equipment_emitters.source import emit_energy_source +from ditto.writers.cim_iec_61968_13.equipment_emitters.load import emit_energy_consumer +from ditto.writers.cim_iec_61968_13.equipment_emitters.line import emit_line_segment +from ditto.writers.cim_iec_61968_13.equipment_emitters.capacitor import emit_capacitor +from ditto.writers.cim_iec_61968_13.equipment_emitters.switch import emit_switch +from ditto.writers.cim_iec_61968_13.equipment_emitters.solar import emit_solar +from ditto.writers.cim_iec_61968_13.equipment_emitters.fuse import emit_fuse +from ditto.writers.cim_iec_61968_13.equipment_emitters.battery import emit_battery +from ditto.writers.cim_iec_61968_13.equipment_emitters.transformer import ( + emit_distribution_transformer, + emit_regulator, +) + + +RDF_NS = "http://www.w3.org/1999/02/22-rdf-syntax-ns#" +CIM_NS = "http://iec.ch/TC57/CIM100#" + +ET.register_namespace("rdf", RDF_NS) +ET.register_namespace("cim", CIM_NS) + + +class Writer(AbstractWriter): + _SUPPORTED_COMPONENT_TYPES = { + "DistributionBus", + "DistributionVoltageSource", + "DistributionLoad", + "MatrixImpedanceBranch", + "DistributionTransformer", + "DistributionRegulator", + "DistributionCapacitor", + "MatrixImpedanceSwitch", + "DistributionSolar", + "DistributionBattery", + "MatrixImpedanceFuse", + } + + def _rdf(self, suffix: str) -> str: + return f"{{{RDF_NS}}}{suffix}" + + def _cim(self, suffix: str) -> str: + return f"{{{CIM_NS}}}{suffix}" + + def _deterministic_id(self, kind: str, name: str) -> str: + return str(uuid5(NAMESPACE_URL, f"ditto-cim:{kind}:{name}")) + + def _safe_text(self, value) -> str: + if value is None: + return "" + if hasattr(value, "value"): + return str(value.value) + return str(value) + + def _add_literal(self, element: ET.Element, prop: str, value) -> None: + child = ET.SubElement(element, self._cim(prop)) + child.text = self._safe_text(value) + + def _add_ref(self, element: ET.Element, prop: str, ref_id: str) -> None: + ET.SubElement( + element, + self._cim(prop), + attrib={self._rdf("resource"): f"#{ref_id}"}, + ) + + def _build_root(self) -> ET.Element: + return ET.Element(self._rdf("RDF")) + + def _create_identified_object(self, root: ET.Element, class_name: str, obj_id: str, name: str): + element = ET.SubElement( + root, self._cim(class_name), attrib={self._rdf("about"): f"#{obj_id}"} + ) + self._add_literal(element, "IdentifiedObject.name", name) + self._add_literal(element, "IdentifiedObject.mRID", obj_id) + return element + + def _quantity(self, value, unit: str | None = None) -> float: + if value is None: + return 0.0 + if hasattr(value, "to") and unit is not None: + return float(value.to(unit).magnitude) + if hasattr(value, "magnitude"): + return float(value.magnitude) + return float(value) + + def _bus_nominal_voltage(self, bus) -> float: + return self._quantity(bus.rated_voltage, "volt") * 1.732 + + def _phase_text(self, phase) -> str: + text = self._safe_text(phase) + return text.replace("Phase.", "") + + def _connection_kind(self, winding) -> str: + connection = self._safe_text(getattr(winding, "connection_type", "STAR")) + return "D" if "DELTA" in connection else "Y" + + def _line_to_line_winding_voltage(self, winding) -> float: + phase_voltage = self._quantity(getattr(winding, "rated_voltage", 0.0), "volt") + num_phases = int(getattr(winding, "num_phases", 1) or 1) + return phase_voltage * 1.732 if num_phases > 1 else phase_voltage + + def _winding_resistance_ohm(self, winding) -> float: + rated_power = self._quantity(getattr(winding, "rated_power", 0.0), "VA") + rated_voltage = self._line_to_line_winding_voltage(winding) + if rated_power <= 0.0: + return 0.0 + return ( + float(getattr(winding, "resistance", 0.0)) / 100.0 * (rated_voltage**2 / rated_power) + ) + + def _winding_reactance_ohm(self, winding, per_x: float | None) -> float: + rated_power = self._quantity(getattr(winding, "rated_power", 0.0), "VA") + rated_voltage = self._line_to_line_winding_voltage(winding) + if rated_power <= 0.0 or per_x is None: + return 0.0 + return float(per_x) / 100.0 * (rated_voltage**2 / rated_power) + + def _winding_phases_text(self, winding_phases: list) -> str: + return "".join(self._phase_text(phase) for phase in winding_phases) + + def _tap_step_values(self, winding) -> tuple[float, int, int, int, int, int]: + total_taps = int(getattr(winding, "total_taps", 32) or 32) + max_tap_pu = float(getattr(winding, "max_tap_pu", 1.1) or 1.1) + min_tap_pu = float(getattr(winding, "min_tap_pu", 0.9) or 0.9) + tap_position = float(getattr(winding, "tap_positions", [1.0])[0] or 1.0) + + dv_pu = (max_tap_pu - min_tap_pu) / total_taps if total_taps > 0 else 0.00625 + if dv_pu <= 0: + dv_pu = 0.00625 + dv_percent = dv_pu * 100.0 + + high_step = int(round((max_tap_pu - 1.0) / dv_pu)) + low_step = int(round((min_tap_pu - 1.0) / dv_pu)) + normal_step = int(round((tap_position - 1.0) / dv_pu)) + neutral_step = 0 + current_step = normal_step + return dv_percent, high_step, low_step, neutral_step, normal_step, current_step + + def _camel_to_snake(self, name: str) -> str: + return re.sub(r"(? str | None: + component_type = component.__class__.__name__ + if component_type in self._SUPPORTED_COMPONENT_TYPES: + return component_type + return None + + def _combine_with_required_buses(self, components: list, buses: list) -> list: + bus_names = {bus.name for bus in buses} + merged = [] + seen = set() + + for bus in buses: + key = (bus.__class__.__name__, bus.name) + seen.add(key) + merged.append(bus) + + for component in components: + key = (component.__class__.__name__, getattr(component, "name", str(id(component)))) + if key in seen: + continue + seen.add(key) + + if component.__class__.__name__ == "DistributionBus": + merged.append(component) + continue + + if hasattr(component, "bus") and getattr(component, "bus", None) is not None: + if component.bus.name in bus_names: + merged.append(component) + continue + + if hasattr(component, "buses") and getattr(component, "buses", None): + buses_list = [bus.name for bus in component.buses] + if all(name in bus_names for name in buses_list): + merged.append(component) + continue + + merged.append(component) + + return merged + + def _create_base_voltage( + self, root: ET.Element, nominal_voltage: float, cache: dict[str, str] + ) -> str: + key = f"{nominal_voltage:.6f}" + if key in cache: + return cache[key] + + base_voltage_id = self._deterministic_id("base_voltage", key) + base_voltage = self._create_identified_object( + root, + "BaseVoltage", + base_voltage_id, + f"BaseVoltage_{int(round(nominal_voltage))}", + ) + self._add_literal(base_voltage, "BaseVoltage.nominalVoltage", nominal_voltage) + cache[key] = base_voltage_id + return base_voltage_id + + def _create_bus_objects( + self, + root: ET.Element, + buses: list, + bus_node_ids: dict[str, str], + bus_location_ids: dict[str, str], + ) -> None: + for bus in buses: + bus_name = bus.name + node_id = self._deterministic_id("connectivity_node", bus_name) + bus_node_ids[bus_name] = node_id + self._create_identified_object(root, "ConnectivityNode", node_id, bus_name) + + location_id = self._deterministic_id("location", bus_name) + bus_location_ids[bus_name] = location_id + location = self._create_identified_object( + root, "Location", location_id, f"Location_{bus_name}" + ) + + position_id = self._deterministic_id("position_point", bus_name) + position = self._create_identified_object( + root, + "PositionPoint", + position_id, + f"Position_{bus_name}", + ) + coordinate = getattr(bus, "coordinate", None) + x = getattr(coordinate, "x", 0.0) + y = getattr(coordinate, "y", 0.0) + self._add_literal(position, "PositionPoint.xPosition", x) + self._add_literal(position, "PositionPoint.yPosition", y) + self._add_ref(position, "PositionPoint.Location", location_id) + self._add_literal(location, "IdentifiedObject.mRID", location_id) + + def _create_terminal( + self, + root: ET.Element, + equipment_id: str, + node_id: str, + suffix: str, + with_limits: bool = False, + ampacity: float = 0.0, + ) -> str: + terminal_id = self._deterministic_id("terminal", f"{equipment_id}:{suffix}") + terminal = self._create_identified_object( + root, "Terminal", terminal_id, f"Terminal_{suffix}" + ) + self._add_ref(terminal, "Terminal.ConductingEquipment", equipment_id) + self._add_ref(terminal, "Terminal.ConnectivityNode", node_id) + + if with_limits: + limit_set_id = self._deterministic_id("operational_limit_set", terminal_id) + limit_set = self._create_identified_object( + root, + "OperationalLimitSet", + limit_set_id, + f"LimitSet_{suffix}", + ) + self._add_ref(limit_set, "OperationalLimitSet.Terminal", terminal_id) + self._add_ref(terminal, "ACDCTerminal.OperationalLimitSet", limit_set_id) + + normal_limit_id = self._deterministic_id("current_limit", f"{terminal_id}:normal") + normal_limit = self._create_identified_object( + root, + "CurrentLimit", + normal_limit_id, + f"CurrentLimitNormal_{suffix}", + ) + self._add_ref(normal_limit, "OperationalLimit.OperationalLimitSet", limit_set_id) + self._add_literal(normal_limit, "CurrentLimit.value", max(ampacity, 0.0)) + + emergency_limit_id = self._deterministic_id( + "current_limit", f"{terminal_id}:emergency" + ) + emergency_limit = self._create_identified_object( + root, + "CurrentLimit", + emergency_limit_id, + f"CurrentLimitEmergency_{suffix}", + ) + self._add_ref(emergency_limit, "OperationalLimit.OperationalLimitSet", limit_set_id) + self._add_literal(emergency_limit, "CurrentLimit.value", max(ampacity * 1.2, ampacity)) + + return terminal_id + + @staticmethod + def _safe_group_name(value: str) -> str: + cleaned = "".join(char if char.isalnum() or char in "-_" else "_" for char in value) + return cleaned.strip("_") or "unknown" + + def _get_component_group(self, component) -> tuple[str, str]: + substation_name = "default_substation" + feeder_name = "default_feeder" + + substation = getattr(component, "substation", None) + feeder = getattr(component, "feeder", None) + + if substation is not None: + substation_name = getattr(substation, "name", str(substation)) + if feeder is not None: + feeder_name = getattr(feeder, "name", str(feeder)) + + return self._safe_group_name(substation_name), self._safe_group_name(feeder_name) + + @staticmethod + def _write_xml(root: ET.Element, output_file: Path) -> None: + tree = ET.ElementTree(root) + output_file.parent.mkdir(parents=True, exist_ok=True) + tree.write(output_file, encoding="utf-8", xml_declaration=True) + + @staticmethod + def _components_by_name(components: list) -> dict[str, list]: + grouped_components: dict[str, list] = defaultdict(list) + for component in components: + grouped_components[component.__class__.__name__].append(component) + return grouped_components + + @staticmethod + def _has_single_bus(component, bus_node_ids: dict[str, str]) -> bool: + bus = getattr(component, "bus", None) + return bus is not None and bus.name in bus_node_ids + + @staticmethod + def _has_two_buses(component, bus_node_ids: dict[str, str]) -> bool: + buses = list(getattr(component, "buses", [])) + if len(buses) < 2: + return False + return buses[0].name in bus_node_ids and buses[1].name in bus_node_ids + + def _emit_single_bus_components( + self, + root: ET.Element, + components_by_name: dict[str, list], + bus_node_ids: dict[str, str], + bus_location_ids: dict[str, str], + base_voltage_cache: dict[str, str], + ) -> None: + single_bus_emitters = [ + ("DistributionVoltageSource", emit_energy_source), + ("DistributionLoad", emit_energy_consumer), + ("DistributionCapacitor", emit_capacitor), + ("DistributionSolar", emit_solar), + ("DistributionBattery", emit_battery), + ] + + for component_name, emitter in single_bus_emitters: + for component in components_by_name.get(component_name, []): + if not self._has_single_bus(component, bus_node_ids): + continue + emitter(self, root, component, bus_node_ids, bus_location_ids, base_voltage_cache) + + def _emit_two_bus_components( + self, + root: ET.Element, + components_by_name: dict[str, list], + bus_node_ids: dict[str, str], + bus_location_ids: dict[str, str], + base_voltage_cache: dict[str, str], + emitted_line_code_ids: set[str], + ) -> None: + for branch in components_by_name.get("MatrixImpedanceBranch", []): + if not self._has_two_buses(branch, bus_node_ids): + continue + emit_line_segment( + self, + root, + branch, + bus_node_ids, + bus_location_ids, + base_voltage_cache, + emitted_line_code_ids, + ) + + for transformer in components_by_name.get("DistributionTransformer", []): + if not self._has_two_buses(transformer, bus_node_ids): + continue + emit_distribution_transformer( + self, + root, + transformer, + bus_node_ids, + bus_location_ids, + base_voltage_cache, + ) + + for regulator in components_by_name.get("DistributionRegulator", []): + if not self._has_two_buses(regulator, bus_node_ids): + continue + emit_regulator( + self, root, regulator, bus_node_ids, bus_location_ids, base_voltage_cache + ) + + for switch in components_by_name.get("MatrixImpedanceSwitch", []): + emit_switch(self, root, switch, bus_node_ids, bus_location_ids, base_voltage_cache) + + for fuse in components_by_name.get("MatrixImpedanceFuse", []): + emit_fuse(self, root, fuse, bus_node_ids, bus_location_ids, base_voltage_cache) + + def _populate_core_graph(self, root: ET.Element, components: list) -> None: + components_by_name = self._components_by_name(components) + buses = components_by_name.get("DistributionBus", []) + + bus_node_ids: dict[str, str] = {} + bus_location_ids: dict[str, str] = {} + base_voltage_cache: dict[str, str] = {} + emitted_line_code_ids: set[str] = set() + + self._create_bus_objects(root, buses, bus_node_ids, bus_location_ids) + + self._emit_single_bus_components( + root, + components_by_name, + bus_node_ids, + bus_location_ids, + base_voltage_cache, + ) + + self._emit_two_bus_components( + root, + components_by_name, + bus_node_ids, + bus_location_ids, + base_voltage_cache, + emitted_line_code_ids, + ) + + def _collect_components(self, component_types: list[type]) -> list: + components = [] + for component_type in component_types: + components.extend(self.system.get_components(component_type)) + return components + + def _build_package_groups( + self, + component_types: list[type], + separate_substations: bool, + separate_feeders: bool, + ) -> dict[tuple[str, str], list]: + groups: dict[tuple[str, str], list] = defaultdict(list) + for component_type in component_types: + for component in self.system.get_components(component_type): + substation_name, feeder_name = self._get_component_group(component) + group_key = ( + substation_name if separate_substations else "all_substations", + feeder_name if separate_feeders else "all_feeders", + ) + groups[group_key].append(component) + return groups + + @staticmethod + def _add_manifest_file_entry( + manifest: ET.Element, + substation_name: str, + feeder_name: str, + file_type: str, + relative_path: Path, + ) -> None: + ET.SubElement( + manifest, + "cim:File", + attrib={ + "substation": substation_name, + "feeder": feeder_name, + "type": file_type, + "path": str(relative_path), + }, + ) + + def _write_single_output(self, output_path: Path, component_types: list[type]) -> None: + root = self._build_root() + components = self._collect_components(component_types) + self._populate_core_graph(root, components) + self._write_xml(root, output_path / "model.xml") + + def _write_combined_package_file( + self, + manifest: ET.Element, + folder: Path, + substation_name: str, + feeder_name: str, + components: list, + ) -> None: + file_name = f"{substation_name}__{feeder_name}.xml" + root = self._build_root() + self._populate_core_graph(root, components) + self._write_xml(root, folder / file_name) + self._add_manifest_file_entry( + manifest, + substation_name, + feeder_name, + "all", + Path(substation_name) / feeder_name / file_name, + ) + + def _write_split_package_files( + self, + manifest: ET.Element, + folder: Path, + substation_name: str, + feeder_name: str, + components: list, + ) -> None: + buses = [ + component + for component in components + if component.__class__.__name__ == "DistributionBus" + ] + component_buckets: dict[str, list] = defaultdict(list) + for component in components: + component_key = self._component_type_key(component) + if component_key is None: + continue + component_buckets[component_key].append(component) + + for component_key, bucket_components in component_buckets.items(): + file_components = self._combine_with_required_buses(bucket_components, buses) + file_suffix = self._camel_to_snake(component_key) + file_name = f"{substation_name}__{feeder_name}__{file_suffix}.xml" + root = self._build_root() + self._populate_core_graph(root, file_components) + self._write_xml(root, folder / file_name) + + self._add_manifest_file_entry( + manifest, + substation_name, + feeder_name, + component_key, + Path(substation_name) / feeder_name / file_name, + ) + + def write( + self, + output_path: Path = Path("./"), + output_mode: str = "single", + separate_substations: bool = True, + separate_feeders: bool = True, + separate_equipment_types: bool = True, + ) -> None: + output_path = Path(output_path) + output_path.mkdir(parents=True, exist_ok=True) + + component_types = sorted( + self.system.get_component_types(), key=lambda component: component.__name__ + ) + + if output_mode == "single": + self._write_single_output(output_path, component_types) + return + + if output_mode != "package": + raise ValueError("output_mode must be either 'single' or 'package'") + + groups = self._build_package_groups( + component_types, separate_substations, separate_feeders + ) + + manifest = ET.Element( + "PackageManifest", + attrib={ + "xmlns:cim": "http://iec.ch/TC57/CIM100#", + }, + ) + for (substation_name, feeder_name), components in groups.items(): + folder = output_path / substation_name / feeder_name + + if not separate_equipment_types: + self._write_combined_package_file( + manifest, folder, substation_name, feeder_name, components + ) + continue + + self._write_split_package_files( + manifest, folder, substation_name, feeder_name, components + ) + + self._write_xml(manifest, output_path / "manifest.xml") diff --git a/src/ditto/writers/opendss/components/distribution_bus.py b/src/ditto/writers/opendss/components/distribution_bus.py index 5c32a55..15fa338 100644 --- a/src/ditto/writers/opendss/components/distribution_bus.py +++ b/src/ditto/writers/opendss/components/distribution_bus.py @@ -18,9 +18,9 @@ def map_name(self): def map_coordinate(self): if hasattr(self.model.coordinate, "x"): - self.opendss_dict["X"] = self.model.coordinate.y + self.opendss_dict["X"] = self.model.coordinate.x if hasattr(self.model.coordinate, "y"): - self.opendss_dict["Y"] = self.model.coordinate.x + self.opendss_dict["Y"] = self.model.coordinate.y def map_rated_voltage(self): kv_rated_voltage = self.model.rated_voltage.to("kV") diff --git a/src/ditto/writers/opendss/controllers/distribution_regulator_controller.py b/src/ditto/writers/opendss/controllers/distribution_regulator_controller.py index 77fb371..70e169e 100644 --- a/src/ditto/writers/opendss/controllers/distribution_regulator_controller.py +++ b/src/ditto/writers/opendss/controllers/distribution_regulator_controller.py @@ -27,10 +27,10 @@ def map_v_setpoint(self): self.opendss_dict["VReg"] = self.model.v_setpoint.to("volts").magnitude def map_min_v_limit(self): - self.model.min_v_limit.to("volts").magnitude + self.opendss_dict["VMinLimit"] = self.model.min_v_limit.to("volts").magnitude def map_max_v_limit(self): - self.model.max_v_limit.to("volts").magnitude + self.opendss_dict["VMaxLimit"] = self.model.max_v_limit.to("volts").magnitude def map_pt_ratio(self): self.opendss_dict["PTRatio"] = self.model.pt_ratio diff --git a/src/ditto/writers/opendss/equipment/distribution_transformer_equipment.py b/src/ditto/writers/opendss/equipment/distribution_transformer_equipment.py index fdf154a..cdb3da3 100644 --- a/src/ditto/writers/opendss/equipment/distribution_transformer_equipment.py +++ b/src/ditto/writers/opendss/equipment/distribution_transformer_equipment.py @@ -48,15 +48,19 @@ def map_windings(self): # resistance pctRs.append(winding.resistance) # rated_power - kVAs.append(winding.rated_power.to("kva").magnitude) + kVAs.append(winding.rated_power.to("kilova").magnitude) # connection_type conns.append(self.connection_map[winding.connection_type]) # TODO: num_phases and is_grounded aren't included - if self.model.is_center_tapped and i == len(self.model.windings): + if self.model.is_center_tapped and i == len(self.model.windings) - 1: kvs.append(nom_voltage) pctRs.append(winding.resistance) - kVAs.append(winding.rated_power.to("kVa").magnitude) + kVAs.append(winding.rated_power.to("kilova").magnitude) conns.append(self.connection_map[winding.connection_type]) + taps.append(tap_pu[0]) + min_tap.append(winding.min_tap_pu) + max_tap.append(winding.max_tap_pu) + num_taps.append(winding.total_taps) self.opendss_dict["kV"] = kvs self.opendss_dict["pctR"] = pctRs self.opendss_dict["kVA"] = kVAs @@ -65,11 +69,9 @@ def map_windings(self): self.opendss_dict[x] = x_value self.opendss_dict["Phases"] = num_phases self.opendss_dict["Tap"] = taps - self.opendss_dict["Tap"] = taps self.opendss_dict["MinTap"] = min_tap self.opendss_dict["MaxTap"] = max_tap self.opendss_dict["NumTaps"] = num_taps - pass def map_coupling_sequences(self): # Used to know the reactance couplings diff --git a/src/ditto/writers/opendss/equipment/gdm-standard-input-action.code-workspace b/src/ditto/writers/opendss/equipment/gdm-standard-input-action.code-workspace deleted file mode 100644 index 8c8d833..0000000 --- a/src/ditto/writers/opendss/equipment/gdm-standard-input-action.code-workspace +++ /dev/null @@ -1,14 +0,0 @@ -{ - "folders": [ - { - "path": "../../../../../../gdm-standard-input-action" - }, - { - "path": "../../../../../../grid-data-models" - }, - { - "path": "../../../../.." - } - ], - "settings": {} -} \ No newline at end of file diff --git a/src/ditto/writers/opendss/opendss_mapper.py b/src/ditto/writers/opendss/opendss_mapper.py index 70bb1f9..44e0979 100644 --- a/src/ditto/writers/opendss/opendss_mapper.py +++ b/src/ditto/writers/opendss/opendss_mapper.py @@ -19,6 +19,24 @@ class OpenDSSMapper(ABC): } connection_map = {"STAR": "wye", "DELTA": "delta", "OPEN_DELTA": "delta", "OPEN_STAR": "wye"} + @property + @abstractmethod + def opendss_file(self): + """Return the OpenDSS file.""" + ... + + @property + @abstractmethod + def altdss_name(self): + """Return the name of the AltDSS class which defines the object.""" + ... + + @property + @abstractmethod + def altdss_composition_name(self): + """Return the name of the AltDSS class which constructs the object through composition""" + ... + def __init__(self, model: Component, system: DistributionSystem): self.model = model self.system = system @@ -26,24 +44,6 @@ def __init__(self, model: Component, system: DistributionSystem): self.substation = "" self.feeder = "" - @property - @abstractmethod - def opendss_file(): - """Return the OpenDSS file.""" - pass - - @property - @abstractmethod - def altdss_name(): - """Return the name of the AltDSS class which defines the object.""" - pass - - @property - @abstractmethod - def altdss_composition_name(): - """Return the name of the AltDSS class which constructs the object through composition""" - pass - def map_common(self): return @@ -62,8 +62,9 @@ def map_feeder(self): self.feeder = self.model.feeder.name def populate_opendss_dictionary(self): - # Should not be populating an existing dictionary. Assert error if not empty - assert len(self.opendss_dict) == 0 + # Should not be populating an existing dictionary. + if len(self.opendss_dict) != 0: + raise ValueError("opendss_dict must be empty before populating") self.map_common() for field in type(self.model).model_fields: mapping_function = getattr(self, "map_" + field) diff --git a/src/ditto/writers/opendss/write.py b/src/ditto/writers/opendss/write.py index f2a55b8..87a485f 100644 --- a/src/ditto/writers/opendss/write.py +++ b/src/ditto/writers/opendss/write.py @@ -25,8 +25,6 @@ class Writer(AbstractWriter): - files = [] - def _get_dss_string(self, model_map: Any) -> str: # Example model_map is instance of DistributionBusMapper altdss_class = getattr(altdss_models, model_map.altdss_name) @@ -46,7 +44,7 @@ def prepare_folder(self, output_path): files_to_remove = directory.rglob("*.dss") for dss_file in files_to_remove: logger.debug(f"Deleting existing file {dss_file}") - # dss_file.unlink() #TODO: deletion causing tets to fail @tarek + dss_file.unlink() def _get_voltage_bases(self) -> list[float]: voltage_bases = [] @@ -75,7 +73,7 @@ def write( # noqa seen_profile = set() output_redirect = Path("") - profiles = self._write_profiles(output_path, seen_profile, output_redirect, base_redirect) + self._write_profiles(output_path, seen_profile, output_redirect, base_redirect) for component_type in component_types: # Example component_type is DistributionBus components = self.system.get_components(component_type) @@ -126,7 +124,7 @@ def write( # noqa controller_mapper_name = controller.__class__.__name__ + "Mapper" if not hasattr(opendss_mapper, controller_mapper_name): logger.warning( - f"Equipment Mapper {controller_mapper_name} not found. Skipping" + f"Controller Mapper {controller_mapper_name} not found. Skipping" ) else: controller_mapper = getattr(opendss_mapper, controller_mapper_name) @@ -135,7 +133,7 @@ def write( # noqa controller_dss_string = self._get_dss_string(controller_map) output_folder = output_path - self._build_directory_structure( + output_folder, output_redirect = self._build_directory_structure( separate_substations, separate_feeders, output_path, @@ -191,8 +189,6 @@ def write( # noqa substations_redirect[model_map.substation].add( Path(equipment_map.opendss_file) ) - if profiles: - substations_redirect if separate_feeders: combined_feeder_sub = Path(model_map.substation) / Path(model_map.feeder) @@ -276,6 +272,8 @@ def _build_directory_structure( output_redirect /= model_map.feeder output_folder.mkdir(exist_ok=True) + return output_folder, output_redirect + def _write_switch_status(self, file_handler: TextIOWrapper): switches: list[MatrixImpedanceSwitch] = list( self.system.get_components(MatrixImpedanceSwitch) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/helpers.py b/tests/helpers.py new file mode 100644 index 0000000..b16381a --- /dev/null +++ b/tests/helpers.py @@ -0,0 +1,60 @@ +"""Shared test utility functions for OpenDSS metric comparison.""" + +from pathlib import Path + +import opendssdirect as odd +from loguru import logger +import numpy as np + + +def get_model_voltage_drop(model_name: str): + model = getattr(odd, model_name) + tr = model.First() + dvs = [] + while tr: + buses = odd.CktElement.BusNames() + voltages = [] + for bus in buses: + bus = bus.split(".")[0] + odd.Circuit.SetActiveBus(bus) + voltage = odd.Bus.puVmagAngle()[::2] + voltages.append(voltage) + + for ii, v1 in enumerate(voltages): + for jj, v2 in enumerate(voltages): + if ii > jj: + if len(v1) == len(v2): + dv = np.abs(np.subtract(v1, v2)) + dvs.extend(list(dv)) + else: + dvs.append(abs(v1[0] - v2[0])) + + tr = model.Next() + xfmr_dvs = [dv for dv in dvs if dv != 0] + return min(xfmr_dvs), max(xfmr_dvs), sum(xfmr_dvs) / len(xfmr_dvs) + + +def get_metrics(dss_model_path: Path | str): + dss_model_path = Path(dss_model_path) + assert dss_model_path.exists(), f"DSS model {dss_model_path} does not exist" + cmd = f'redirect "{dss_model_path}"' + logger.debug(f"Running OpenDSS command -> {cmd}") + odd.Text.Command("clear") + odd.Text.Command(cmd) + odd.Solution.Solve() + + feeder_head_p, feeder_head_q = odd.Circuit.TotalPower() + voltages = odd.Circuit.AllBusMagPu() + min_voltage = min(voltages) + max_voltage = max(voltages) + avg_voltage = sum(voltages) / len(voltages) + + max_dv_xfmr, min_dv_xfmr, avg_dv_xfmr = get_model_voltage_drop("Transformers") + max_dv_line, min_dv_line, avg_dv_line = get_model_voltage_drop("Lines") + + base_metrics = [min_voltage, max_voltage, avg_voltage, feeder_head_p, feeder_head_q] + xfmr_voltages = [max_dv_xfmr, min_dv_xfmr, avg_dv_xfmr] + line_voltages = [max_dv_line, min_dv_line, avg_dv_line] + base_metrics.extend(xfmr_voltages) + base_metrics.extend(line_voltages) + return base_metrics diff --git a/tests/test_cim/test_cim_query_contracts.py b/tests/test_cim/test_cim_query_contracts.py new file mode 100644 index 0000000..fe6cdd8 --- /dev/null +++ b/tests/test_cim/test_cim_query_contracts.py @@ -0,0 +1,288 @@ +from rdflib import Graph, Literal, Namespace, RDF, URIRef + +from ditto.readers.cim_iec_61968_13.queries import ( + query_batteries, + query_capacitors, + query_distribution_buses, + query_distribution_regulators, + query_line_codes, + query_line_segments, + query_load_break_switches, + query_loads, + query_power_transformers, + query_regulator_controllers, + query_source, + query_transformer_windings, +) + + +_CIM = Namespace("http://iec.ch/TC57/CIM100#") + + +def _empty_cim_graph() -> Graph: + graph = Graph() + graph.bind("cim", _CIM) + graph.bind("rdf", RDF) + return graph + + +def test_query_contracts_empty_graph_schema(): + graph = _empty_cim_graph() + + expectations = [ + ( + query_distribution_buses, + ["x", "y", "rated_voltage", "bus"], + ), + ( + query_line_segments, + [ + "line", + "voltage", + "length", + "phase_count", + "line_code", + "bus_1", + "phases_1", + "bus_2", + "phases_2", + ], + ), + ( + query_line_codes, + ["line_code", "phase_count", "r", "x", "b", "ampacity_normal", "ampacity_emergency"], + ), + ( + query_load_break_switches, + [ + "switch_name", + "capacity", + "ratedCurrent", + "normally_open", + "is_open", + "voltage", + "bus_1", + "bus_2", + ], + ), + ( + query_power_transformers, + [ + "xfmr", + "apparent_power", + "rated_voltage", + "vector_group", + "per_resistance", + "conn", + "angle", + "winding", + "bus", + "xfmr_end", + ], + ), + ( + query_distribution_regulators, + [ + "xfmr", + "apparent_power", + "rated_voltage", + "per_resistance", + "conn", + "angle", + "winding", + "bus", + "xfmr_end", + "phase", + "max_tap", + "min_tap", + "neutral_tap", + "normal_tap", + "dv", + "current_tap", + "z_1_leakage", + "z_0_leakage", + "z_1_loadloss", + "z_0_loadloss", + ], + ), + ( + query_transformer_windings, + ["winding", "r1", "x1", "r0", "x0", "xfmr_end_1", "xfmr_end_2"], + ), + ( + query_capacitors, + [ + "capacitor", + "rated_voltage", + "conn", + "bus", + "b1", + "g1", + "b0", + "g0", + "phase", + "steps", + ], + ), + ( + query_source, + ["source", "rated_voltage", "src_voltage", "src_angle", "r1", "x1", "r0", "x0", "bus"], + ), + ( + query_loads, + [ + "load", + "active power", + "reactive power", + "rated_voltage", + "grounded", + "phase", + "conn", + "bus", + "z_p", + "i_p", + "p_p", + "z_q", + "i_q", + "p_q", + "p_exp", + "q_exp", + ], + ), + ( + query_batteries, + [ + "battery", + "rated_energy", + "stored_energy", + "max_p", + "p", + "q", + "rated_s", + "rated_voltage", + "phase", + "bus", + ], + ), + ( + query_regulator_controllers, + [ + "regulator", + "neutral_voltage", + "initial_delay", + "subsequent_delay", + "ltc_flag", + "enabled", + "pt_ratio", + "ct_ratio", + "ct_rating", + "mode", + "bus", + "phase", + "target", + "deadband", + "ldc", + "line_drop_r", + "line_drop_x", + "reversible", + "max_voltage", + "min_voltage", + ], + ), + ] + + for query_func, expected_columns in expectations: + dataframe = query_func(graph) + assert dataframe.empty + assert list(dataframe.columns) == expected_columns + + +def test_query_batteries_minimal_graph_normalizes_and_orders_phases(): + graph = _empty_cim_graph() + + unit = URIRef("urn:test:battery-unit:1") + pec = URIRef("urn:test:pec:1") + base_voltage = URIRef("urn:test:base-voltage:1") + terminal = URIRef("urn:test:terminal:1") + node = URIRef("urn:test:node:1") + phase_c = URIRef("urn:test:pec-phase:c") + phase_a = URIRef("urn:test:pec-phase:a") + + graph.add((unit, RDF.type, _CIM.BatteryUnit)) + graph.add((unit, _CIM["IdentifiedObject.name"], Literal("battery_1"))) + graph.add((unit, _CIM["BatteryUnit.ratedE"], Literal(120.0))) + graph.add((unit, _CIM["BatteryUnit.storedE"], Literal(80.0))) + + graph.add((pec, RDF.type, _CIM.PowerElectronicsConnection)) + graph.add((pec, _CIM["PowerElectronicsConnection.PowerElectronicsUnit"], unit)) + graph.add((pec, _CIM["PowerElectronicsConnection.maxP"], Literal(30.0))) + graph.add((pec, _CIM["PowerElectronicsConnection.p"], Literal(5.0))) + graph.add((pec, _CIM["PowerElectronicsConnection.q"], Literal(1.5))) + graph.add((pec, _CIM["PowerElectronicsConnection.ratedS"], Literal(35.0))) + graph.add((pec, _CIM["ConductingEquipment.BaseVoltage"], base_voltage)) + + graph.add((base_voltage, _CIM["BaseVoltage.nominalVoltage"], Literal(12470.0))) + + graph.add((terminal, RDF.type, _CIM.Terminal)) + graph.add((terminal, _CIM["Terminal.ConductingEquipment"], pec)) + graph.add((terminal, _CIM["Terminal.ConnectivityNode"], node)) + graph.add((node, _CIM["IdentifiedObject.name"], Literal("bus_1"))) + + graph.add((phase_c, RDF.type, _CIM.PowerElectronicsConnectionPhase)) + graph.add((phase_c, _CIM["PowerElectronicsConnectionPhase.PowerElectronicsConnection"], pec)) + graph.add((phase_c, _CIM["PowerElectronicsConnectionPhase.phase"], _CIM["SinglePhaseKind.C"])) + + graph.add((phase_a, RDF.type, _CIM.PowerElectronicsConnectionPhase)) + graph.add((phase_a, _CIM["PowerElectronicsConnectionPhase.PowerElectronicsConnection"], pec)) + graph.add((phase_a, _CIM["PowerElectronicsConnectionPhase.phase"], _CIM["SinglePhaseKind.A"])) + + dataframe = query_batteries(graph) + + assert len(dataframe) == 1 + assert dataframe["battery"].iloc[0] == "battery_1" + assert dataframe["bus"].iloc[0] == "bus_1" + assert dataframe["phase"].iloc[0] == "A,C" + + +def test_query_line_segments_single_terminal_line_returns_empty_schema(): + graph = _empty_cim_graph() + + line = URIRef("urn:test:line:1") + line_phase = URIRef("urn:test:line-phase:1") + terminal = URIRef("urn:test:terminal:1") + node = URIRef("urn:test:node:1") + base_voltage = URIRef("urn:test:base-voltage:1") + impedance = URIRef("urn:test:per-length-impedance:1") + + graph.add((line, RDF.type, _CIM.ACLineSegment)) + graph.add((line, _CIM["IdentifiedObject.name"], Literal("line_1"))) + graph.add((line, _CIM["Conductor.length"], Literal(100.0))) + graph.add((line, _CIM["ConductingEquipment.BaseVoltage"], base_voltage)) + graph.add((base_voltage, _CIM["BaseVoltage.nominalVoltage"], Literal(12470.0))) + + graph.add((line_phase, _CIM["ACLineSegmentPhase.ACLineSegment"], line)) + graph.add((line_phase, _CIM["ACLineSegmentPhase.phase"], _CIM["SinglePhaseKind.A"])) + + graph.add((line, _CIM["ACLineSegment.PerLengthImpedance"], impedance)) + graph.add((impedance, _CIM["PerLengthPhaseImpedance.conductorCount"], Literal(1))) + graph.add((impedance, _CIM["IdentifiedObject.name"], Literal("code_1"))) + + graph.add((terminal, RDF.type, _CIM.Terminal)) + graph.add((terminal, _CIM["Terminal.ConductingEquipment"], line)) + graph.add((terminal, _CIM["Terminal.ConnectivityNode"], node)) + graph.add((node, _CIM["IdentifiedObject.name"], Literal("bus_1"))) + + dataframe = query_line_segments(graph) + + assert dataframe.empty + assert list(dataframe.columns) == [ + "line", + "voltage", + "length", + "phase_count", + "line_code", + "bus_1", + "phases_1", + "bus_2", + "phases_2", + ] diff --git a/tests/test_cim/test_cim_reader.py b/tests/test_cim/test_cim_reader.py index d09ea3c..a7a15d3 100644 --- a/tests/test_cim/test_cim_reader.py +++ b/tests/test_cim/test_cim_reader.py @@ -1,67 +1,13 @@ from pathlib import Path -import opendssdirect as odd -from loguru import logger import numpy as np from ditto.readers.cim_iec_61968_13.reader import Reader from ditto.writers.opendss.write import Writer +from tests.helpers import get_metrics -def get_model_voltage_drop(model_name: str): - model = getattr(odd, model_name) - tr = model.First() - dvs = [] - while tr: - buses = odd.CktElement.BusNames() - voltages = [] - for bus in buses: - bus = bus.split(".")[0] - odd.Circuit.SetActiveBus(bus) - voltage = odd.Bus.puVmagAngle()[::2] - voltages.append(voltage) - - for ii, v1 in enumerate(voltages): - for jj, v2 in enumerate(voltages): - if ii > jj: - if len(v1) == len(v2): - dv = np.abs(np.subtract(v1, v2)) - dvs.extend(list(dv)) - else: - dvs.append(abs(v1[0] - v2[0])) - - tr = model.Next() - xfmr_dvs = [dv for dv in dvs if dv != 0] - return min(xfmr_dvs), max(xfmr_dvs), sum(xfmr_dvs) / len(xfmr_dvs) - - -def get_metrics(dss_model_path: Path | str): - dss_model_path = Path(dss_model_path) - assert dss_model_path.exists(), f"DSS model {dss_model_path} does not exist" - cmd = f'redirect "{dss_model_path}"' - logger.debug(f"Running OpenDSS command -> {cmd}") - odd.Text.Command("clear") - odd.Text.Command(cmd) - odd.Solution.Solve() - - feeder_head_p, feeder_head_q = odd.Circuit.TotalPower() - voltages = odd.Circuit.AllBusMagPu() - min_voltage = min(voltages) - max_voltage = max(voltages) - avg_voltage = sum(voltages) / len(voltages) - - max_dv_xfmr, min_dv_xfmr, avg_dv_xfmr = get_model_voltage_drop("Transformers") - max_dv_line, min_dv_line, avg_dv_line = get_model_voltage_drop("Lines") - - base_metrics = [min_voltage, max_voltage, avg_voltage, feeder_head_p, feeder_head_q] - xfmr_voltages = [max_dv_xfmr, min_dv_xfmr, avg_dv_xfmr] - line_voltages = [max_dv_line, min_dv_line, avg_dv_line] - base_metrics.extend(xfmr_voltages) - base_metrics.extend(line_voltages) - return base_metrics - - -def test_query_aclinesegment(ieee13_node_xml_file, fixed_tmp_path): +def test_cim_to_opendss_roundtrip(ieee13_node_xml_file, tmp_path): ieee13_node_dss_file = ( Path(__file__).parent.parent / "data" / "opendss_circuit_models" / "ieee13" / "Master.dss" ) @@ -70,8 +16,8 @@ def test_query_aclinesegment(ieee13_node_xml_file, fixed_tmp_path): cim_reader.read() system = cim_reader.get_system() writer = Writer(system) - writer.write(output_path=fixed_tmp_path, separate_substations=False, separate_feeders=False) - post_converion_metrics = get_metrics(fixed_tmp_path / "Master.dss") + writer.write(output_path=tmp_path, separate_substations=False, separate_feeders=False) + post_converion_metrics = get_metrics(tmp_path / "Master.dss") assert np.allclose( - pre_converion_metrics, post_converion_metrics, rtol=0.1, atol=0.1 + pre_converion_metrics, post_converion_metrics, rtol=0.01, atol=0.01 ), "Round trip coversion exceeds error tolerance" diff --git a/tests/test_cim/test_cim_roundtrip.py b/tests/test_cim/test_cim_roundtrip.py new file mode 100644 index 0000000..97a4d7f --- /dev/null +++ b/tests/test_cim/test_cim_roundtrip.py @@ -0,0 +1,544 @@ +"""CIM → GDM → CIM → GDM roundtrip tests for information loss detection. + +These tests verify that converting through the CIM format preserves component +counts, names, and key attributes. The roundtrip path is: + CIM XML → (Reader) → GDM DistributionSystem → (Writer) → CIM XML → (Reader) → GDM + +Known limitations +----------------- +- Solar and Fuse: The CIM writer emits them but the reader has no SPARQL queries + to parse them back. They are excluded from roundtrip checks. +""" + +from pathlib import Path + +import pytest + +from gdm.distribution import DistributionSystem +from gdm.distribution.components import ( + DistributionBus, + DistributionCapacitor, + DistributionLoad, + DistributionRegulator, + DistributionTransformer, + DistributionVoltageSource, + MatrixImpedanceBranch, + MatrixImpedanceSwitch, +) +from gdm.distribution.enums import Phase, VoltageTypes +from gdm.distribution.equipment import ( + CapacitorEquipment, + LoadEquipment, + MatrixImpedanceBranchEquipment, + MatrixImpedanceSwitchEquipment, + PhaseCapacitorEquipment, + PhaseLoadEquipment, + VoltageSourceEquipment, + PhaseVoltageSourceEquipment, +) +from gdm.quantities import ( + ActivePower, + ReactivePower, + Voltage, + Resistance, + Reactance, + Distance, +) + +from ditto.readers.cim_iec_61968_13.reader import Reader as CimReader +from ditto.writers.cim_iec_61968_13.write import Writer as CimWriter + +# Component types supported by both the CIM writer and reader (can roundtrip). +ROUNDTRIP_COMPONENT_TYPES = [ + DistributionBus, + DistributionVoltageSource, + DistributionLoad, + MatrixImpedanceBranch, + DistributionTransformer, + DistributionRegulator, + DistributionCapacitor, + MatrixImpedanceSwitch, +] + + +def _get_component_counts(system: DistributionSystem) -> dict[str, int]: + """Return {type_name: count} for all roundtrip-safe component types.""" + return { + cls.__name__: len(list(system.get_components(cls))) for cls in ROUNDTRIP_COMPONENT_TYPES + } + + +def _get_component_names(system: DistributionSystem, component_type) -> set[str]: + """Return the set of component names for a given type.""" + return {c.name for c in system.get_components(component_type)} + + +def _cim_roundtrip(system: DistributionSystem, tmp_path: Path) -> DistributionSystem: + """Write a GDM system to CIM XML and read it back.""" + writer = CimWriter(system) + writer.write(output_path=tmp_path, output_mode="single") + reader = CimReader(tmp_path / "model.xml") + reader.read() + return reader.get_system() + + +def _build_synthetic_system() -> DistributionSystem: + """Build a minimal system without transformers (which cannot roundtrip yet). + + Components: 3 buses, 1 voltage source, 1 load, 1 line, 1 capacitor, 1 switch. + """ + from gdm.quantities import ( + CapacitancePULength, + Current, + ReactancePULength, + ResistancePULength, + ) + + system = DistributionSystem(auto_add_composed_components=True) + + source_bus = DistributionBus( + name="source_bus", + phases=[Phase.A, Phase.B, Phase.C], + rated_voltage=Voltage(12.47, "kilovolt"), + voltage_type=VoltageTypes.LINE_TO_LINE, + voltagelimits=[], + coordinate=None, + ) + bus_1 = DistributionBus( + name="bus_1", + phases=[Phase.A, Phase.B, Phase.C], + rated_voltage=Voltage(12.47, "kilovolt"), + voltage_type=VoltageTypes.LINE_TO_LINE, + voltagelimits=[], + coordinate=None, + ) + bus_2 = DistributionBus( + name="bus_2", + phases=[Phase.A, Phase.B, Phase.C], + rated_voltage=Voltage(12.47, "kilovolt"), + voltage_type=VoltageTypes.LINE_TO_LINE, + voltagelimits=[], + coordinate=None, + ) + + vsource = DistributionVoltageSource( + name="vsource_1", + bus=source_bus, + phases=[Phase.A, Phase.B, Phase.C], + equipment=VoltageSourceEquipment( + name="vsource_equip_1", + sources=[ + PhaseVoltageSourceEquipment( + name=f"phase_vsource_{ph.value}", + r0=Resistance(0.001, "ohm"), + r1=Resistance(0.001, "ohm"), + x0=Reactance(0.001, "ohm"), + x1=Reactance(0.001, "ohm"), + voltage=Voltage(12.47, "kilovolt"), + voltage_type=VoltageTypes.LINE_TO_LINE, + angle=120.0 * i, + ) + for i, ph in enumerate([Phase.A, Phase.B, Phase.C]) + ], + ), + ) + + line_equipment = MatrixImpedanceBranchEquipment( + name="line_equip_1", + r_matrix=ResistancePULength( + [ + [0.0881, 0.0312, 0.0306], + [0.0312, 0.0902, 0.0316], + [0.0306, 0.0316, 0.0865], + ], + "ohm/mi", + ), + x_matrix=ReactancePULength( + [ + [0.2074, 0.0935, 0.0855], + [0.0935, 0.2008, 0.0951], + [0.0855, 0.0951, 0.2049], + ], + "ohm/mi", + ), + c_matrix=CapacitancePULength( + [ + [2.903, -0.679, -0.350], + [-0.679, 3.159, -0.585], + [-0.350, -0.585, 2.810], + ], + "nanofarad/mi", + ), + ampacity=Current(400, "ampere"), + ) + + line_1 = MatrixImpedanceBranch( + name="line_source_to_bus1", + buses=[source_bus, bus_1], + length=Distance(500, "meter"), + phases=[Phase.A, Phase.B, Phase.C], + equipment=line_equipment, + ) + + load_1 = DistributionLoad( + name="load_1", + bus=bus_1, + phases=[Phase.A, Phase.B, Phase.C], + equipment=LoadEquipment( + name="load_equip_1", + phase_loads=[ + PhaseLoadEquipment( + name=f"phase_load_{ph.value}", + real_power=ActivePower(100, "kilowatt"), + reactive_power=ReactivePower(50, "kilovar"), + z_real=0.0, + z_imag=0.0, + i_real=0.0, + i_imag=0.0, + p_real=1.0, + p_imag=1.0, + ) + for ph in [Phase.A, Phase.B, Phase.C] + ], + ), + ) + + capacitor_1 = DistributionCapacitor( + name="cap_1", + bus=bus_2, + phases=[Phase.A, Phase.B, Phase.C], + controllers=[], + equipment=CapacitorEquipment( + name="cap_equip_1", + rated_voltage=Voltage(12.47, "kilovolt"), + voltage_type=VoltageTypes.LINE_TO_LINE, + phase_capacitors=[ + PhaseCapacitorEquipment( + name=f"phase_cap_{ph.value}", + rated_reactive_power=ReactivePower(200, "kilovar"), + resistance=Resistance(0, "ohm"), + reactance=Reactance(0, "ohm"), + num_banks_on=1, + num_banks=1, + ) + for ph in [Phase.A, Phase.B, Phase.C] + ], + ), + ) + + switch_equipment = MatrixImpedanceSwitchEquipment( + name="switch_equip_1", + r_matrix=ResistancePULength( + [[0.0001, 0.0, 0.0], [0.0, 0.0001, 0.0], [0.0, 0.0, 0.0001]], + "ohm/mi", + ), + x_matrix=ReactancePULength( + [[0.0001, 0.0, 0.0], [0.0, 0.0001, 0.0], [0.0, 0.0, 0.0001]], + "ohm/mi", + ), + c_matrix=CapacitancePULength( + [[0.0, 0.0, 0.0], [0.0, 0.0, 0.0], [0.0, 0.0, 0.0]], + "nanofarad/mi", + ), + ampacity=Current(400, "ampere"), + ) + + switch_1 = MatrixImpedanceSwitch( + name="switch_bus1_to_bus2", + buses=[bus_1, bus_2], + length=Distance(1, "meter"), + phases=[Phase.A, Phase.B, Phase.C], + equipment=switch_equipment, + is_closed=[True, True, True], + ) + + system.add_components( + source_bus, + bus_1, + bus_2, + vsource, + line_1, + load_1, + capacitor_1, + switch_1, + ) + return system + + +# --------------------------------------------------------------------------- +# Test: Synthetic system roundtrip (no transformers — they have a known bug) +# --------------------------------------------------------------------------- + + +class TestSyntheticCimRoundtrip: + """Roundtrip a hand-built system through CIM write → read. + + Transformers are excluded because the CIM reader assigns the wrong bus + voltages for transformer-connected buses (see module docstring). + """ + + @pytest.fixture() + def original_system(self) -> DistributionSystem: + return _build_synthetic_system() + + @pytest.fixture() + def roundtripped_system(self, original_system, tmp_path) -> DistributionSystem: + return _cim_roundtrip(original_system, tmp_path) + + def test_bus_count_preserved(self, original_system, roundtripped_system): + orig = len(list(original_system.get_components(DistributionBus))) + rt = len(list(roundtripped_system.get_components(DistributionBus))) + assert rt == orig + + def test_bus_names_preserved(self, original_system, roundtripped_system): + orig_names = _get_component_names(original_system, DistributionBus) + rt_names = _get_component_names(roundtripped_system, DistributionBus) + assert {n.lower() for n in orig_names} == {n.lower() for n in rt_names} + + def test_bus_voltages_preserved(self, original_system, roundtripped_system): + orig_buses = { + b.name.lower(): b.rated_voltage + for b in original_system.get_components(DistributionBus) + } + for bus in roundtripped_system.get_components(DistributionBus): + orig = orig_buses[bus.name.lower()] + # Convert both to the same unit for comparison + orig_volts = orig.to("volt").magnitude + rt_volts = bus.rated_voltage.to("volt").magnitude + assert ( + abs(rt_volts - orig_volts) < 1.0 + ), f"Bus {bus.name}: {bus.rated_voltage} != {orig}" + + def test_voltage_source_preserved(self, original_system, roundtripped_system): + orig = list(original_system.get_components(DistributionVoltageSource)) + rt = list(roundtripped_system.get_components(DistributionVoltageSource)) + assert len(rt) == len(orig) + assert rt[0].name.lower() == orig[0].name.lower() + + def test_load_names_preserved(self, original_system, roundtripped_system): + """Load names survive roundtrip. + + The CIM writer splits a multi-phase load into per-phase EnergyConsumers, + so the roundtripped system may have more load objects (one per original + phase) than the original. We check that every original name appears. + """ + orig_names = {ld.name.lower() for ld in original_system.get_components(DistributionLoad)} + rt_names = {ld.name.lower() for ld in roundtripped_system.get_components(DistributionLoad)} + assert orig_names <= rt_names or orig_names == rt_names + + def test_load_bus_assignment_preserved(self, original_system, roundtripped_system): + """Loads remain on the same bus (checked by name match).""" + orig_bus_map = { + ld.name.lower(): ld.bus.name.lower() + for ld in original_system.get_components(DistributionLoad) + } + for ld in roundtripped_system.get_components(DistributionLoad): + orig_bus = orig_bus_map.get(ld.name.lower()) + if orig_bus is not None: + assert ld.bus.name.lower() == orig_bus + + def test_line_count_and_name_preserved(self, original_system, roundtripped_system): + orig = list(original_system.get_components(MatrixImpedanceBranch)) + rt = list(roundtripped_system.get_components(MatrixImpedanceBranch)) + assert len(rt) == len(orig) + assert {ln.name.lower() for ln in rt} == {ln.name.lower() for ln in orig} + + def test_line_bus_connections_preserved(self, original_system, roundtripped_system): + orig_lines = { + ln.name.lower(): tuple(sorted(b.name.lower() for b in ln.buses)) + for ln in original_system.get_components(MatrixImpedanceBranch) + } + for ln in roundtripped_system.get_components(MatrixImpedanceBranch): + assert tuple(sorted(b.name.lower() for b in ln.buses)) == orig_lines[ln.name.lower()] + + def test_line_length_preserved(self, original_system, roundtripped_system): + orig_lines = { + ln.name.lower(): ln.length.to("meter").magnitude + for ln in original_system.get_components(MatrixImpedanceBranch) + } + for ln in roundtripped_system.get_components(MatrixImpedanceBranch): + rt_m = ln.length.to("meter").magnitude + assert abs(rt_m - orig_lines[ln.name.lower()]) < 1.0 + + def test_capacitor_names_preserved(self, original_system, roundtripped_system): + """Capacitor names survive roundtrip. + + Like loads, the CIM writer may split a multi-phase capacitor into + per-phase LinearShuntCompensators, so the count may increase. + """ + orig_names = { + c.name.lower() for c in original_system.get_components(DistributionCapacitor) + } + rt_names = { + c.name.lower() for c in roundtripped_system.get_components(DistributionCapacitor) + } + assert orig_names <= rt_names or orig_names == rt_names + + def test_switch_count_and_name_preserved(self, original_system, roundtripped_system): + orig = list(original_system.get_components(MatrixImpedanceSwitch)) + rt = list(roundtripped_system.get_components(MatrixImpedanceSwitch)) + assert len(rt) == len(orig) + assert {s.name.lower() for s in rt} == {s.name.lower() for s in orig} + + def test_switch_bus_connections_preserved(self, original_system, roundtripped_system): + orig_sw = { + sw.name.lower(): tuple(sorted(b.name.lower() for b in sw.buses)) + for sw in original_system.get_components(MatrixImpedanceSwitch) + } + for sw in roundtripped_system.get_components(MatrixImpedanceSwitch): + assert tuple(sorted(b.name.lower() for b in sw.buses)) == orig_sw[sw.name.lower()] + + +# --------------------------------------------------------------------------- +# Test: CIM → GDM → CIM → GDM (IEEE 13-node, full model) +# --------------------------------------------------------------------------- + + +class TestCimRoundtripIEEE13: + """Full roundtrip tests using the IEEE 13-node CIM XML fixture.""" + + @pytest.fixture() + def original_system(self, ieee13_node_xml_file) -> DistributionSystem: + reader = CimReader(ieee13_node_xml_file) + reader.read() + return reader.get_system() + + @pytest.fixture() + def roundtripped_system( + self, original_system: DistributionSystem, tmp_path + ) -> DistributionSystem: + return _cim_roundtrip(original_system, tmp_path) + + def test_component_counts_are_preserved(self, original_system, roundtripped_system): + """Every supported component type retains the same count. + + The CIM writer splits multi-phase loads and capacitors into per-phase + CIM objects, so the roundtripped count may be higher for those types. + We allow ``actual >= expected`` for them instead of strict equality. + """ + # Types where per-phase splitting can increase the count on roundtrip. + PER_PHASE_SPLIT_TYPES = {"DistributionLoad", "DistributionCapacitor"} + + original_counts = _get_component_counts(original_system) + roundtripped_counts = _get_component_counts(roundtripped_system) + for type_name, expected in original_counts.items(): + actual = roundtripped_counts.get(type_name, 0) + if type_name in PER_PHASE_SPLIT_TYPES: + assert ( + actual >= expected + ), f"{type_name}: expected at least {expected}, got {actual}" + else: + assert actual == expected, f"{type_name}: expected {expected}, got {actual}" + + @pytest.mark.parametrize( + "component_type", + ROUNDTRIP_COMPONENT_TYPES, + ids=[c.__name__ for c in ROUNDTRIP_COMPONENT_TYPES], + ) + def test_component_names_are_preserved( + self, original_system, roundtripped_system, component_type + ): + """Component names survive the roundtrip (case-insensitive).""" + original_names = {n.lower() for n in _get_component_names(original_system, component_type)} + roundtripped_names = { + n.lower() for n in _get_component_names(roundtripped_system, component_type) + } + missing = original_names - roundtripped_names + assert not missing, f"{component_type.__name__}: names lost in roundtrip: {missing}" + + def test_bus_rated_voltages_preserved(self, original_system, roundtripped_system): + orig_buses = { + b.name.lower(): b.rated_voltage + for b in original_system.get_components(DistributionBus) + } + for bus in roundtripped_system.get_components(DistributionBus): + orig = orig_buses.get(bus.name.lower()) + if orig is None: + continue + assert abs(bus.rated_voltage.to("volt").magnitude - orig.to("volt").magnitude) < 1.0 + + def test_line_lengths_preserved(self, original_system, roundtripped_system): + orig_lines = { + ln.name.lower(): ln.length + for ln in original_system.get_components(MatrixImpedanceBranch) + } + for ln in roundtripped_system.get_components(MatrixImpedanceBranch): + orig = orig_lines.get(ln.name.lower()) + if orig is None: + continue + assert abs(ln.length.to("meter").magnitude - orig.to("meter").magnitude) < 1.0 + + def test_load_bus_assignments_preserved(self, original_system, roundtripped_system): + orig_loads = { + ld.name.lower(): ld.bus.name.lower() + for ld in original_system.get_components(DistributionLoad) + } + for ld in roundtripped_system.get_components(DistributionLoad): + orig_bus = orig_loads.get(ld.name.lower()) + if orig_bus is None: + continue + assert ld.bus.name.lower() == orig_bus + + +# --------------------------------------------------------------------------- +# Test: OpenDSS → GDM → CIM → GDM (IEEE 13-node) +# --------------------------------------------------------------------------- + + +class TestOpenDSSToCimRoundtrip: + """Read IEEE 13-node from OpenDSS, write CIM, read CIM back, compare.""" + + @pytest.fixture() + def opendss_system(self) -> DistributionSystem: + from ditto.readers.opendss.reader import Reader as OpenDSSReader + + master_file = ( + Path(__file__).parent.parent + / "data" + / "opendss_circuit_models" + / "ieee13" + / "Master.dss" + ) + reader = OpenDSSReader(master_file) + return reader.get_system() + + @pytest.fixture() + def roundtripped_system( + self, opendss_system: DistributionSystem, tmp_path + ) -> DistributionSystem: + return _cim_roundtrip(opendss_system, tmp_path) + + def test_component_counts_within_tolerance(self, opendss_system, roundtripped_system): + """Component counts preserved or only slightly reduced.""" + original_counts = _get_component_counts(opendss_system) + roundtripped_counts = _get_component_counts(roundtripped_system) + + for type_name, expected in original_counts.items(): + if expected == 0: + continue + actual = roundtripped_counts.get(type_name, 0) + loss_pct = (expected - actual) / expected * 100 + assert loss_pct <= 1, f"{type_name}: lost {loss_pct:.0f}% ({expected} → {actual})" + + def test_buses_survive_roundtrip(self, opendss_system, roundtripped_system): + original_names = {b.name.lower() for b in opendss_system.get_components(DistributionBus)} + roundtripped_names = { + b.name.lower() for b in roundtripped_system.get_components(DistributionBus) + } + missing = original_names - roundtripped_names + loss_pct = len(missing) / len(original_names) * 100 if original_names else 0 + assert loss_pct <= 1, f"Lost {len(missing)}/{len(original_names)} buses" + + def test_loads_survive_roundtrip(self, opendss_system, roundtripped_system): + original = len(list(opendss_system.get_components(DistributionLoad))) + roundtripped = len(list(roundtripped_system.get_components(DistributionLoad))) + if original > 0: + loss_pct = (original - roundtripped) / original * 100 + assert loss_pct <= 1 + + def test_lines_survive_roundtrip(self, opendss_system, roundtripped_system): + original = len(list(opendss_system.get_components(MatrixImpedanceBranch))) + roundtripped = len(list(roundtripped_system.get_components(MatrixImpedanceBranch))) + if original > 0: + loss_pct = (original - roundtripped) / original * 100 + assert loss_pct <= 1 diff --git a/tests/test_cim/test_cim_writer.py b/tests/test_cim/test_cim_writer.py new file mode 100644 index 0000000..9143b4e --- /dev/null +++ b/tests/test_cim/test_cim_writer.py @@ -0,0 +1,141 @@ +from pathlib import Path +from defusedxml import ElementTree as ET + +import pytest +from gdm.distribution.components import DistributionBattery, DistributionBus +from gdm.distribution.equipment import BatteryEquipment, InverterEquipment +from gdm.distribution.enums import Phase, VoltageTypes +from gdm.quantities import ActivePower, ApparentPower, EnergyDC, ReactivePower, Voltage + +from ditto.readers.opendss.reader import Reader +from ditto.writers.cim_iec_61968_13.write import Writer + + +_BASE = Path(__file__).parents[1] +_IEEE13_DSS = _BASE / "data" / "opendss_circuit_models" / "ieee13" / "Master.dss" +_P4U_DT0_DSS = ( + _BASE + / "data" + / "opendss_circuit_models" + / "P4U" + / "p4uhs0_4" + / "p4uhs0_4--p4udt0" + / "Master.dss" +) + + +def test_cim_writer_single_mode(tmp_path): + system = Reader(_IEEE13_DSS).get_system() + writer = Writer(system) + + writer.write(output_path=tmp_path, output_mode="single") + + output_file = tmp_path / "model.xml" + assert output_file.exists() + + tree = ET.parse(output_file) + root = tree.getroot() + assert root.tag.endswith("RDF") + + +def test_cim_writer_package_mode(tmp_path): + system = Reader(_IEEE13_DSS).get_system() + writer = Writer(system) + + writer.write(output_path=tmp_path, output_mode="package") + + manifest_file = tmp_path / "manifest.xml" + assert manifest_file.exists() + + manifest_tree = ET.parse(manifest_file) + manifest_root = manifest_tree.getroot() + assert manifest_root.tag == "PackageManifest" + + package_files = list(tmp_path.rglob("*.xml")) + assert len(package_files) > 1 + + package_names = [path.name for path in package_files] + assert any("distribution_bus" in name for name in package_names) + assert any("distribution_load" in name for name in package_names) + assert any("matrix_impedance_branch" in name for name in package_names) + + +def test_cim_writer_invalid_mode(tmp_path): + system = Reader(_IEEE13_DSS).get_system() + writer = Writer(system) + + with pytest.raises(ValueError, match="output_mode"): + writer.write(output_path=tmp_path, output_mode="invalid") + + +def test_cim_writer_serializes_solar_and_fuse_components(tmp_path): + system = Reader(_P4U_DT0_DSS).get_system() + writer = Writer(system) + + writer.write(output_path=tmp_path, output_mode="single") + output_file = tmp_path / "model.xml" + assert output_file.exists() + + tree = ET.parse(output_file) + root = tree.getroot() + ns = {"cim": "http://iec.ch/TC57/CIM100#"} + + assert len(root.findall("cim:PhotoVoltaicUnit", ns)) >= 1 + assert len(root.findall("cim:PowerElectronicsConnection", ns)) >= 1 + assert len(root.findall("cim:Fuse", ns)) >= 1 + + writer.write(output_path=tmp_path / "package", output_mode="package") + package_names = [path.name for path in (tmp_path / "package").rglob("*.xml")] + assert any("distribution_solar" in name for name in package_names) + assert any("matrix_impedance_fuse" in name for name in package_names) + + +def test_cim_writer_serializes_battery_components(tmp_path): + system = Reader(_IEEE13_DSS).get_system() + bus = next(iter(system.get_components(DistributionBus))) + + battery = DistributionBattery.model_construct( + name="test_battery", + bus=bus, + phases=[Phase.A, Phase.B, Phase.C], + active_power=ActivePower(25.0, "kilowatt"), + reactive_power=ReactivePower(5.0, "kilovar"), + controller=None, + inverter=InverterEquipment.model_construct( + name="test_battery_inverter", + rated_apparent_power=ApparentPower(30.0, "kilova"), + rise_limit=None, + fall_limit=None, + cutout_percent=0.1, + cutin_percent=0.1, + dc_to_ac_efficiency=0.96, + eff_curve=None, + ), + equipment=BatteryEquipment.model_construct( + name="test_battery_equipment", + rated_energy=EnergyDC(100.0, "kilowatthour"), + rated_power=ActivePower(25.0, "kilowatt"), + charging_efficiency=0.95, + discharging_efficiency=0.95, + idling_efficiency=0.99, + rated_voltage=Voltage(12.47, "kilovolt"), + voltage_type=VoltageTypes.LINE_TO_LINE, + ), + ) + system.add_components(battery) + + writer = Writer(system) + writer.write(output_path=tmp_path, output_mode="single") + + output_file = tmp_path / "model.xml" + assert output_file.exists() + tree = ET.parse(output_file) + root = tree.getroot() + ns = {"cim": "http://iec.ch/TC57/CIM100#"} + + assert len(root.findall("cim:BatteryUnit", ns)) >= 1 + assert len(root.findall("cim:PowerElectronicsConnection", ns)) >= 1 + + writer.write(output_path=tmp_path / "package", output_mode="package") + package_names = [path.name for path in (tmp_path / "package").rglob("*.xml")] + assert any("distribution_battery" in name for name in package_names) diff --git a/tests/test_cim/test_cim_writer_reader_queries.py b/tests/test_cim/test_cim_writer_reader_queries.py new file mode 100644 index 0000000..cb60af9 --- /dev/null +++ b/tests/test_cim/test_cim_writer_reader_queries.py @@ -0,0 +1,196 @@ +from pathlib import Path + +from rdflib import Graph + +from ditto.readers.opendss.reader import Reader as OpenDSSReader +from ditto.readers.cim_iec_61968_13.reader import Reader as CimReader +from ditto.readers.cim_iec_61968_13.queries import ( + query_batteries, + query_capacitors, + query_distribution_buses, + query_distribution_regulators, + query_load_break_switches, + query_line_codes, + query_line_segments, + query_loads, + query_power_transformers, + query_regulator_controllers, + query_source, + query_transformer_windings, +) +from ditto.writers.cim_iec_61968_13.write import Writer as CimWriter +from gdm.distribution.components import ( + DistributionBattery, + DistributionBus, + DistributionCapacitor, + DistributionRegulator, + MatrixImpedanceSwitch, +) +from gdm.distribution import DistributionSystem +from gdm.distribution.controllers import RegulatorController +from gdm.distribution.equipment import BatteryEquipment, InverterEquipment +from gdm.distribution.enums import Phase, VoltageTypes +from gdm.quantities import ActivePower, ApparentPower, EnergyDC, ReactivePower, Voltage + + +_BASE = Path(__file__).parents[1] +_IEEE13_DSS = _BASE / "data" / "opendss_circuit_models" / "ieee13" / "Master.dss" + + +def test_cim_writer_core_reader_query_compatibility(tmp_path): + system = OpenDSSReader(_IEEE13_DSS).get_system() + writer = CimWriter(system) + + writer.write(output_path=tmp_path, output_mode="single") + cim_file = tmp_path / "model.xml" + + graph = Graph() + graph.parse(cim_file, format="xml") + + buses = query_distribution_buses(graph) + lines = query_line_segments(graph) + line_codes = query_line_codes(graph) + loads = query_loads(graph) + sources = query_source(graph) + + assert not buses.empty + assert not lines.empty + assert not line_codes.empty + assert not loads.empty + assert not sources.empty + + +def test_cim_writer_transformer_regulator_query_compatibility(tmp_path): + source_system = OpenDSSReader(_IEEE13_DSS).get_system() + source_regulators = len(list(source_system.get_components(DistributionRegulator))) + source_controllers = len(list(source_system.get_components(RegulatorController))) + source_capacitors = len(list(source_system.get_components(DistributionCapacitor))) + source_switches = len(list(source_system.get_components(MatrixImpedanceSwitch))) + + writer = CimWriter(source_system) + writer.write(output_path=tmp_path, output_mode="single") + + graph = Graph() + graph.parse(tmp_path / "model.xml", format="xml") + + buses = query_distribution_buses(graph) + loads = query_loads(graph) + sources = query_source(graph) + lines = query_line_segments(graph) + transformers = query_power_transformers(graph) + winding_couplings = query_transformer_windings(graph) + regulators = query_distribution_regulators(graph) + controllers = query_regulator_controllers(graph) + capacitors = query_capacitors(graph) + switches = query_load_break_switches(graph) + + assert not buses.empty + assert not loads.empty + assert not sources.empty + assert not lines.empty + assert not transformers.empty + assert not winding_couplings.empty + assert len(regulators) == source_regulators + assert len(controllers) == source_controllers + assert capacitors["capacitor"].nunique() == source_capacitors + assert switches["switch_name"].nunique() == source_switches + + +def test_cim_writer_battery_query_compatibility(tmp_path): + system = OpenDSSReader(_IEEE13_DSS).get_system() + bus = next(iter(system.get_components(DistributionBus))) + + battery = DistributionBattery.model_construct( + name="query_battery", + bus=bus, + phases=[Phase.A, Phase.B, Phase.C], + active_power=ActivePower(30.0, "kilowatt"), + reactive_power=ReactivePower(5.0, "kilovar"), + controller=None, + inverter=InverterEquipment.model_construct( + name="query_battery_inverter", + rated_apparent_power=ApparentPower(35.0, "kilova"), + rise_limit=None, + fall_limit=None, + cutout_percent=0.1, + cutin_percent=0.1, + dc_to_ac_efficiency=0.96, + eff_curve=None, + ), + equipment=BatteryEquipment.model_construct( + name="query_battery_equipment", + rated_energy=EnergyDC(120.0, "kilowatthour"), + rated_power=ActivePower(30.0, "kilowatt"), + charging_efficiency=0.95, + discharging_efficiency=0.95, + idling_efficiency=0.99, + rated_voltage=Voltage(12.47, "kilovolt"), + voltage_type=VoltageTypes.LINE_TO_LINE, + ), + ) + system.add_components(battery) + + writer = CimWriter(system) + writer.write(output_path=tmp_path, output_mode="single") + + graph = Graph() + graph.parse(tmp_path / "model.xml", format="xml") + + batteries = query_batteries(graph) + assert not batteries.empty + assert batteries["battery"].nunique() == 1 + assert batteries["battery"].iloc[0] == "query_battery" + + +def test_cim_writer_battery_reader_compatibility(tmp_path): + system = DistributionSystem(auto_add_composed_components=True) + bus = DistributionBus.model_construct( + name="battery_bus", + phases=[Phase.A, Phase.B, Phase.C], + rated_voltage=Voltage(7.2, "kilovolt"), + voltage_type=VoltageTypes.LINE_TO_GROUND, + voltagelimits=[], + coordinate=None, + ) + system.add_components(bus) + + battery = DistributionBattery.model_construct( + name="reader_battery", + bus=bus, + phases=[Phase.A, Phase.B, Phase.C], + active_power=ActivePower(30.0, "kilowatt"), + reactive_power=ReactivePower(5.0, "kilovar"), + controller=None, + inverter=InverterEquipment.model_construct( + name="reader_battery_inverter", + rated_apparent_power=ApparentPower(35.0, "kilova"), + rise_limit=None, + fall_limit=None, + cutout_percent=0.1, + cutin_percent=0.1, + dc_to_ac_efficiency=0.96, + eff_curve=None, + ), + equipment=BatteryEquipment.model_construct( + name="reader_battery_equipment", + rated_energy=EnergyDC(120.0, "kilowatthour"), + rated_power=ActivePower(30.0, "kilowatt"), + charging_efficiency=0.95, + discharging_efficiency=0.95, + idling_efficiency=0.99, + rated_voltage=Voltage(12.47, "kilovolt"), + voltage_type=VoltageTypes.LINE_TO_LINE, + ), + ) + system.add_components(battery) + + writer = CimWriter(system) + writer.write(output_path=tmp_path, output_mode="single") + + reader = CimReader(tmp_path / "model.xml") + reader.read() + parsed_system = reader.get_system() + + parsed_batteries = list(parsed_system.get_components(DistributionBattery)) + assert len(parsed_batteries) == 1 + assert parsed_batteries[0].name == "reader_battery" diff --git a/tests/test_cli.py b/tests/test_cli.py index 0a1a947..7de731c 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -19,6 +19,7 @@ def test_list_writers(): assert result.exit_code == 0 output = result.stdout assert "opendss" in output + assert "cim_iec_61968_13" in output def test_convert_missing_args(): diff --git a/tests/test_mcp/test_mcp_tools.py b/tests/test_mcp/test_mcp_tools.py index fa50a06..b9e2702 100644 --- a/tests/test_mcp/test_mcp_tools.py +++ b/tests/test_mcp/test_mcp_tools.py @@ -15,6 +15,7 @@ list_readers, list_writers, load_gdm_json, + read_cim_model, read_opendss_model, write_opendss, ) @@ -44,6 +45,7 @@ def test_list_writers(): writers = list_writers() assert isinstance(writers, list) assert "opendss" in writers + assert "cim_iec_61968_13" in writers # --------------------------------------------------------------------------- @@ -115,6 +117,44 @@ def test_load_gdm_json_roundtrip(self, tmp_path): assert result["total_components"] > 0 +# --------------------------------------------------------------------------- +# CIM reader +# --------------------------------------------------------------------------- + + +class TestCIMModel: + """Tests that load the IEEE 13-node CIM model.""" + + @pytest.fixture(autouse=True) + def _setup(self): + """Load the model once and clean up after.""" + _SYNC_STATE.systems.clear() + read_cim_model(str(_CIM_XML), name="cim13") + yield + _SYNC_STATE.systems.clear() + + def test_read_cim_model(self): + assert "cim13" in _SYNC_STATE.systems + + def test_get_system_summary(self): + summary = get_system_summary("cim13") + assert summary["name"] == "cim13" + assert isinstance(summary["component_types"], dict) + assert summary["total_components"] > 0 + + def test_get_components_buses(self): + components = get_components("DistributionBus", name="cim13") + assert isinstance(components, list) + assert len(components) > 0 + assert "name" in components[0] + + def test_export_gdm_json(self, tmp_path): + json_path = tmp_path / "cim_model.json" + result = export_gdm_json(name="cim13", output_path=str(json_path)) + assert "exported" in result.lower() or "JSON" in result + assert json_path.exists() + + # --------------------------------------------------------------------------- # Conversion tool # --------------------------------------------------------------------------- @@ -139,22 +179,22 @@ def test_convert_opendss_to_opendss(self, tmp_path): assert out.exists() def test_convert_unknown_reader(self, tmp_path): - result = convert_model( - reader_type="nonexistent_format", - writer_type="opendss", - input_path=str(tmp_path / "fake"), - output_path=str(tmp_path), - ) - assert "Unknown reader" in result + with pytest.raises(ValueError, match="Unknown reader"): + convert_model( + reader_type="nonexistent_format", + writer_type="opendss", + input_path=str(tmp_path / "fake"), + output_path=str(tmp_path), + ) def test_convert_unknown_writer(self, tmp_path): - result = convert_model( - reader_type="opendss", - writer_type="nonexistent_format", - input_path=str(tmp_path / "fake"), - output_path=str(tmp_path), - ) - assert "Unknown writer" in result + with pytest.raises(ValueError, match="Unknown writer"): + convert_model( + reader_type="opendss", + writer_type="nonexistent_format", + input_path=str(tmp_path / "fake"), + output_path=str(tmp_path), + ) # --------------------------------------------------------------------------- diff --git a/tests/test_opendss/test_opendss_reader.py b/tests/test_opendss/test_opendss_reader.py index 9a13e64..0eef7c3 100644 --- a/tests/test_opendss/test_opendss_reader.py +++ b/tests/test_opendss/test_opendss_reader.py @@ -9,7 +9,7 @@ base_path = Path(__file__).parents[1] opendss_circuit_models = base_path / "data" / "opendss_circuit_models" -assert opendss_circuit_models.exists, f"{opendss_circuit_models} does not exist" +assert opendss_circuit_models.exists(), f"{opendss_circuit_models} does not exist" OPENDSS_CASEFILES = list(opendss_circuit_models.rglob("Master.dss")) @@ -21,6 +21,11 @@ def test_serialize_opendss_model(opendss_file: Path, fixed_tmp_path): export_path.mkdir(parents=True, exist_ok=True) parser = Reader(opendss_file) system = parser.get_system() + + # Verify the parsed system has components + component_types = list(system.get_component_types()) + assert len(component_types) > 0, "Parsed system has no component types" + json_path = export_path / (opendss_file.stem.lower() + ".json") system.to_json(json_path, overwrite=True) assert json_path.exists(), "Failed to export the json file" diff --git a/tests/test_opendss/test_opendss_writer.py b/tests/test_opendss/test_opendss_writer.py index 771182e..cbd7740 100644 --- a/tests/test_opendss/test_opendss_writer.py +++ b/tests/test_opendss/test_opendss_writer.py @@ -1,5 +1,7 @@ """Module for testing writers.""" +from pathlib import Path + from gdm.distribution import DistributionSystem from gdm.distribution.components import ( DistributionVoltageSource, @@ -29,18 +31,28 @@ @pytest.mark.parametrize("component", MODULES) -def test_component(component, fixed_tmp_path): +def test_component(component, tmp_path): system = DistributionSystem( name=f"test {component.__name__}", auto_add_composed_components=True ) system.add_component(component.example()) writer = Writer(system) - writer.write(output_path=fixed_tmp_path, separate_substations=False, separate_feeders=False) + writer.write(output_path=tmp_path, separate_substations=False, separate_feeders=False) + + # Verify at least one .dss file was written with content + dss_files = list(Path(tmp_path).rglob("*.dss")) + assert len(dss_files) > 0, f"No .dss files produced for {component.__name__}" + for dss_file in dss_files: + assert dss_file.stat().st_size > 0, f"Empty .dss file: {dss_file.name}" -def test_all_types(fixed_tmp_path): +def test_all_types(tmp_path): system = DistributionSystem(name="test full system", auto_add_composed_components=True) for component in MODULES: system.add_component(component.example()) writer = Writer(system) - writer.write(output_path=fixed_tmp_path, separate_substations=True, separate_feeders=True) + writer.write(output_path=tmp_path, separate_substations=True, separate_feeders=True) + + # Verify output files were produced + dss_files = list(Path(tmp_path).rglob("*.dss")) + assert len(dss_files) > 0, "No .dss files produced for full system write" diff --git a/tests/test_opendss/test_roundtrip_conversion.py b/tests/test_opendss/test_roundtrip_conversion.py index 71fe0ea..de55435 100644 --- a/tests/test_opendss/test_roundtrip_conversion.py +++ b/tests/test_opendss/test_roundtrip_conversion.py @@ -1,15 +1,12 @@ from pathlib import Path -import glob -import os -import opendssdirect as odd -from loguru import logger import numpy as np import pytest from ditto.writers.opendss.write import Writer from ditto.readers.opendss.reader import Reader from ditto.enumerations import OpenDSSFileTypes +from tests.helpers import get_metrics test_folder = Path(__file__).parent.parent @@ -24,72 +21,15 @@ ] -def get_model_voltage_drop(model_name: str): - model = getattr(odd, model_name) - tr = model.First() - dvs = [] - while tr: - buses = odd.CktElement.BusNames() - voltages = [] - for bus in buses: - bus = bus.split(".")[0] - odd.Circuit.SetActiveBus(bus) - voltage = odd.Bus.puVmagAngle()[::2] - voltages.append(voltage) - - for ii, v1 in enumerate(voltages): - for jj, v2 in enumerate(voltages): - if ii > jj: - if len(v1) == len(v2): - dv = np.abs(np.subtract(v1, v2)) - dvs.extend(list(dv)) - else: - dvs.append(abs(v1[0] - v2[0])) - - tr = model.Next() - xfmr_dvs = [dv for dv in dvs if dv != 0] - return min(xfmr_dvs), max(xfmr_dvs), sum(xfmr_dvs) / len(xfmr_dvs) - - -def get_metrics(dss_model_path: Path | str): - dss_model_path = Path(dss_model_path) - assert dss_model_path.exists(), f"DSS model {dss_model_path} does not exist" - cmd = f'redirect "{dss_model_path}"' - logger.debug(f"Running OpenDSS command -> {cmd}") - odd.Text.Command("clear") - odd.Text.Command(cmd) - odd.Solution.Solve() - - feeder_head_p, feeder_head_q = odd.Circuit.TotalPower() - voltages = odd.Circuit.AllBusMagPu() - min_voltage = min(voltages) - max_voltage = max(voltages) - avg_voltage = sum(voltages) / len(voltages) - - max_dv_xfmr, min_dv_xfmr, avg_dv_xfmr = get_model_voltage_drop("Transformers") - max_dv_line, min_dv_line, avg_dv_line = get_model_voltage_drop("Lines") - - base_metrics = [min_voltage, max_voltage, avg_voltage, feeder_head_p, feeder_head_q] - xfmr_voltages = [max_dv_xfmr, min_dv_xfmr, avg_dv_xfmr] - line_voltages = [max_dv_line, min_dv_line, avg_dv_line] - base_metrics.extend(xfmr_voltages) - base_metrics.extend(line_voltages) - return base_metrics - - @pytest.mark.parametrize("DSS_MODEL", TEST_MODELS) -def test_opendss_roundtrip_converion(DSS_MODEL, fixed_tmp_path): +def test_opendss_roundtrip_converion(DSS_MODEL, tmp_path): pre_converion_metrics = get_metrics(DSS_MODEL) reader = Reader(DSS_MODEL) writer = Writer(reader.get_system()) - csv_files = glob.glob(os.path.join(fixed_tmp_path, "*.dss")) - for file in csv_files: - os.remove(file) - logger.debug(f"Deleted: {file}") - assert fixed_tmp_path.exists(), f"Export path: {fixed_tmp_path}" - writer.write(fixed_tmp_path, separate_substations=False, separate_feeders=False) - dss_master_file = fixed_tmp_path / OpenDSSFileTypes.MASTER_FILE.value + assert tmp_path.exists(), f"Export path: {tmp_path}" + writer.write(tmp_path, separate_substations=False, separate_feeders=False) + dss_master_file = tmp_path / OpenDSSFileTypes.MASTER_FILE.value assert dss_master_file.exists() post_converion_metrics = get_metrics(dss_master_file) assert np.allclose( diff --git a/tests/test_opendss/test_system_with_profiles.py b/tests/test_opendss/test_system_with_profiles.py index d228947..37bf252 100644 --- a/tests/test_opendss/test_system_with_profiles.py +++ b/tests/test_opendss/test_system_with_profiles.py @@ -1,9 +1,5 @@ -import glob -import os - from infrasys import NonSequentialTimeSeries from gdm.distribution import DistributionSystem -from loguru import logger from ditto.writers.opendss.write import Writer @@ -11,20 +7,14 @@ def test_export_opends_model_with_profiles( distribution_system_with_single_timeseries: DistributionSystem, tmp_path ): - fixed_tmp_path = tmp_path - distribution_system_with_single_timeseries.info() writer = Writer(distribution_system_with_single_timeseries) - csv_files = glob.glob(os.path.join(fixed_tmp_path, "*.dss")) - for file in csv_files: - os.remove(file) - logger.debug(f"Deleted: {file}") - assert fixed_tmp_path.exists(), f"Export path: {fixed_tmp_path}" - writer.write(fixed_tmp_path, separate_substations=False, separate_feeders=False) + assert tmp_path.exists(), f"Export path: {tmp_path}" + writer.write(tmp_path, separate_substations=False, separate_feeders=False) assert ( - fixed_tmp_path / "LoadShape.dss" - ).exists(), f"LoadShape.dss file not found in the export path: {fixed_tmp_path}" - with open(fixed_tmp_path / "Master.dss", "r", encoding="utf-8") as file: + tmp_path / "LoadShape.dss" + ).exists(), f"LoadShape.dss file not found in the export path: {tmp_path}" + with open(tmp_path / "Master.dss", "r", encoding="utf-8") as file: content = file.read() assert "redirect LoadShape.dss" in content @@ -32,25 +22,19 @@ def test_export_opends_model_with_profiles( def test_export_opends_model_with_discontineous_profiles( distribution_system_with_nonsequential_timeseries: DistributionSystem, tmp_path ): - fixed_tmp_path = tmp_path - writer = Writer(distribution_system_with_nonsequential_timeseries) - csv_files = glob.glob(os.path.join(fixed_tmp_path, "*.dss")) - for file in csv_files: - os.remove(file) - logger.debug(f"Deleted: {file}") - assert fixed_tmp_path.exists(), f"Export path: {fixed_tmp_path}" + assert tmp_path.exists(), f"Export path: {tmp_path}" writer.write( - fixed_tmp_path, + tmp_path, separate_substations=False, separate_feeders=False, profile_type=NonSequentialTimeSeries, ) assert ( - fixed_tmp_path / "LoadShape.dss" - ).exists(), f"LoadShape.dss file not found in the export path: {fixed_tmp_path}" + tmp_path / "LoadShape.dss" + ).exists(), f"LoadShape.dss file not found in the export path: {tmp_path}" - with open(fixed_tmp_path / "Master.dss", "r", encoding="utf-8") as file: + with open(tmp_path / "Master.dss", "r", encoding="utf-8") as file: content = file.read() assert "redirect LoadShape.dss" in content