ubc-solar-physics 1.0.5__cp313-cp313-macosx_11_0_arm64.whl → 1.7.3__cp313-cp313-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.
Files changed (33) hide show
  1. physics/_version.py +2 -2
  2. physics/environment/__init__.py +0 -7
  3. physics/environment/gis/base_gis.py +14 -0
  4. physics/environment/gis/gis.py +37 -3
  5. physics/environment/gis/gis.rs +91 -3
  6. physics/environment/meteorology/clouded_meteorology.py +8 -9
  7. physics/environment/meteorology/irradiant_meteorology.py +11 -10
  8. physics/lib.rs +72 -6
  9. physics/models/battery/__init__.py +12 -1
  10. physics/models/battery/basic_battery.py +0 -1
  11. physics/models/battery/battery.rs +102 -0
  12. physics/models/battery/battery_config.py +107 -0
  13. physics/models/battery/battery_config.toml +6 -0
  14. physics/models/battery/battery_model.py +226 -0
  15. physics/models/battery/kalman_filter.py +223 -0
  16. physics/models/battery.rs +1 -1
  17. physics/models/motor/__init__.py +3 -1
  18. physics/models/motor/advanced_motor.py +196 -0
  19. physics/models/motor/base_motor.py +2 -0
  20. physics/models/motor/basic_motor.py +33 -14
  21. physics/models/regen/basic_regen.py +14 -1
  22. physics/models.rs +1 -1
  23. physics_rs/__init__.pyi +111 -0
  24. physics_rs.cpython-313-darwin.so +0 -0
  25. {ubc_solar_physics-1.0.5.dist-info → ubc_solar_physics-1.7.3.dist-info}/METADATA +12 -5
  26. ubc_solar_physics-1.7.3.dist-info/RECORD +57 -0
  27. {ubc_solar_physics-1.0.5.dist-info → ubc_solar_physics-1.7.3.dist-info}/WHEEL +1 -1
  28. ubc_solar_physics-1.7.3.dist-info/top_level.txt +2 -0
  29. core.cpython-313-darwin.so +0 -0
  30. physics/environment/race.py +0 -89
  31. ubc_solar_physics-1.0.5.dist-info/RECORD +0 -52
  32. ubc_solar_physics-1.0.5.dist-info/top_level.txt +0 -1
  33. {ubc_solar_physics-1.0.5.dist-info → ubc_solar_physics-1.7.3.dist-info}/LICENSE +0 -0
physics/_version.py CHANGED
@@ -12,5 +12,5 @@ __version__: str
12
12
  __version_tuple__: VERSION_TUPLE
13
13
  version_tuple: VERSION_TUPLE
14
14
 
15
- __version__ = version = '1.0.5'
16
- __version_tuple__ = version_tuple = (1, 0, 5)
15
+ __version__ = version = '1.7.3'
16
+ __version_tuple__ = version_tuple = (1, 7, 3)
@@ -1,8 +1,3 @@
1
- from .race import (
2
- Race,
3
- compile_races
4
- )
5
-
6
1
  from .gis import (
7
2
  GIS,
8
3
  )
@@ -17,6 +12,4 @@ __all__ = [
17
12
  "IrradiantMeteorology",
18
13
  "CloudedMeteorology",
19
14
  "GIS",
20
- "Race",
21
- "compile_races"
22
15
  ]
@@ -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
@@ -1,9 +1,10 @@
1
1
  import logging
2
2
  import math
3
- import core
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 core.closest_gis_indices_loop(distances, self.path_distances)
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 rust core.closest_gis_indices_loop. See parent function for documentation details.
123
+ Python implementation of use_compiled core.closest_gis_indices_loop. See parent function for documentation details.
90
124
 
91
125
  """
92
126
 
@@ -1,5 +1,4 @@
1
- use chrono::{Datelike, NaiveDateTime, Timelike};
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
+ }
@@ -1,9 +1,8 @@
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
- from physics.environment.race import Race
5
4
  from numba import jit
6
- import core
5
+ import physics_rs
7
6
  from typing import Optional
8
7
  import datetime
9
8
 
@@ -28,7 +27,7 @@ class CloudedMeteorology(BaseMeteorology):
28
27
 
29
28
  self.last_updated_time = self._weather_forecast[0, 0, 2]
30
29
 
31
- def spatially_localize(self, cumulative_distances: np.ndarray) -> None:
30
+ def spatially_localize(self, cumulative_distances: np.ndarray, simplify_weather: bool = False) -> None:
32
31
  """
33
32
 
34
33
  IMPORTANT: we only have weather coordinates for a discrete set of coordinates. However, the car could be at any
@@ -46,14 +45,14 @@ class CloudedMeteorology(BaseMeteorology):
46
45
  `get_weather_forecast_in_time()` method.
47
46
 
48
47
  :param np.ndarray cumulative_distances: NumPy Array representing cumulative distances theoretically achievable for a given input speed array
49
-
48
+ :param bool simplify_weather: enable to only use a single weather coordinate (for track races without varying weather)
50
49
  """
51
50
 
52
- # if racing FSGP, there is no need for distance calculations. We will return only the origin coordinate
51
+ # If racing a track race, there is no need for distance calculations. We will return only the origin coordinate
53
52
  # This characterizes the weather at every point along the FSGP tracks
54
53
  # with the weather at a single coordinate on the track, which is great for reducing the API calls and is a
55
54
  # reasonable assumption to make for FSGP only.
56
- if self._race.race_type == Race.FSGP:
55
+ if simplify_weather:
57
56
  self._weather_indices = np.zeros_like(cumulative_distances, dtype=int)
58
57
  return
59
58
 
@@ -71,7 +70,7 @@ class CloudedMeteorology(BaseMeteorology):
71
70
  # contains the average distance between two consecutive elements in the cumulative_weather_path_distances array
72
71
  average_distances = np.abs(np.diff(cumulative_weather_path_distances) / 2)
73
72
 
74
- return core.closest_weather_indices_loop(cumulative_distances, average_distances)
73
+ return physics_rs.closest_weather_indices_loop(cumulative_distances, average_distances)
75
74
 
76
75
  def temporally_localize(self, unix_timestamps, start_time, tick) -> None:
77
76
  """
@@ -97,7 +96,7 @@ class CloudedMeteorology(BaseMeteorology):
97
96
  :rtype: np.ndarray
98
97
 
99
98
  """
100
- weather_data = core.weather_in_time(unix_timestamps.astype(np.int64), self._weather_indices.astype(np.int64), self._weather_forecast, 4)
99
+ weather_data = physics_rs.weather_in_time(unix_timestamps.astype(np.int64), self._weather_indices.astype(np.int64), self._weather_forecast, 4)
101
100
  # roll_by_tick = int(3600 / tick) * (24 + start_hour - hour_from_unix_timestamp(weather_data[0, 2]))
102
101
  # weather_data = np.roll(weather_data, -roll_by_tick, 0)
103
102
 
@@ -125,7 +124,7 @@ class CloudedMeteorology(BaseMeteorology):
125
124
  :rtype: np.ndarray
126
125
 
127
126
  """
128
- day_of_year, local_time = core.calculate_array_ghi_times(local_times)
127
+ day_of_year, local_time = physics_rs.calculate_array_ghi_times(local_times)
129
128
 
130
129
  ghi = self._calculate_GHI(coords[:, 0], coords[:, 1], time_zones,
131
130
  day_of_year, local_time, elevations, self._cloud_cover)
@@ -1,8 +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
- from physics.environment.race import Race
5
- import core
4
+ import physics_rs
6
5
  from typing import Optional
7
6
 
8
7
 
@@ -12,6 +11,7 @@ class IrradiantMeteorology(BaseMeteorology):
12
11
  solar irradiance data, but not cloud cover.
13
12
 
14
13
  """
14
+
15
15
  def __init__(self, race, weather_forecasts):
16
16
  self._race = race
17
17
  self._raw_weather_data = weather_forecasts
@@ -25,16 +25,18 @@ class IrradiantMeteorology(BaseMeteorology):
25
25
 
26
26
  super().__init__()
27
27
 
28
- def spatially_localize(self, cumulative_distances: np.ndarray) -> None:
28
+ def spatially_localize(self, cumulative_distances: np.ndarray, simplify_weather: bool = False) -> None:
29
29
  """
30
30
 
31
31
  :param np.ndarray cumulative_distances: NumPy Array representing cumulative distances theoretically achievable for a given input speed array
32
+ :param bool simplify_weather: enable to only use a single weather coordinate (for track races without varying weather)
33
+
32
34
  """
33
- # if racing FSGP, there is no need for distance calculations. We will return only the origin coordinate
35
+ # If racing a track race, there is no need for distance calculations. We will return only the origin coordinate
34
36
  # This characterizes the weather at every point along the FSGP tracks
35
37
  # with the weather at a single coordinate on the track, which is great for reducing the API calls and is a
36
38
  # reasonable assumption to make for FSGP only.
37
- if self._race.race_type == Race.FSGP:
39
+ if simplify_weather:
38
40
  self._weather_indices = np.zeros_like(cumulative_distances, dtype=int)
39
41
  return
40
42
 
@@ -52,7 +54,7 @@ class IrradiantMeteorology(BaseMeteorology):
52
54
  # contains the average distance between two consecutive elements in the cumulative_weather_path_distances array
53
55
  average_distances = np.abs(np.diff(cumulative_weather_path_distances) / 2)
54
56
 
55
- self._weather_indices = core.closest_weather_indices_loop(cumulative_distances, average_distances)
57
+ self._weather_indices = physics_rs.closest_weather_indices_loop(cumulative_distances, average_distances)
56
58
 
57
59
  def temporally_localize(self, unix_timestamps, start_time, tick) -> None:
58
60
  """
@@ -75,8 +77,9 @@ class IrradiantMeteorology(BaseMeteorology):
75
77
  :returns: a SolcastEnvironment object with time_dt, latitude, longitude, wind_speed, wind_direction, and ghi.
76
78
  :rtype: SolcastEnvironment
77
79
  """
78
- forecasts_array = core.weather_in_time(unix_timestamps.astype(np.int64), self._weather_indices.astype(np.int64),
79
- self._raw_weather_data, 0)
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)
80
83
 
81
84
  self._time_dt = forecasts_array[:, 0]
82
85
  self._latitude = forecasts_array[:, 1]
@@ -102,5 +105,3 @@ class IrradiantMeteorology(BaseMeteorology):
102
105
 
103
106
  """
104
107
  return self.solar_irradiance
105
-
106
-
physics/lib.rs CHANGED
@@ -1,13 +1,13 @@
1
- use chrono::{Datelike, NaiveDateTime, Timelike};
2
- use numpy::ndarray::{s, Array, Array2, ArrayViewD, ArrayViewMut2, ArrayViewMut3, Axis};
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, rust_closest_timestamp_indices};
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 = "core")]
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
  ]
@@ -1,6 +1,5 @@
1
1
  import numpy as np
2
2
  from numpy.polynomial import Polynomial
3
-
4
3
  from physics.models.battery.base_battery import BaseBattery
5
4
 
6
5
 
@@ -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
+ }