shepherd-core 2025.6.4__py3-none-any.whl → 2025.10.1__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 (45) hide show
  1. shepherd_core/data_models/__init__.py +4 -2
  2. shepherd_core/data_models/base/content.py +2 -0
  3. shepherd_core/data_models/content/__init__.py +4 -2
  4. shepherd_core/data_models/content/{virtual_harvester.py → virtual_harvester_config.py} +3 -3
  5. shepherd_core/data_models/content/{virtual_source.py → virtual_source_config.py} +82 -58
  6. shepherd_core/data_models/content/virtual_source_fixture.yaml +24 -24
  7. shepherd_core/data_models/content/virtual_storage_config.py +426 -0
  8. shepherd_core/data_models/content/virtual_storage_fixture_creator.py +267 -0
  9. shepherd_core/data_models/content/virtual_storage_fixture_ideal.yaml +637 -0
  10. shepherd_core/data_models/content/virtual_storage_fixture_lead.yaml +49 -0
  11. shepherd_core/data_models/content/virtual_storage_fixture_lipo.yaml +735 -0
  12. shepherd_core/data_models/content/virtual_storage_fixture_mlcc.yaml +200 -0
  13. shepherd_core/data_models/content/virtual_storage_fixture_param_experiments.py +151 -0
  14. shepherd_core/data_models/content/virtual_storage_fixture_super.yaml +150 -0
  15. shepherd_core/data_models/content/virtual_storage_fixture_tantal.yaml +550 -0
  16. shepherd_core/data_models/experiment/observer_features.py +8 -2
  17. shepherd_core/data_models/experiment/target_config.py +1 -1
  18. shepherd_core/data_models/task/emulation.py +9 -6
  19. shepherd_core/data_models/task/firmware_mod.py +1 -0
  20. shepherd_core/data_models/task/harvest.py +4 -4
  21. shepherd_core/data_models/task/observer_tasks.py +5 -2
  22. shepherd_core/data_models/task/programming.py +1 -0
  23. shepherd_core/data_models/task/testbed_tasks.py +6 -1
  24. shepherd_core/decoder_waveform/uart.py +2 -1
  25. shepherd_core/fw_tools/patcher.py +60 -34
  26. shepherd_core/fw_tools/validation.py +7 -1
  27. shepherd_core/inventory/system.py +1 -1
  28. shepherd_core/reader.py +4 -3
  29. shepherd_core/version.py +1 -1
  30. shepherd_core/vsource/__init__.py +4 -0
  31. shepherd_core/vsource/virtual_converter_model.py +27 -26
  32. shepherd_core/vsource/virtual_harvester_model.py +27 -19
  33. shepherd_core/vsource/virtual_harvester_simulation.py +38 -39
  34. shepherd_core/vsource/virtual_source_model.py +17 -13
  35. shepherd_core/vsource/virtual_source_simulation.py +71 -73
  36. shepherd_core/vsource/virtual_storage_model.py +164 -0
  37. shepherd_core/vsource/virtual_storage_model_fixed_point_math.py +58 -0
  38. shepherd_core/vsource/virtual_storage_models_kibam.py +449 -0
  39. shepherd_core/vsource/virtual_storage_simulator.py +104 -0
  40. {shepherd_core-2025.6.4.dist-info → shepherd_core-2025.10.1.dist-info}/METADATA +4 -6
  41. {shepherd_core-2025.6.4.dist-info → shepherd_core-2025.10.1.dist-info}/RECORD +44 -32
  42. shepherd_core/data_models/virtual_source_doc.txt +0 -207
  43. {shepherd_core-2025.6.4.dist-info → shepherd_core-2025.10.1.dist-info}/WHEEL +0 -0
  44. {shepherd_core-2025.6.4.dist-info → shepherd_core-2025.10.1.dist-info}/top_level.txt +0 -0
  45. {shepherd_core-2025.6.4.dist-info → shepherd_core-2025.10.1.dist-info}/zip-safe +0 -0
@@ -0,0 +1,449 @@
1
+ """Original KiBaM-Models with varying quality of detail."""
2
+
3
+ import math
4
+ import sys
5
+ from collections.abc import Callable
6
+
7
+ from pydantic import BaseModel
8
+ from pydantic import PositiveFloat
9
+ from pydantic import PositiveInt
10
+ from pydantic import validate_call
11
+ from typing_extensions import Self
12
+
13
+ from shepherd_core.data_models.content.virtual_storage_config import LuT_SIZE
14
+ from shepherd_core.data_models.content.virtual_storage_config import TIMESTEP_s_DEFAULT
15
+ from shepherd_core.data_models.content.virtual_storage_config import VirtualStorageConfig
16
+ from shepherd_core.data_models.content.virtual_storage_config import soc_t
17
+
18
+ from .virtual_storage_model import ModelStorage
19
+
20
+
21
+ class LUT(BaseModel):
22
+ """Dynamic look-up table that can automatically be generated from a function."""
23
+
24
+ x_min: float
25
+ y_values: list[float]
26
+ length: int
27
+ interpolate: bool = False
28
+
29
+ @classmethod
30
+ @validate_call
31
+ def generate(
32
+ cls,
33
+ x_min: PositiveFloat,
34
+ y_fn: Callable,
35
+ lut_size: PositiveInt = LuT_SIZE,
36
+ *,
37
+ optimize_clamp: bool = False,
38
+ interpolate: bool = False,
39
+ ) -> Self:
40
+ """
41
+ Generate a LUT with a specific width from a provided function.
42
+
43
+ It has a minimum value, a size / width and a scale (linear / log2).
44
+ y_fnc is a function that takes an argument and produces the lookup value.
45
+ """
46
+ if interpolate:
47
+ # Note: dynamically creating .get() with setattr() was not successful
48
+ optimize_clamp = False
49
+
50
+ offset = 0.5 if optimize_clamp else 0
51
+ x_values = [(i + offset) * x_min for i in range(lut_size)]
52
+ y_values = [y_fn(x) for x in x_values]
53
+ return cls(x_min=x_min, y_values=y_values, length=lut_size, interpolate=interpolate)
54
+
55
+ def get(self, x_value: float) -> float:
56
+ return self.get_interpol(x_value) if self.interpolate else self.get_discrete(x_value)
57
+
58
+ def get_discrete(self, x_value: float) -> float:
59
+ """Discrete LuT-lookup with typical stairs."""
60
+ num = int(x_value / self.x_min)
61
+ # ⤷ round() would be more appropriate, but in c/pru its just integer math
62
+ idx = max(0, num)
63
+ if idx >= self.length: # len(self.y_values)
64
+ idx = self.length - 1
65
+ return self.y_values[idx]
66
+
67
+ def get_interpol(self, x_value: float) -> float:
68
+ """LuT-lookup with additional interpolation.
69
+
70
+ Note: optimize-clamp must be disabled, otherwise this produces an offset
71
+ """
72
+ num = x_value / self.x_min
73
+ if num <= 0:
74
+ return self.y_values[0]
75
+ if num >= self.length - 1:
76
+ return self.y_values[self.length - 1]
77
+
78
+ idx: int = math.floor(num)
79
+ # high could be math.ceil(num), but also idx+1
80
+ num_f: float = num - idx
81
+ y_base = self.y_values[idx]
82
+ y_delta = self.y_values[idx + 1] - y_base
83
+ # TODO: y_delta[idx_l] could be a seconds LuT
84
+ return y_base + y_delta * num_f
85
+
86
+
87
+ class ModelKiBaM(ModelStorage):
88
+ """Naive implementation of the full hybrid KiBaM model from the paper.
89
+
90
+ Introduced in "A Hybrid Battery Model Capable of Capturing Dynamic Circuit
91
+ Characteristics and Nonlinear Capacity Effects".
92
+
93
+ It is mostly focused on discharge, so it won't support
94
+
95
+ - rate capacity effect and transients during charging
96
+ - self discharge (as it was deemed too small)
97
+ """
98
+
99
+ @validate_call
100
+ def __init__(
101
+ self,
102
+ cfg: VirtualStorageConfig,
103
+ SoC_init: soc_t | None = None,
104
+ dt_s: PositiveFloat = TIMESTEP_s_DEFAULT,
105
+ ) -> None:
106
+ # metadata for simulator
107
+ self.cfg: VirtualStorageConfig = cfg
108
+ self.dt_s: float = dt_s
109
+ # state
110
+ self.SoC: float = SoC_init if SoC_init is not None else cfg.SoC_init
111
+ self.time_s: float = 0
112
+
113
+ # Rate capacity effect
114
+ self.C_unavailable: float = 0
115
+ self.C_unavailable_last: float = 0
116
+
117
+ # Transient tracking
118
+ self.V_transient_S_max: float = 0
119
+ self.V_transient_L_max: float = 0
120
+ self.discharge_last: bool = False
121
+
122
+ # Modified transient tracking
123
+ self.V_transient_S: float = 0
124
+ self.V_transient_L: float = 0
125
+
126
+ def step(self, I_charge_A: float) -> tuple[float, float, float, float]:
127
+ """Calculate the battery SoC & cell-voltage after drawing a current over a time-step."""
128
+ # Step 1 verified separately using Figure 4
129
+ # Steps 1 and 2 verified separately using Figure 10
130
+ # Complete model verified using Figures 8 (a, b) and Figure 9 (a, b)
131
+ I_cell = -I_charge_A
132
+
133
+ # Step 0: Determine whether battery is charging or resting and
134
+ # calculate time since last switch
135
+ if self.discharge_last != (I_cell > 0): # Reset time delta when current sign changes
136
+ self.discharge_last = I_cell > 0
137
+ self.time_s = 0
138
+ self.C_unavailable_last = self.C_unavailable
139
+ # ⤷ Save C_unavailable at time of switch
140
+
141
+ self.time_s += self.dt_s
142
+ # ⤷ Consider time delta including this iteration (we want v_trans after the current step)
143
+
144
+ # Step 1: Calculate unavailable capacity after dt
145
+ # (due to rate capacity and recovery effect) (equation 17)
146
+ # Note: it seems possible to remove the 2nd branch if
147
+ # charging is considered (see Plus-Model)
148
+ if I_cell > 0: # Discharging
149
+ self.C_unavailable = (
150
+ self.C_unavailable_last * math.pow(math.e, -self.cfg.kdash * self.time_s)
151
+ + (1 - self.cfg.p_rce)
152
+ * I_cell
153
+ / self.cfg.p_rce
154
+ * (1 - math.pow(math.e, -self.cfg.kdash * self.time_s))
155
+ / self.cfg.kdash
156
+ )
157
+ else: # Recovering
158
+ self.C_unavailable = self.C_unavailable_last * math.pow(
159
+ math.e, -self.cfg.kdash * self.time_s
160
+ )
161
+
162
+ # Step 2: Calculate SoC after dt (equation 6; modified for discrete operation)
163
+ # ⤷ MODIFIED: clamp both SoC to 0..1
164
+ self.SoC = self.SoC - 1 / self.cfg.q_As * (I_cell * self.dt_s)
165
+ self.SoC = min(max(self.SoC, 0.0), 1.0)
166
+ SoC_eff = self.SoC - 1 / self.cfg.q_As * self.C_unavailable
167
+ SoC_eff = max(SoC_eff, 0.0)
168
+
169
+ # Step 3: Calculate V_OC after dt (equation 7)
170
+ V_OC = self.cfg.calc_V_OC(SoC_eff)
171
+
172
+ # Step 4: Calculate resistance and capacitance values after dt (equation 12)
173
+ R_series = self.cfg.calc_R_series(SoC_eff)
174
+ R_transient_S = self.cfg.calc_R_transient_S(SoC_eff)
175
+ C_transient_S = self.cfg.calc_C_transient_S(SoC_eff)
176
+ R_transient_L = self.cfg.calc_R_transient_L(SoC_eff)
177
+ C_transient_L = self.cfg.calc_C_transient_L(SoC_eff)
178
+
179
+ # Step 5: Calculate transient voltages (equations 10 and 11)
180
+ # ⤷ MODIFIED: prevent both tau_X from becoming 0
181
+ tau_S = max(R_transient_S * C_transient_S, sys.float_info.min)
182
+ if I_cell > 0: # Discharging
183
+ V_transient_S = R_transient_S * I_cell * (1 - math.pow(math.e, -self.time_s / tau_S))
184
+ self.V_transient_S_max = V_transient_S
185
+ else: # Recovering
186
+ V_transient_S = self.V_transient_S_max * math.pow(math.e, -self.time_s / tau_S)
187
+
188
+ tau_L = max(R_transient_L * C_transient_L, sys.float_info.min)
189
+ if I_cell > 0: # Discharging
190
+ V_transient_L = R_transient_L * I_cell * (1 - math.pow(math.e, -self.time_s / tau_L))
191
+ self.V_transient_L_max = V_transient_L
192
+ else: # Recovering
193
+ V_transient_L = self.V_transient_L_max * math.pow(math.e, -self.time_s / tau_L)
194
+
195
+ # Step 6: Calculate cell voltage (equations 8 and 9)
196
+ # ⤷ MODIFIED: limit V_cell to >=0
197
+ V_transient = V_transient_S + V_transient_L
198
+ V_cell = V_OC - I_cell * R_series - V_transient
199
+ V_cell = max(V_cell, 0)
200
+
201
+ return V_OC, V_cell, self.SoC, SoC_eff
202
+
203
+
204
+ class ModelKiBaMPlus(ModelStorage):
205
+ """Hybrid KiBaM model from the paper with certain extensions.
206
+
207
+ Extended by [@jonkub](https://github.com/jonkub) with streamlined math.
208
+
209
+ Modifications:
210
+
211
+ 1. support rate capacity during charging (Step 1)
212
+ 2. support transient tracking during charging (Step 5)
213
+ 3. support self discharge (step 2a) via a parallel leakage resistor
214
+ 4. support signaling 0 % SoC by nulling voltage
215
+ """
216
+
217
+ @validate_call
218
+ def __init__(
219
+ self,
220
+ cfg: VirtualStorageConfig,
221
+ SoC_init: soc_t | None = None,
222
+ dt_s: PositiveFloat = TIMESTEP_s_DEFAULT,
223
+ ) -> None:
224
+ # metadata for simulator
225
+ self.cfg: VirtualStorageConfig = cfg
226
+ self.dt_s: float = dt_s
227
+ # state
228
+ self.SoC: float = SoC_init if SoC_init is not None else cfg.SoC_init
229
+ self.time_s: float = 0
230
+
231
+ # Rate capacity effect
232
+ self.C_unavailable: float = 0
233
+ self.C_unavailable_last: float = 0
234
+
235
+ # Transient tracking
236
+ self.discharge_last: bool = False
237
+
238
+ # Modified transient tracking
239
+ self.V_transient_S: float = 0
240
+ self.V_transient_L: float = 0
241
+
242
+ def step(self, I_charge_A: float) -> tuple[float, float, float, float]:
243
+ """Calculate the battery SoC & cell-voltage after drawing a current over a time-step.
244
+
245
+ - Step 1 verified separately using Figure 4
246
+ - Steps 1 and 2 verified separately using Figure 10
247
+ - Complete model verified using Figures 8 (a, b) and Figure 9 (a, b)
248
+ """
249
+ I_cell = -I_charge_A
250
+
251
+ # Step 0: Determine whether battery is charging or resting and
252
+ # calculate time since last switch
253
+ if self.discharge_last != (I_cell > 0): # Reset time delta when current sign changes
254
+ self.discharge_last = I_cell > 0
255
+ self.time_s = 0
256
+ self.C_unavailable_last = self.C_unavailable # Save C_unavailable at time of switch
257
+
258
+ self.time_s += self.dt_s
259
+ # ⤷ Consider time delta including this iteration (we want v_trans after the current step)
260
+
261
+ # Step 1: Calculate unavailable capacity after dt
262
+ # (due to rate capacity and recovery effect) (equation 17)
263
+ # TODO: if this should be used in production, additional verification is required
264
+ # (analytically derive versions of eq. 16/17 without time range restrictions)
265
+ # parameters for rate effect could only be valid for discharge
266
+ # Note: other paper has charging-curves (fig9b) - could be used for verification
267
+ self.C_unavailable = (
268
+ self.C_unavailable_last * math.pow(math.e, -self.cfg.kdash * self.time_s)
269
+ + (1 - self.cfg.p_rce)
270
+ * I_cell
271
+ / self.cfg.p_rce
272
+ * (1 - math.pow(math.e, -self.cfg.kdash * self.time_s))
273
+ / self.cfg.kdash
274
+ )
275
+
276
+ # Step 2a: Calculate and add self-discharge current to SoC-Eq. below
277
+ I_leak = self.cfg.calc_V_OC(self.SoC) / self.cfg.R_leak_Ohm
278
+
279
+ # Step 2: Calculate SoC after dt (equation 6; modified for discrete operation)
280
+ # ⤷ MODIFIED: clamp both SoC to 0..1
281
+ self.SoC = self.SoC - (I_cell + I_leak) * self.dt_s / self.cfg.q_As
282
+ self.SoC = min(max(self.SoC, 0.0), 1.0)
283
+ SoC_eff = self.SoC - 1 / self.cfg.q_As * self.C_unavailable
284
+ SoC_eff = min(max(SoC_eff, 0.0), 1.0)
285
+ # ⤷ Note: limiting SoC_eff to <=1 should NOT be needed, but
286
+ # C_unavailable can become negative during charging (see assumption in step1).
287
+
288
+ # Step 3: Calculate V_OC after dt (equation 7)
289
+ V_OC = self.cfg.calc_V_OC(SoC_eff)
290
+
291
+ # Step 4: Calculate resistance and capacitance values after dt (equation 12)
292
+ R_series = self.cfg.calc_R_series(SoC_eff)
293
+ R_transient_S = self.cfg.calc_R_transient_S(SoC_eff)
294
+ C_transient_S = self.cfg.calc_C_transient_S(SoC_eff)
295
+ R_transient_L = self.cfg.calc_R_transient_L(SoC_eff)
296
+ C_transient_L = self.cfg.calc_C_transient_L(SoC_eff)
297
+
298
+ # Step 5: Calculate transient voltages (equations 10 and 11)
299
+ # ⤷ MODIFIED: prevent both tau_X from becoming 0
300
+ tau_S = max(R_transient_S * C_transient_S, sys.float_info.min)
301
+ tau_L = max(R_transient_L * C_transient_L, sys.float_info.min)
302
+ self.V_transient_S = R_transient_S * I_cell + (
303
+ self.V_transient_S - R_transient_S * I_cell
304
+ ) * math.pow(math.e, -self.dt_s / tau_S)
305
+ self.V_transient_L = R_transient_L * I_cell + (
306
+ self.V_transient_L - R_transient_L * I_cell
307
+ ) * math.pow(math.e, -self.dt_s / tau_L)
308
+
309
+ # Step 6: Calculate cell voltage (equations 8 and 9)
310
+ # ⤷ MODIFIED: limit V_cell to >=0
311
+ V_transient = self.V_transient_S + self.V_transient_L
312
+ V_cell = V_OC - I_cell * R_series - V_transient
313
+ V_cell = max(V_cell, 0)
314
+ if self.SoC == 0:
315
+ V_cell = 0 # make sure no energy can be extracted when empty
316
+
317
+ return V_OC, V_cell, self.SoC, SoC_eff
318
+
319
+
320
+ class ModelKiBaMSimple(ModelStorage):
321
+ """PRU-optimized model with a set of simplifications.
322
+
323
+ Modifications by [@jonkub](https://github.com/jonkub):
324
+
325
+ - omit transient voltages (step 4 & 5, expensive calculation)
326
+ - omit rate capacity effect (step 1, expensive calculation)
327
+ - replace two expensive Fn by LuT
328
+ - mapping SoC to open circuit voltage (step 3)
329
+ - mapping SoC to series resistance (step 4)
330
+ - add self discharge resistance (step 2a)
331
+ - support signaling 0 % SoC by nulling voltage
332
+
333
+ Compared to the current shepherd capacitor (charge-based), it:
334
+
335
+ - supports emulation of battery types like lipo and lead acid (non-linear SOC-to-V_OC mapping)
336
+ - has a parallel leakage resistor instead of an oversimplified leakage current
337
+ - a series resistance is added to improve model matching
338
+ - as a drawback the open circuit voltage is quantified and shows steps (LuT with 128 entries)
339
+
340
+ """
341
+
342
+ @validate_call
343
+ def __init__(
344
+ self,
345
+ cfg: VirtualStorageConfig,
346
+ SoC_init: soc_t | None = None,
347
+ dt_s: PositiveFloat = TIMESTEP_s_DEFAULT,
348
+ *,
349
+ optimize_clamp: bool = False,
350
+ interpolate: bool = False,
351
+ ) -> None:
352
+ # metadata for simulator
353
+ self.cfg: VirtualStorageConfig = cfg
354
+ self.dt_s = dt_s
355
+ # pre-calculate constants
356
+ self.V_OC_LuT: LUT = LUT.generate(
357
+ 1.0 / LuT_SIZE,
358
+ y_fn=cfg.calc_V_OC,
359
+ lut_size=LuT_SIZE,
360
+ optimize_clamp=optimize_clamp,
361
+ interpolate=interpolate,
362
+ )
363
+ self.R_series_LuT: LUT = LUT.generate(
364
+ 1.0 / LuT_SIZE,
365
+ y_fn=cfg.calc_R_series,
366
+ lut_size=LuT_SIZE,
367
+ optimize_clamp=optimize_clamp,
368
+ interpolate=interpolate,
369
+ )
370
+ self.Constant_s_per_As: float = dt_s / cfg.q_As
371
+ self.Constant_1_per_Ohm: float = 1.0 / cfg.R_leak_Ohm
372
+ # state
373
+ self.SoC: float = SoC_init if SoC_init is not None else cfg.SoC_init
374
+
375
+ def step(self, I_charge_A: float) -> tuple[float, float, float, float]:
376
+ """Calculate the battery SoC & cell-voltage after drawing a current over a time-step."""
377
+ I_cell = -I_charge_A
378
+ # Step 2a: Calculate self-discharge (drainage)
379
+ I_leak = self.V_OC_LuT.get(self.SoC) * self.Constant_1_per_Ohm
380
+
381
+ # Step 2: Calculate SoC after dt (equation 6; modified for discrete operation)
382
+ # = SoC - 1 / C * (i_cell * dt)
383
+ self.SoC = self.SoC - (I_cell + I_leak) * self.Constant_s_per_As
384
+ SoC_eff = self.SoC = min(max(self.SoC, 0.0), 1.0)
385
+ # ⤷ MODIFIED: removed term due to omission of rate capacity effect
386
+ # ⤷ MODIFIED: clamp SoC to 0..1
387
+
388
+ # Step 3: Calculate V_OC after dt (equation 7)
389
+ # MODIFIED to use a lookup table instead
390
+ V_OC = self.V_OC_LuT.get(SoC_eff)
391
+
392
+ # Step 4: Calculate resistance and capacitance values after dt (equation 12)
393
+ # MODIFIED: removed terms due to omission of transient voltages
394
+ # MODIFIED to use a lookup table instead
395
+ R_series = self.R_series_LuT.get(SoC_eff)
396
+
397
+ # Step 5: Calculate transient voltages (equations 10 and 11)
398
+ # MODIFIED: removed due to omission of transient voltages
399
+
400
+ # Step 6: Calculate cell voltage (equations 8 and 9)
401
+ # MODIFIED: removed term due to omission of transient voltages
402
+ # MODIFIED: limit V_cell to >=0
403
+ V_cell = V_OC - I_cell * R_series
404
+ V_cell = max(V_cell, 0.0)
405
+ if self.SoC == 0:
406
+ V_cell = 0 # make sure no energy can be extracted when empty
407
+
408
+ return V_OC, V_cell, self.SoC, SoC_eff
409
+
410
+
411
+ class ModelShpCap(ModelStorage):
412
+ """A derived model from shepherd-codebase for comparing to KiBaM-capacitor.
413
+
414
+ This model was used for the intermediate storage capacitor until
415
+ the battery-model was implemented.
416
+ """
417
+
418
+ @validate_call
419
+ def __init__(
420
+ self,
421
+ cfg: VirtualStorageConfig,
422
+ SoC_init: soc_t | None = None,
423
+ dt_s: PositiveFloat = TIMESTEP_s_DEFAULT,
424
+ ) -> None:
425
+ # metadata for simulator
426
+ self.cfg: VirtualStorageConfig = cfg
427
+ self.dt_s = dt_s
428
+ # pre-calculate constants
429
+ self.V_mid_max_V = cfg.calc_V_OC(1.0)
430
+ C_mid_uF = 1e6 * cfg.q_As / self.V_mid_max_V
431
+ C_mid_uF = max(C_mid_uF, 0.001)
432
+ SAMPLERATE_SPS = 1.0 / dt_s
433
+ self.Constant_s_per_F = 1e6 / (C_mid_uF * SAMPLERATE_SPS)
434
+ self.Constant_1_per_Ohm: float = 1.0 / cfg.R_leak_Ohm
435
+ # state
436
+ SoC_init = SoC_init if SoC_init is not None else cfg.SoC_init
437
+ self.V_mid_V = cfg.calc_V_OC(SoC_init)
438
+
439
+ def step(self, I_charge_A: float) -> tuple[float, float, float, float]:
440
+ # in PRU P_inp and P_out are calculated and combined to determine current
441
+ # similar to: P_sum_W = P_inp_W - P_out_W, I_mid_A = P_sum_W / V_mid_V
442
+ I_mid_A = I_charge_A - self.V_mid_V * self.Constant_1_per_Ohm
443
+ dV_mid_V = I_mid_A * self.Constant_s_per_F
444
+ self.V_mid_V += dV_mid_V
445
+
446
+ self.V_mid_V = min(self.V_mid_V, self.V_mid_max_V)
447
+ self.V_mid_V = max(self.V_mid_V, sys.float_info.min)
448
+ SoC = self.V_mid_V / self.V_mid_max_V
449
+ return self.V_mid_V, self.V_mid_V, SoC, SoC
@@ -0,0 +1,104 @@
1
+ """Simulator for the virtual storage models / algorithms."""
2
+
3
+ from collections.abc import Callable
4
+ from pathlib import Path
5
+
6
+ import numpy as np
7
+ from pydantic import PositiveFloat
8
+ from pydantic import validate_call
9
+
10
+ from shepherd_core import log
11
+
12
+ from .virtual_storage_model import ModelStorage
13
+
14
+
15
+ class StorageSimulator:
16
+ """The simulator benchmarks a set of storage-models.
17
+
18
+ - monitors cell-current and voltage, open circuit voltage, state of charge and time
19
+ - takes config with a list of storage-models and timebase
20
+ - runs with a total step-count as config and a current-providing function
21
+ taking time, cell-voltage and SoC as arguments
22
+
23
+ The recorded data can be visualized by generating plots.
24
+ """
25
+
26
+ def __init__(self, models: list[ModelStorage], dt_s: PositiveFloat) -> None:
27
+ self.models = models
28
+ self.dt_s = dt_s
29
+ for model in self.models:
30
+ if self.dt_s != model.dt_s:
31
+ raise ValueError("timebase on models do not match")
32
+ self.t_s: np.ndarray | None = None
33
+
34
+ # models return V_cell, SoC_eff, V_OC
35
+ self.I_input: np.ndarray | None = None
36
+ self.V_OC: np.ndarray | None = None
37
+ self.V_cell: np.ndarray | None = None
38
+ self.SoC: np.ndarray | None = None
39
+ self.SoC_eff: np.ndarray | None = None
40
+
41
+ @validate_call
42
+ def run(self, fn: Callable, duration_s: PositiveFloat) -> None:
43
+ self.t_s = np.arange(0, duration_s, self.dt_s)
44
+ self.I_input = np.zeros((len(self.models), self.t_s.shape[0]))
45
+ self.V_OC = np.zeros((len(self.models), self.t_s.shape[0]))
46
+ self.V_cell = np.zeros((len(self.models), self.t_s.shape[0]))
47
+ self.SoC = np.zeros((len(self.models), self.t_s.shape[0]))
48
+ self.SoC_eff = np.zeros((len(self.models), self.t_s.shape[0]))
49
+ for i, model in enumerate(self.models):
50
+ SoC = 1.0
51
+ V_cell = 0.0
52
+ for j, t_s in enumerate(self.t_s):
53
+ I_charge = fn(t_s, SoC, V_cell)
54
+ V_OC, V_cell, SoC, SoC_eff = model.step(I_charge)
55
+ self.I_input[i, j] = I_charge
56
+ self.V_OC[i, j] = V_OC
57
+ self.V_cell[i, j] = V_cell
58
+ self.SoC[i, j] = SoC
59
+ self.SoC_eff[i, j] = SoC_eff
60
+
61
+ @validate_call
62
+ def plot(self, path: Path, title: str, *, plot_delta_v: bool = False) -> None:
63
+ try:
64
+ # keep dependencies low
65
+ from matplotlib import pyplot as plt # noqa: PLC0415
66
+ except ImportError:
67
+ log.warning("Matplotlib not installed, plotting of results disabled")
68
+ return
69
+
70
+ offset = 1 if plot_delta_v else 0
71
+ fig, axs = plt.subplots(4 + offset, 1, sharex="all", figsize=(10, 2 * 6), layout="tight")
72
+ axs[0].set_title(title)
73
+ axs[0].set_ylabel("State of Charge [n]")
74
+ # ⤷ Note: SoC-eff is also available, but unused
75
+ axs[0].grid(visible=True)
76
+ axs[1].set_ylabel("Open-circuit voltage [V]")
77
+ axs[1].grid(visible=True)
78
+ axs[2].set_ylabel("Cell voltage [V]")
79
+ axs[2].grid(visible=True)
80
+ if plot_delta_v:
81
+ axs[3].set_ylabel("Cell voltage delta [V]")
82
+ axs[3].grid(visible=True)
83
+ axs[3 + offset].set_ylabel("Charge current [A]")
84
+ axs[3 + offset].set_xlabel("time [s]")
85
+ axs[3 + offset].grid(visible=True)
86
+
87
+ for i, model in enumerate(self.models):
88
+ axs[0].plot(
89
+ self.t_s, self.SoC[i], label=f"{type(model).__name__} {model.cfg.name}", alpha=0.7
90
+ )
91
+ axs[1].plot(self.t_s, self.V_OC[i], label=type(model).__name__, alpha=0.7)
92
+ axs[2].plot(self.t_s, self.V_cell[i], label=type(model).__name__, alpha=0.7)
93
+ if plot_delta_v: # assumes that timestamps are identical
94
+ axs[3].plot(
95
+ self.t_s,
96
+ [v - ref for v, ref in zip(self.V_cell[i], self.V_cell[0], strict=False)],
97
+ label=type(model).__name__,
98
+ alpha=0.7,
99
+ )
100
+ axs[3 + offset].plot(self.t_s, self.I_input[i], label=type(model).__name__, alpha=0.7)
101
+ axs[0].legend()
102
+ plt.savefig(path / f"{title}.png")
103
+ plt.close(fig)
104
+ plt.clf()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: shepherd_core
3
- Version: 2025.6.4
3
+ Version: 2025.10.1
4
4
  Summary: Programming- and CLI-Interface for the h5-dataformat of the Shepherd-Testbed
5
5
  Author-email: Ingmar Splitt <ingmar.splitt@tu-dresden.de>
6
6
  Maintainer-email: Ingmar Splitt <ingmar.splitt@tu-dresden.de>
@@ -22,6 +22,7 @@ Classifier: Programming Language :: Python :: 3.10
22
22
  Classifier: Programming Language :: Python :: 3.11
23
23
  Classifier: Programming Language :: Python :: 3.12
24
24
  Classifier: Programming Language :: Python :: 3.13
25
+ Classifier: Programming Language :: Python :: 3.14
25
26
  Classifier: License :: OSI Approved :: MIT License
26
27
  Classifier: Operating System :: OS Independent
27
28
  Classifier: Natural Language :: English
@@ -43,15 +44,12 @@ Requires-Dist: pwntools-elf-only; extra == "elf"
43
44
  Provides-Extra: inventory
44
45
  Requires-Dist: psutil; extra == "inventory"
45
46
  Provides-Extra: dev
46
- Requires-Dist: twine; extra == "dev"
47
- Requires-Dist: pre-commit; extra == "dev"
48
- Requires-Dist: pyright; extra == "dev"
49
- Requires-Dist: ruff; extra == "dev"
50
- Requires-Dist: mypy; extra == "dev"
51
47
  Requires-Dist: types-PyYAML; extra == "dev"
52
48
  Provides-Extra: test
53
49
  Requires-Dist: pytest; extra == "test"
54
50
  Requires-Dist: coverage; extra == "test"
51
+ Provides-Extra: all
52
+ Requires-Dist: shepherd-core[dev,elf,inventory,test]; extra == "all"
55
53
 
56
54
  # Core Library
57
55