shepherd-core 2025.8.1__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 (38) 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/target_config.py +1 -1
  17. shepherd_core/data_models/task/emulation.py +1 -1
  18. shepherd_core/data_models/task/harvest.py +1 -1
  19. shepherd_core/decoder_waveform/uart.py +1 -1
  20. shepherd_core/inventory/system.py +1 -1
  21. shepherd_core/reader.py +4 -3
  22. shepherd_core/version.py +1 -1
  23. shepherd_core/vsource/__init__.py +4 -0
  24. shepherd_core/vsource/virtual_converter_model.py +27 -26
  25. shepherd_core/vsource/virtual_harvester_model.py +27 -19
  26. shepherd_core/vsource/virtual_harvester_simulation.py +38 -39
  27. shepherd_core/vsource/virtual_source_model.py +17 -13
  28. shepherd_core/vsource/virtual_source_simulation.py +71 -73
  29. shepherd_core/vsource/virtual_storage_model.py +164 -0
  30. shepherd_core/vsource/virtual_storage_model_fixed_point_math.py +58 -0
  31. shepherd_core/vsource/virtual_storage_models_kibam.py +449 -0
  32. shepherd_core/vsource/virtual_storage_simulator.py +104 -0
  33. {shepherd_core-2025.8.1.dist-info → shepherd_core-2025.10.1.dist-info}/METADATA +2 -1
  34. {shepherd_core-2025.8.1.dist-info → shepherd_core-2025.10.1.dist-info}/RECORD +37 -25
  35. shepherd_core/data_models/virtual_source_doc.txt +0 -207
  36. {shepherd_core-2025.8.1.dist-info → shepherd_core-2025.10.1.dist-info}/WHEEL +0 -0
  37. {shepherd_core-2025.8.1.dist-info → shepherd_core-2025.10.1.dist-info}/top_level.txt +0 -0
  38. {shepherd_core-2025.8.1.dist-info → shepherd_core-2025.10.1.dist-info}/zip-safe +0 -0
@@ -0,0 +1,164 @@
1
+ """this is ported py-version of the pru-code.
2
+
3
+ Goals:
4
+
5
+ - stay close to original code-base (fixed-point integer math)
6
+ - offer a comparison for the tests
7
+ - step 1 to a virtualization of emulation
8
+
9
+ NOTE1: DO NOT OPTIMIZE -> stay close to original c-code-base
10
+
11
+ Compromises:
12
+
13
+ - Py has to map the settings-list to internal vars -> is kernel-task
14
+
15
+ Expected deviations:
16
+
17
+ - lead charge ramp maxes out early on cell-voltage (max of V_uV_n8 is 16.78 V)
18
+
19
+ """
20
+
21
+ from pydantic import PositiveFloat
22
+ from pydantic import validate_call
23
+
24
+ from shepherd_core import log
25
+ from shepherd_core.data_models.content.virtual_storage_config import LuT_SIZE
26
+ from shepherd_core.data_models.content.virtual_storage_config import LuT_SIZE_LOG
27
+ from shepherd_core.data_models.content.virtual_storage_config import StoragePRUConfig
28
+ from shepherd_core.data_models.content.virtual_storage_config import TIMESTEP_s_DEFAULT
29
+ from shepherd_core.data_models.content.virtual_storage_config import VirtualStorageConfig
30
+ from shepherd_core.data_models.content.virtual_storage_config import soc_t
31
+
32
+
33
+ class ModelStorage:
34
+ """Abstract base class for storage models."""
35
+
36
+ def step(self, I_charge_A: float) -> tuple[float, float, float, float]: ...
37
+
38
+
39
+ def u32s(i: float) -> int:
40
+ """Guard to supervise calculated model-states."""
41
+ if i >= 2**32:
42
+ log.warning("u32-overflow")
43
+ if i < 0:
44
+ log.warning("u32-underflow")
45
+ return int(min(max(i, 0), 2**32 - 1))
46
+
47
+
48
+ def u64s(i: float) -> int:
49
+ """Guard to supervise calculated model-states."""
50
+ if i >= 2**64:
51
+ log.warning("u64-overflow")
52
+ if i < 0:
53
+ log.warning("u64-underflow")
54
+ return int(min(max(i, 0), 2**64 - 1))
55
+
56
+
57
+ class VirtualStorageModelPRU:
58
+ """Ported python version of the pru vStorage.
59
+
60
+ This model should behave like ModelKiBaMSimple
61
+ """
62
+
63
+ SoC_MAX_1_n62: int = 2**62 - 1
64
+ SoC_TO_POS_DIV: int = 2 ** (62 - LuT_SIZE_LOG)
65
+
66
+ @validate_call
67
+ def __init__(
68
+ self,
69
+ cfg: StoragePRUConfig,
70
+ SoC_init: soc_t | None = None,
71
+ ) -> None:
72
+ self.cfg_pru = cfg
73
+ # state
74
+ SoC_1_n30: float = 2**30 * SoC_init if SoC_init is not None else self.cfg_pru.SoC_init_1_n30
75
+ self.SoC_1_n62 = round(2**32 * SoC_1_n30)
76
+ self.V_OC_uV_n8 = self.cfg_pru.LuT_VOC_uV_n8[self.pos_LuT(self.SoC_1_n62)]
77
+
78
+ def pos_LuT(self, SoC_1_n62: int) -> int:
79
+ pos = u32s(SoC_1_n62 // self.SoC_TO_POS_DIV)
80
+ if pos >= LuT_SIZE:
81
+ pos = LuT_SIZE - 1
82
+ return pos
83
+
84
+ def calc_V_OC_uV(self) -> int:
85
+ pos_LuT = self.pos_LuT(self.SoC_1_n62)
86
+ return round(self.cfg_pru.LuT_VOC_uV_n8[pos_LuT] // 2**8)
87
+
88
+ def step(self, I_delta_nA_n4: float, *, is_charging: bool) -> float:
89
+ """Calculate the battery SoC & cell-voltage after drawing a current over a time-step.
90
+
91
+ Note: 3x u64 multiplications
92
+ """
93
+ dSoC_leak_1_n62 = u64s((self.V_OC_uV_n8 // 2**6) * self.cfg_pru.Constant_1_per_uV_n60)
94
+ if self.SoC_1_n62 >= dSoC_leak_1_n62:
95
+ self.SoC_1_n62 = u64s(self.SoC_1_n62 - dSoC_leak_1_n62)
96
+ else:
97
+ self.SoC_1_n62 = 0
98
+
99
+ dSoC_1_n62 = u64s(I_delta_nA_n4 * self.cfg_pru.Constant_1_per_nA_n60 // (2**2))
100
+ if is_charging:
101
+ self.SoC_1_n62 = u64s(self.SoC_1_n62 + dSoC_1_n62)
102
+ self.SoC_1_n62 = min(self.SoC_MAX_1_n62, self.SoC_1_n62)
103
+ elif self.SoC_1_n62 > dSoC_1_n62:
104
+ self.SoC_1_n62 = u64s(self.SoC_1_n62 - dSoC_1_n62)
105
+ else:
106
+ self.SoC_1_n62 = 0
107
+
108
+ pos_LuT = self.pos_LuT(self.SoC_1_n62)
109
+ self.V_OC_uV_n8 = self.cfg_pru.LuT_VOC_uV_n8[pos_LuT]
110
+ # TODO: is interpolation possible?
111
+ R_series_kOhm_n32 = self.cfg_pru.LuT_RSeries_kOhm_n32[pos_LuT]
112
+ V_delta_uV_n8 = u32s(u64s(I_delta_nA_n4 * R_series_kOhm_n32) // 2**28)
113
+
114
+ if is_charging:
115
+ V_cell_uV_n8 = u32s(self.V_OC_uV_n8 + V_delta_uV_n8)
116
+ elif self.V_OC_uV_n8 > V_delta_uV_n8:
117
+ V_cell_uV_n8 = u32s(self.V_OC_uV_n8 - V_delta_uV_n8)
118
+ else:
119
+ V_cell_uV_n8 = 0
120
+
121
+ if self.SoC_1_n62 == 0:
122
+ return 0 # cell voltage breaks down
123
+ return V_cell_uV_n8 // 2**8 # u32 uV
124
+
125
+
126
+ class VirtualStorageModel(VirtualStorageModelPRU, ModelStorage):
127
+ """Higher level Model that can run on a coarser timebase.
128
+
129
+ This model should behave like ModelKiBaMSimple
130
+ """
131
+
132
+ @validate_call
133
+ def __init__(
134
+ self,
135
+ cfg: VirtualStorageConfig,
136
+ SoC_init: soc_t | None = None,
137
+ dt_s: PositiveFloat = TIMESTEP_s_DEFAULT,
138
+ *,
139
+ optimize_clamp: bool = True,
140
+ ) -> None:
141
+ # metadata for simulator
142
+ self.cfg: VirtualStorageConfig = cfg
143
+ self.dt_s = dt_s
144
+ # prepare PRU-Model
145
+ cfg_pru = StoragePRUConfig.from_vstorage(
146
+ cfg, TIMESTEP_s_DEFAULT, optimize_clamp=optimize_clamp
147
+ )
148
+ super().__init__(cfg_pru, SoC_init=SoC_init)
149
+
150
+ # just for simulation
151
+ self.steps_per_frame = round(dt_s / TIMESTEP_s_DEFAULT)
152
+
153
+ def step(self, I_charge_A: float) -> tuple[float, float, float, float]:
154
+ """Slower outer step with step-size of simulation."""
155
+ I_delta_nA_n4 = abs(2**4 * (1e9 * I_charge_A))
156
+ is_charging = I_charge_A >= 0
157
+ for _ in range(self.steps_per_frame - 1):
158
+ super().step(I_delta_nA_n4, is_charging=is_charging)
159
+ V_cell_uV = super().step(I_delta_nA_n4, is_charging=is_charging)
160
+ # code below just for simulation
161
+ V_OC = (1e-6 / 2**8) * self.V_OC_uV_n8
162
+ V_cell = 1e-6 * V_cell_uV
163
+ SoC = (1.0 / 2**62) * self.SoC_1_n62
164
+ return V_OC, V_cell, SoC, SoC
@@ -0,0 +1,58 @@
1
+ """Playground to determine the best integer-math and fit for constants."""
2
+
3
+ from itertools import product
4
+
5
+ from shepherd_core import log
6
+ from shepherd_core.data_models.content.virtual_storage_config import LuT_SIZE_LOG
7
+
8
+
9
+ def u32l(i: float) -> int:
10
+ """Guard to supervise calculated model-states."""
11
+ if i >= 2**32:
12
+ log.warning("u32-overflow (%d)", i)
13
+ if i < 0:
14
+ log.warning("u32-underflow (%d)", i)
15
+ return round(min(max(i, 0), 2**32 - 1))
16
+
17
+
18
+ # #### I_charge-to-dSoC #####
19
+ # Goal: Allow ~1 uF Capacitor to 800 mAh battery
20
+
21
+ dt_s = 10e-6
22
+ qs_As = [1e-6 * 1 * 2.0, 1e-6 * 5 * 2.5, 1e-6 * 10 * 3.6, 800 * 3.6]
23
+ Constant_1u_per_nA_n40 = [u32l((2**40 / 1e3) * dt_s / q_As) for q_As in qs_As]
24
+ Constant_1_per_nA_n60 = [u32l((2**60 / 1e9) * dt_s / q_As) for q_As in qs_As]
25
+
26
+ # #### SoC-to-position #####
27
+ # Goal: As-simple-as-possible
28
+
29
+ SoCs_1u_n32 = [round(x / 10 * 1e6 * 2**32) for x in range(11)]
30
+ LUT_SIZE = 128
31
+
32
+ # First Approach (1 Division in pos-calc -> impossible for PRU)
33
+ SoC_min_1u = round(1e6 / LUT_SIZE)
34
+ positions1 = [int(SoC_1u_n32 / SoC_min_1u / 2**32) for SoC_1u_n32 in SoCs_1u_n32]
35
+
36
+ # Second Approach
37
+ SoC_min = 1.0 / LUT_SIZE
38
+ inv_SoC_min_1M_n32 = round(2**32 / 1e6 / SoC_min) # 1M / SoC_min
39
+ positions2 = [
40
+ int(int(SoC_1u_n32 / 2**32) * inv_SoC_min_1M_n32 / 2**32) for SoC_1u_n32 in SoCs_1u_n32
41
+ ]
42
+
43
+ # third approach
44
+ SoCs_1_n62 = [round(x / 10 * 2**62) for x in range(11)]
45
+ positions3 = [int(int(SoC_1_n62 / 2**32) * LUT_SIZE / 2**30) for SoC_1_n62 in SoCs_1_n62]
46
+
47
+ # final approach (upper u32 & rest of shift)
48
+ positions4 = [u32l(SoC_1_n62 // 2 ** (62 - LuT_SIZE_LOG)) for SoC_1_n62 in SoCs_1_n62]
49
+
50
+ # #### R_Leak-to-dSoC #####
51
+ # Goal: biggest possible dynamic range
52
+
53
+ Rs_leak_Ohm = [1e3, 10e3, 100e3, 1e6, 10e6]
54
+ Constants_1_per_V = [dt_s / q_As / R_leak_Ohm for q_As, R_leak_Ohm in product(qs_As, Rs_leak_Ohm)]
55
+ Constants_1u_per_uV_n40 = [u32l((2**40) * c_1_per_V) for c_1_per_V in Constants_1_per_V]
56
+ Constants_1_per_uV_n60 = [u32l((2**60 / 1e6) * c_1_per_V) for c_1_per_V in Constants_1_per_V]
57
+
58
+ print("done") # noqa: T201
@@ -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