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.
- shepherd_core/data_models/__init__.py +4 -2
- shepherd_core/data_models/base/content.py +2 -0
- shepherd_core/data_models/content/__init__.py +4 -2
- shepherd_core/data_models/content/{virtual_harvester.py → virtual_harvester_config.py} +3 -3
- shepherd_core/data_models/content/{virtual_source.py → virtual_source_config.py} +82 -58
- shepherd_core/data_models/content/virtual_source_fixture.yaml +24 -24
- shepherd_core/data_models/content/virtual_storage_config.py +426 -0
- shepherd_core/data_models/content/virtual_storage_fixture_creator.py +267 -0
- shepherd_core/data_models/content/virtual_storage_fixture_ideal.yaml +637 -0
- shepherd_core/data_models/content/virtual_storage_fixture_lead.yaml +49 -0
- shepherd_core/data_models/content/virtual_storage_fixture_lipo.yaml +735 -0
- shepherd_core/data_models/content/virtual_storage_fixture_mlcc.yaml +200 -0
- shepherd_core/data_models/content/virtual_storage_fixture_param_experiments.py +151 -0
- shepherd_core/data_models/content/virtual_storage_fixture_super.yaml +150 -0
- shepherd_core/data_models/content/virtual_storage_fixture_tantal.yaml +550 -0
- shepherd_core/data_models/experiment/observer_features.py +8 -2
- shepherd_core/data_models/experiment/target_config.py +1 -1
- shepherd_core/data_models/task/emulation.py +9 -6
- shepherd_core/data_models/task/firmware_mod.py +1 -0
- shepherd_core/data_models/task/harvest.py +4 -4
- shepherd_core/data_models/task/observer_tasks.py +5 -2
- shepherd_core/data_models/task/programming.py +1 -0
- shepherd_core/data_models/task/testbed_tasks.py +6 -1
- shepherd_core/decoder_waveform/uart.py +2 -1
- shepherd_core/fw_tools/patcher.py +60 -34
- shepherd_core/fw_tools/validation.py +7 -1
- shepherd_core/inventory/system.py +1 -1
- shepherd_core/reader.py +4 -3
- shepherd_core/version.py +1 -1
- shepherd_core/vsource/__init__.py +4 -0
- shepherd_core/vsource/virtual_converter_model.py +27 -26
- shepherd_core/vsource/virtual_harvester_model.py +27 -19
- shepherd_core/vsource/virtual_harvester_simulation.py +38 -39
- shepherd_core/vsource/virtual_source_model.py +17 -13
- shepherd_core/vsource/virtual_source_simulation.py +71 -73
- shepherd_core/vsource/virtual_storage_model.py +164 -0
- shepherd_core/vsource/virtual_storage_model_fixed_point_math.py +58 -0
- shepherd_core/vsource/virtual_storage_models_kibam.py +449 -0
- shepherd_core/vsource/virtual_storage_simulator.py +104 -0
- {shepherd_core-2025.6.4.dist-info → shepherd_core-2025.10.1.dist-info}/METADATA +4 -6
- {shepherd_core-2025.6.4.dist-info → shepherd_core-2025.10.1.dist-info}/RECORD +44 -32
- shepherd_core/data_models/virtual_source_doc.txt +0 -207
- {shepherd_core-2025.6.4.dist-info → shepherd_core-2025.10.1.dist-info}/WHEEL +0 -0
- {shepherd_core-2025.6.4.dist-info → shepherd_core-2025.10.1.dist-info}/top_level.txt +0 -0
- {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.
|
|
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
|
|