Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions docs/api/cim_reader.md
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
40 changes: 40 additions & 0 deletions docs/api/cim_writer.md
Original file line number Diff line number Diff line change
@@ -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:
```
2 changes: 1 addition & 1 deletion docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions docs/reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
24 changes: 24 additions & 0 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
7 changes: 4 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 = []
Expand All @@ -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",
Expand Down Expand Up @@ -50,7 +49,8 @@ dev = [
"pytest",
"pytest-cov",
"ruff",
"typer"
"typer",
"defusedxml"
]
mcp = [
"mcp[cli]",
Expand All @@ -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]
Expand Down
2 changes: 2 additions & 0 deletions src/ditto/mcp/docs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
}


Expand Down
36 changes: 9 additions & 27 deletions src/ditto/mcp/server.py
Original file line number Diff line number Diff line change
@@ -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::

Expand All @@ -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
# ---------------------------------------------------------------------------
Expand All @@ -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,
)

# ---------------------------------------------------------------------------
Expand All @@ -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 []


Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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())
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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()

Expand Down
3 changes: 3 additions & 0 deletions src/ditto/readers/cim_iec_61968_13/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
24 changes: 24 additions & 0 deletions src/ditto/readers/cim_iec_61968_13/cim_mapper.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,32 @@
from abc import ABC
from typing import Any

from gdm.distribution import DistributionSystem


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
Original file line number Diff line number Diff line change
@@ -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)
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down
Loading