Repository: ROVI-org/auto-soh
Pull request #135: Cache the SOC interpolation functions
Author: login: WardLT
Created: 2025-03-05T14:30:18Z | Merged: 2025-03-12T12:09:09Z

Avoid re-fitting the SOC interpolation functions at each step.


Diff:
diff --git a/moirae/models/ecm/components.py b/moirae/models/ecm/components.py
index cb314bba..346371ee 100644
--- a/moirae/models/ecm/components.py
+++ b/moirae/models/ecm/components.py
@@ -28,7 +28,7 @@ def value(self, amp_seconds: float) -> None:
         self.base_values = amp_seconds / 3600.
 
     @property
-    def amp_hour(self) -> float:
+    def amp_hour(self) -> np.ndarray:
         """
         Returns capacity in Amp-hour, as it was initialized.
         """
diff --git a/moirae/models/ecm/utils.py b/moirae/models/ecm/utils.py
index 4bf43a63..bf1c15ce 100644
--- a/moirae/models/ecm/utils.py
+++ b/moirae/models/ecm/utils.py
@@ -1,8 +1,9 @@
+"""Functions used in multiple components"""
 from typing import List, Optional, Union, Literal, Callable, Iterator
 from numbers import Number
 
 import numpy as np
-from pydantic import Field
+from pydantic import Field, PrivateAttr
 from scipy.interpolate import interp1d
 from numpy.polynomial.legendre import Legendre
 
@@ -10,8 +11,7 @@
 
 
 class SOCInterpolatedHealth(HealthVariable):
-    """Defines basic functionality for HealthVariables that need interpolation between SOC pinpoints
-    """
+    """Health variables which vary as a function of SOC"""
     base_values: ListParameter = \
         Field(default=0, description='Values at specified SOCs')
     soc_pinpoints: Optional[NumpyType] = Field(default=None, description='SOC pinpoints for interpolation.')
@@ -19,25 +19,46 @@ class SOCInterpolatedHealth(HealthVariable):
                                  'quadratic', 'cubic', 'previous', 'next'] = \
         Field(default='linear', description='Type of interpolation to perform')
 
+    # Internal caches
+    _ppoly_cache: tuple[np.ndarray, np.ndarray, Callable] | None = PrivateAttr(None)
+    """Cache for the interpolation function, and the interpolation points/soc points at which it was produced"""
+
     def iter_parameters(self, updatable_only: bool = True, recurse: bool = True) -> Iterator[tuple[str, np.ndarray]]:
         for name, param in super().iter_parameters(updatable_only, recurse):
             if name != "soc_pinpoints":
                 yield name, param
 
-    @property
-    def _interp_func(self) -> Callable:
-        """
-        Interpolate values. If soc_pinpoints have not been set, assume
-        internal_parameters are evenly spread on an SOC interval [0,1].
+    def _get_function(self) -> Callable:
+        """Retrieve the interpolation function
 
+        Uses the internal cache, :attr:`_ppoly_cache`, if the :attr:`base_values` has not changed.
+
+        Returns:
+            The polynomial of interest
         """
+
+        # Return the cached spline if it exists and the base values have not changed
+        if self._ppoly_cache is not None:
+            org_soc, orig_array, ppoly = self._ppoly_cache
+            if np.array_equal(orig_array, self.base_values) \
+                    and np.array_equal(org_soc, self.soc_pinpoints):
+                return ppoly
+
+            # Otherwise, clear the cache and continue
+            self._ppoly_cache = None
+
+        # Generate the SOC interpolation points if they don't already exist
         if self.soc_pinpoints is None:
             self.soc_pinpoints = np.linspace(0, 1, self.base_values.shape[-1])
+
+        # Make the spline then cache it
         func = interp1d(self.soc_pinpoints,
                         self.base_values,
                         kind=self.interpolation_style,
                         bounds_error=False,
                         fill_value='extrapolate')
+        if self.interpolation_style not in {'linear', 'nearest', 'nearest-up'}:  # Don't cache lazy models
+            self._ppoly_cache = (self.soc_pinpoints.copy(), self.base_values.copy(), func)
         return func
 
     # TODO (wardlt): We might only use the diagonal terms from the output, maybe simplify by not computing off-diagonal
@@ -50,7 +71,7 @@ def get_value(self, soc: Union[Number, List, np.ndarray]) -> np.ndarray:
         of the SOC array, and `soc_dim` is the dimensionality of the SOC. The SOC must be passed as either:
         1. a 2D array of shape `(soc_batch_size, soc_dim)`
         2. a 1D array of shape `(soc_dim,)`, in which case we will consider the `soc_batch_size` to be equal to 1
-        3. a 0D array (that is, a numer), in which case both `soc_batch_size` and `soc_dim` are equal to 1.
+        3. a 0D array (that is, a number), in which case both `soc_batch_size` and `soc_dim` are equal to 1.
 
         Args:
             soc: Values at which to compute the property
@@ -69,7 +90,7 @@ def get_value(self, soc: Union[Number, List, np.ndarray]) -> np.ndarray:
             y = np.swapaxes(y, 0, 1)  # shape = (internal_batch, soc_batch, soc_dim)
         # Otherwise, run the interpolator, but the results mean something different
         else:
-            y = self._interp_func(soc)  # interpolator adds batch dimension:
+            y = self._get_function()(soc)  # interpolator adds batch dimension:
             # If the SOC was batched, the shape is (internal_batch, soc_batch, soc_dim)
             # Otherwise, the shape is (internal_batch, soc_dim)
             y = y.reshape((internal_batch_size, soc_batch_size, soc_dim))
@@ -174,32 +195,23 @@ def hysteresis_solver_const_sign(
         kappa: Union[float, np.ndarray],
         dt: Union[float, np.ndarray],
         i0: Union[float, np.ndarray],
-        alpha: Union[float, np.ndarray]) -> float:
+        alpha: Union[float, np.ndarray]
+) -> float:
     """
     Helper function to solve for hysteresis at time dt given initial conditions,
     parameters, and current and current slope. Assumes current does not change
     sign during time interval
 
-    Arguments
-    ---------
-    h0: float
-        Initial value of hysteresis, corresponding to h[0]
-    M: float
-        Asymptotic value of hysteresis at present condition (the value h[t]
-        should approach)
-    kappa: float
-        Constant representing the product of gamma (SOC-based rate at which
-        hysteresis approaches M), Coulombic efficienty, and 1/Qt
-    dt: float
-        Length of time interval
-    i0: float
-        Initial current
-    alpha:
-        Slope of current profile during time interval
-
-    Outputs
-    -------
-    h_dt: float
+    Args:
+        h0: Initial value of hysteresis, corresponding to h[0]
+        M: Asymptotic value of hysteresis at present condition (the value h[t] should approach)
+        kappa: Constant representing the product of gamma (SOC-based rate at which hysteresis approaches M),
+            Coulombic efficienty, and 1/Qt
+        dt: Length of time interval
+        i0: Initial current
+        alpha: Slope of current profile during time interval
+
+    Returns:
         Hysteresis value at the end of the time interval
     """
     assert i0 * (i0 + (alpha * dt)) >= 0, 'Current flips sign in interval dt!!'
diff --git a/tests/models/ecm/test_ecm_utils.py b/tests/models/ecm/test_ecm_utils.py
index ea308e58..1b07dadb 100644
--- a/tests/models/ecm/test_ecm_utils.py
+++ b/tests/models/ecm/test_ecm_utils.py
@@ -82,9 +82,11 @@ def test_quad(quadratic):
 
 def test_cube(cubic):
     values = np.random.rand(100)
-    assert np.isclose(values * values * values,
-                      cubic.get_value(values),
+    result = cubic.get_value(values)
+    assert np.isclose(values * values * values, result,
                       atol=1e-12).all(), 'Wrong cubic interpolation!'
+    cached_result = cubic.get_value(values)
+    assert np.allclose(result, cached_result)
 
 
 def test_serialization():


Reviews:
user: login: victorventuri
body: I think this is fine, but I don't think we are yet at a point where we can take full advantage of this, as most of our interpolations are linear and come with close to no computational burden to be done on-the-fly/as needed.
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-03-05T20:46:41Z
user: login: WardLT
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-05T20:52:46Z
user: login: victorventuri
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-05T21:12:48Z

Review comments:
diff_hunk: @@ -28,7 +28,7 @@ def value(self, amp_seconds: float) -> None:
         self.base_values = amp_seconds / 3600.
 
     @property
-    def amp_hour(self) -> float:
+    def amp_hour(self) -> np.ndarray:
path: moirae/models/ecm/components.py
user: login: victorventuri
body: thank you, this had been bugging me for soooooo long and i always forgot to change it
created_at: 2025-03-05T20:38:14Z
author_association: COLLABORATOR
diff_hunk: @@ -28,7 +28,7 @@ def value(self, amp_seconds: float) -> None:
         self.base_values = amp_seconds / 3600.
 
     @property
-    def amp_hour(self) -> float:
+    def amp_hour(self) -> np.ndarray:
path: moirae/models/ecm/components.py
user: login: WardLT
body: Knowing that message annoys you, I can do one better! I fixed the thousands of errors that come out of battdat's use of deprecated features in battery-data-toolkit: https://github.com/ROVI-org/battery-data-toolkit/pull/127
created_at: 2025-03-05T20:52:46Z
author_association: CONTRIBUTOR
diff_hunk: @@ -28,7 +28,7 @@ def value(self, amp_seconds: float) -> None:
         self.base_values = amp_seconds / 3600.
 
     @property
-    def amp_hour(self) -> float:
+    def amp_hour(self) -> np.ndarray:
path: moirae/models/ecm/components.py
user: login: victorventuri
body: Fantastic :heart_eyes:! are they merged to main on the toolkit side?
created_at: 2025-03-05T21:12:48Z
author_association: COLLABORATOR

PR comments:
user: login: WardLT
created_at: 2025-03-05T20:54:49Z
body: How about I add in some logic to skip caching for linear interpolation? I'd like to keep this logic in here so that we can use it later if we move to cubic splines or whatever
author_association: CONTRIBUTOR
user: login: victorventuri
created_at: 2025-03-05T21:14:02Z
body: Yeah, that's a good idea! This way, we know that everything we have already done should be working exactly the same, but we have now access to additional functionality as well!

author_association: COLLABORATOR