ubc-solar-physics 1.7.7__cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.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/__init__.py +14 -0
- physics/_version.py +16 -0
- physics/environment/__init__.py +15 -0
- physics/environment/environment.rs +2 -0
- physics/environment/gis/__init__.py +7 -0
- physics/environment/gis/base_gis.py +38 -0
- physics/environment/gis/gis.py +371 -0
- physics/environment/gis/gis.rs +113 -0
- physics/environment/gis.rs +1 -0
- physics/environment/meteorology/__init__.py +3 -0
- physics/environment/meteorology/base_meteorology.py +69 -0
- physics/environment/meteorology/clouded_meteorology.py +600 -0
- physics/environment/meteorology/irradiant_meteorology.py +107 -0
- physics/environment/meteorology/meteorology.rs +138 -0
- physics/environment/meteorology.rs +1 -0
- physics/environment.rs +2 -0
- physics/lib.rs +164 -0
- physics/models/__init__.py +13 -0
- physics/models/arrays/__init__.py +7 -0
- physics/models/arrays/arrays.rs +0 -0
- physics/models/arrays/base_array.py +6 -0
- physics/models/arrays/basic_array.py +39 -0
- physics/models/arrays.rs +1 -0
- physics/models/battery/__init__.py +18 -0
- physics/models/battery/base_battery.py +29 -0
- physics/models/battery/basic_battery.py +140 -0
- 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 -0
- physics/models/constants.py +23 -0
- physics/models/lvs/__init__.py +7 -0
- physics/models/lvs/base_lvs.py +6 -0
- physics/models/lvs/basic_lvs.py +18 -0
- physics/models/lvs/lvs.rs +0 -0
- physics/models/lvs.rs +1 -0
- physics/models/motor/__init__.py +9 -0
- physics/models/motor/advanced_motor.py +196 -0
- physics/models/motor/base_motor.py +8 -0
- physics/models/motor/basic_motor.py +193 -0
- physics/models/motor/motor.rs +0 -0
- physics/models/motor.rs +1 -0
- physics/models/regen/__init__.py +7 -0
- physics/models/regen/base_regen.py +6 -0
- physics/models/regen/basic_regen.py +52 -0
- physics/models/regen/regen.rs +0 -0
- physics/models/regen.rs +1 -0
- physics/models.rs +5 -0
- physics_rs/__init__.pyi +111 -0
- physics_rs.cpython-313-aarch64-linux-gnu.so +0 -0
- ubc_solar_physics-1.7.7.dist-info/LICENSE +21 -0
- ubc_solar_physics-1.7.7.dist-info/METADATA +142 -0
- ubc_solar_physics-1.7.7.dist-info/RECORD +57 -0
- ubc_solar_physics-1.7.7.dist-info/WHEEL +6 -0
- ubc_solar_physics-1.7.7.dist-info/top_level.txt +2 -0
@@ -0,0 +1,140 @@
|
|
1
|
+
import numpy as np
|
2
|
+
from numpy.polynomial import Polynomial
|
3
|
+
from physics.models.battery.base_battery import BaseBattery
|
4
|
+
|
5
|
+
|
6
|
+
class BasicBattery(BaseBattery):
|
7
|
+
"""
|
8
|
+
Class representing the DayBreak battery pack.
|
9
|
+
|
10
|
+
Attributes:
|
11
|
+
max_voltage (float): maximum voltage of the DayBreak battery pack (V)
|
12
|
+
min_voltage (float): minimum voltage of the DayBreak battery pack (V)
|
13
|
+
max_current_capacity (float): nominal capacity of the DayBreak battery pack (Ah)
|
14
|
+
max_energy_capacity (float): nominal energy capacity of the DayBreak battery pack (Wh)
|
15
|
+
|
16
|
+
state_of_charge (float): instantaneous battery state-of-charge (0.00 - 1.00)
|
17
|
+
discharge_capacity (float): instantaneous amount of charge extracted from battery (Ah)
|
18
|
+
voltage (float): instantaneous voltage of the battery (V)
|
19
|
+
stored_energy (float): instantaneous energy stored in the battery (Wh)
|
20
|
+
"""
|
21
|
+
|
22
|
+
def __init__(self, state_of_charge, max_voltage, min_voltage, max_current_capacity, max_energy_capacity):
|
23
|
+
"""
|
24
|
+
|
25
|
+
Constructor for BasicBattery class.
|
26
|
+
|
27
|
+
:param float state_of_charge: initial battery state of charge
|
28
|
+
|
29
|
+
"""
|
30
|
+
|
31
|
+
# ----- DayBreak battery constants -----
|
32
|
+
|
33
|
+
self.max_voltage = max_voltage
|
34
|
+
self.min_voltage = min_voltage
|
35
|
+
self.max_current_capacity = max_current_capacity
|
36
|
+
self.max_energy_capacity = max_energy_capacity
|
37
|
+
|
38
|
+
# ----- DayBreak battery equations -----
|
39
|
+
|
40
|
+
self.calculate_voltage_from_discharge_capacity = calculate_voltage_from_discharge_capacity()
|
41
|
+
|
42
|
+
self.calculate_energy_from_discharge_capacity = calculate_energy_from_discharge_capacity()
|
43
|
+
|
44
|
+
self.calculate_soc_from_discharge_capacity = calculate_soc_from_discharge_capacity(self.max_current_capacity)
|
45
|
+
|
46
|
+
self.calculate_discharge_capacity_from_soc = calculate_discharge_capacity_from_soc(self.max_current_capacity)
|
47
|
+
|
48
|
+
self.calculate_discharge_capacity_from_energy = calculate_discharge_capacity_from_energy()
|
49
|
+
|
50
|
+
# ----- DayBreak battery variables -----
|
51
|
+
|
52
|
+
self.state_of_charge = state_of_charge
|
53
|
+
|
54
|
+
# SOC -> discharge_capacity
|
55
|
+
self.discharge_capacity = self.calculate_discharge_capacity_from_soc(self.state_of_charge)
|
56
|
+
|
57
|
+
# discharge_capacity -> voltage
|
58
|
+
self.voltage = self.calculate_voltage_from_discharge_capacity(self.discharge_capacity)
|
59
|
+
|
60
|
+
# discharge_capacity -> energy
|
61
|
+
self.stored_energy = self.max_energy_capacity - self.calculate_energy_from_discharge_capacity(
|
62
|
+
self.discharge_capacity)
|
63
|
+
|
64
|
+
# ----- DayBreak battery initialisation -----
|
65
|
+
|
66
|
+
super().__init__(self.stored_energy, self.max_current_capacity, self.max_energy_capacity,
|
67
|
+
self.max_voltage, self.min_voltage, self.voltage, self.state_of_charge)
|
68
|
+
|
69
|
+
def update_array(self, cumulative_energy_array):
|
70
|
+
"""
|
71
|
+
Performs energy calculations with NumPy arrays
|
72
|
+
|
73
|
+
:param cumulative_energy_array: a NumPy array containing the cumulative energy changes at each time step
|
74
|
+
experienced by the battery
|
75
|
+
|
76
|
+
:return: soc_array – a NumPy array containing the battery state of charge at each time step
|
77
|
+
|
78
|
+
:return: voltage_array – a NumPy array containing the voltage of the battery at each time step
|
79
|
+
|
80
|
+
:return: stored_energy_array– a NumPy array containing the energy stored in the battery at each time step
|
81
|
+
|
82
|
+
"""
|
83
|
+
|
84
|
+
stored_energy_array = np.full_like(cumulative_energy_array, fill_value=self.stored_energy)
|
85
|
+
stored_energy_array += cumulative_energy_array / 3600
|
86
|
+
stored_energy_array = np.clip(stored_energy_array, a_min=0, a_max=self.max_energy_capacity)
|
87
|
+
|
88
|
+
energy_discharged_array = np.full_like(cumulative_energy_array, fill_value=self.max_energy_capacity) - \
|
89
|
+
stored_energy_array
|
90
|
+
|
91
|
+
discharge_capacity_array = self.calculate_discharge_capacity_from_energy(energy_discharged_array)
|
92
|
+
|
93
|
+
soc_array = self.calculate_soc_from_discharge_capacity(discharge_capacity_array)
|
94
|
+
voltage_array = self.calculate_voltage_from_discharge_capacity(discharge_capacity_array)
|
95
|
+
|
96
|
+
return soc_array, voltage_array, stored_energy_array
|
97
|
+
|
98
|
+
def get_raw_soc(self, cumulative_energy_array):
|
99
|
+
"""
|
100
|
+
|
101
|
+
Return the not truncated (SOC is allowed to go above 100% and below 0%) state of charge.
|
102
|
+
|
103
|
+
:param np.ndarray cumulative_energy_array: a NumPy array containing the cumulative energy changes at each time step
|
104
|
+
experienced by the battery
|
105
|
+
|
106
|
+
:return: a NumPy array containing the battery state of charge at each time step
|
107
|
+
:rtype: np.ndarray
|
108
|
+
|
109
|
+
"""
|
110
|
+
|
111
|
+
stored_energy_array = np.full_like(cumulative_energy_array, fill_value=self.stored_energy)
|
112
|
+
stored_energy_array += cumulative_energy_array / 3600
|
113
|
+
|
114
|
+
energy_discharged_array = np.full_like(cumulative_energy_array, fill_value=self.max_energy_capacity) - stored_energy_array
|
115
|
+
|
116
|
+
discharge_capacity_array = self.calculate_discharge_capacity_from_energy(energy_discharged_array)
|
117
|
+
|
118
|
+
soc_array = self.calculate_soc_from_discharge_capacity(discharge_capacity_array)
|
119
|
+
|
120
|
+
return soc_array
|
121
|
+
|
122
|
+
|
123
|
+
def calculate_voltage_from_discharge_capacity():
|
124
|
+
return Polynomial([117.6, -0.858896]) # -0.8589x + 117.6
|
125
|
+
|
126
|
+
|
127
|
+
def calculate_energy_from_discharge_capacity():
|
128
|
+
return Polynomial([0, 117.6, -0.429448]) # -0.4294x^2 + 117.6x
|
129
|
+
|
130
|
+
|
131
|
+
def calculate_soc_from_discharge_capacity(max_current_capacity):
|
132
|
+
return Polynomial([1, -1 / max_current_capacity])
|
133
|
+
|
134
|
+
|
135
|
+
def calculate_discharge_capacity_from_soc(max_current_capacity):
|
136
|
+
return Polynomial([max_current_capacity, -max_current_capacity])
|
137
|
+
|
138
|
+
|
139
|
+
def calculate_discharge_capacity_from_energy():
|
140
|
+
return lambda x: 136.92 - np.sqrt(18747.06027 - 2.32857 * x)
|
@@ -0,0 +1,102 @@
|
|
1
|
+
use std::f64;
|
2
|
+
use numpy::ndarray::{ArrayView1};
|
3
|
+
|
4
|
+
fn get_lookup_index(soc: f64, quantization_step: f64, num_indices: usize, min_soc: f64) -> usize {
|
5
|
+
// Apply the same formula as in Python
|
6
|
+
let index = ((soc - min_soc) / quantization_step).floor() as usize;
|
7
|
+
|
8
|
+
// Clamp the index to be between 0 and num_indices - 1
|
9
|
+
index.min(num_indices - 1) // equivalent to max(0, min(num_indices - 1, index))
|
10
|
+
}
|
11
|
+
|
12
|
+
/// Evaluate a polynomial given coefficients and an input value (x)
|
13
|
+
fn evaluate_lookup(lookup: &[f64], quantization_step: f64, value: f64, min_soc: f64) -> f64 {
|
14
|
+
let index = get_lookup_index(value, quantization_step, lookup.len(), min_soc);
|
15
|
+
lookup[index]
|
16
|
+
}
|
17
|
+
|
18
|
+
/// Evolve the battery state for a single step
|
19
|
+
fn battery_evolve(
|
20
|
+
current: f64, // Amperes
|
21
|
+
tick: f64, // Seconds
|
22
|
+
state_of_charge: f64, // Dimensionless, 0 < SOC < 1
|
23
|
+
polarization_potential: f64, // Volts
|
24
|
+
polarization_resistance: f64, // Ohms
|
25
|
+
internal_resistance: f64, // Ohms
|
26
|
+
open_circuit_voltage: f64, // Volts
|
27
|
+
time_constant: f64, // Seconds
|
28
|
+
nominal_charge_capacity: f64, // Nominal charge capacity (Coulombs)
|
29
|
+
) -> (f64, f64, f64) {
|
30
|
+
// Update state of charge and polarization potential
|
31
|
+
let new_state_of_charge: f64 = state_of_charge + (current * tick / nominal_charge_capacity);
|
32
|
+
let new_polarization_potential: f64 = f64::exp(-tick / time_constant) * polarization_potential
|
33
|
+
+ current * polarization_resistance * (1.0 - f64::exp(-tick / time_constant));
|
34
|
+
let terminal_voltage: f64 = open_circuit_voltage + new_polarization_potential
|
35
|
+
+ (current * internal_resistance); // Terminal voltage
|
36
|
+
|
37
|
+
(new_state_of_charge, new_polarization_potential, terminal_voltage)
|
38
|
+
}
|
39
|
+
|
40
|
+
// Update battery state, using either energy or current draw
|
41
|
+
pub fn update_battery_state(
|
42
|
+
energy_or_current_array: ArrayView1<'_, f64>, // Power (W*s) or current (Amperes)
|
43
|
+
tick: f64, // Seconds
|
44
|
+
initial_state_of_charge: f64, // dimensionless, 0 < SOC < 1
|
45
|
+
initial_polarization_potential: f64, // Volts
|
46
|
+
internal_resistance_lookup: ArrayView1<'_, f64>,// Coefficients for internal resistance
|
47
|
+
open_circuit_voltage_lookup: ArrayView1<'_, f64>, // Coefficients for open-circuit voltage
|
48
|
+
polarization_resistance_lookup: ArrayView1<'_, f64>, // Coefficients for polarization resistance
|
49
|
+
capacitance_lookup: ArrayView1<'_, f64>, // Coefficients for polarization capacitance
|
50
|
+
nominal_charge_capacity: f64, // Coulombs
|
51
|
+
is_energy_input: bool, // Whether the input is power or current,
|
52
|
+
quantization_step: f64, // The quantization step size of SOC for lookup tables
|
53
|
+
min_soc: f64,
|
54
|
+
|
55
|
+
) -> (Vec<f64>, Vec<f64>) {
|
56
|
+
let mut state_of_charge: f64 = initial_state_of_charge;
|
57
|
+
let mut polarization_potential: f64 = initial_polarization_potential;
|
58
|
+
let mut soc_array: Vec<f64> = Vec::with_capacity(energy_or_current_array.len());
|
59
|
+
let mut voltage_array: Vec<f64> = Vec::with_capacity(energy_or_current_array.len());
|
60
|
+
|
61
|
+
for &input in energy_or_current_array.iter() {
|
62
|
+
// Interpolate values from coefficient
|
63
|
+
let open_circuit_voltage = evaluate_lookup(open_circuit_voltage_lookup.as_slice().unwrap(), quantization_step, state_of_charge, min_soc);
|
64
|
+
let internal_resistance = evaluate_lookup(internal_resistance_lookup.as_slice().unwrap(), quantization_step, state_of_charge, min_soc);
|
65
|
+
let polarization_resistance = evaluate_lookup(polarization_resistance_lookup.as_slice().unwrap(), quantization_step, state_of_charge, min_soc);
|
66
|
+
let capacitance = evaluate_lookup(capacitance_lookup.as_slice().unwrap(), quantization_step, state_of_charge, min_soc);
|
67
|
+
let time_constant = polarization_resistance * capacitance;
|
68
|
+
|
69
|
+
// Calculate current from power or use the current directly
|
70
|
+
let current: f64 = if is_energy_input {
|
71
|
+
// Use the last voltage to calculate current, or an absurdly large number if it is the
|
72
|
+
// first, because we don't know voltage yet, so we will have a very small initial
|
73
|
+
// current, no matter what. We shouldn't be starting to simulate when the battery is
|
74
|
+
// in an active state anyway, so this should be an alright compromise.
|
75
|
+
input / (tick * voltage_array.last().unwrap_or(&10000.0)) // I = (E / dt) / V
|
76
|
+
} else {
|
77
|
+
input // Current is directly given in the current input array
|
78
|
+
};
|
79
|
+
|
80
|
+
let (new_state_of_charge, new_polarization_potential, terminal_voltage) = battery_evolve(
|
81
|
+
current,
|
82
|
+
tick,
|
83
|
+
state_of_charge,
|
84
|
+
polarization_potential,
|
85
|
+
polarization_resistance,
|
86
|
+
internal_resistance,
|
87
|
+
open_circuit_voltage,
|
88
|
+
time_constant,
|
89
|
+
nominal_charge_capacity,
|
90
|
+
);
|
91
|
+
|
92
|
+
// Update state for the next iteration
|
93
|
+
state_of_charge = new_state_of_charge;
|
94
|
+
polarization_potential = new_polarization_potential;
|
95
|
+
|
96
|
+
// Store results
|
97
|
+
soc_array.push(new_state_of_charge);
|
98
|
+
voltage_array.push(terminal_voltage);
|
99
|
+
}
|
100
|
+
|
101
|
+
(soc_array, voltage_array)
|
102
|
+
}
|
@@ -0,0 +1,107 @@
|
|
1
|
+
import tomli as tomllib
|
2
|
+
import pathlib
|
3
|
+
from scipy import optimize
|
4
|
+
import numpy as np
|
5
|
+
from physics.models.battery import SOCDependent
|
6
|
+
from typing import cast
|
7
|
+
from numpy.typing import NDArray
|
8
|
+
|
9
|
+
|
10
|
+
class BatteryModelConfig:
|
11
|
+
"""
|
12
|
+
A concrete implementation of the `EquivalentCircuitModelConfig` protocol.
|
13
|
+
|
14
|
+
This implementation fits values of U_oc, R_0, R_P, and C_P at various state-of-charge (SOC) values
|
15
|
+
to a seventh degree polynomial to generate a smooth function mapping SOC to each battery parameter.
|
16
|
+
|
17
|
+
For example, R_0 = R_0_data[i] when Soc = Soc_data[i].
|
18
|
+
"""
|
19
|
+
def __init__(self, R_0_data, Soc_data, R_P_data, C_P_data, Uoc_data, Q_total):
|
20
|
+
# ----- Initialize Parameters -----
|
21
|
+
def quintic_polynomial(x, x0, x1, x2, x3, x4, x5, x6, x7):
|
22
|
+
return np.polyval(np.array([x0, x1, x2, x3, x4, x5, x6, x7]), x)
|
23
|
+
|
24
|
+
self._U_oc_coefficients, _ = optimize.curve_fit(quintic_polynomial, Soc_data, Uoc_data)
|
25
|
+
self._R_0_coefficients, _ = optimize.curve_fit(quintic_polynomial, Soc_data, R_0_data)
|
26
|
+
self._C_P_coefficients, _ = optimize.curve_fit(quintic_polynomial, Soc_data, C_P_data)
|
27
|
+
self._R_P_coefficients, _ = optimize.curve_fit(quintic_polynomial, Soc_data, R_P_data)
|
28
|
+
|
29
|
+
# Casts are just for the type-checker to know that np.polyval will work as SOCDependent
|
30
|
+
self._U_oc: SOCDependent = cast(SOCDependent, lambda soc: np.polyval(self._U_oc_coefficients, soc)) # V
|
31
|
+
self._R_0: SOCDependent = cast(SOCDependent, lambda soc: np.polyval(self._R_0_coefficients, soc)) # Ohms
|
32
|
+
self._R_P: SOCDependent = cast(SOCDependent, lambda soc: np.polyval(self._R_P_coefficients, soc)) # Ohms
|
33
|
+
self._C_P: SOCDependent = cast(SOCDependent, lambda soc: np.polyval(self._C_P_coefficients, soc)) # Farads
|
34
|
+
|
35
|
+
self._Q_total = Q_total
|
36
|
+
|
37
|
+
@property
|
38
|
+
def get_Uoc(self) -> SOCDependent:
|
39
|
+
return self._U_oc
|
40
|
+
|
41
|
+
@property
|
42
|
+
def get_R_0(self) -> SOCDependent:
|
43
|
+
return self._R_0
|
44
|
+
|
45
|
+
@property
|
46
|
+
def get_R_P(self) -> SOCDependent:
|
47
|
+
return self._R_P
|
48
|
+
|
49
|
+
@property
|
50
|
+
def get_C_P(self) -> SOCDependent:
|
51
|
+
return self._C_P
|
52
|
+
|
53
|
+
@property
|
54
|
+
def Q_total(self) -> float:
|
55
|
+
return self._Q_total
|
56
|
+
|
57
|
+
|
58
|
+
class KalmanFilterConfig:
|
59
|
+
def __init__(
|
60
|
+
self,
|
61
|
+
battery_model_config: BatteryModelConfig,
|
62
|
+
process_noise_matrix: NDArray,
|
63
|
+
state_covariance_matrix: NDArray,
|
64
|
+
measurement_noise_vector: NDArray
|
65
|
+
):
|
66
|
+
self._battery_model_config = battery_model_config
|
67
|
+
self._process_noise_matrix = process_noise_matrix
|
68
|
+
self._state_covariance_matrix = state_covariance_matrix
|
69
|
+
self._measurement_noise_vector = measurement_noise_vector
|
70
|
+
|
71
|
+
@property
|
72
|
+
def battery_model_config(self) -> BatteryModelConfig:
|
73
|
+
"""
|
74
|
+
Configuration of the underlying `EquivalentCircuitModel`.
|
75
|
+
"""
|
76
|
+
return self._battery_model_config
|
77
|
+
|
78
|
+
@property
|
79
|
+
def process_noise_matrix(self) -> NDArray[float]:
|
80
|
+
"""
|
81
|
+
A 2x2 matrix containing the process noise covariance matrix where [0, 0] is the SOC evolution
|
82
|
+
noise and [1, 1] is the polarization potential evolution noise.
|
83
|
+
"""
|
84
|
+
return self._process_noise_matrix
|
85
|
+
|
86
|
+
@property
|
87
|
+
def state_covariance_matrix(self) -> NDArray[float]:
|
88
|
+
"""
|
89
|
+
A 2x2 matrix containing the state covariance matrix where [0, 0] is the SOC covariance
|
90
|
+
noise and [1, 1] is the polarization potential covariance.
|
91
|
+
"""
|
92
|
+
return self._state_covariance_matrix
|
93
|
+
|
94
|
+
@property
|
95
|
+
def measurement_noise_vector(self) -> NDArray[float]:
|
96
|
+
"""
|
97
|
+
A 1x1 vector containing the noise expected in the terminal voltage measurement.
|
98
|
+
"""
|
99
|
+
return self._measurement_noise_vector
|
100
|
+
|
101
|
+
|
102
|
+
def load_battery_config(absolute_path: str | pathlib.Path) -> BatteryModelConfig:
|
103
|
+
# Build the full path to the config file
|
104
|
+
full_path = pathlib.Path(absolute_path)
|
105
|
+
with open(full_path, 'rb') as f:
|
106
|
+
data = tomllib.load(f)
|
107
|
+
return BatteryModelConfig(**data)
|
@@ -0,0 +1,6 @@
|
|
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]
|
@@ -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)
|