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.
- 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/target_config.py +1 -1
- shepherd_core/data_models/task/emulation.py +1 -1
- shepherd_core/data_models/task/harvest.py +1 -1
- shepherd_core/decoder_waveform/uart.py +1 -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.8.1.dist-info → shepherd_core-2025.10.1.dist-info}/METADATA +2 -1
- {shepherd_core-2025.8.1.dist-info → shepherd_core-2025.10.1.dist-info}/RECORD +37 -25
- shepherd_core/data_models/virtual_source_doc.txt +0 -207
- {shepherd_core-2025.8.1.dist-info → shepherd_core-2025.10.1.dist-info}/WHEEL +0 -0
- {shepherd_core-2025.8.1.dist-info → shepherd_core-2025.10.1.dist-info}/top_level.txt +0 -0
- {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
|