ubc-solar-physics 1.1.0__cp312-cp312-macosx_11_0_arm64.whl → 1.7.4__cp312-cp312-macosx_11_0_arm64.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/base_gis.py +14 -0
- physics/environment/gis/gis.py +37 -3
- physics/environment/gis/gis.rs +91 -3
- physics/environment/meteorology/clouded_meteorology.py +4 -4
- physics/environment/meteorology/irradiant_meteorology.py +6 -6
- physics/lib.rs +72 -6
- physics/models/battery/__init__.py +12 -1
- physics/models/battery/basic_battery.py +0 -1
- physics/models/battery/battery.rs +102 -0
- physics/models/battery/battery_config.py +107 -0
- physics/models/battery/battery_config.toml +6 -0
- physics/models/battery/battery_model.py +226 -0
- physics/models/battery/kalman_filter.py +223 -0
- physics/models/battery.rs +1 -1
- physics/models/motor/__init__.py +3 -1
- physics/models/motor/advanced_motor.py +196 -0
- physics/models/motor/base_motor.py +2 -0
- physics/models/motor/basic_motor.py +33 -14
- physics/models/regen/basic_regen.py +14 -1
- physics/models.rs +1 -1
- physics_rs/__init__.pyi +111 -0
- physics_rs.cpython-312-darwin.so +0 -0
- {ubc_solar_physics-1.1.0.dist-info → ubc_solar_physics-1.7.4.dist-info}/METADATA +12 -5
- {ubc_solar_physics-1.1.0.dist-info → ubc_solar_physics-1.7.4.dist-info}/RECORD +28 -22
- {ubc_solar_physics-1.1.0.dist-info → ubc_solar_physics-1.7.4.dist-info}/WHEEL +1 -1
- ubc_solar_physics-1.7.4.dist-info/top_level.txt +2 -0
- core.cpython-312-darwin.so +0 -0
- ubc_solar_physics-1.1.0.dist-info/top_level.txt +0 -1
- {ubc_solar_physics-1.1.0.dist-info → ubc_solar_physics-1.7.4.dist-info}/LICENSE +0 -0
@@ -0,0 +1,226 @@
|
|
1
|
+
import numpy as np
|
2
|
+
import physics_rs
|
3
|
+
from typing import Callable, TypeAlias, Protocol, runtime_checkable, Optional, cast
|
4
|
+
from numpy.typing import NDArray
|
5
|
+
|
6
|
+
|
7
|
+
SOCDependent: TypeAlias = Callable[[float | NDArray[float]], float | NDArray[float]]
|
8
|
+
|
9
|
+
|
10
|
+
@runtime_checkable
|
11
|
+
class EquivalentCircuitModelConfig(Protocol):
|
12
|
+
"""
|
13
|
+
A specification for a configuration object which contains the requisite data to specify
|
14
|
+
a `EquivalentCircuitBatteryModel`.
|
15
|
+
"""
|
16
|
+
|
17
|
+
@property
|
18
|
+
def get_Uoc(self) -> SOCDependent:
|
19
|
+
"""
|
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
|
22
|
+
"""
|
23
|
+
...
|
24
|
+
|
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
|
+
...
|
32
|
+
|
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
|
+
...
|
40
|
+
|
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
|
+
...
|
48
|
+
|
49
|
+
@property
|
50
|
+
def Q_total(self) -> float:
|
51
|
+
"""
|
52
|
+
The total charge capacity of the battery pack, in Coulombs.
|
53
|
+
"""
|
54
|
+
...
|
55
|
+
|
56
|
+
|
57
|
+
class EquivalentCircuitBatteryModel:
|
58
|
+
"""
|
59
|
+
A first-order Thevenin equivalent model of a lithium-ion battery pack
|
60
|
+
"""
|
61
|
+
|
62
|
+
def __init__(self, battery_config: EquivalentCircuitModelConfig, state_of_charge: float = 1.0):
|
63
|
+
"""
|
64
|
+
Constructor for the EquivalentCircuitBatteryModel class.
|
65
|
+
|
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).
|
68
|
+
"""
|
69
|
+
|
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.
|
129
|
+
|
130
|
+
Only ONE of `current_array` or `delta_energy_array` should be provided.
|
131
|
+
|
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.
|
139
|
+
|
140
|
+
:param NDArray delta_energy_array: Array of energy changes (J) at each time step.
|
141
|
+
:param float tick: Time interval for each step (seconds).
|
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]
|
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
|
154
|
+
|
155
|
+
if use_compiled:
|
156
|
+
return physics_rs.update_battery_state(
|
157
|
+
energy_or_current,
|
158
|
+
tick,
|
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
|
169
|
+
)
|
170
|
+
|
171
|
+
else:
|
172
|
+
return self._update_array_py(energy_or_current, tick, current_array is None)
|
173
|
+
|
174
|
+
def _update_array_py(self, energy_or_current, tick, is_power):
|
175
|
+
"""
|
176
|
+
Perform energy calculations using Python (fallback method if Rust is disabled).
|
177
|
+
|
178
|
+
:param NDArray energy_or_current: Array of energy changes (J) at each time step.
|
179
|
+
:param float tick: Time interval for each step (seconds).
|
180
|
+
|
181
|
+
:return: A tuple containing arrays for state-of-charge and voltage.
|
182
|
+
"""
|
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
|
202
|
+
|
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)
|
@@ -0,0 +1,223 @@
|
|
1
|
+
import numpy as np
|
2
|
+
from filterpy.kalman import ExtendedKalmanFilter
|
3
|
+
from typing import Protocol, runtime_checkable, cast, Callable
|
4
|
+
from physics.models.battery import EquivalentCircuitModelConfig
|
5
|
+
from numpy.typing import NDArray
|
6
|
+
|
7
|
+
|
8
|
+
@runtime_checkable
|
9
|
+
class FilteredBatteryModelConfig(Protocol):
|
10
|
+
"""
|
11
|
+
A specification for a configuration object which contains the requisite data to specify
|
12
|
+
a `FilteredBatteryModel`.
|
13
|
+
"""
|
14
|
+
@property
|
15
|
+
def battery_model_config(self) -> EquivalentCircuitModelConfig:
|
16
|
+
"""
|
17
|
+
Configuration of the underlying `EquivalentCircuitModel`.
|
18
|
+
"""
|
19
|
+
...
|
20
|
+
|
21
|
+
@property
|
22
|
+
def process_noise_matrix(self) -> NDArray[float]:
|
23
|
+
"""
|
24
|
+
A 2x2 matrix containing the process noise covariance matrix where [0, 0] is the SOC evolution
|
25
|
+
noise and [1, 1] is the polarization potential evolution noise.
|
26
|
+
"""
|
27
|
+
...
|
28
|
+
|
29
|
+
@property
|
30
|
+
def state_covariance_matrix(self) -> NDArray[float]:
|
31
|
+
"""
|
32
|
+
A 2x2 matrix containing the state covariance matrix where [0, 0] is the SOC covariance
|
33
|
+
noise and [1, 1] is the polarization potential covariance.
|
34
|
+
"""
|
35
|
+
...
|
36
|
+
|
37
|
+
@property
|
38
|
+
def measurement_noise_vector(self) -> NDArray[float]:
|
39
|
+
"""
|
40
|
+
A 1x1 vector containing the noise expected in the terminal voltage measurement.
|
41
|
+
"""
|
42
|
+
...
|
43
|
+
|
44
|
+
|
45
|
+
class FilteredBatteryModel:
|
46
|
+
"""
|
47
|
+
`FilteredBatteryModel` is a first-order Thevenin equivalent model of a lithium-ion battery packed, wrapped
|
48
|
+
in a Kalman filter which uses voltage measurements with model predictions.
|
49
|
+
"""
|
50
|
+
def __init__(
|
51
|
+
self,
|
52
|
+
battery_config: FilteredBatteryModelConfig,
|
53
|
+
initial_SOC: float = 1.0,
|
54
|
+
initial_Uc: float = 0.0,
|
55
|
+
alpha: float = 0.9
|
56
|
+
):
|
57
|
+
"""
|
58
|
+
:param FilteredBatteryModelConfig battery_config: Contains Kalman filter state estimation configuration and
|
59
|
+
underlying equivalent circuit model configuration.
|
60
|
+
:param float initial_SOC: Initial SOC of the battery, in the range (0, 1].
|
61
|
+
:param float initial_Uc: Initial polarization voltage of the battery in Volts.
|
62
|
+
"""
|
63
|
+
# Initial state
|
64
|
+
assert 0.0 <= initial_SOC <= 1.1, "`initial_SOC` must be in (0, 1.1]!"
|
65
|
+
|
66
|
+
self._SOC = initial_SOC # State of Charge
|
67
|
+
self._Uc = initial_Uc # Polarization Voltage
|
68
|
+
|
69
|
+
# Load Config data
|
70
|
+
self._Q_total = battery_config.battery_model_config.Q_total
|
71
|
+
|
72
|
+
# These `cast` calls just promise to the type-checker that these will map floats to floats
|
73
|
+
self._U_oc = cast(Callable[[float], float], battery_config.battery_model_config.get_Uoc)
|
74
|
+
self._R_0 = cast(Callable[[float], float], battery_config.battery_model_config.get_R_0)
|
75
|
+
self._R_P = cast(Callable[[float], float], battery_config.battery_model_config.get_R_P)
|
76
|
+
self._C_P = cast(Callable[[float], float], battery_config.battery_model_config.get_C_P)
|
77
|
+
|
78
|
+
self._tau: Callable[[float], float] = lambda soc: self._R_P(soc) * self._C_P(soc) # Characteristic Time in s
|
79
|
+
|
80
|
+
def central_difference_derivative(func, value, h=1e-6):
|
81
|
+
"""
|
82
|
+
Compute the derivative of an arbitrary function `func` at `SOC` using central difference.
|
83
|
+
|
84
|
+
:param func: The function to differentiate.
|
85
|
+
:param value: The point at which to compute the derivative.
|
86
|
+
:param h: Step size for the finite difference.
|
87
|
+
:return: The derivative of the function at `SOC`.
|
88
|
+
"""
|
89
|
+
return (func(value + h) - func(value - h)) / (2 * h)
|
90
|
+
|
91
|
+
self._dUoc_dSOC = lambda soc: central_difference_derivative(self._U_oc, np.minimum(1.0, soc)) # dUOC wrt to SOC
|
92
|
+
self._dR0_dSOC = lambda soc: central_difference_derivative(self._R_0, np.minimum(1.0, soc)) # dR0 wrt to SOC
|
93
|
+
|
94
|
+
# Initializing the EKF object
|
95
|
+
self._ekf = ExtendedKalmanFilter(dim_x=2, dim_z=1)
|
96
|
+
|
97
|
+
# State Vector
|
98
|
+
self._ekf.x = np.array([
|
99
|
+
self._SOC,
|
100
|
+
self._Uc]
|
101
|
+
)
|
102
|
+
|
103
|
+
self._ekf.P = battery_config.state_covariance_matrix
|
104
|
+
self._ekf.Q = battery_config.process_noise_matrix
|
105
|
+
self._ekf.R = battery_config.measurement_noise_vector
|
106
|
+
|
107
|
+
assert 0 <= alpha <= 1, "`alpha` should be between 0 and 1!"
|
108
|
+
self._alpha = alpha
|
109
|
+
|
110
|
+
self._filtered_I = 0
|
111
|
+
self._predicted_measurement = 0
|
112
|
+
|
113
|
+
@property
|
114
|
+
def SOC(self) -> float:
|
115
|
+
"""
|
116
|
+
Return the current SOC of the battery.
|
117
|
+
|
118
|
+
:return: The current state of charge.
|
119
|
+
"""
|
120
|
+
return self._SOC
|
121
|
+
|
122
|
+
@property
|
123
|
+
def Uc(self) -> float:
|
124
|
+
"""
|
125
|
+
Return the polarization voltage of the battery.
|
126
|
+
|
127
|
+
:return: The current polarization voltage.
|
128
|
+
"""
|
129
|
+
return self._Uc
|
130
|
+
|
131
|
+
@property
|
132
|
+
def Ut(self) -> float:
|
133
|
+
"""
|
134
|
+
Return the predicted terminal voltage for the last prediction step.
|
135
|
+
|
136
|
+
:return: The predicted terminal voltage.
|
137
|
+
"""
|
138
|
+
return self._predicted_measurement
|
139
|
+
|
140
|
+
def update_filter(self, measured_Ut, current):
|
141
|
+
"""
|
142
|
+
Update the filter based on a new measurement and the predicted state.
|
143
|
+
This function should be called after `predict_state` in a typical predict-update workflow.
|
144
|
+
|
145
|
+
:param float measured_Ut: The actual voltage across the terminals of the battery.
|
146
|
+
:param float current: The current being sourced by the battery.
|
147
|
+
"""
|
148
|
+
# Simple low-pass filter to current
|
149
|
+
self._filtered_I = self._alpha * self._filtered_I + (1 - self._alpha) * current
|
150
|
+
|
151
|
+
self._ekf.update(z=measured_Ut, HJacobian=self._measurement_jacobian, Hx=self._measurement_function)
|
152
|
+
|
153
|
+
self._SOC, self._Uc = self._ekf.x
|
154
|
+
self._SOC = np.clip(self._SOC, 0.0, 1.1)
|
155
|
+
|
156
|
+
def predict_state(self, current, time_step):
|
157
|
+
"""
|
158
|
+
Predict the next evolution of the state vector (SOC, Uc).
|
159
|
+
This function should be called before updating the filter in a typical predict-update workflow.
|
160
|
+
|
161
|
+
:param float current: The current being sourced by the battery.
|
162
|
+
Sign convention is that positive indicates current being drawn.
|
163
|
+
:param float time_step: Time elapsed between this prediction and the last updated state of the filter (seconds).
|
164
|
+
"""
|
165
|
+
# Control matrix B (for input current I_k)
|
166
|
+
self._ekf.B = np.array([
|
167
|
+
-time_step / self._Q_total,
|
168
|
+
self._R_P(self._SOC) * (1 - np.exp(-time_step / (self._tau(self._SOC)))),
|
169
|
+
])
|
170
|
+
self._ekf.F = self._state_jacobian(time_step)
|
171
|
+
|
172
|
+
self._ekf.predict(u=current)
|
173
|
+
self._SOC, self._Uc = self._ekf.x
|
174
|
+
|
175
|
+
def predict_then_update(self, measured_Ut: float, current: float, time_step: float):
|
176
|
+
"""
|
177
|
+
Predict the next evolution of the state vector (SOC, Uc), then update the filter
|
178
|
+
based on this prediction and a measurement. Abstracts the full predict-update workflow of the EKF.
|
179
|
+
|
180
|
+
:param float measured_Ut: The actual voltage across the terminals of the battery.
|
181
|
+
:param float current: The current being sourced by the battery. Positive indicates current being drawn.
|
182
|
+
:param float time_step: Time elapsed between this prediction and the last updated state of the filter (seconds).
|
183
|
+
"""
|
184
|
+
self.predict_state(current, time_step)
|
185
|
+
self.update_filter(measured_Ut, current)
|
186
|
+
|
187
|
+
def _state_jacobian(self, time_step):
|
188
|
+
"""
|
189
|
+
Return the state Jacobian matrix for the current time step.
|
190
|
+
|
191
|
+
:param float time_step: Time elapsed between this prediction and the last updated state of the filter (seconds).
|
192
|
+
:return: The state Jacobian matrix.
|
193
|
+
:rtype: np.ndarray
|
194
|
+
"""
|
195
|
+
return np.array([[1, 0], [0, np.exp(-time_step / self._tau(self._SOC))]])
|
196
|
+
|
197
|
+
def _measurement_jacobian(self, x):
|
198
|
+
"""
|
199
|
+
Return the measurement Jacobian matrix for the current state vector.
|
200
|
+
|
201
|
+
:param list[float, float] x: The state vector [SOC, Uc].
|
202
|
+
:return: The measurement Jacobian matrix.
|
203
|
+
:rtype: np.ndarray
|
204
|
+
"""
|
205
|
+
SOC = x[0]
|
206
|
+
dUoc_dSOC = self._dUoc_dSOC(SOC)
|
207
|
+
dR0_dSOC = self._dR0_dSOC(SOC)
|
208
|
+
|
209
|
+
return np.array([[dUoc_dSOC - dR0_dSOC * self._filtered_I, -1]])
|
210
|
+
|
211
|
+
def _measurement_function(self, x) -> float:
|
212
|
+
"""
|
213
|
+
Return the measurement function relating terminal voltage to SOC and polarization voltage.
|
214
|
+
|
215
|
+
:param list[float, float] x: The state vector [SOC, Uc].
|
216
|
+
:return: The predicted terminal voltage.
|
217
|
+
"""
|
218
|
+
SOC, Uc = x
|
219
|
+
Uoc = self._U_oc(SOC)
|
220
|
+
R0 = self._R_0(SOC)
|
221
|
+
self._predicted_measurement = Uoc - Uc - R0 * self._filtered_I
|
222
|
+
|
223
|
+
return self._predicted_measurement
|
physics/models/battery.rs
CHANGED
@@ -1 +1 @@
|
|
1
|
-
mod battery;
|
1
|
+
pub mod battery;
|
physics/models/motor/__init__.py
CHANGED
@@ -0,0 +1,196 @@
|
|
1
|
+
import numpy as np
|
2
|
+
from haversine import haversine, Unit
|
3
|
+
from numpy.typing import NDArray
|
4
|
+
from physics.models.motor import BasicMotor
|
5
|
+
|
6
|
+
|
7
|
+
class AdvancedMotor(BasicMotor):
|
8
|
+
def __init__(self, **kwargs):
|
9
|
+
super().__init__(**kwargs)
|
10
|
+
self.cornering_coefficient = 15 # tuned to Day 1 and 3 FSGP data
|
11
|
+
|
12
|
+
def calculate_energy_in(self, required_speed_kmh, gradients, wind_speeds, tick, coords):
|
13
|
+
"""
|
14
|
+
A function which takes in array of elevation, array of wind speed, required
|
15
|
+
speed, returns the consumed energy.
|
16
|
+
|
17
|
+
:param np.ndarray required_speed_kmh: (float[N]) required speed array in km/h
|
18
|
+
:param np.ndarray gradients: (float[N]) gradient at parts of the road
|
19
|
+
:param np.ndarray wind_speeds: (float[N]) speeds of wind in m/s, where > 0 means against the direction of the vehicle
|
20
|
+
:param float tick: length of 1 update cycle in seconds
|
21
|
+
:param np.ndarray coords: ([float[N,2]) The lat,lon coordinate of the car at each tick
|
22
|
+
:returns: (float[N]) energy expended by the motor at every tick
|
23
|
+
:rtype: np.ndarray
|
24
|
+
|
25
|
+
"""
|
26
|
+
net_force, required_angular_speed_rads = self.calculate_net_force(required_speed_kmh, wind_speeds, gradients)
|
27
|
+
|
28
|
+
cornering_work = self.calculate_cornering_losses(required_speed_kmh, coords, tick)
|
29
|
+
|
30
|
+
motor_output_energies = required_angular_speed_rads * net_force * self.tire_radius * tick + cornering_work
|
31
|
+
motor_output_energies = np.clip(motor_output_energies, a_min=0, a_max=None)
|
32
|
+
|
33
|
+
e_m = self.calculate_motor_efficiency(required_angular_speed_rads, motor_output_energies, tick)
|
34
|
+
e_mc = self.calculate_motor_controller_efficiency(required_angular_speed_rads, motor_output_energies, tick)
|
35
|
+
|
36
|
+
motor_controller_input_energies = motor_output_energies / (e_m * e_mc)
|
37
|
+
|
38
|
+
# Filter out and replace negative energy consumption as 0
|
39
|
+
motor_controller_input_energies = np.where(motor_controller_input_energies > 0,
|
40
|
+
motor_controller_input_energies, 0)
|
41
|
+
|
42
|
+
return motor_controller_input_energies
|
43
|
+
|
44
|
+
def calculate_cornering_losses(self, required_speed_kmh, coords, tick):
|
45
|
+
"""
|
46
|
+
Calculate the energy losses due to cornering based on vehicle speed and trajectory.
|
47
|
+
|
48
|
+
:param np.ndarray required_speed_kmh: (float[N]) Required speed array in km/h
|
49
|
+
:param np.ndarray coords: (float[N, 2]) Array containing latitude and longitude coordinates of the car at each tick
|
50
|
+
:param float tick: Length of one update cycle in seconds
|
51
|
+
:returns: (float[N]) Energy loss due to cornering at each tick
|
52
|
+
:rtype: np.ndarray
|
53
|
+
"""
|
54
|
+
required_speed_ms = required_speed_kmh / 3.6
|
55
|
+
cornering_radii = self.calculate_radii(coords)
|
56
|
+
|
57
|
+
centripetal_lateral_force = self.vehicle_mass * (required_speed_ms ** 2) / cornering_radii
|
58
|
+
centripetal_lateral_force = np.clip(centripetal_lateral_force, a_min=0, a_max=10000)
|
59
|
+
|
60
|
+
slip_angles_degrees = self.get_slip_angle_for_tire_force(centripetal_lateral_force)
|
61
|
+
slip_angles_radians = np.radians(slip_angles_degrees)
|
62
|
+
slip_distances = np.tan(slip_angles_radians) * required_speed_ms * tick
|
63
|
+
|
64
|
+
return slip_distances * centripetal_lateral_force * self.cornering_coefficient
|
65
|
+
|
66
|
+
def calculate_radii(self, coords):
|
67
|
+
"""
|
68
|
+
Calculate the cornering radii for a given set of waypoints.
|
69
|
+
|
70
|
+
:param np.ndarray coords: (float[N, 2]) Array containing latitude and longitude coordinates of the car's path
|
71
|
+
:returns: (float[N]) Array of cornering radii at each waypoint
|
72
|
+
:rtype: np.ndarray
|
73
|
+
"""
|
74
|
+
|
75
|
+
# pop off last coordinate if first and last coordinate are the same
|
76
|
+
repeated_last_coordinate = False
|
77
|
+
if np.array_equal(coords[0], coords[len(coords) - 1]):
|
78
|
+
coords = coords[:-1]
|
79
|
+
repeated_last_coordinate = True
|
80
|
+
|
81
|
+
cornering_radii = np.empty(len(coords))
|
82
|
+
for i in range(len(coords)):
|
83
|
+
# if the next point or previous point is out of bounds, wrap the index around the array
|
84
|
+
i2 = (i - 1) % len(coords)
|
85
|
+
i3 = (i + 1) % len(coords)
|
86
|
+
current_point = coords[i]
|
87
|
+
previous_point = coords[i2]
|
88
|
+
next_point = coords[i3]
|
89
|
+
|
90
|
+
x1 = 0
|
91
|
+
y1 = 0
|
92
|
+
x2, y2 = self.calculate_meter_distance(current_point, previous_point)
|
93
|
+
x3, y3 = self.calculate_meter_distance(current_point, next_point)
|
94
|
+
cornering_radii[i] = self.radius_of_curvature(x1, y1, x2, y2, x3, y3)
|
95
|
+
|
96
|
+
# If the last coordinate was removed, duplicate the first radius value to the end of the array
|
97
|
+
if repeated_last_coordinate:
|
98
|
+
cornering_radii = np.append(cornering_radii, cornering_radii[0])
|
99
|
+
|
100
|
+
# ensure that super large radii are bounded by a large number, like 10000
|
101
|
+
cornering_radii = np.where(np.isnan(cornering_radii), 10000, cornering_radii)
|
102
|
+
cornering_radii = np.where(cornering_radii > 10000, 10000, cornering_radii)
|
103
|
+
|
104
|
+
return cornering_radii
|
105
|
+
|
106
|
+
def generate_slip_angle_lookup(self, min_degrees, max_degrees, num_elements):
|
107
|
+
"""
|
108
|
+
Generate a lookup table of slip angles and corresponding tire forces using Pacejka's Magic Formula.
|
109
|
+
|
110
|
+
https://www.edy.es/dev/docs/pacejka-94-parameters-explained-a-comprehensive-guide/
|
111
|
+
|
112
|
+
:param float min_degrees: Minimum slip angle in degrees
|
113
|
+
:param float max_degrees: Maximum slip angle in degrees
|
114
|
+
:param int num_elements: Number of discrete elements in the lookup table
|
115
|
+
:returns: (float[num_elements], float[num_elements]) Arrays of slip angles (degrees) and corresponding tire forces (Newtons)
|
116
|
+
:rtype: tuple[np.ndarray, np.ndarray]
|
117
|
+
"""
|
118
|
+
|
119
|
+
b = .25 # Stiffness
|
120
|
+
c = 2.2 # Shape
|
121
|
+
d = 2.75 # Peak
|
122
|
+
e = 1.0 # Curvature
|
123
|
+
|
124
|
+
fz = self.vehicle_mass * 9.81 # Newtons
|
125
|
+
|
126
|
+
slip_angles = np.linspace(min_degrees, max_degrees, num_elements)
|
127
|
+
tire_forces = fz * d * np.sin(
|
128
|
+
c * np.arctan(b * slip_angles - e * (b * slip_angles - np.arctan(b * slip_angles))))
|
129
|
+
|
130
|
+
return slip_angles, tire_forces
|
131
|
+
|
132
|
+
def get_slip_angle_for_tire_force(self, desired_tire_force):
|
133
|
+
slip_angles, tire_forces = self.generate_slip_angle_lookup(0, 70, 100000)
|
134
|
+
|
135
|
+
# Use the numpy interpolation function to find slip angle for the given tire force
|
136
|
+
estimated_slip_angle = np.interp(desired_tire_force, tire_forces, slip_angles)
|
137
|
+
|
138
|
+
return estimated_slip_angle
|
139
|
+
|
140
|
+
@staticmethod
|
141
|
+
def calculate_meter_distance(coord1: NDArray, coord2: NDArray):
|
142
|
+
"""
|
143
|
+
Calculate the x and y distance in meters between two latitude-longitude coordinates.
|
144
|
+
|
145
|
+
:param tuple coord1: (float[2]) The (latitude, longitude) coordinates of the first point
|
146
|
+
:param tuple coord2: (float[2]) The (latitude, longitude) coordinates of the second point
|
147
|
+
:returns: (float[2]) The x (longitude) and y (latitude) distances in meters
|
148
|
+
:rtype: tuple
|
149
|
+
"""
|
150
|
+
lat1, lon1 = coord1
|
151
|
+
lat2, lon2 = coord2
|
152
|
+
|
153
|
+
# Base coordinate
|
154
|
+
coord_base = (lat1, lon1)
|
155
|
+
# Coordinate for latitude difference (keep longitude the same)
|
156
|
+
coord_lat = (lat2, lon1)
|
157
|
+
# Coordinate for longitude difference (keep latitude the same)
|
158
|
+
coord_long = (lat1, lon2)
|
159
|
+
|
160
|
+
# Calculate y distance (latitude difference)
|
161
|
+
y_distance = haversine(coord_base, coord_lat, unit=Unit.METERS)
|
162
|
+
# Calculate x distance (longitude difference)
|
163
|
+
x_distance = haversine(coord_base, coord_long, unit=Unit.METERS)
|
164
|
+
|
165
|
+
if lat2 < lat1:
|
166
|
+
y_distance = -y_distance
|
167
|
+
if lon2 < lon1:
|
168
|
+
x_distance = -x_distance
|
169
|
+
|
170
|
+
return x_distance, y_distance
|
171
|
+
|
172
|
+
@staticmethod
|
173
|
+
def radius_of_curvature(x1, y1, x2, y2, x3, y3):
|
174
|
+
"""
|
175
|
+
Uses the circumcircle the radius of curvature of a circle passing through three points.
|
176
|
+
|
177
|
+
:param float x1: X-coordinate of the first point
|
178
|
+
:param float y1: Y-coordinate of the first point
|
179
|
+
:param float x2: X-coordinate of the second point
|
180
|
+
:param float y2: Y-coordinate of the second point
|
181
|
+
:param float x3: X-coordinate of the third point
|
182
|
+
:param float y3: Y-coordinate of the third point
|
183
|
+
:returns: Radius of curvature of the circle passing through the three points
|
184
|
+
:rtype: float
|
185
|
+
"""
|
186
|
+
numerator = np.sqrt(
|
187
|
+
((x3 - x2) ** 2 + (y3 - y2) ** 2) *
|
188
|
+
((x1 - x3) ** 2 + (y1 - y3) ** 2) *
|
189
|
+
((x2 - x1) ** 2 + (y2 - y1) ** 2)
|
190
|
+
)
|
191
|
+
|
192
|
+
denominator = 2 * abs(
|
193
|
+
((x2 - x1) * (y1 - y3) - (x1 - x3) * (y2 - y1))
|
194
|
+
)
|
195
|
+
|
196
|
+
return numerator / denominator
|