ubc-solar-physics 1.1.0__cp39-cp39-macosx_11_0_arm64.whl → 1.7.3__cp39-cp39-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-39-darwin.so +0 -0
- {ubc_solar_physics-1.1.0.dist-info → ubc_solar_physics-1.7.3.dist-info}/METADATA +12 -5
- {ubc_solar_physics-1.1.0.dist-info → ubc_solar_physics-1.7.3.dist-info}/RECORD +28 -22
- {ubc_solar_physics-1.1.0.dist-info → ubc_solar_physics-1.7.3.dist-info}/WHEEL +1 -1
- ubc_solar_physics-1.7.3.dist-info/top_level.txt +2 -0
- core.cpython-39-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.3.dist-info}/LICENSE +0 -0
physics/_version.py
CHANGED
@@ -1,5 +1,6 @@
|
|
1
1
|
from abc import ABC, abstractmethod
|
2
2
|
import numpy as np
|
3
|
+
from numpy.typing import ArrayLike, NDArray
|
3
4
|
|
4
5
|
|
5
6
|
class BaseGIS(ABC):
|
@@ -22,3 +23,16 @@ class BaseGIS(ABC):
|
|
22
23
|
@abstractmethod
|
23
24
|
def get_path(self) -> np.ndarray:
|
24
25
|
raise NotImplementedError
|
26
|
+
|
27
|
+
@abstractmethod
|
28
|
+
def calculate_current_heading_array(self) -> np.ndarray:
|
29
|
+
raise NotImplementedError
|
30
|
+
|
31
|
+
def calculate_driving_speeds(
|
32
|
+
self,
|
33
|
+
average_lap_speeds: ArrayLike,
|
34
|
+
simulation_dt: int,
|
35
|
+
driving_allowed: ArrayLike,
|
36
|
+
idle_time: int
|
37
|
+
) -> NDArray[float]:
|
38
|
+
raise NotImplementedError
|
physics/environment/gis/gis.py
CHANGED
@@ -1,9 +1,10 @@
|
|
1
1
|
import logging
|
2
2
|
import math
|
3
|
-
import
|
3
|
+
import physics_rs
|
4
4
|
import numpy as np
|
5
5
|
import sys
|
6
6
|
|
7
|
+
from numpy.typing import ArrayLike, NDArray
|
7
8
|
from tqdm import tqdm
|
8
9
|
from xml.dom import minidom
|
9
10
|
from haversine import haversine, Unit
|
@@ -80,13 +81,46 @@ class GIS(BaseGIS):
|
|
80
81
|
:rtype: np.ndarray
|
81
82
|
|
82
83
|
"""
|
83
|
-
return
|
84
|
+
return physics_rs.closest_gis_indices_loop(distances, self.path_distances)
|
85
|
+
|
86
|
+
def calculate_driving_speeds(
|
87
|
+
self,
|
88
|
+
average_lap_speeds: ArrayLike,
|
89
|
+
simulation_dt: int,
|
90
|
+
driving_allowed: ArrayLike,
|
91
|
+
idle_time: int
|
92
|
+
) -> NDArray[float]:
|
93
|
+
"""
|
94
|
+
Generate valid driving speeds as a simulation-time array given a set of average speeds for each
|
95
|
+
simulated lap.
|
96
|
+
Driving speeds will only be non-zero when we are allowed to drive, and the speed
|
97
|
+
for every tick during a lap will be that lap's corresponding desired average speed for as long
|
98
|
+
as it takes to complete the lap.
|
99
|
+
|
100
|
+
:param average_lap_speeds: An array of average speeds in m/s, one for each simulated lap.
|
101
|
+
If there are more speeds given than laps available, the unused speeds will be silently ignored.
|
102
|
+
If there are too few, an error will be returned.
|
103
|
+
:param simulation_dt: The simulated tick length.
|
104
|
+
:param driving_allowed: A simulation-time boolean where the `True` elements are when we
|
105
|
+
are allowed to drive, and `False` is when we are not. Requires that (at least) the first element is
|
106
|
+
`False` due to the race beginning in the morning before we are allowed to drive.
|
107
|
+
:param idle_time: The length of time to pause driving upon processing a "0m/s" average speed.
|
108
|
+
:return: A simulation-time array of driving speeds in m/s, or an error if there weren't enough
|
109
|
+
laps provided to fill the entire simulation time.
|
110
|
+
"""
|
111
|
+
return physics_rs.get_driving_speeds(
|
112
|
+
np.array(average_lap_speeds).astype(np.float64),
|
113
|
+
simulation_dt,
|
114
|
+
np.array(driving_allowed).astype(bool),
|
115
|
+
self.path_length,
|
116
|
+
idle_time
|
117
|
+
)
|
84
118
|
|
85
119
|
@staticmethod
|
86
120
|
def _python_calculate_closest_gis_indices(distances, path_distances):
|
87
121
|
"""
|
88
122
|
|
89
|
-
Python implementation of
|
123
|
+
Python implementation of use_compiled core.closest_gis_indices_loop. See parent function for documentation details.
|
90
124
|
|
91
125
|
"""
|
92
126
|
|
physics/environment/gis/gis.rs
CHANGED
@@ -1,5 +1,4 @@
|
|
1
|
-
use
|
2
|
-
use numpy::ndarray::{s, Array, Array2, ArrayViewD, ArrayViewMut2, ArrayViewMut3, Axis};
|
1
|
+
use numpy::ndarray::{ArrayViewD, ArrayView1};
|
3
2
|
|
4
3
|
pub fn rust_closest_gis_indices_loop(
|
5
4
|
distances: ArrayViewD<'_, f64>,
|
@@ -15,6 +14,9 @@ pub fn rust_closest_gis_indices_loop(
|
|
15
14
|
while distance_travelled > path_distances[current_coord_index] {
|
16
15
|
distance_travelled -= path_distances[current_coord_index];
|
17
16
|
current_coord_index += 1;
|
17
|
+
if current_coord_index >= path_distances.len() {
|
18
|
+
current_coord_index = 0;
|
19
|
+
}
|
18
20
|
}
|
19
21
|
|
20
22
|
current_coord_index = std::cmp::min(current_coord_index, path_distances.len() - 1);
|
@@ -22,4 +24,90 @@ pub fn rust_closest_gis_indices_loop(
|
|
22
24
|
}
|
23
25
|
|
24
26
|
result
|
25
|
-
}
|
27
|
+
}
|
28
|
+
|
29
|
+
///
|
30
|
+
/// Generate valid driving speeds as a simulation-time array given a set of average speeds for each
|
31
|
+
/// simulated lap.
|
32
|
+
/// Driving speeds will only be non-zero when we are allowed to drive, and the speed
|
33
|
+
/// for every tick during a lap will be that lap's corresponding desired average speed for as long
|
34
|
+
/// as it takes to complete the lap.
|
35
|
+
/// An average speed of 0m/s for a lap will be interpreted as "sit and charge" for `idle_time`
|
36
|
+
/// ticks.
|
37
|
+
///
|
38
|
+
/// # Arguments
|
39
|
+
///
|
40
|
+
/// * `average_speeds`: An array of average speeds in m/s, one for each simulated lap. If there are more
|
41
|
+
/// speeds given than laps available, the unused speeds will be silently ignored. If there are too
|
42
|
+
/// few, an error will be returned.
|
43
|
+
/// * `simulation_dt`: The simulated tick length
|
44
|
+
/// * `driving_allowed_boolean`: A simulation-time boolean where the `True` elements are when we
|
45
|
+
/// are allowed to drive, and `False` is when we are not.
|
46
|
+
/// * `track_length`: The length of the track in meters.
|
47
|
+
/// * `idle_time`: The number of ticks to "sit and charge" when desired.
|
48
|
+
///
|
49
|
+
/// Returns: A simulation-time array of driving speeds in m/s, or an error if there weren't enough
|
50
|
+
/// laps provided to fill the entire simulation time.
|
51
|
+
///
|
52
|
+
pub fn get_driving_speeds(
|
53
|
+
average_speeds: ArrayView1<'_, f64>, // Average speeds in m/s
|
54
|
+
simulation_dt: i64, // Time step in seconds
|
55
|
+
driving_allowed_boolean: ArrayView1<'_, bool>, // Simulation-time boolean array
|
56
|
+
track_length: f64, // Track length in meters
|
57
|
+
idle_time: i64 // Time to idle in seconds
|
58
|
+
) -> Result<Vec<f64>, &'static str> {
|
59
|
+
let ticks_to_complete_lap: Vec<i64> = average_speeds.iter().map(| &average_speed | {
|
60
|
+
if average_speed > 0.0 {
|
61
|
+
// The number of ticks is the number of seconds, divided by seconds per tick
|
62
|
+
(track_length / average_speed / simulation_dt as f64).ceil() as i64
|
63
|
+
} else {
|
64
|
+
(idle_time as f64 / simulation_dt as f64).ceil() as i64
|
65
|
+
}
|
66
|
+
}).collect();
|
67
|
+
|
68
|
+
let mut lap_index: usize = 0;
|
69
|
+
let mut lap_speed: f64 = average_speeds[lap_index];
|
70
|
+
|
71
|
+
let mut ticks_to_lap_completion: i64 = ticks_to_complete_lap[lap_index];
|
72
|
+
|
73
|
+
let mut driving_speeds: Vec<f64> = Vec::with_capacity(driving_allowed_boolean.len());
|
74
|
+
for driving_allowed in driving_allowed_boolean.iter() {
|
75
|
+
if !driving_allowed {
|
76
|
+
// If we aren't allowed to drive, speed should be zero. Also, we should mark that we are
|
77
|
+
// done our lap since it means we ended the day in the middle of the lap, and we will
|
78
|
+
// start the next day at the beginning of a new lap, not where we ended off.
|
79
|
+
|
80
|
+
// If it's the first lap, we don't want to skip because we are probably in the morning
|
81
|
+
// where we haven't begun driving yet.
|
82
|
+
if lap_index > 0 {
|
83
|
+
ticks_to_lap_completion = 0;
|
84
|
+
}
|
85
|
+
|
86
|
+
driving_speeds.push(0.0)
|
87
|
+
} else {
|
88
|
+
// If we are driving, we should decrement ticks to lap completion. If its already
|
89
|
+
// zero, that means that we are done the lap and should move onto the next lap.
|
90
|
+
if ticks_to_lap_completion > 0 {
|
91
|
+
ticks_to_lap_completion -= 1;
|
92
|
+
|
93
|
+
driving_speeds.push(lap_speed)
|
94
|
+
} else {
|
95
|
+
// To advance to the next lap, increment the index and evaluate new variables
|
96
|
+
lap_index += 1;
|
97
|
+
if lap_index >= average_speeds.len() {
|
98
|
+
return Err("Not enough average speeds!")
|
99
|
+
}
|
100
|
+
|
101
|
+
// We subtract 1 since this iteration counts for the next lap, not the one
|
102
|
+
// that we just finished
|
103
|
+
ticks_to_lap_completion = ticks_to_complete_lap[lap_index] - 1;
|
104
|
+
lap_speed = average_speeds[lap_index];
|
105
|
+
|
106
|
+
driving_speeds.push(lap_speed)
|
107
|
+
}
|
108
|
+
}
|
109
|
+
|
110
|
+
}
|
111
|
+
|
112
|
+
Ok(driving_speeds)
|
113
|
+
}
|
@@ -2,7 +2,7 @@ from physics.environment.meteorology.base_meteorology import BaseMeteorology
|
|
2
2
|
from physics.environment.gis.gis import calculate_path_distances
|
3
3
|
import numpy as np
|
4
4
|
from numba import jit
|
5
|
-
import
|
5
|
+
import physics_rs
|
6
6
|
from typing import Optional
|
7
7
|
import datetime
|
8
8
|
|
@@ -70,7 +70,7 @@ class CloudedMeteorology(BaseMeteorology):
|
|
70
70
|
# contains the average distance between two consecutive elements in the cumulative_weather_path_distances array
|
71
71
|
average_distances = np.abs(np.diff(cumulative_weather_path_distances) / 2)
|
72
72
|
|
73
|
-
return
|
73
|
+
return physics_rs.closest_weather_indices_loop(cumulative_distances, average_distances)
|
74
74
|
|
75
75
|
def temporally_localize(self, unix_timestamps, start_time, tick) -> None:
|
76
76
|
"""
|
@@ -96,7 +96,7 @@ class CloudedMeteorology(BaseMeteorology):
|
|
96
96
|
:rtype: np.ndarray
|
97
97
|
|
98
98
|
"""
|
99
|
-
weather_data =
|
99
|
+
weather_data = physics_rs.weather_in_time(unix_timestamps.astype(np.int64), self._weather_indices.astype(np.int64), self._weather_forecast, 4)
|
100
100
|
# roll_by_tick = int(3600 / tick) * (24 + start_hour - hour_from_unix_timestamp(weather_data[0, 2]))
|
101
101
|
# weather_data = np.roll(weather_data, -roll_by_tick, 0)
|
102
102
|
|
@@ -124,7 +124,7 @@ class CloudedMeteorology(BaseMeteorology):
|
|
124
124
|
:rtype: np.ndarray
|
125
125
|
|
126
126
|
"""
|
127
|
-
day_of_year, local_time =
|
127
|
+
day_of_year, local_time = physics_rs.calculate_array_ghi_times(local_times)
|
128
128
|
|
129
129
|
ghi = self._calculate_GHI(coords[:, 0], coords[:, 1], time_zones,
|
130
130
|
day_of_year, local_time, elevations, self._cloud_cover)
|
@@ -1,7 +1,7 @@
|
|
1
1
|
from physics.environment.meteorology.base_meteorology import BaseMeteorology
|
2
2
|
from physics.environment.gis.gis import calculate_path_distances
|
3
3
|
import numpy as np
|
4
|
-
import
|
4
|
+
import physics_rs
|
5
5
|
from typing import Optional
|
6
6
|
|
7
7
|
|
@@ -11,6 +11,7 @@ class IrradiantMeteorology(BaseMeteorology):
|
|
11
11
|
solar irradiance data, but not cloud cover.
|
12
12
|
|
13
13
|
"""
|
14
|
+
|
14
15
|
def __init__(self, race, weather_forecasts):
|
15
16
|
self._race = race
|
16
17
|
self._raw_weather_data = weather_forecasts
|
@@ -53,7 +54,7 @@ class IrradiantMeteorology(BaseMeteorology):
|
|
53
54
|
# contains the average distance between two consecutive elements in the cumulative_weather_path_distances array
|
54
55
|
average_distances = np.abs(np.diff(cumulative_weather_path_distances) / 2)
|
55
56
|
|
56
|
-
self._weather_indices =
|
57
|
+
self._weather_indices = physics_rs.closest_weather_indices_loop(cumulative_distances, average_distances)
|
57
58
|
|
58
59
|
def temporally_localize(self, unix_timestamps, start_time, tick) -> None:
|
59
60
|
"""
|
@@ -76,8 +77,9 @@ class IrradiantMeteorology(BaseMeteorology):
|
|
76
77
|
:returns: a SolcastEnvironment object with time_dt, latitude, longitude, wind_speed, wind_direction, and ghi.
|
77
78
|
:rtype: SolcastEnvironment
|
78
79
|
"""
|
79
|
-
forecasts_array =
|
80
|
-
|
80
|
+
forecasts_array = physics_rs.weather_in_time(unix_timestamps.astype(np.int64),
|
81
|
+
self._weather_indices.astype(np.int64),
|
82
|
+
self._raw_weather_data, 0)
|
81
83
|
|
82
84
|
self._time_dt = forecasts_array[:, 0]
|
83
85
|
self._latitude = forecasts_array[:, 1]
|
@@ -103,5 +105,3 @@ class IrradiantMeteorology(BaseMeteorology):
|
|
103
105
|
|
104
106
|
"""
|
105
107
|
return self.solar_irradiance
|
106
|
-
|
107
|
-
|
physics/lib.rs
CHANGED
@@ -1,13 +1,13 @@
|
|
1
|
-
use
|
2
|
-
use numpy::
|
3
|
-
use numpy::{PyArray, PyArrayDyn, PyReadwriteArrayDyn};
|
1
|
+
use numpy::ndarray::ArrayViewD;
|
2
|
+
use numpy::{PyArray, PyArrayDyn, PyReadwriteArrayDyn, PyReadwriteArray1, PyReadonlyArray1, PyArray1};
|
4
3
|
use pyo3::prelude::*;
|
5
4
|
use pyo3::types::PyModule;
|
6
5
|
|
7
6
|
pub mod environment;
|
8
7
|
pub mod models;
|
9
|
-
use crate::environment::gis::gis::rust_closest_gis_indices_loop;
|
10
|
-
use crate::environment::meteorology::meteorology::{rust_calculate_array_ghi_times, rust_closest_weather_indices_loop, rust_weather_in_time
|
8
|
+
use crate::environment::gis::gis::{rust_closest_gis_indices_loop, get_driving_speeds};
|
9
|
+
use crate::environment::meteorology::meteorology::{rust_calculate_array_ghi_times, rust_closest_weather_indices_loop, rust_weather_in_time};
|
10
|
+
use crate::models::battery::battery::update_battery_state;
|
11
11
|
|
12
12
|
fn constrain_speeds(speed_limits: ArrayViewD<f64>, speeds: ArrayViewD<f64>, tick: i32) -> Vec<f64> {
|
13
13
|
let mut distance: f64 = 0.0;
|
@@ -25,7 +25,7 @@ fn constrain_speeds(speed_limits: ArrayViewD<f64>, speeds: ArrayViewD<f64>, tic
|
|
25
25
|
|
26
26
|
/// A Python module implemented in Rust. The name of this function is the Rust module name!
|
27
27
|
#[pymodule]
|
28
|
-
#[pyo3(name = "
|
28
|
+
#[pyo3(name = "physics_rs")]
|
29
29
|
fn rust_simulation(_py: Python, m: &PyModule) -> PyResult<()> {
|
30
30
|
#[pyfn(m)]
|
31
31
|
#[pyo3(name = "constrain_speeds")]
|
@@ -94,5 +94,71 @@ fn rust_simulation(_py: Python, m: &PyModule) -> PyResult<()> {
|
|
94
94
|
py_result
|
95
95
|
}
|
96
96
|
|
97
|
+
#[pyfn(m)]
|
98
|
+
#[pyo3(name = "update_battery_state")]
|
99
|
+
fn update_battery_state_py<'py>(
|
100
|
+
py: Python<'py>,
|
101
|
+
python_energy_or_current_array: PyReadwriteArray1<'py, f64>,
|
102
|
+
time_step: f64,
|
103
|
+
initial_state_of_charge: f64,
|
104
|
+
initial_polarization_potential: f64,
|
105
|
+
python_internal_resistance_lookup: PyReadwriteArray1<'py, f64>,
|
106
|
+
python_open_circuit_voltage_lookup: PyReadwriteArray1<'py, f64>,
|
107
|
+
python_polarization_resistance_lookup: PyReadwriteArray1<'py, f64>,
|
108
|
+
python_polarization_capacitance_lookup: PyReadwriteArray1<'py, f64>,
|
109
|
+
nominal_charge_capacity: f64,
|
110
|
+
is_power: bool,
|
111
|
+
quantization_step: f64,
|
112
|
+
min_soc: f64,
|
113
|
+
) -> (&'py PyArray1<f64>, &'py PyArray1<f64>) {
|
114
|
+
let energy_or_current_array = python_energy_or_current_array.as_array();
|
115
|
+
let internal_resistance_lookup = python_internal_resistance_lookup.as_array();
|
116
|
+
let open_circuit_voltage_lookup = python_open_circuit_voltage_lookup.as_array();
|
117
|
+
let polarization_resistance_lookup = python_polarization_resistance_lookup.as_array();
|
118
|
+
let polarization_capacitance_lookup = python_polarization_capacitance_lookup.as_array();
|
119
|
+
let (soc_array, voltage_array): (Vec<f64>, Vec<f64>) = update_battery_state(
|
120
|
+
energy_or_current_array,
|
121
|
+
time_step,
|
122
|
+
initial_state_of_charge,
|
123
|
+
initial_polarization_potential,
|
124
|
+
internal_resistance_lookup,
|
125
|
+
open_circuit_voltage_lookup,
|
126
|
+
polarization_resistance_lookup,
|
127
|
+
polarization_capacitance_lookup,
|
128
|
+
nominal_charge_capacity,
|
129
|
+
is_power,
|
130
|
+
quantization_step,
|
131
|
+
min_soc
|
132
|
+
);
|
133
|
+
let py_soc_array = PyArray::from_vec(py, soc_array);
|
134
|
+
let py_voltage_array = PyArray::from_vec(py, voltage_array);
|
135
|
+
(py_soc_array, py_voltage_array)
|
136
|
+
}
|
137
|
+
|
138
|
+
#[pyfn(m)]
|
139
|
+
#[pyo3(name = "get_driving_speeds")]
|
140
|
+
fn py_get_driving_speeds<'py>(
|
141
|
+
py: Python<'py>,
|
142
|
+
py_average_speeds: PyReadonlyArray1<'py, f64>, // Average speeds in m/s
|
143
|
+
simulation_dt: i64, // Time step in seconds
|
144
|
+
py_driving_allowed_boolean: PyReadonlyArray1<'py, bool>, // Simulation-time boolean array
|
145
|
+
track_length: f64, // Track length in meters
|
146
|
+
idle_time: i64 // Time to idle in seconds
|
147
|
+
) -> PyResult<&'py PyArray1<f64>> {
|
148
|
+
let average_speeds = py_average_speeds.as_array();
|
149
|
+
let driving_allowed_boolean = py_driving_allowed_boolean.as_array();
|
150
|
+
|
151
|
+
match get_driving_speeds(
|
152
|
+
average_speeds,
|
153
|
+
simulation_dt,
|
154
|
+
driving_allowed_boolean,
|
155
|
+
track_length,
|
156
|
+
idle_time
|
157
|
+
) {
|
158
|
+
Ok(driving_speeds) => Ok(PyArray1::from_vec(py, driving_speeds)),
|
159
|
+
Err(error) => Err(pyo3::exceptions::PyValueError::new_err(error))
|
160
|
+
}
|
161
|
+
}
|
162
|
+
|
97
163
|
Ok(())
|
98
164
|
}
|
@@ -1,7 +1,18 @@
|
|
1
1
|
from .base_battery import BaseBattery
|
2
2
|
from .basic_battery import BasicBattery
|
3
|
+
from .battery_model import EquivalentCircuitBatteryModel, EquivalentCircuitModelConfig, SOCDependent
|
4
|
+
from .kalman_filter import FilteredBatteryModel, FilteredBatteryModelConfig
|
5
|
+
from .battery_config import BatteryModelConfig, load_battery_config, KalmanFilterConfig
|
3
6
|
|
4
7
|
__all__ = [
|
5
8
|
"BaseBattery",
|
6
|
-
"BasicBattery"
|
9
|
+
"BasicBattery",
|
10
|
+
"EquivalentCircuitBatteryModel",
|
11
|
+
"FilteredBatteryModel",
|
12
|
+
"BatteryModelConfig",
|
13
|
+
"load_battery_config",
|
14
|
+
"EquivalentCircuitModelConfig",
|
15
|
+
"FilteredBatteryModelConfig",
|
16
|
+
"KalmanFilterConfig",
|
17
|
+
"SOCDependent"
|
7
18
|
]
|
@@ -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]
|