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.
- dertwin/__init__.py +0 -0
- dertwin/controllers/__init__.py +0 -0
- dertwin/controllers/device_controller.py +77 -0
- dertwin/controllers/site_controller.py +206 -0
- dertwin/core/__init__.py +0 -0
- dertwin/core/clock.py +24 -0
- dertwin/core/device.py +23 -0
- dertwin/core/engine.py +100 -0
- dertwin/core/registers.py +156 -0
- dertwin/devices/__init__.py +0 -0
- dertwin/devices/bess/__init__.py +0 -0
- dertwin/devices/bess/battery.py +238 -0
- dertwin/devices/bess/bess.py +58 -0
- dertwin/devices/bess/controller.py +155 -0
- dertwin/devices/bess/inverter.py +64 -0
- dertwin/devices/bess/simulator.py +206 -0
- dertwin/devices/energy_meter/__init__.py +0 -0
- dertwin/devices/energy_meter/model.py +65 -0
- dertwin/devices/energy_meter/simulator.py +66 -0
- dertwin/devices/external/__init__.py +0 -0
- dertwin/devices/external/ambient_temperature.py +57 -0
- dertwin/devices/external/external_models.py +138 -0
- dertwin/devices/external/grid_frequency.py +131 -0
- dertwin/devices/external/grid_voltage.py +132 -0
- dertwin/devices/external/irradiance.py +60 -0
- dertwin/devices/external/power_flow.py +63 -0
- dertwin/devices/pv/__init__.py +0 -0
- dertwin/devices/pv/controller.py +44 -0
- dertwin/devices/pv/inverter.py +106 -0
- dertwin/devices/pv/panel.py +69 -0
- dertwin/devices/pv/pv.py +47 -0
- dertwin/devices/pv/simulator.py +143 -0
- dertwin/logging_config.py +32 -0
- dertwin/main.py +56 -0
- dertwin/protocol/__init__.py +0 -0
- dertwin/protocol/modbus.py +207 -0
- dertwin/telemetry/__init__.py +0 -0
- dertwin/telemetry/base.py +14 -0
- dertwin/telemetry/bess.py +90 -0
- dertwin/telemetry/energy_meter.py +46 -0
- dertwin/telemetry/pv.py +36 -0
- dertwin-0.1.0.dist-info/METADATA +274 -0
- dertwin-0.1.0.dist-info/RECORD +47 -0
- dertwin-0.1.0.dist-info/WHEEL +5 -0
- dertwin-0.1.0.dist-info/entry_points.txt +2 -0
- dertwin-0.1.0.dist-info/licenses/LICENSE +21 -0
- 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}")
|
dertwin/core/__init__.py
ADDED
|
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
|