dertwin 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. dertwin/__init__.py +0 -0
  2. dertwin/controllers/__init__.py +0 -0
  3. dertwin/controllers/device_controller.py +77 -0
  4. dertwin/controllers/site_controller.py +206 -0
  5. dertwin/core/__init__.py +0 -0
  6. dertwin/core/clock.py +24 -0
  7. dertwin/core/device.py +23 -0
  8. dertwin/core/engine.py +100 -0
  9. dertwin/core/registers.py +156 -0
  10. dertwin/devices/__init__.py +0 -0
  11. dertwin/devices/bess/__init__.py +0 -0
  12. dertwin/devices/bess/battery.py +238 -0
  13. dertwin/devices/bess/bess.py +58 -0
  14. dertwin/devices/bess/controller.py +155 -0
  15. dertwin/devices/bess/inverter.py +64 -0
  16. dertwin/devices/bess/simulator.py +206 -0
  17. dertwin/devices/energy_meter/__init__.py +0 -0
  18. dertwin/devices/energy_meter/model.py +65 -0
  19. dertwin/devices/energy_meter/simulator.py +66 -0
  20. dertwin/devices/external/__init__.py +0 -0
  21. dertwin/devices/external/ambient_temperature.py +57 -0
  22. dertwin/devices/external/external_models.py +138 -0
  23. dertwin/devices/external/grid_frequency.py +131 -0
  24. dertwin/devices/external/grid_voltage.py +132 -0
  25. dertwin/devices/external/irradiance.py +60 -0
  26. dertwin/devices/external/power_flow.py +63 -0
  27. dertwin/devices/pv/__init__.py +0 -0
  28. dertwin/devices/pv/controller.py +44 -0
  29. dertwin/devices/pv/inverter.py +106 -0
  30. dertwin/devices/pv/panel.py +69 -0
  31. dertwin/devices/pv/pv.py +47 -0
  32. dertwin/devices/pv/simulator.py +143 -0
  33. dertwin/logging_config.py +32 -0
  34. dertwin/main.py +56 -0
  35. dertwin/protocol/__init__.py +0 -0
  36. dertwin/protocol/modbus.py +207 -0
  37. dertwin/telemetry/__init__.py +0 -0
  38. dertwin/telemetry/base.py +14 -0
  39. dertwin/telemetry/bess.py +90 -0
  40. dertwin/telemetry/energy_meter.py +46 -0
  41. dertwin/telemetry/pv.py +36 -0
  42. dertwin-0.1.0.dist-info/METADATA +274 -0
  43. dertwin-0.1.0.dist-info/RECORD +47 -0
  44. dertwin-0.1.0.dist-info/WHEEL +5 -0
  45. dertwin-0.1.0.dist-info/entry_points.txt +2 -0
  46. dertwin-0.1.0.dist-info/licenses/LICENSE +21 -0
  47. dertwin-0.1.0.dist-info/top_level.txt +1 -0
dertwin/__init__.py ADDED
File without changes
File without changes
@@ -0,0 +1,77 @@
1
+ from typing import List, Dict
2
+ from dertwin.core.device import SimulatedDevice
3
+ from dertwin.core.registers import RegisterMap
4
+ from dertwin.protocol.modbus import (
5
+ collect_write_instructions,
6
+ write_telemetry_registers,
7
+ write_command_registers,
8
+ )
9
+
10
+ class DeviceController:
11
+
12
+ def __init__(
13
+ self,
14
+ device: SimulatedDevice,
15
+ protocols: List,
16
+ register_map: RegisterMap,
17
+ ):
18
+ self.device = device
19
+ self.protocols = protocols
20
+ self.register_map = register_map
21
+ self._last_commands: Dict[str, float] = {}
22
+ self._initialized = False
23
+
24
+ def step(self, dt: float):
25
+ commands = self.collect_commands()
26
+
27
+ # First run: initialize baseline without applying
28
+ if not self._initialized:
29
+ self._last_commands = dict(commands)
30
+ self.device.init_applied_commands(commands)
31
+ self._initialized = True
32
+
33
+ elif commands and commands != self._last_commands:
34
+ applied = self.device.apply_commands(commands)
35
+ self.write_protocol_commands(applied)
36
+ self._last_commands = dict(commands)
37
+
38
+ self.device.update(dt)
39
+
40
+ telemetry = self.device.get_telemetry().to_dict()
41
+ self.apply_telemetry(telemetry)
42
+
43
+ def collect_commands(self) -> Dict[str, float]:
44
+ merged: Dict[str, float] = {}
45
+
46
+ for proto in self.protocols:
47
+ raw_cmds = collect_write_instructions(
48
+ self.register_map.writes,
49
+ proto.context,
50
+ proto.unit_id,
51
+ )
52
+
53
+ for vendor_name, value in raw_cmds.items():
54
+ reg = self.register_map.get_by_name(vendor_name)
55
+
56
+ internal_name = reg.internal_name or reg.name
57
+ merged[internal_name] = value
58
+
59
+ return merged
60
+
61
+ def apply_telemetry(self, telemetry: Dict[str, float]) -> None:
62
+ for proto in self.protocols:
63
+ write_telemetry_registers(
64
+ self.register_map.reads,
65
+ proto.context,
66
+ proto.unit_id,
67
+ telemetry,
68
+ )
69
+
70
+ def write_protocol_commands(self, commands: Dict[str, float]) -> None:
71
+ for proto in self.protocols:
72
+ write_command_registers(
73
+ self.register_map.writes,
74
+ proto.context,
75
+ proto.unit_id,
76
+ commands,
77
+ )
@@ -0,0 +1,206 @@
1
+ import asyncio
2
+ import logging
3
+ from pathlib import Path
4
+ from typing import Dict, List, Optional, Callable
5
+
6
+ from dertwin.core.clock import SimulationClock
7
+ from dertwin.core.engine import SimulationEngine
8
+ from dertwin.controllers.device_controller import DeviceController
9
+ from dertwin.core.registers import RegisterMap
10
+
11
+ from dertwin.devices.bess.simulator import BESSSimulator
12
+ from dertwin.devices.pv.simulator import PVSimulator
13
+ from dertwin.devices.energy_meter.simulator import EnergyMeterSimulator
14
+
15
+ from dertwin.devices.external.external_models import ExternalModels
16
+
17
+ from dertwin.protocol.modbus import ModbusSimulator
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+
22
+ class SiteController:
23
+ """
24
+ Config-driven site runtime orchestrator.
25
+ Fully owns lifecycle of engine + protocols.
26
+ """
27
+
28
+ def __init__(self, config: Dict):
29
+ self.config = config
30
+ self.register_map_root = Path(config.get("register_map_root", "."))
31
+
32
+ self.clock = SimulationClock(
33
+ step=config.get("step", 0.1),
34
+ real_time=config.get("real_time", True),
35
+ )
36
+
37
+ self.engine: Optional[SimulationEngine] = None
38
+ self.controllers: List[DeviceController] = []
39
+ self.protocols: List[ModbusSimulator] = []
40
+
41
+ self._tasks: List[asyncio.Task] = []
42
+ self._built = False
43
+ self._running = False
44
+
45
+ self.external_models: Optional[ExternalModels] = None
46
+
47
+ # ==========================================================
48
+ # BUILD SITE
49
+ # ==========================================================
50
+
51
+ def build(self) -> None:
52
+ logger.info("Building site: %s", self.config.get("site_name", "unnamed"))
53
+
54
+
55
+ if self.config.get("external_models"):
56
+ self.external_models = ExternalModels.from_config(self.config.get("external_models"))
57
+ else:
58
+ self.external_models = ExternalModels.build_default()
59
+
60
+ devices_by_type: Dict[str, List] = {}
61
+ devices: List = []
62
+
63
+ # Create devices
64
+ for asset in [a for a in self.config["assets"] if a["type"] != "energy_meter"]:
65
+ device = self._create_device(asset)
66
+ devices.append(device)
67
+ devices_by_type.setdefault(asset["type"], []).append(device)
68
+
69
+ self.external_models.power_model = ExternalModels.build_power_model(devices_by_type, self.config.get("external_models"))
70
+
71
+
72
+ # ------------------------------------------------------
73
+ # Create Energy Meters
74
+ # ------------------------------------------------------
75
+
76
+ for _ in [a for a in self.config["assets"] if a["type"] == "energy_meter"]:
77
+ meter = EnergyMeterSimulator(
78
+ power_model=self.external_models.power_model,
79
+ grid_model=self.external_models.grid_frequency_model,
80
+ grid_voltage_model=self.external_models.grid_voltage_model,
81
+ )
82
+
83
+ devices.append(meter)
84
+ devices_by_type.setdefault("energy_meter", []).append(meter)
85
+
86
+ # Create controllers + protocols
87
+ for asset, device in zip(self.config["assets"], devices):
88
+
89
+ for proto_cfg in asset.get("protocols", []):
90
+
91
+ if proto_cfg["kind"] != "modbus_tcp":
92
+ raise ValueError(f"Unsupported protocol kind: {proto_cfg['kind']}")
93
+
94
+ map_path = Path(proto_cfg["register_map"])
95
+ if not map_path.is_absolute():
96
+ map_path = self.register_map_root / map_path
97
+
98
+ register_map = RegisterMap.from_yaml(map_path)
99
+
100
+ modbus = ModbusSimulator(
101
+ address=proto_cfg["ip"],
102
+ port=proto_cfg["port"],
103
+ unit_id=proto_cfg.get("unit_id", 1),
104
+ )
105
+
106
+ self.protocols.append(modbus)
107
+
108
+ controller = DeviceController(
109
+ device=device,
110
+ protocols=[modbus],
111
+ register_map=register_map,
112
+ )
113
+
114
+ self.controllers.append(controller)
115
+
116
+ self.engine = SimulationEngine(
117
+ devices=self.controllers,
118
+ clock=self.clock,
119
+ external_models=self.external_models,
120
+ )
121
+ # Set simulation start time if specified (e.g. start_time_h: 12.0 for noon)
122
+ start_time_h = self.config.get("start_time_h", 0.0)
123
+ if start_time_h:
124
+ self.clock.time = start_time_h * 3600.0
125
+
126
+ self._built = True
127
+
128
+ self._built = True
129
+
130
+ async def start(self):
131
+ if not self._built:
132
+ raise RuntimeError("Site must be built before start()")
133
+
134
+ if self._running:
135
+ return
136
+
137
+ logger.info("Starting site runtime")
138
+ self._running = True
139
+
140
+ # Start protocol servers
141
+ for proto in self.protocols:
142
+ task = asyncio.create_task(proto.run_server())
143
+ self._tasks.append(task)
144
+
145
+ # Only run engine loop in real-time mode
146
+ if self.clock.real_time:
147
+ engine_task = asyncio.create_task(self.engine.run())
148
+ self._tasks.append(engine_task)
149
+
150
+ try:
151
+ await asyncio.gather(*self._tasks)
152
+ except asyncio.CancelledError:
153
+ logger.info("Site tasks cancelled")
154
+
155
+ async def stop(self):
156
+ if not self._running:
157
+ return
158
+
159
+ logger.info("Stopping site runtime")
160
+
161
+ # Stop engine loop
162
+ if self.engine:
163
+ self.engine.stop()
164
+
165
+ # Graceful protocol shutdown
166
+ for proto in self.protocols:
167
+ await proto.shutdown()
168
+
169
+ # Cancel remaining tasks
170
+ for task in self._tasks:
171
+ task.cancel()
172
+
173
+ await asyncio.gather(*self._tasks, return_exceptions=True)
174
+
175
+ self._tasks.clear()
176
+ self._running = False
177
+
178
+ # ------------------------------------------------------------------
179
+
180
+ def _create_device(self, asset_cfg: Dict):
181
+ dtype = asset_cfg["type"]
182
+
183
+ if dtype == "bess":
184
+ return BESSSimulator(
185
+ capacity_kwh=asset_cfg.get("capacity_kwh", 100.0),
186
+ initial_soc=asset_cfg.get("initial_soc", 50.0),
187
+ max_charge_kw=asset_cfg.get("max_charge_kw", 20.0),
188
+ max_discharge_kw=asset_cfg.get("max_discharge_kw", 20.0),
189
+ ramp_rate_kw_per_s=asset_cfg.get("ramp_rate_kw_per_s", 100.0),
190
+ ambient_temp_model=self.external_models.ambient_temperature_model,
191
+ grid_voltage_model=self.external_models.grid_voltage_model,
192
+ grid_frequency_model=self.external_models.grid_frequency_model,
193
+ )
194
+
195
+ if dtype == "inverter":
196
+ return PVSimulator(
197
+ rated_kw=asset_cfg.get("rated_kw", 10.0),
198
+ module_efficiency=asset_cfg.get("module_efficiency", 0.20),
199
+ area_m2=asset_cfg.get("area_m2", None),
200
+ ambient_temp_model=self.external_models.ambient_temperature_model,
201
+ grid_voltage_model=self.external_models.grid_voltage_model,
202
+ grid_frequency_model=self.external_models.grid_frequency_model,
203
+ irradiance_model=self.external_models.irradiance_model,
204
+ )
205
+
206
+ raise ValueError(f"Unknown or unsupported asset type: {dtype}")
File without changes
dertwin/core/clock.py ADDED
@@ -0,0 +1,24 @@
1
+ import asyncio
2
+ from time import monotonic
3
+
4
+
5
+ class SimulationClock:
6
+ def __init__(self, step: float = 0.1, real_time: bool = True):
7
+ self.time = 0.0
8
+ self.step = step
9
+ self.real_time = real_time
10
+ self._last_wall_time = monotonic()
11
+
12
+ async def tick(self):
13
+ self.time += self.step
14
+
15
+ if self.real_time:
16
+ target = self._last_wall_time + self.step
17
+ now = monotonic()
18
+ sleep_time = max(0.0, target - now)
19
+ self._last_wall_time = target
20
+ await asyncio.sleep(sleep_time)
21
+
22
+ def reset(self):
23
+ self.time = 0.0
24
+ self._last_wall_time = monotonic()
dertwin/core/device.py ADDED
@@ -0,0 +1,23 @@
1
+ from abc import ABC, abstractmethod
2
+ from typing import Dict
3
+
4
+ from dertwin.telemetry.base import TelemetryBase
5
+
6
+
7
+ class SimulatedDevice(ABC):
8
+
9
+ @abstractmethod
10
+ def update(self, dt: float) -> None:
11
+ pass
12
+
13
+ @abstractmethod
14
+ def get_telemetry(self) -> TelemetryBase:
15
+ pass
16
+
17
+ @abstractmethod
18
+ def apply_commands(self, commands: Dict[str, float]) -> Dict[str, float]:
19
+ pass
20
+
21
+ @abstractmethod
22
+ def init_applied_commands(self, commands: Dict[str, float]) -> Dict[str, float]:
23
+ pass
dertwin/core/engine.py ADDED
@@ -0,0 +1,100 @@
1
+ import logging
2
+ from typing import List, Optional
3
+
4
+ from dertwin.controllers.device_controller import DeviceController
5
+ from dertwin.core.clock import SimulationClock
6
+ from dertwin.devices.external.external_models import ExternalModels
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+
11
+ class SimulationEngine:
12
+ """
13
+ Deterministic simulation engine.
14
+
15
+ Execution order per tick:
16
+
17
+ 1. external_models.update()
18
+ 2. device_controller.step()
19
+ 3. clock.tick()
20
+ """
21
+
22
+ def __init__(
23
+ self,
24
+ devices: List[DeviceController],
25
+ clock: SimulationClock,
26
+ external_models: Optional[ExternalModels] = None,
27
+ ):
28
+ self.devices = devices
29
+ self.clock = clock
30
+ self.external_models = external_models
31
+ self.sim_time = self.clock.time
32
+
33
+ self._running = False
34
+ self._tick_count = 0
35
+
36
+ # ---------------------------------------------------------
37
+ # REAL TIME LOOP
38
+ # ---------------------------------------------------------
39
+
40
+ async def run(self):
41
+
42
+ if not self.clock.real_time:
43
+ raise RuntimeError(
44
+ "Engine.run() should not be used in deterministic mode"
45
+ )
46
+
47
+ logger.info(
48
+ "Simulation engine started | step=%.3fs",
49
+ self.clock.step,
50
+ )
51
+
52
+ self._running = True
53
+
54
+ while self._running:
55
+ await self.step_once()
56
+
57
+ # ---------------------------------------------------------
58
+ # SINGLE STEP (Deterministic Safe)
59
+ # ---------------------------------------------------------
60
+
61
+ async def step_once(self):
62
+
63
+ dt = self.clock.step
64
+ self.sim_time = self.clock.time
65
+
66
+ # =====================================================
67
+ # STEP EXTERNAL FIRST
68
+ # =====================================================
69
+
70
+ if self.external_models:
71
+ self.external_models.update(self.sim_time, dt)
72
+
73
+ # =====================================================
74
+ # STEP DEVICES
75
+ # =====================================================
76
+
77
+ for device in self.devices:
78
+ device.step(dt)
79
+
80
+ # =====================================================
81
+ # LOGGING
82
+ # =====================================================
83
+
84
+ self._tick_count += 1
85
+
86
+ if self._tick_count % 100 == 0:
87
+ logger.info(
88
+ "Simulation tick | t=%.2fs | ticks=%d",
89
+ self.sim_time,
90
+ self._tick_count,
91
+ )
92
+
93
+ # =====================================================
94
+ # ADVANCE CLOCK
95
+ # =====================================================
96
+
97
+ await self.clock.tick()
98
+
99
+ def stop(self):
100
+ self._running = False
@@ -0,0 +1,156 @@
1
+ from dataclasses import dataclass
2
+ from enum import Enum
3
+ from pathlib import Path
4
+ from typing import Dict, List, Tuple, Optional
5
+
6
+ import yaml
7
+
8
+
9
+ # ==========================================================
10
+ # ENUMS
11
+ # ==========================================================
12
+
13
+ class RegisterDirection(str, Enum):
14
+ READ = "read"
15
+ WRITE = "write"
16
+
17
+
18
+ # ==========================================================
19
+ # REGISTER DEFINITION
20
+ # ==========================================================
21
+
22
+ @dataclass(frozen=True)
23
+ class RegisterDefinition:
24
+ name: str
25
+ internal_name: str
26
+ address: int
27
+ func: int
28
+ direction: RegisterDirection
29
+ type: str
30
+ count: int
31
+ scale: float = 1.0
32
+ unit: str = ""
33
+ options: Optional[Dict[int, str]] = None
34
+ description: Optional[str] = None
35
+
36
+ @property
37
+ def key(self) -> Tuple[int, int, RegisterDirection]:
38
+ """
39
+ Unique identifier inside protocol space.
40
+ """
41
+ return self.address, self.func, self.direction
42
+
43
+
44
+ class RegisterMap:
45
+ """
46
+ Immutable container for device register definitions.
47
+ Provides validation and fast lookup.
48
+ """
49
+
50
+ def __init__(self, registers: List[RegisterDefinition]):
51
+ self._registers = registers
52
+
53
+ # Lookup tables
54
+ self._by_key: Dict[Tuple[int, int, RegisterDirection], RegisterDefinition] = {}
55
+ self._by_name: Dict[str, RegisterDefinition] = {}
56
+ self._reads: Dict[int, RegisterDefinition] = {}
57
+ self._writes: Dict[int, RegisterDefinition] = {}
58
+
59
+ self._build_indexes()
60
+
61
+ @classmethod
62
+ def from_yaml(cls, path: Path) -> "RegisterMap":
63
+ if not path.exists():
64
+ raise FileNotFoundError(f"Register map not found: {path}")
65
+
66
+ with open(path, "r") as f:
67
+ data = yaml.safe_load(f)
68
+
69
+ raw_registers = data.get("registers", [])
70
+ definitions: List[RegisterDefinition] = []
71
+
72
+ for entry in raw_registers:
73
+ definitions.append(cls._parse_entry(entry))
74
+
75
+ return cls(definitions)
76
+
77
+ @staticmethod
78
+ def _parse_entry(entry: Dict) -> RegisterDefinition:
79
+ try:
80
+ direction = RegisterDirection(entry["direction"])
81
+ except ValueError:
82
+ raise ValueError(f"Invalid register direction: {entry['direction']}")
83
+
84
+ if "count" not in entry:
85
+ raise ValueError(f"Missing 'count' for register: {entry['name']}")
86
+
87
+ return RegisterDefinition(
88
+ name=entry["name"],
89
+ internal_name=entry["internal_name"],
90
+ address=entry["address"],
91
+ func=entry["func"],
92
+ direction=direction,
93
+ type=entry["type"],
94
+ count=entry["count"],
95
+ scale=entry.get("scale", 1.0),
96
+ unit=entry.get("unit", ""),
97
+ options=entry.get("options"),
98
+ description=entry.get("description"),
99
+ )
100
+
101
+ def _build_indexes(self):
102
+ for reg in self._registers:
103
+
104
+ # Check duplicate key
105
+ if reg.key in self._by_key:
106
+ raise ValueError(
107
+ f"Duplicate register definition: {reg.key}"
108
+ )
109
+
110
+ # Check duplicate name
111
+ if reg.name in self._by_name:
112
+ raise ValueError(
113
+ f"Duplicate register name: {reg.name}"
114
+ )
115
+
116
+ self._by_key[reg.key] = reg
117
+ self._by_name[reg.name] = reg
118
+
119
+ # Simple address-based lookup (per direction)
120
+ if reg.direction == RegisterDirection.READ:
121
+ if reg.address in self._reads:
122
+ raise ValueError(
123
+ f"Overlapping READ register at address {reg.address}"
124
+ )
125
+ self._reads[reg.address] = reg
126
+
127
+ if reg.direction == RegisterDirection.WRITE:
128
+ if reg.address in self._writes:
129
+ raise ValueError(
130
+ f"Overlapping WRITE register at address {reg.address}"
131
+ )
132
+ self._writes[reg.address] = reg
133
+
134
+ def get_by_name(self, name: str) -> RegisterDefinition:
135
+ return self._by_name[name]
136
+
137
+ def get(self, address: int, func: int, direction: RegisterDirection) -> Optional[RegisterDefinition]:
138
+ return self._by_key.get((address, func, direction))
139
+
140
+ def read_register(self, address: int) -> Optional[RegisterDefinition]:
141
+ return self._reads.get(address)
142
+
143
+ def write_register(self, address: int) -> Optional[RegisterDefinition]:
144
+ return self._writes.get(address)
145
+
146
+ @property
147
+ def all(self) -> List[RegisterDefinition]:
148
+ return list(self._registers)
149
+
150
+ @property
151
+ def reads(self) -> List[RegisterDefinition]:
152
+ return [r for r in self._registers if r.direction == RegisterDirection.READ]
153
+
154
+ @property
155
+ def writes(self) -> List[RegisterDefinition]:
156
+ return [r for r in self._registers if r.direction == RegisterDirection.WRITE]
File without changes
File without changes