ubc-solar-physics 1.5.0__cp312-cp312-win_amd64.whl → 1.7.0__cp312-cp312-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.
@@ -1,8 +1,6 @@
1
- R_0_data = [0.002564, 0.002541, 0.002541, 0.002558, 0.002549, 0.002574, 0.002596, 0.002626, 0.002676, 0.002789]
2
- R_P = 0.000530
3
- C_P = 14646
4
- Q_total = 259200
5
- SOC_data = [0.0752, 0.1705, 0.2677, 0.366, 0.4654, 0.5666, 0.6701, 0.7767, 0.8865, 1.0]
6
- Uoc_data = [3.481, 3.557, 3.597, 3.623, 3.660, 3.750, 3.846, 3.946, 4.056, 4.183]
7
- max_current_capacity = 40
8
- max_energy_capacity = 500
1
+ R_0_data = [0.17953765302439662, 0.15580951404728172, 0.14176929930784543, 0.11043950958574644, 0.13930042505446938, 0.1552885289394773, 0.044070982259896085, 0.2208806896239539, 0.15116267852908616, 0.6553961767519164]
2
+ R_P_data = [0.04153180244191346, 0.10674683402208612, 0.061085424180509884, 0.0781407642082238, 0.05537901113775878, 0.09732054673529467, 0.07662520885708152, 0.09799857401036915, 0.42622740149661487, 0.2718418915736874]
3
+ C_P_data = [14824.398495212006, 1587.5971318119796, 341.1064063616048, 1243.182413110655, 619.5791066439332, 2252.7885790042164, 954.5884882581622, 515.7219779825028, 431.10892633451135, 195.14394897766627]
4
+ Uoc_data = [131.88002282453857, 129.4574321366064, 125.5750277614186, 121.99586066440303, 118.69893412178982, 115.71854177322408, 111.99025635444923, 108.29354777060836, 98.23397960300946, 95.24125831782388]
5
+ Q_total = 151000.0
6
+ Soc_data = [1.0000113624123392, 0.8815263722745977, 0.7671918526292492, 0.6206071038045673, 0.4911613638651783, 0.3606311083423134, 0.23687514228021178, 0.12073345089992571, 0.01456057818183809, 0.0070648691224265425]
@@ -1,135 +1,226 @@
1
1
  import numpy as np
2
- import core
3
- from scipy import optimize
4
- from physics.models.battery.battery_config import BatteryModelConfig
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
- class BatteryModel:
7
+ SOCDependent: TypeAlias = Callable[[float | NDArray[float]], float | NDArray[float]]
8
+
9
+
10
+ @runtime_checkable
11
+ class EquivalentCircuitModelConfig(Protocol):
8
12
  """
9
- Class representing the Thevenin equivalent battery model with modular parameters.
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
- def __init__(self, battery_config: BatteryModelConfig, state_of_charge=1):
17
+ @property
18
+ def get_Uoc(self) -> SOCDependent:
28
19
  """
29
- Constructor for the BatteryModel class.
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
- # ----- Load Config -----
36
-
37
- self.R_P = battery_config.R_P
38
- self.C_P = battery_config.C_P
39
- self.max_current_capacity = battery_config.max_current_capacity
40
- self.max_energy_capacity = battery_config.max_energy_capacity
41
- self.nominal_charge_capacity = battery_config.Q_total
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
- # ----- Initialize Parameters -----
47
- def quintic_polynomial(x, x0, x1, x2, x3, x4):
48
- return np.polyval(np.array([x0, x1, x2, x3, x4]), x)
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
- self.U_oc_coefficients, _ = optimize.curve_fit(quintic_polynomial, Soc_data, Uoc_data)
51
- self.R_0_coefficients, _ = optimize.curve_fit(quintic_polynomial, Soc_data, R_0_data)
52
- self.U_oc = lambda soc: np.polyval(self.U_oc_coefficients, soc) # V
53
- self.R_0 = lambda soc: np.polyval(self.R_0_coefficients, soc) # Ohms
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
- self.U_P = 0.0 # V
56
- self.U_L = 0.0 # V
57
- self.state_of_charge = state_of_charge
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
- # calculated the charging and discharging currents
62
- self.discharge_current = lambda P, U_oc, U_P, R_0: ((U_oc - U_P) - np.sqrt(
63
- np.power((U_oc - U_P), 2) - 4 * R_0 * P)) / (2 * R_0)
64
- self.charge_current = lambda P, U_oc, U_P, R_0: (-(U_oc + U_P) + np.sqrt(
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 _evolve(self, power: float, tick: float) -> None:
62
+ def __init__(self, battery_config: EquivalentCircuitModelConfig, state_of_charge: float = 1.0):
68
63
  """
69
- Update the battery state given the power and time elapsed.
64
+ Constructor for the EquivalentCircuitBatteryModel class.
70
65
 
71
- :param float power: Power applied to the battery (W). Positive for charging, negative for discharging.
72
- :param float T: Time interval over which the power is applied (seconds).
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
- current = self.discharge_current(power, U_oc, U_P, R_0) if power <= 0 else self.charge_current(power, U_oc, U_P, R_0) # Current (A)
82
-
83
- new_soc = soc + (current * tick / Q)
84
- new_U_P = np.exp(-tick / self.tau) * U_P + current * R_P * (1 - np.exp(-tick / self.tau))
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
- self.state_of_charge = new_soc
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
- def update_array(self, delta_energy_array, tick, rust=True):
91
- """
92
- Compute the battery's state of charge, voltage, and stored energy over time.
93
- This function is a wrapper for the Rust-based and Python-based implementations.
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 np.ndarray delta_energy_array: Array of energy changes (J) at each time step.
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 bool rust: If True, use Rust-based calculations (default is True).
98
-
99
- :return: A tuple containing arrays for state-of-charge, voltage, and stored energy.
100
- :rtype: tuple[np.ndarray, np.ndarray, np.ndarray]
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 rust:
104
- return core.update_battery_array(
105
- delta_energy_array,
155
+ if use_compiled:
156
+ return physics_rs.update_battery_state(
157
+ energy_or_current,
106
158
  tick,
107
- self.state_of_charge,
108
- self.U_P,
109
- self.R_P,
110
- self.R_0_coefficients,
111
- self.U_oc_coefficients,
112
- self.tau,
113
- self.nominal_charge_capacity
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(delta_energy_array, tick)
172
+ return self._update_array_py(energy_or_current, tick, current_array is None)
117
173
 
118
- def _update_array_py(self, delta_energy_array, tick):
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 np.ndarray delta_energy_array: Array of energy changes (J) at each time step.
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(delta_energy_array, dtype=float)
129
- voltage = np.empty_like(delta_energy_array, dtype=float)
130
- for i, energy in enumerate(delta_energy_array):
131
- self._evolve(energy, tick)
132
- soc[i] = self.state_of_charge
133
- voltage[i] = self.U_L
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)