ubc-solar-physics 1.6.0__cp39-cp39-win_amd64.whl → 1.7.0__cp39-cp39-win_amd64.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.
- physics/_version.py +2 -2
- physics/environment/gis/gis.py +4 -4
- physics/environment/meteorology/clouded_meteorology.py +4 -4
- physics/environment/meteorology/irradiant_meteorology.py +6 -6
- physics/lib.rs +30 -22
- physics/models/battery/__init__.py +10 -6
- physics/models/battery/battery.rs +49 -25
- physics/models/battery/battery_config.py +102 -17
- physics/models/battery/battery_config.toml +6 -8
- physics/models/battery/battery_model.py +188 -97
- physics/models/battery/kalman_filter.py +145 -263
- physics_rs/__init__.pyi +111 -0
- physics_rs.cp39-win_amd64.pyd +0 -0
- {ubc_solar_physics-1.6.0.dist-info → ubc_solar_physics-1.7.0.dist-info}/METADATA +2 -1
- {ubc_solar_physics-1.6.0.dist-info → ubc_solar_physics-1.7.0.dist-info}/RECORD +18 -17
- ubc_solar_physics-1.7.0.dist-info/top_level.txt +2 -0
- core.cp39-win_amd64.pyd +0 -0
- ubc_solar_physics-1.6.0.dist-info/top_level.txt +0 -1
- {ubc_solar_physics-1.6.0.dist-info → ubc_solar_physics-1.7.0.dist-info}/LICENSE +0 -0
- {ubc_solar_physics-1.6.0.dist-info → ubc_solar_physics-1.7.0.dist-info}/WHEEL +0 -0
@@ -1,135 +1,226 @@
|
|
1
1
|
import numpy as np
|
2
|
-
import
|
3
|
-
from
|
4
|
-
from
|
2
|
+
import physics_rs
|
3
|
+
from typing import Callable, TypeAlias, Protocol, runtime_checkable, Optional, cast
|
4
|
+
from numpy.typing import NDArray
|
5
5
|
|
6
6
|
|
7
|
-
|
7
|
+
SOCDependent: TypeAlias = Callable[[float | NDArray[float]], float | NDArray[float]]
|
8
|
+
|
9
|
+
|
10
|
+
@runtime_checkable
|
11
|
+
class EquivalentCircuitModelConfig(Protocol):
|
8
12
|
"""
|
9
|
-
|
10
|
-
|
11
|
-
Attributes:
|
12
|
-
R_P (float): Polarization resistance of the battery (Ohms).
|
13
|
-
C_P (float): Polarization capacitance of the battery (Farads).
|
14
|
-
max_current_capacity (float): Nominal capacity of the battery (Ah).
|
15
|
-
max_energy_capacity (float): Maximum energy capacity of the battery (Wh).
|
16
|
-
nominal_charge_capacity (float): Total charge capacity of the battery (Coulombs).
|
17
|
-
state_of_charge (float): Current state of charge (dimensionless, 0.0 to 1.0).
|
18
|
-
U_oc_coefficients (np.ndarray): Coefficients for the open-circuit voltage polynomial.
|
19
|
-
R_0_coefficients (np.ndarray): Coefficients for the ohmic resistance polynomial.
|
20
|
-
U_oc (callable): Function for open-circuit voltage as a function of state of charge (V).
|
21
|
-
R_0 (callable): Function for ohmic resistance as a function of state of charge (Ohms).
|
22
|
-
U_P (float): Current polarization potential (V).
|
23
|
-
U_L (float): Current terminal voltage (V).
|
24
|
-
tau (float): Time constant of the battery model (seconds).
|
13
|
+
A specification for a configuration object which contains the requisite data to specify
|
14
|
+
a `EquivalentCircuitBatteryModel`.
|
25
15
|
"""
|
26
16
|
|
27
|
-
|
17
|
+
@property
|
18
|
+
def get_Uoc(self) -> SOCDependent:
|
28
19
|
"""
|
29
|
-
|
30
|
-
|
31
|
-
:param BatteryModelConfig battery_config: Configuration object containing the battery's parameters and data.
|
32
|
-
:param float state_of_charge: Initial state of charge of the battery (default is 1.0, fully charged).
|
20
|
+
A map from an SOC to Uoc (open-circuit voltage).
|
21
|
+
Should be compatible with non-vectorized and vectorized calls: float -> float or NDArray -> NDArray
|
33
22
|
"""
|
23
|
+
...
|
34
24
|
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
Soc_data = battery_config.SOC_data
|
43
|
-
Uoc_data = battery_config.Uoc_data
|
44
|
-
R_0_data = battery_config.R_0_data
|
25
|
+
@property
|
26
|
+
def get_R_0(self) -> SOCDependent:
|
27
|
+
"""
|
28
|
+
A map from an SOC to R_0 (internal resistance).
|
29
|
+
Should be compatible with non-vectorized and vectorized calls: float -> float or NDArray -> NDArray
|
30
|
+
"""
|
31
|
+
...
|
45
32
|
|
46
|
-
|
47
|
-
|
48
|
-
|
33
|
+
@property
|
34
|
+
def get_R_P(self) -> SOCDependent:
|
35
|
+
"""
|
36
|
+
A map from an SOC to R_P (polarization resistance).
|
37
|
+
Should be compatible with non-vectorized and vectorized calls: float -> float or NDArray -> NDArray
|
38
|
+
"""
|
39
|
+
...
|
49
40
|
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
|
41
|
+
@property
|
42
|
+
def get_C_P(self) -> SOCDependent:
|
43
|
+
"""
|
44
|
+
A map from an SOC to C_P (polarization capacitance).
|
45
|
+
Should be compatible with non-vectorized and vectorized calls: float -> float or NDArray -> NDArray
|
46
|
+
"""
|
47
|
+
...
|
54
48
|
|
55
|
-
|
56
|
-
|
57
|
-
|
49
|
+
@property
|
50
|
+
def Q_total(self) -> float:
|
51
|
+
"""
|
52
|
+
The total charge capacity of the battery pack, in Coulombs.
|
53
|
+
"""
|
54
|
+
...
|
58
55
|
|
59
|
-
self.tau = self.R_P * self.C_P # Characteristic Time (seconds)
|
60
56
|
|
61
|
-
|
62
|
-
|
63
|
-
|
64
|
-
|
65
|
-
np.power((U_oc + U_P), 2) + 4 * R_0 * P)) / (2 * R_0)
|
57
|
+
class EquivalentCircuitBatteryModel:
|
58
|
+
"""
|
59
|
+
A first-order Thevenin equivalent model of a lithium-ion battery pack
|
60
|
+
"""
|
66
61
|
|
67
|
-
def
|
62
|
+
def __init__(self, battery_config: EquivalentCircuitModelConfig, state_of_charge: float = 1.0):
|
68
63
|
"""
|
69
|
-
|
64
|
+
Constructor for the EquivalentCircuitBatteryModel class.
|
70
65
|
|
71
|
-
:param
|
72
|
-
:param float
|
66
|
+
:param BatteryModelConfig battery_config: Configuration object containing the battery's parameters and data.
|
67
|
+
:param float state_of_charge: Initial state of charge of the battery (default is 1.0, fully charged).
|
73
68
|
"""
|
74
|
-
soc = self.state_of_charge # State of Charge (dimensionless, 0 < soc < 1)
|
75
|
-
U_P = self.U_P # Polarization Potential (V)
|
76
|
-
R_P = self.R_P # Polarization Resistance (Ohms)
|
77
|
-
U_oc = self.U_oc(soc) # Open-Circuit Potential (V)
|
78
|
-
R_0 = self.R_0(soc) # Ohmic Resistance (Ohms)
|
79
|
-
Q = self.nominal_charge_capacity # Nominal Charge Capacity (C)
|
80
69
|
|
81
|
-
|
82
|
-
|
83
|
-
|
84
|
-
|
70
|
+
# We initialize the active components as uncharged
|
71
|
+
self._U_P = 0.0 # V
|
72
|
+
self._U_L = 0.0 # V
|
73
|
+
self._state_of_charge = state_of_charge
|
74
|
+
self._nominal_charge_capacity = battery_config.Q_total
|
75
|
+
|
76
|
+
# Now, the config contains methods to map SOC to each respective parameter.
|
77
|
+
# We can't efficiently pass these functions to compiled libraries.
|
78
|
+
# Instead, we will pre-compute the parameters as a function of SOC
|
79
|
+
# to create fine lookup tables as a portable substitute for runtime computation.
|
80
|
+
|
81
|
+
# Things are going to get a tiny bit messy here, so we will go through this carefully.
|
82
|
+
# I'll write what each resulting map achieves below each code block.
|
83
|
+
|
84
|
+
# Firstly, we're going to discretize SOC by making a range of SOC values in the range [-0.05, 1.1], because
|
85
|
+
# sometimes we are marginally outside the range (0.0, 1.0].
|
86
|
+
# We will quantize at about 4 digits of precision, so ~10,000 values
|
87
|
+
self._min_soc = -0.05
|
88
|
+
self._max_soc = 1.1
|
89
|
+
self._num_indices = int((self._max_soc - self._min_soc) * 10000)
|
90
|
+
SOC_values = np.linspace(self._min_soc, self._max_soc, self._num_indices, dtype=float)
|
91
|
+
# maps: (discrete index) -> (SOC)
|
92
|
+
|
93
|
+
# Now, we're going to create a map from an arbitrary SOC, to the index of the closest SOC
|
94
|
+
# value in our quantized SOC range (`SOC_values`)
|
95
|
+
self._quantization_step: float = (self._max_soc - self._min_soc) / self._num_indices
|
96
|
+
self._soc_to_index = lambda _soc: int(
|
97
|
+
max(0, min(self._num_indices - 1, (_soc - self._min_soc) // self._quantization_step))
|
98
|
+
)
|
99
|
+
# maps: (SOC) -> (discrete index)
|
100
|
+
|
101
|
+
# Now, calculate the value of each parameter for each discrete SOC value using the injected `get_` functions
|
102
|
+
self._U_oc_lookup: NDArray[float] = battery_config.get_Uoc(SOC_values)
|
103
|
+
self._R_0_lookup: NDArray[float] = battery_config.get_R_0(SOC_values)
|
104
|
+
self._R_P_lookup: NDArray[float] = battery_config.get_R_P(SOC_values)
|
105
|
+
self._C_P_lookup: NDArray[float] = battery_config.get_C_P(SOC_values)
|
106
|
+
# maps: (discrete index) -> (parameter)
|
107
|
+
|
108
|
+
# Finally, combine the above maps to create a map from an arbitrary SOC to each battery parameter, using
|
109
|
+
# the discrete lookup tables
|
110
|
+
# These `cast` calls just promise to the type-checker that these will map floats to floats
|
111
|
+
self._U_oc = cast(Callable[[float], float], lambda SOC: self._U_oc_lookup[self._soc_to_index(SOC)])
|
112
|
+
self._R_0 = cast(Callable[[float], float], lambda SOC: self._R_0_lookup[self._soc_to_index(SOC)])
|
113
|
+
self._R_P = cast(Callable[[float], float], lambda SOC: self._R_P_lookup[self._soc_to_index(SOC)])
|
114
|
+
self._C_P = cast(Callable[[float], float], lambda SOC: self._C_P_lookup[self._soc_to_index(SOC)])
|
115
|
+
# maps: ((SOC) -> (discrete index)) -> ((discrete index) -> (parameter)) |==> (SOC) -> (parameter)
|
116
|
+
|
117
|
+
self._tau: Callable[[float], float] = lambda soc: self._R_P(soc) * self._C_P(soc) # Characteristic Time in s
|
118
|
+
|
119
|
+
def update_array(
|
120
|
+
self,
|
121
|
+
tick: float,
|
122
|
+
delta_energy_array: Optional[NDArray] = None,
|
123
|
+
current_array: Optional[NDArray] = None,
|
124
|
+
use_compiled: bool = True
|
125
|
+
) -> tuple[NDArray, NDArray]:
|
126
|
+
"""
|
127
|
+
Compute the battery's state of charge and terminal voltage over time in response to a
|
128
|
+
time series of energy/current draw from a load.
|
85
129
|
|
86
|
-
|
87
|
-
self.U_P = new_U_P
|
88
|
-
self.U_L = U_oc + U_P + (current * R_0)
|
130
|
+
Only ONE of `current_array` or `delta_energy_array` should be provided.
|
89
131
|
|
90
|
-
|
91
|
-
|
92
|
-
|
93
|
-
|
132
|
+
Notes
|
133
|
+
-----
|
134
|
+
If both current and power are known, current should be provided.
|
135
|
+
The model implementation requires current for calculations, so it must be derived from power if power
|
136
|
+
was provided.
|
137
|
+
Computing current from power relies on voltage, which is a model output, and therefore
|
138
|
+
the derived current could be less accurate.
|
94
139
|
|
95
|
-
:param
|
140
|
+
:param NDArray delta_energy_array: Array of energy changes (J) at each time step.
|
96
141
|
:param float tick: Time interval for each step (seconds).
|
97
|
-
:param
|
98
|
-
|
99
|
-
|
100
|
-
:
|
142
|
+
:param NDArray current_array: Array of current draw (positive sign convention) in Amperes at each time step.
|
143
|
+
:param bool use_compiled: If `True`, use compiled binaries for calculations.
|
144
|
+
Disable for better debugging.
|
145
|
+
:return: A tuple containing arrays for state-of-charge and terminal voltage.
|
146
|
+
:raises ValueError: If BOTH or NEITHER of `current_array` or `delta_energy_array` are provided.
|
147
|
+
:rtype: tuple[NDArray, NDArray]
|
101
148
|
"""
|
149
|
+
if (delta_energy_array is None) == (current_array is None): # Enforce that only one should be provided
|
150
|
+
raise ValueError("Exactly one of `delta_energy_array` or `current_array` "
|
151
|
+
"must be provided, not both or neither.")
|
152
|
+
|
153
|
+
energy_or_current = delta_energy_array if delta_energy_array is not None else current_array
|
102
154
|
|
103
|
-
if
|
104
|
-
return
|
105
|
-
|
155
|
+
if use_compiled:
|
156
|
+
return physics_rs.update_battery_state(
|
157
|
+
energy_or_current,
|
106
158
|
tick,
|
107
|
-
self.
|
108
|
-
self.
|
109
|
-
self.
|
110
|
-
self.
|
111
|
-
self.
|
112
|
-
self.
|
113
|
-
self.
|
159
|
+
self._state_of_charge,
|
160
|
+
self._U_P,
|
161
|
+
self._R_0_lookup,
|
162
|
+
self._U_oc_lookup,
|
163
|
+
self._R_P_lookup,
|
164
|
+
self._C_P_lookup,
|
165
|
+
self._nominal_charge_capacity,
|
166
|
+
current_array is None, # Pass to the library if `energy_or_current` is current or power,
|
167
|
+
self._quantization_step,
|
168
|
+
self._min_soc
|
114
169
|
)
|
170
|
+
|
115
171
|
else:
|
116
|
-
return self._update_array_py(
|
172
|
+
return self._update_array_py(energy_or_current, tick, current_array is None)
|
117
173
|
|
118
|
-
def _update_array_py(self,
|
174
|
+
def _update_array_py(self, energy_or_current, tick, is_power):
|
119
175
|
"""
|
120
176
|
Perform energy calculations using Python (fallback method if Rust is disabled).
|
121
177
|
|
122
|
-
:param
|
178
|
+
:param NDArray energy_or_current: Array of energy changes (J) at each time step.
|
123
179
|
:param float tick: Time interval for each step (seconds).
|
124
180
|
|
125
181
|
:return: A tuple containing arrays for state-of-charge and voltage.
|
126
|
-
:rtype: tuple[np.ndarray, np.ndarray]
|
127
182
|
"""
|
128
|
-
soc = np.empty_like(
|
129
|
-
voltage = np.empty_like(
|
130
|
-
|
131
|
-
|
132
|
-
|
133
|
-
|
183
|
+
soc = np.empty_like(energy_or_current, dtype=float)
|
184
|
+
voltage = np.empty_like(energy_or_current, dtype=float)
|
185
|
+
|
186
|
+
for (i, value) in enumerate(energy_or_current):
|
187
|
+
if is_power:
|
188
|
+
# Use the last voltage to calculate current, or an absurdly large number if it is the first,
|
189
|
+
# because we don't know voltage yet.
|
190
|
+
# We will have a very small initial current, no matter what.
|
191
|
+
# We shouldn't be starting to simulate when the battery is in an active state anyway,
|
192
|
+
# so this should be an alright compromise.
|
193
|
+
last_terminal_voltage = voltage[i - 1] if i - 1 >= 0 else 10000
|
194
|
+
|
195
|
+
current: float = value / (tick * last_terminal_voltage)
|
196
|
+
else:
|
197
|
+
current = value
|
198
|
+
|
199
|
+
self._evolve(current, tick)
|
200
|
+
soc[i] = self._state_of_charge
|
201
|
+
voltage[i] = self._U_L
|
134
202
|
|
135
203
|
return soc, voltage
|
204
|
+
|
205
|
+
def _evolve(self, current: float, tick: float) -> None:
|
206
|
+
"""
|
207
|
+
Update the battery state given the current and time elapsed.
|
208
|
+
|
209
|
+
:param float current: Current applied to the battery (A).
|
210
|
+
Positive for charging, negative for discharging.
|
211
|
+
:param float tick: Time interval over which the power is applied (seconds).
|
212
|
+
"""
|
213
|
+
soc = self._state_of_charge # State of Charge (dimensionless, 0 < soc < 1)
|
214
|
+
U_P = self._U_P # Polarization Potential (V)
|
215
|
+
R_P = self._R_P(soc) # Polarization Resistance (Ohms)
|
216
|
+
U_oc = self._U_oc(soc) # Open-Circuit Potential (V)
|
217
|
+
R_0 = self._R_0(soc) # Ohmic Resistance (Ohms)
|
218
|
+
Q = self._nominal_charge_capacity # Nominal Charge Capacity (C)
|
219
|
+
tau = self._tau(soc) # Time constant (s)
|
220
|
+
|
221
|
+
new_soc = soc + (current * tick / Q)
|
222
|
+
new_U_P = np.exp(-tick / tau) * U_P + current * R_P * (1 - np.exp(-tick / tau))
|
223
|
+
|
224
|
+
self._state_of_charge = new_soc
|
225
|
+
self._U_P = new_U_P
|
226
|
+
self._U_L = U_oc + new_U_P + (current * R_0)
|