shepherd-core 2025.8.1__py3-none-any.whl → 2026.2.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 (82) hide show
  1. shepherd_core/config.py +1 -1
  2. shepherd_core/data_models/__init__.py +8 -4
  3. shepherd_core/data_models/base/cal_measurement.py +7 -2
  4. shepherd_core/data_models/base/calibration.py +23 -12
  5. shepherd_core/data_models/base/content.py +12 -2
  6. shepherd_core/data_models/base/shepherd.py +13 -4
  7. shepherd_core/data_models/base/wrapper.py +2 -0
  8. shepherd_core/data_models/content/__init__.py +8 -4
  9. shepherd_core/data_models/content/_external_fixtures.yaml +104 -96
  10. shepherd_core/data_models/content/_metadata_eenvs_bonito.yaml +436 -0
  11. shepherd_core/data_models/content/_metadata_eenvs_synthetic_multivariate_random_walk.yaml +164 -0
  12. shepherd_core/data_models/content/_metadata_eenvs_synthetic_on_off_markov.yaml +3280 -0
  13. shepherd_core/data_models/content/_metadata_eenvs_synthetic_on_off_windows.yaml +3260 -0
  14. shepherd_core/data_models/content/_metadata_eenvs_synthetic_static.yaml +450 -0
  15. shepherd_core/data_models/content/energy_environment.py +341 -23
  16. shepherd_core/data_models/content/energy_environment_fixture.yaml +21 -18
  17. shepherd_core/data_models/content/enum_datatypes.py +109 -0
  18. shepherd_core/data_models/content/firmware.py +44 -16
  19. shepherd_core/data_models/content/{virtual_harvester.py → virtual_harvester_config.py} +13 -96
  20. shepherd_core/data_models/content/{virtual_source.py → virtual_source_config.py} +103 -60
  21. shepherd_core/data_models/content/virtual_source_fixture.yaml +24 -24
  22. shepherd_core/data_models/content/virtual_storage_config.py +429 -0
  23. shepherd_core/data_models/content/virtual_storage_fixture_creator.py +267 -0
  24. shepherd_core/data_models/content/virtual_storage_fixture_ideal.yaml +637 -0
  25. shepherd_core/data_models/content/virtual_storage_fixture_lead.yaml +49 -0
  26. shepherd_core/data_models/content/virtual_storage_fixture_lipo.yaml +735 -0
  27. shepherd_core/data_models/content/virtual_storage_fixture_mlcc.yaml +200 -0
  28. shepherd_core/data_models/content/virtual_storage_fixture_param_experiments.py +151 -0
  29. shepherd_core/data_models/content/virtual_storage_fixture_super.yaml +150 -0
  30. shepherd_core/data_models/content/virtual_storage_fixture_tantal.yaml +550 -0
  31. shepherd_core/data_models/experiment/experiment.py +38 -13
  32. shepherd_core/data_models/experiment/observer_features.py +17 -4
  33. shepherd_core/data_models/experiment/target_config.py +56 -8
  34. shepherd_core/data_models/task/__init__.py +13 -2
  35. shepherd_core/data_models/task/emulation.py +10 -6
  36. shepherd_core/data_models/task/firmware_mod.py +3 -1
  37. shepherd_core/data_models/task/harvest.py +3 -1
  38. shepherd_core/data_models/task/helper_paths.py +2 -2
  39. shepherd_core/data_models/task/observer_tasks.py +8 -6
  40. shepherd_core/data_models/task/programming.py +4 -2
  41. shepherd_core/data_models/task/testbed_tasks.py +8 -2
  42. shepherd_core/data_models/testbed/cape.py +2 -0
  43. shepherd_core/data_models/testbed/gpio.py +2 -0
  44. shepherd_core/data_models/testbed/mcu.py +2 -0
  45. shepherd_core/data_models/testbed/observer.py +2 -0
  46. shepherd_core/data_models/testbed/target.py +7 -5
  47. shepherd_core/data_models/testbed/target_fixture.old1 +1 -1
  48. shepherd_core/data_models/testbed/target_fixture.yaml +1 -1
  49. shepherd_core/data_models/testbed/testbed.py +17 -15
  50. shepherd_core/decoder_waveform/uart.py +1 -1
  51. shepherd_core/exit_handler.py +22 -0
  52. shepherd_core/fw_tools/converter.py +2 -2
  53. shepherd_core/fw_tools/validation.py +1 -1
  54. shepherd_core/inventory/__init__.py +23 -21
  55. shepherd_core/inventory/system.py +3 -3
  56. shepherd_core/logger.py +0 -1
  57. shepherd_core/reader.py +32 -27
  58. shepherd_core/testbed_client/cache_path.py +3 -3
  59. shepherd_core/testbed_client/client_abc_fix.py +14 -3
  60. shepherd_core/testbed_client/client_web.py +7 -5
  61. shepherd_core/testbed_client/fixtures.py +7 -7
  62. shepherd_core/version.py +1 -1
  63. shepherd_core/vsource/__init__.py +4 -0
  64. shepherd_core/vsource/virtual_converter_model.py +29 -28
  65. shepherd_core/vsource/virtual_harvester_model.py +29 -21
  66. shepherd_core/vsource/virtual_harvester_simulation.py +38 -39
  67. shepherd_core/vsource/virtual_source_model.py +18 -14
  68. shepherd_core/vsource/virtual_source_simulation.py +71 -73
  69. shepherd_core/vsource/virtual_storage_model.py +164 -0
  70. shepherd_core/vsource/virtual_storage_model_fixed_point_math.py +58 -0
  71. shepherd_core/vsource/virtual_storage_models_kibam.py +449 -0
  72. shepherd_core/vsource/virtual_storage_simulator.py +104 -0
  73. shepherd_core/writer.py +16 -9
  74. {shepherd_core-2025.8.1.dist-info → shepherd_core-2026.2.1.dist-info}/METADATA +6 -3
  75. shepherd_core-2026.2.1.dist-info/RECORD +102 -0
  76. {shepherd_core-2025.8.1.dist-info → shepherd_core-2026.2.1.dist-info}/WHEEL +1 -1
  77. shepherd_core-2026.2.1.dist-info/licenses/LICENSE +21 -0
  78. shepherd_core/data_models/content/firmware_datatype.py +0 -15
  79. shepherd_core/data_models/virtual_source_doc.txt +0 -207
  80. shepherd_core-2025.8.1.dist-info/RECORD +0 -83
  81. {shepherd_core-2025.8.1.dist-info → shepherd_core-2026.2.1.dist-info}/top_level.txt +0 -0
  82. {shepherd_core-2025.8.1.dist-info → shepherd_core-2026.2.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 -= 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 -= (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 -= (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()
shepherd_core/writer.py CHANGED
@@ -22,7 +22,7 @@ from .config import config
22
22
  from .data_models.base.calibration import CalibrationEmulator as CalEmu
23
23
  from .data_models.base.calibration import CalibrationHarvester as CalHrv
24
24
  from .data_models.base.calibration import CalibrationSeries as CalSeries
25
- from .data_models.content.energy_environment import EnergyDType
25
+ from .data_models.content.enum_datatypes import EnergyDType
26
26
  from .data_models.task import Compression
27
27
  from .data_models.task.emulation import c_translate
28
28
  from .reader import Reader
@@ -116,11 +116,12 @@ class Writer(Reader):
116
116
 
117
117
  if not hasattr(self, "_logger"):
118
118
  self._logger: logging.Logger = logging.getLogger("SHPCore.Writer")
119
+ self._logger.setLevel(logging.DEBUG if verbose else logging.INFO)
119
120
  # -> logger gets configured in reader()
120
121
 
121
122
  if self._modify or force_overwrite or not file_path.exists():
122
123
  file_path = file_path.resolve()
123
- self._logger.info("Storing data to '%s'", file_path)
124
+ self._logger.debug("Storing data to '%s'", file_path)
124
125
  elif file_path.exists() and not file_path.is_file():
125
126
  msg = f"Path is not a file ({file_path})"
126
127
  raise TypeError(msg)
@@ -154,22 +155,22 @@ class Writer(Reader):
154
155
  if "mode" not in self.h5file.attrs:
155
156
  self.h5file.attrs["mode"] = self.MODE_DEFAULT
156
157
 
157
- _dtypes = self.MODE_TO_DTYPE[self.get_mode()]
158
+ dtypes_ = self.MODE_TO_DTYPE[self.get_mode()]
158
159
 
159
160
  # Handle Datatype
160
161
  if isinstance(datatype, str):
161
162
  datatype = EnergyDType[datatype]
162
- if isinstance(datatype, EnergyDType) and datatype not in _dtypes:
163
- msg = f"Can't handle value '{datatype}' of datatype (choose one of {_dtypes})"
163
+ if isinstance(datatype, EnergyDType) and datatype not in dtypes_:
164
+ msg = f"Can't handle value '{datatype}' of datatype (choose one of {dtypes_})"
164
165
  raise ValueError(msg)
165
166
 
166
167
  if isinstance(datatype, EnergyDType):
167
168
  self.h5file["data"].attrs["datatype"] = datatype.name
168
169
  if "datatype" not in self.h5file["data"].attrs:
169
170
  self.h5file["data"].attrs["datatype"] = self.DATATYPE_DEFAULT.name
170
- if self.get_datatype() not in _dtypes:
171
+ if self.get_datatype() not in dtypes_:
171
172
  msg = (
172
- f"Can't handle value '{self.get_datatype()}' of datatype (choose one of {_dtypes})"
173
+ f"Can't handle value '{self.get_datatype()}' of datatype (choose one of {dtypes_})"
173
174
  )
174
175
  raise ValueError(msg)
175
176
 
@@ -183,6 +184,12 @@ class Writer(Reader):
183
184
  raise ValueError("Window Size argument needed for ivcurve-Datatype")
184
185
 
185
186
  # Handle Cal
187
+ if isinstance(cal_data, CalEmu):
188
+ msg = (
189
+ "Writer got a CalibrationEmulator()-object without information "
190
+ "about the TargetPort. Possibly wrong cal-data stored in hdf5!"
191
+ )
192
+ self._logger.warning(msg)
186
193
  if isinstance(cal_data, (CalEmu, CalHrv)):
187
194
  cal_data = CalSeries.from_cal(cal_data)
188
195
 
@@ -215,7 +222,7 @@ class Writer(Reader):
215
222
  ) -> None:
216
223
  self._align()
217
224
  self._refresh_file_stats()
218
- self._logger.info(
225
+ self._logger.debug(
219
226
  "closing hdf5 file, %.1f s iv-data, size = %.3f MiB, rate = %.0f KiB/s",
220
227
  self.runtime_s,
221
228
  self.file_size / 2**20,
@@ -296,7 +303,7 @@ class Writer(Reader):
296
303
  timestamp = int(timestamp)
297
304
  if isinstance(timestamp, int):
298
305
  time_series_ns = self.sample_interval_ns * np.arange(len_new).astype("u8")
299
- timestamp = timestamp + time_series_ns
306
+ timestamp += time_series_ns
300
307
  if isinstance(timestamp, np.ndarray):
301
308
  len_new = min(len_new, timestamp.size)
302
309
  else:
@@ -1,9 +1,10 @@
1
1
  Metadata-Version: 2.4
2
- Name: shepherd_core
3
- Version: 2025.8.1
2
+ Name: shepherd-core
3
+ Version: 2026.2.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>
7
+ License-Expression: MIT
7
8
  Project-URL: Documentation, https://github.com/nes-lab/shepherd-tools/blob/main/README.md
8
9
  Project-URL: Issues, https://github.com/nes-lab/shepherd-tools/issues
9
10
  Project-URL: Source, https://pypi.org/project/shepherd-core/
@@ -22,11 +23,12 @@ Classifier: Programming Language :: Python :: 3.10
22
23
  Classifier: Programming Language :: Python :: 3.11
23
24
  Classifier: Programming Language :: Python :: 3.12
24
25
  Classifier: Programming Language :: Python :: 3.13
25
- Classifier: License :: OSI Approved :: MIT License
26
+ Classifier: Programming Language :: Python :: 3.14
26
27
  Classifier: Operating System :: OS Independent
27
28
  Classifier: Natural Language :: English
28
29
  Requires-Python: >=3.10
29
30
  Description-Content-Type: text/markdown
31
+ License-File: LICENSE
30
32
  Requires-Dist: h5py
31
33
  Requires-Dist: numpy
32
34
  Requires-Dist: pyYAML
@@ -49,6 +51,7 @@ Requires-Dist: pytest; extra == "test"
49
51
  Requires-Dist: coverage; extra == "test"
50
52
  Provides-Extra: all
51
53
  Requires-Dist: shepherd-core[dev,elf,inventory,test]; extra == "all"
54
+ Dynamic: license-file
52
55
 
53
56
  # Core Library
54
57