Repository: ROVI-org/auto-soh
Pull request #148: Use Thevenin's predictor interface
Author: login: WardLT
Created: 2025-04-21T19:45:54Z | Merged: 2025-04-22T20:24:02Z

The Prediction interface of Thevenin is dramatically faster than the Model interface for our step-by-step solutions. See Thevenin's docs, https://thevenin.readthedocs.io/latest/examples/prediction_interface.html


Diff:
diff --git a/moirae/models/thevenin/__init__.py b/moirae/models/thevenin/__init__.py
index 214410bb..6d58d9bb 100644
--- a/moirae/models/thevenin/__init__.py
+++ b/moirae/models/thevenin/__init__.py
@@ -3,10 +3,10 @@
 from functools import partial
 from typing import Iterator
 
-from thevenin import Model, Experiment
+from thevenin import Prediction, TransientState
 
 from ..base import CellModel, InputQuantities, OutputQuantities
-from .state import TheveninASOH, TheveninTransient
+from moirae.models.thevenin.state import TheveninASOH, TheveninTransient
 from .ins_outs import TheveninInput
 
 
@@ -21,51 +21,82 @@ class TheveninModel(CellModel):
     def __init__(self, isothermal: bool = False):
         self.isothermal = isothermal
         # TODO (wardlt): Add a maximum timestep size? Victor was using that to avoid the issues with the integrator
+        self._predictor = None  # Cache the predictor used between steps
 
-    def _make_models(self, transient: TheveninTransient, asoh: TheveninASOH, inputs: TheveninInput) -> Iterator[Model]:
+    def _make_models(self, transient: TheveninTransient, asoh: TheveninASOH, inputs: TheveninInput) \
+            -> Iterator[tuple[Prediction, TransientState]]:
         """
-        Generate a model for each member of the batch of experimental conditions
+        Update the predictor and state for each member of the batch of experimental conditions
 
         Args:
             transient: Batch of transient states
             asoh: Batch of health variables
+            inputs: Inputs to the battery system
 
         Yields:
-            A model for each member of the batch
+            A Prediction class and Thevenin-compatible state for each member of the batch
         """
 
-        for b in range(max(transient.batch_size, asoh.batch_size, inputs.batch_size)):
-            # The value of each member of the transient or ASOH is a 2D array with the first dimension either 1
-            #  or batch_size. The % signs below are a short syntax for either using the same value for all batches
-            #  (anything mod 1 is 0) or the appropriate member of the batch
-            params = {'num_RC_pairs': asoh.num_rc_elements, 'isothermal': self.isothermal}
-            for scalar, value in [
-                ('soc0', transient.soc), ('capacity', asoh.capacity), ('mass', asoh.mass), ('Cp', asoh.c_p),
-                ('T_inf', inputs.t_inf), ('h_therm', asoh.h_thermal), ('A_therm', asoh.a_therm), ('ce', asoh.ce),
-                ('gamma', asoh.gamma),
-            ]:
-                params[scalar] = value[b % value.shape[0], 0]
-
-            # Add the SOC and series resistors as functions where we pin the batch ID to the appropriate value
-            params['ocv'] = partial(asoh.ocv, batch_id=b)
-            params['R0'] = partial(asoh.r[0], batch_id=b)
-            params['M_hyst'] = partial(asoh.m_hyst, batch_id=b)
-
-            # Append the RC elements
-            for r in range(params['num_RC_pairs']):
-                params[f'R{r + 1}'] = partial(asoh.r[r + 1], batch_id=b)
-                params[f'C{r + 1}'] = partial(asoh.c[r], batch_id=b)
-
-            # Make the model
-            model = Model(params=params)
-
-            # Update the state of the RC elements
-            #  TODO (wardlt): Working with Corey on an official route for setting these SVs.
-            #   This is his current recommendation
-            if params['num_RC_pairs'] > 0:
-                model._sv0[model._ptr['eta_j']] = transient.eta[b % transient.eta.shape[0]]
-
-            yield model
+        for batch_id in range(max(transient.batch_size, asoh.batch_size, inputs.batch_size)):
+            # Convert the parameters from Moira
+            params, state = self._moirae_to_thevenin(batch_id, asoh, inputs, transient)
+
+            # Make or update the model
+            if self._predictor is None or self._predictor.num_RC_pairs != params['num_RC_pairs']:
+                self._predictor = Prediction(params=params)
+            else:
+                for key, val in params.items():
+                    setattr(self._predictor, key, val)
+
+            yield self._predictor, state
+
+    def _moirae_to_thevenin(self,
+                            batch_id: int,
+                            asoh: TheveninASOH,
+                            inputs: TheveninInput,
+                            transient: TheveninTransient) -> tuple[dict[str, float | np.ndarray], TransientState]:
+        """Assemble Thevenin-compatible parameters for a single batch member
+        from their description in Moirae
+
+        ArgS:
+            batch_id: Batch index
+            asoh: Object holding the battery health parameters
+            inputs: Inputs applied to the battery system
+            transient: Transient state
+
+        Returns:
+            - Dictionary of parameters to applied to Predictor
+            - Transient state of the battery
+        """
+        # The value of each member of the transient or ASOH is a 2D array with the first dimension either 1
+        #  or batch_size. The % signs below are a short syntax for either using the same value for all batches
+        #  (anything mod 1 is 0) or the appropriate member of the batch
+        params = {'num_RC_pairs': asoh.num_rc_elements, 'isothermal': self.isothermal}
+        for scalar, value in [
+            ('capacity', asoh.capacity), ('mass', asoh.mass), ('Cp', asoh.c_p),
+            ('T_inf', inputs.t_inf), ('h_therm', asoh.h_thermal), ('A_therm', asoh.a_therm), ('ce', asoh.ce),
+            ('gamma', asoh.gamma), ('soc0', transient.soc)
+        ]:
+            params[scalar] = value[batch_id % value.shape[0], 0]
+
+        # Add the SOC and series resistors as functions where we pin the batch ID to the appropriate value
+        params['ocv'] = partial(asoh.ocv, batch_id=batch_id)
+        params['R0'] = partial(asoh.r[0], batch_id=batch_id)
+        params['M_hyst'] = partial(asoh.m_hyst, batch_id=batch_id)
+
+        # Append the RC elements
+        for r in range(params['num_RC_pairs']):
+            params[f'R{r + 1}'] = partial(asoh.r[r + 1], batch_id=batch_id)
+            params[f'C{r + 1}'] = partial(asoh.c[r], batch_id=batch_id)
+
+        # Make the state
+        state = TransientState(
+            soc=transient.soc[batch_id % transient.soc.shape[0], 0],
+            T_cell=transient.temp[batch_id % transient.temp.shape[0], 0],
+            hyst=transient.hyst[batch_id % transient.hyst.shape[0], 0],
+            eta_j=None if params['num_RC_pairs'] == 0 else transient.eta[batch_id % transient.eta.shape[0], :]
+        )
+        return params, state
 
     def update_transient_state(
             self,
@@ -83,22 +114,20 @@ def update_transient_state(
         output_array = np.zeros((batch_size, len(transient_state)))
 
         # Iterate over models representing each member of the batch
-        for model_i, model in enumerate(self._make_models(transient_state, asoh, new_inputs)):
+        for model_i, (model, state) in enumerate(self._make_models(transient_state, asoh, new_inputs)):
             # Propagate the system under a constant current load
-            # TODO (wardlt): Make current time-dependent, which is possible by passing a function to add_step
-            exp = Experiment()
+            # TODO (wardlt): Make current time-dependent, which is possible by passing a callable
             cur_time = new_inputs.time[model_i % new_inputs.time.shape[0], 0]
             pre_time = previous_inputs.time[model_i % previous_inputs.time.shape[0], 0]
-            exp.add_step('current_A',
-                         -new_inputs.current[model_i % new_inputs.current.shape[0]],  # Sign convention is opposite
-                         (cur_time - pre_time, 2))
-            sln = model.run(exp)
+            current = -new_inputs.current[model_i % new_inputs.current.shape[0], 0]
+            sln = model.take_step(state, current=current, delta_t=cur_time - pre_time)
 
             # Fill in the state variables
-            output_array[model_i, 0] = sln.vars['soc'][-1]
-            output_array[model_i, 1] = sln.vars['temperature_K'][-1]
-            for rc_i in range(asoh.num_rc_elements):
-                output_array[model_i, 2 + rc_i] = sln.vars[f'eta{rc_i + 1}_V'][-1]
+            output_array[model_i, 0] = sln.soc
+            output_array[model_i, 1] = sln.T_cell
+            output_array[model_i, 2] = sln.hyst
+            if model.num_RC_pairs > 0:
+                output_array[model_i, 3:] = sln.eta_j
 
         return transient_state.make_copy(output_array)
 
diff --git a/moirae/models/thevenin/state.py b/moirae/models/thevenin/state.py
index f5590aea..3b1b1211 100644
--- a/moirae/models/thevenin/state.py
+++ b/moirae/models/thevenin/state.py
@@ -62,6 +62,8 @@ class TheveninTransient(GeneralContainer):
     """State of charge for the battery system"""
     temp: ScalarParameter = 298.
     """Temperature of the battery (units: K)"""
+    hyst: ScalarParameter = 0.
+    """Hysteresis voltage (units: V)"""
     eta: ListParameter = ()
     """Overpotential for the RC elements (units: V)"""
 
diff --git a/performance-tests/online/camp/thevenin/joint.py b/performance-tests/online/camp/thevenin/joint.py
index 60d62d6b..d19f01b6 100644
--- a/performance-tests/online/camp/thevenin/joint.py
+++ b/performance-tests/online/camp/thevenin/joint.py
@@ -48,17 +48,17 @@
 asoh_covariance += ((2.5e-03 * initial_asoh.r[0].soc_coeffs[0, :]) ** 2).tolist()  # +/- std_dev^2 of R0
 asoh_covariance = np.diag(asoh_covariance)
 
-# For the transients, assume SOC is a uniform random variable in [0,1], and hysteresis has 2*std_dev of 1 mV
+# For the transients, assume SOC is a uniform random variable in [0,1], and hysteresis has 2*std_dev of 1 uV
 init_transients = TheveninTransient.from_asoh(initial_asoh)
 init_transients.soc = np.atleast_2d(1.)
-tran_covariance = np.diag([1 / 12, 1.])
+tran_covariance = np.diag([1 / 100, 0.01, 1e-6])
 
 # Make the noise terms
 #  Logic from: https://github.com/ROVI-org/auto-soh/blob/main/notebooks/demonstrate_joint_ukf.ipynb
 voltage_err = 1.0e-03  # mV voltage error
 noise_sensor = ((voltage_err / 2) ** 2) * np.eye(1)
 noise_asoh = 1.0e-10 * np.eye(asoh_covariance.shape[0])
-noise_tran = 1.0e-08 * np.eye(2)
+noise_tran = 1.0e-08 * np.eye(3)
 
 estimator = JointEstimator.initialize_unscented_kalman_filter(
     cell_model=TheveninModel(),
diff --git a/performance-tests/run-performance-test.py b/performance-tests/run-performance-test.py
index 20259f09..ea3b76be 100644
--- a/performance-tests/run-performance-test.py
+++ b/performance-tests/run-performance-test.py
@@ -15,6 +15,7 @@
 import requests
 import yaml
 
+from moirae import __version__
 from moirae.interface.hdf5 import HDF5Writer
 from moirae.estimators.online import OnlineEstimator
 from moirae.models.ecm import EquivalentCircuitModel
@@ -59,8 +60,6 @@ def load_estimator(text: str, variable_name: str = 'estimator', working_dir: Pat
 
     parser = ArgumentParser()
     write_levels = ['full', 'mean_cov', 'mean_var', 'mean', 'none']
-    parser.add_argument('--dataset', default='camp', help='Dataset used for benchmark test')
-    parser.add_argument('--model', default='ecm', help='Numerical model used to describe the battery system')
     parser.add_argument('--estimator', type=Path, required=True, help='Path to the estimator being tested')
     parser.add_argument('--pbar', action='store_true', help='Show the progress bar')
     parser.add_argument('--per-timestep-write', choices=write_levels, default='none',
@@ -79,26 +78,29 @@ def load_estimator(text: str, variable_name: str = 'estimator', working_dir: Pat
         logger.setLevel(logging.INFO)
     my_logger.info('Started performance test')
 
+    # Get the name of the dataset and model from the estimator path
+    dataset_name, model_name = args.estimator.parts[-3:-1]
+
     # Open the dataset, if not already downloaded
-    in_data_path = Path('datasets') / f'{args.dataset}.h5'
+    in_data_path = Path('datasets') / f'{dataset_name}.h5'
     if not in_data_path.exists():
         with open('datasets.yml') as fp:
             datasets = yaml.safe_load(fp)
             if args.dataset not in datasets:
-                raise ValueError(f'No such dataset: {args.dataset}. See `datasets.yml` for available options')
+                raise ValueError(f'No such dataset: {dataset_name}. See `datasets.yml` for available options')
             dataset_url = datasets[args.dataset]['url']
 
-        my_logger.info(f'Downloading data for {args.dataset}')
+        my_logger.info(f'Downloading data for {dataset_name}')
         in_data_path.parent.mkdir(exist_ok=True, parents=True)
         with in_data_path.open('wb') as fp:
             copyfileobj(requests.get(dataset_url, stream=True).raw, fp)
     my_logger.info(f'Using data from: {in_data_path}')
 
     # Create the numerical model
-    if args.model not in _models:
-        raise ValueError(f'No such model: {args.model}. Options: {", ".join(_models.keys())}')
-    model = _models[args.model]
-    my_logger.info(f'Selected model: {args.model}')
+    if model_name not in _models:
+        raise ValueError(f'No such model: {model_name}. Options: {", ".join(_models.keys())}')
+    model = _models[model_name]
+    my_logger.info(f'Selected model: {model}')
 
     # Get how long to read a file on this filesystem
     start_time = perf_counter()
@@ -107,8 +109,6 @@ def load_estimator(text: str, variable_name: str = 'estimator', working_dir: Pat
     my_logger.info(f'Reading the dataset from disk requires {read_time:.3f} seconds')
 
     # Load the model
-    if args.estimator.parts[-3:-1] != (args.dataset, args.model):
-        my_logger.warning(f'Discrepancy between estimator path {args.estimator} and dataset={args.dataset} model={args.model}')
     estimator = load_estimator(args.estimator.read_text(), working_dir=args.estimator.parent)
     my_logger.info(f'Loaded an {type(estimator)} from {args.estimator}')
 
@@ -136,10 +136,11 @@ def load_estimator(text: str, variable_name: str = 'estimator', working_dir: Pat
     git_version = run(['git', 'rev-parse', 'HEAD'], capture_output=True, text=True).stdout.strip()
     output = {
         'hostname': node(),
-        'version': git_version,
+        'git_version': git_version,
+        'version': __version__,
         'date': datetime.now().isoformat(),
-        'model': args.model,
-        'dataset': args.dataset,
+        'model': model_name,
+        'dataset': dataset_name,
         'estimator': args.estimator.with_suffix('').name,
         'timestep_write_level': args.per_timestep_write,
         'cycle_write_level': args.per_cycle_write,
diff --git a/tests/models/test_thevenin.py b/tests/models/test_thevenin.py
index 5a5348f8..419e595e 100644
--- a/tests/models/test_thevenin.py
+++ b/tests/models/test_thevenin.py
@@ -4,6 +4,7 @@
 import numpy as np
 
 from moirae.estimators.online.joint import JointEstimator
+from moirae.models.base import OutputQuantities
 from moirae.models.thevenin import TheveninInput, TheveninTransient, TheveninModel
 from moirae.models.thevenin.components import SOCPolynomialVariable, SOCTempPolynomialVariable
 from moirae.models.thevenin.state import TheveninASOH
@@ -105,8 +106,8 @@ def test_multiple_steps(asoh):
     # Test a single step of 30s charging
     model = TheveninModel()
     state = model.update_transient_state(pre_inputs, new_inputs, state, asoh)
-    assert len(state) == 2 + asoh.num_rc_elements
-    assert not np.isclose(state.to_numpy(), 0.).any()  # Including the SOC and RC elements
+    assert len(state) == 3 + asoh.num_rc_elements
+    assert not np.isclose(state.to_numpy()[:, :2], 0.).any()  # Including the SOC and RC elements
 
     # Ensure no errors if the time between timesteps is zero
     new_state = model.update_transient_state(new_inputs, new_inputs, state, asoh)
@@ -157,6 +158,32 @@ def test_multiple_steps(asoh):
     assert np.isclose(v.terminal_voltage, 1.5).all()
 
 
+def test_batching():
+    """Evaluate whether batches of ASOH coordinates are treated properly"""
+
+    # Run an experiment where the serial resistances are increasing
+    model = TheveninModel()
+    asoh = rint.model_copy(deep=True)
+    asoh.r[0].soc_coeffs = np.array([[0.01], [0.02], [0.03]])
+    assert asoh.batch_size == 3
+
+    # The temperature of the cell should be higher with higher resistance
+    state = TheveninTransient.from_asoh(asoh)
+    pre_inputs = TheveninInput(current=1., time=0., t_inf=298.)
+    new_inputs = TheveninInput(current=1., time=30., t_inf=298.)
+
+    new_state = model.update_transient_state(pre_inputs, new_inputs, state, asoh)
+    assert new_state.batch_size == 3
+    assert (np.diff(new_state.temp[:, 0]) > 0).all()  # Temperatures should be increasing
+    assert np.std(new_state.soc) == 0.  # SOC should all be the same
+
+    # Compute the voltage
+    soc = 1. / 120
+    v = 1.5 + soc + 1 * asoh.r[0].soc_coeffs[:, 0]
+    pred_v = model.calculate_terminal_voltage(new_inputs, new_state, asoh)
+    assert np.allclose(v, pred_v.terminal_voltage[:, 0], atol=1e-3)  # Differences are due to temp
+
+
 def test_estimator():
     """Make sure everything functions with an estimator"""
 
@@ -166,31 +193,71 @@ def test_estimator():
     pre_inputs = TheveninInput(current=1., time=0., t_inf=298.)
     new_inputs = TheveninInput(current=1., time=30., t_inf=298.)
 
-    model = TheveninModel()
-    expected_state = model.update_transient_state(pre_inputs, new_inputs, state, asoh)
-    expected_v = model.calculate_terminal_voltage(new_inputs, expected_state, asoh)
+    model = TheveninModel(isothermal=True)
 
     # Make a joint estimator with the R0 as an adjustable parameter so that we
     #  get batching on at least one variable
-    asoh.mark_updatable('r.0.t_coeffs')
+    asoh.mark_updatable('r.0.soc_coeffs')
     est = JointEstimator.initialize_unscented_kalman_filter(
         cell_model=model,
         initial_asoh=asoh,
         initial_transients=state,
         initial_inputs=pre_inputs,
-        covariance_transient=np.diag([0.05, 0.1]),
+        covariance_transient=np.diag([0.05, 0.1, 1e-6]),
         covariance_asoh=np.diag([1e-3] * 2),
-        transient_covariance_process_noise=np.diag([0.01] * 2),
+        transient_covariance_process_noise=np.diag([0.01] * 3),
         asoh_covariance_process_noise=np.diag([1e-3] * 2),
         covariance_sensor_noise=np.diag([1e-3])
     )
-    assert est.state.get_covariance().shape == (4, 4)
+    assert est.state.get_covariance().shape == (5, 5)
 
-    est_state, est_outputs = est.step(new_inputs, expected_v)
-    assert np.isclose(est_outputs.get_mean(), expected_v.terminal_voltage)
+    # Write down the expected results after 30 seconds of a 1A charge
+    soc = 1. / 120
+    expected_state = [soc, 298, 0]
+    expected_v = 1.5 + soc + 1 * (0.01 * (1 + soc))
+
+    est_state, est_outputs = est.step(new_inputs, OutputQuantities(terminal_voltage=expected_v))
+    assert np.isclose(est_outputs.get_mean(), expected_v)
     est_mean = est_state.get_mean()
-    assert np.allclose(expected_state.to_numpy(), est_mean[:2])  # SOC, T
-    assert np.allclose(asoh.r[0].t_coeffs, est_mean[2:])  # Temp dependence of R
+    assert np.allclose(expected_state, est_mean[:3], rtol=1e-4)  # SOC, T, hyst
+    assert np.allclose(asoh.r[0].soc_coeffs, est_mean[3:], atol=1e-6)  # Temp dependence of R
+
+    # Perturb the r0 and see if it converges back to the correct value
+    #  Run several cycles of 1 A charge/discharge
+    asoh.r[0].soc_coeffs[0, 0] = 0.011
+    state.soc = np.atleast_2d(soc)
+    est = JointEstimator.initialize_unscented_kalman_filter(
+        cell_model=model,
+        initial_asoh=asoh,
+        initial_transients=state,
+        initial_inputs=pre_inputs,
+        covariance_transient=np.diag([0.001, 0.1, 1e-6]),
+        covariance_asoh=np.diag([1e-3] * 2),
+        transient_covariance_process_noise=np.diag([0.01] * 3),
+        asoh_covariance_process_noise=np.diag([1e-3] * 2),
+        covariance_sensor_noise=np.diag([1e-3])
+    )
+    assert np.isclose(est.get_estimated_state()[1].r[0].soc_coeffs[0, 0], 0.011)
+
+    def _soc_over_time(t: float):
+        """Actual SOC as a function of time"""
+        time_in_cycle = t % 7200
+        return time_in_cycle / 3600 if time_in_cycle < 3600 else 1 - (time_in_cycle - 3600) / 3600
+
+    def _iv_over_time(t: float):
+        time_in_cycle = t % 7200
+        current = 1 if time_in_cycle < 3600 else -1
+        soc = _soc_over_time(t)
+        return current, 1.5 + soc + current * 0.01 * (1 + soc)
+
+    for time in np.linspace(0, 7200 * 2, 500):
+        i, v = _iv_over_time(time)
+        est.step(
+            TheveninInput(current=i, time=time, t_inf=298),
+            OutputQuantities(terminal_voltage=v)
+        )
+    est_tran, est_asoh = est.get_estimated_state()
+    assert np.isclose(est_tran.soc, 0., atol=0.05)
 
 
 def test_overpotentials():


Reviews:
user: login: victorventuri
body: I am not as familiar with the `Prediction` interface for `thevenin`, so it's not as easy for me to fully grasp how this is working in a short period of time. Before merging, however, I think we need to make sure the estimator that is based on `thevenin` converges when initialized away from the ground truth over a certain number of cycles. 
state: CHANGES_REQUESTED
author_association: COLLABORATOR
submitted_at: 2025-04-21T21:17:34Z
user: login: WardLT
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-21T21:46:38Z
user: login: WardLT
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-21T21:49:24Z
user: login: WardLT
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-21T21:52:39Z
user: login: victorventuri
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-21T21:58:39Z
user: login: victorventuri
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-21T22:03:53Z
user: login: WardLT
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-21T22:08:45Z

Review comments:
diff_hunk: @@ -21,51 +21,82 @@ class TheveninModel(CellModel):
     def __init__(self, isothermal: bool = False):
         self.isothermal = isothermal
         # TODO (wardlt): Add a maximum timestep size? Victor was using that to avoid the issues with the integrator
+        self._predictor = None  # Cache the predictor used between steps
 
-    def _make_models(self, transient: TheveninTransient, asoh: TheveninASOH, inputs: TheveninInput) -> Iterator[Model]:
+    def _make_models(self, transient: TheveninTransient, asoh: TheveninASOH, inputs: TheveninInput) \
+            -> Iterator[tuple[Prediction, TransientState]]:
         """
-        Generate a model for each member of the batch of experimental conditions
+        Update the predictor and state for each member of the batch of experimental conditions
 
         Args:
             transient: Batch of transient states
             asoh: Batch of health variables
+            inputs: Inputs to the battery system
 
         Yields:
-            A model for each member of the batch
+            A Prediction class and Thevenin-compatible state for each member of the batch
         """
 
-        for b in range(max(transient.batch_size, asoh.batch_size, inputs.batch_size)):
-            # The value of each member of the transient or ASOH is a 2D array with the first dimension either 1
-            #  or batch_size. The % signs below are a short syntax for either using the same value for all batches
-            #  (anything mod 1 is 0) or the appropriate member of the batch
-            params = {'num_RC_pairs': asoh.num_rc_elements, 'isothermal': self.isothermal}
-            for scalar, value in [
-                ('soc0', transient.soc), ('capacity', asoh.capacity), ('mass', asoh.mass), ('Cp', asoh.c_p),
-                ('T_inf', inputs.t_inf), ('h_therm', asoh.h_thermal), ('A_therm', asoh.a_therm), ('ce', asoh.ce),
-                ('gamma', asoh.gamma),
-            ]:
-                params[scalar] = value[b % value.shape[0], 0]
-
-            # Add the SOC and series resistors as functions where we pin the batch ID to the appropriate value
-            params['ocv'] = partial(asoh.ocv, batch_id=b)
-            params['R0'] = partial(asoh.r[0], batch_id=b)
-            params['M_hyst'] = partial(asoh.m_hyst, batch_id=b)
-
-            # Append the RC elements
-            for r in range(params['num_RC_pairs']):
-                params[f'R{r + 1}'] = partial(asoh.r[r + 1], batch_id=b)
-                params[f'C{r + 1}'] = partial(asoh.c[r], batch_id=b)
-
-            # Make the model
-            model = Model(params=params)
-
-            # Update the state of the RC elements
-            #  TODO (wardlt): Working with Corey on an official route for setting these SVs.
-            #   This is his current recommendation
-            if params['num_RC_pairs'] > 0:
-                model._sv0[model._ptr['eta_j']] = transient.eta[b % transient.eta.shape[0]]
-
-            yield model
+        for batch_id in range(max(transient.batch_size, asoh.batch_size, inputs.batch_size)):
+            # Convert the parameters from Moira
+            params, state = self._moirae_to_thevenin(batch_id, asoh, inputs, transient)
+
+            # Make or update the model
+            if self._predictor is None or self._predictor.num_RC_pairs != params['num_RC_pairs']:
+                self._predictor = Prediction(params=params)
+            else:
+                for key, val in params.items():
+                    setattr(self._predictor, key, val)
+
+            yield self._predictor, state
+
+    def _moirae_to_thevenin(self,
+                            batch_id: int,
+                            asoh: TheveninASOH,
+                            inputs: TheveninInput,
+                            transient: TheveninTransient) -> tuple[dict[str, float | np.ndarray], TransientState]:
+        """Assemble Thevenin-compatible parameters for a single batch member
+        from their description in Moirae
+
+        ArgS:
+            batch_id: Batch index
+            asoh: Object holding the battery health parameters
+            inputs: Inputs applied to the battery system
+            transient: Transient state
+
+        Returns:
+            - Dictionary of parameters to applied to Predictor
+            - Transient state of the battery
+        """
+        # The value of each member of the transient or ASOH is a 2D array with the first dimension either 1
+        #  or batch_size. The % signs below are a short syntax for either using the same value for all batches
+        #  (anything mod 1 is 0) or the appropriate member of the batch
+        params = {'num_RC_pairs': asoh.num_rc_elements, 'isothermal': self.isothermal, 'soc0': 0.}
path: moirae/models/thevenin/__init__.py
user: login: victorventuri
body: Shouldn't `soc0` parameter be the same as the one stored in the transient state object?
created_at: 2025-04-21T20:51:03Z
author_association: COLLABORATOR
diff_hunk: @@ -178,19 +178,19 @@ def test_estimator():
         initial_asoh=asoh,
         initial_transients=state,
         initial_inputs=pre_inputs,
-        covariance_transient=np.diag([0.05, 0.1]),
+        covariance_transient=np.diag([0.05, 0.1, 1e-6]),
         covariance_asoh=np.diag([1e-3] * 2),
-        transient_covariance_process_noise=np.diag([0.01] * 2),
+        transient_covariance_process_noise=np.diag([0.01] * 3),
         asoh_covariance_process_noise=np.diag([1e-3] * 2),
         covariance_sensor_noise=np.diag([1e-3])
     )
-    assert est.state.get_covariance().shape == (4, 4)
+    assert est.state.get_covariance().shape == (5, 5)
 
     est_state, est_outputs = est.step(new_inputs, expected_v)
     assert np.isclose(est_outputs.get_mean(), expected_v.terminal_voltage)
     est_mean = est_state.get_mean()
-    assert np.allclose(expected_state.to_numpy(), est_mean[:2])  # SOC, T
-    assert np.allclose(asoh.r[0].t_coeffs, est_mean[2:])  # Temp dependence of R
+    assert np.allclose(expected_state.to_numpy(), est_mean[:3], rtol=1e-4)  # SOC, T, hyst
path: tests/models/test_thevenin.py
user: login: victorventuri
body: To me, this doesn't feel like a proper test of the estimator, as it only makes sure one step does not deviate significantly from the ground truth at which the parameters were initialized. Given we do not control `thevenin`, I find it even more important to make sure the estimator that uses it converges to relevant statistics when initialized not exactly at the ground truth. I've tried to do that [here](https://github.com/ROVI-org/auto-soh/blob/main/tests/estimators/online/test_joint_ukf.py#L99) when testing the joint estimator. This will make this test slower, but, if we need to, we can mark it as slow and let the weekly test-runs raise issues if something ever comes up. (it shouldn't be too bad though, seeing as `thevenin` has a much better simulation capability than pure `moirae`)
created_at: 2025-04-21T21:15:41Z
author_association: COLLABORATOR
diff_hunk: @@ -21,51 +21,82 @@ class TheveninModel(CellModel):
     def __init__(self, isothermal: bool = False):
         self.isothermal = isothermal
         # TODO (wardlt): Add a maximum timestep size? Victor was using that to avoid the issues with the integrator
+        self._predictor = None  # Cache the predictor used between steps
 
-    def _make_models(self, transient: TheveninTransient, asoh: TheveninASOH, inputs: TheveninInput) -> Iterator[Model]:
+    def _make_models(self, transient: TheveninTransient, asoh: TheveninASOH, inputs: TheveninInput) \
+            -> Iterator[tuple[Prediction, TransientState]]:
         """
-        Generate a model for each member of the batch of experimental conditions
+        Update the predictor and state for each member of the batch of experimental conditions
 
         Args:
             transient: Batch of transient states
             asoh: Batch of health variables
+            inputs: Inputs to the battery system
 
         Yields:
-            A model for each member of the batch
+            A Prediction class and Thevenin-compatible state for each member of the batch
         """
 
-        for b in range(max(transient.batch_size, asoh.batch_size, inputs.batch_size)):
-            # The value of each member of the transient or ASOH is a 2D array with the first dimension either 1
-            #  or batch_size. The % signs below are a short syntax for either using the same value for all batches
-            #  (anything mod 1 is 0) or the appropriate member of the batch
-            params = {'num_RC_pairs': asoh.num_rc_elements, 'isothermal': self.isothermal}
-            for scalar, value in [
-                ('soc0', transient.soc), ('capacity', asoh.capacity), ('mass', asoh.mass), ('Cp', asoh.c_p),
-                ('T_inf', inputs.t_inf), ('h_therm', asoh.h_thermal), ('A_therm', asoh.a_therm), ('ce', asoh.ce),
-                ('gamma', asoh.gamma),
-            ]:
-                params[scalar] = value[b % value.shape[0], 0]
-
-            # Add the SOC and series resistors as functions where we pin the batch ID to the appropriate value
-            params['ocv'] = partial(asoh.ocv, batch_id=b)
-            params['R0'] = partial(asoh.r[0], batch_id=b)
-            params['M_hyst'] = partial(asoh.m_hyst, batch_id=b)
-
-            # Append the RC elements
-            for r in range(params['num_RC_pairs']):
-                params[f'R{r + 1}'] = partial(asoh.r[r + 1], batch_id=b)
-                params[f'C{r + 1}'] = partial(asoh.c[r], batch_id=b)
-
-            # Make the model
-            model = Model(params=params)
-
-            # Update the state of the RC elements
-            #  TODO (wardlt): Working with Corey on an official route for setting these SVs.
-            #   This is his current recommendation
-            if params['num_RC_pairs'] > 0:
-                model._sv0[model._ptr['eta_j']] = transient.eta[b % transient.eta.shape[0]]
-
-            yield model
+        for batch_id in range(max(transient.batch_size, asoh.batch_size, inputs.batch_size)):
+            # Convert the parameters from Moira
+            params, state = self._moirae_to_thevenin(batch_id, asoh, inputs, transient)
+
+            # Make or update the model
+            if self._predictor is None or self._predictor.num_RC_pairs != params['num_RC_pairs']:
+                self._predictor = Prediction(params=params)
+            else:
+                for key, val in params.items():
+                    setattr(self._predictor, key, val)
+
+            yield self._predictor, state
+
+    def _moirae_to_thevenin(self,
+                            batch_id: int,
+                            asoh: TheveninASOH,
+                            inputs: TheveninInput,
+                            transient: TheveninTransient) -> tuple[dict[str, float | np.ndarray], TransientState]:
+        """Assemble Thevenin-compatible parameters for a single batch member
+        from their description in Moirae
+
+        ArgS:
+            batch_id: Batch index
+            asoh: Object holding the battery health parameters
+            inputs: Inputs applied to the battery system
+            transient: Transient state
+
+        Returns:
+            - Dictionary of parameters to applied to Predictor
+            - Transient state of the battery
+        """
+        # The value of each member of the transient or ASOH is a 2D array with the first dimension either 1
+        #  or batch_size. The % signs below are a short syntax for either using the same value for all batches
+        #  (anything mod 1 is 0) or the appropriate member of the batch
+        params = {'num_RC_pairs': asoh.num_rc_elements, 'isothermal': self.isothermal, 'soc0': 0.}
path: moirae/models/thevenin/__init__.py
user: login: WardLT
body: I don't believe `soc0` is actually used, but updated this line to use the current SOC regardless
created_at: 2025-04-21T21:46:38Z
author_association: CONTRIBUTOR
diff_hunk: @@ -178,19 +178,19 @@ def test_estimator():
         initial_asoh=asoh,
         initial_transients=state,
         initial_inputs=pre_inputs,
-        covariance_transient=np.diag([0.05, 0.1]),
+        covariance_transient=np.diag([0.05, 0.1, 1e-6]),
         covariance_asoh=np.diag([1e-3] * 2),
-        transient_covariance_process_noise=np.diag([0.01] * 2),
+        transient_covariance_process_noise=np.diag([0.01] * 3),
         asoh_covariance_process_noise=np.diag([1e-3] * 2),
         covariance_sensor_noise=np.diag([1e-3])
     )
-    assert est.state.get_covariance().shape == (4, 4)
+    assert est.state.get_covariance().shape == (5, 5)
 
     est_state, est_outputs = est.step(new_inputs, expected_v)
     assert np.isclose(est_outputs.get_mean(), expected_v.terminal_voltage)
     est_mean = est_state.get_mean()
-    assert np.allclose(expected_state.to_numpy(), est_mean[:2])  # SOC, T
-    assert np.allclose(asoh.r[0].t_coeffs, est_mean[2:])  # Temp dependence of R
+    assert np.allclose(expected_state.to_numpy(), est_mean[:3], rtol=1e-4)  # SOC, T, hyst
path: tests/models/test_thevenin.py
user: login: WardLT
body: I agree with your desire to further test the joint estimator, but I'd push back on doing it here. I only intend this test to ensure that the interface between Thevenin and the estimator works plausibly, and rely on tests for the joint estimator to assure the estimator is correct.
created_at: 2025-04-21T21:49:24Z
author_association: CONTRIBUTOR
diff_hunk: @@ -178,19 +178,19 @@ def test_estimator():
         initial_asoh=asoh,
         initial_transients=state,
         initial_inputs=pre_inputs,
-        covariance_transient=np.diag([0.05, 0.1]),
+        covariance_transient=np.diag([0.05, 0.1, 1e-6]),
         covariance_asoh=np.diag([1e-3] * 2),
-        transient_covariance_process_noise=np.diag([0.01] * 2),
+        transient_covariance_process_noise=np.diag([0.01] * 3),
         asoh_covariance_process_noise=np.diag([1e-3] * 2),
         covariance_sensor_noise=np.diag([1e-3])
     )
-    assert est.state.get_covariance().shape == (4, 4)
+    assert est.state.get_covariance().shape == (5, 5)
 
     est_state, est_outputs = est.step(new_inputs, expected_v)
     assert np.isclose(est_outputs.get_mean(), expected_v.terminal_voltage)
     est_mean = est_state.get_mean()
-    assert np.allclose(expected_state.to_numpy(), est_mean[:2])  # SOC, T
-    assert np.allclose(asoh.r[0].t_coeffs, est_mean[2:])  # Temp dependence of R
+    assert np.allclose(expected_state.to_numpy(), est_mean[:3], rtol=1e-4)  # SOC, T, hyst
path: tests/models/test_thevenin.py
user: login: WardLT
body: >  Given we do not control thevenin, I find it even more important to make sure the estimator that uses it converges to relevant statistics when initialized not exactly at the ground truth.

Also, we're just going to rely on thevenin being correct. We cannot control everything.

Would you prefer a test against an analytical solution of an ECM rather than assuring that results are self consistent with calling thevenin a different way?
created_at: 2025-04-21T21:52:39Z
author_association: CONTRIBUTOR
diff_hunk: @@ -178,19 +178,19 @@ def test_estimator():
         initial_asoh=asoh,
         initial_transients=state,
         initial_inputs=pre_inputs,
-        covariance_transient=np.diag([0.05, 0.1]),
+        covariance_transient=np.diag([0.05, 0.1, 1e-6]),
         covariance_asoh=np.diag([1e-3] * 2),
-        transient_covariance_process_noise=np.diag([0.01] * 2),
+        transient_covariance_process_noise=np.diag([0.01] * 3),
         asoh_covariance_process_noise=np.diag([1e-3] * 2),
         covariance_sensor_noise=np.diag([1e-3])
     )
-    assert est.state.get_covariance().shape == (4, 4)
+    assert est.state.get_covariance().shape == (5, 5)
 
     est_state, est_outputs = est.step(new_inputs, expected_v)
     assert np.isclose(est_outputs.get_mean(), expected_v.terminal_voltage)
     est_mean = est_state.get_mean()
-    assert np.allclose(expected_state.to_numpy(), est_mean[:2])  # SOC, T
-    assert np.allclose(asoh.r[0].t_coeffs, est_mean[2:])  # Temp dependence of R
+    assert np.allclose(expected_state.to_numpy(), est_mean[:3], rtol=1e-4)  # SOC, T, hyst
path: tests/models/test_thevenin.py
user: login: victorventuri
body: My primary concerns is that the way `thevenin` handles transient and health variables is different from what we do in `moirae` (at least, it was so in the old `Model` class from `thevenin`). For example, updating the health variables required some additional re-preparation of the model (there is a `pre()` method that is called every time the model was initialized, and then again every time the parameters are updated)
created_at: 2025-04-21T21:58:39Z
author_association: COLLABORATOR
diff_hunk: @@ -178,19 +178,19 @@ def test_estimator():
         initial_asoh=asoh,
         initial_transients=state,
         initial_inputs=pre_inputs,
-        covariance_transient=np.diag([0.05, 0.1]),
+        covariance_transient=np.diag([0.05, 0.1, 1e-6]),
         covariance_asoh=np.diag([1e-3] * 2),
-        transient_covariance_process_noise=np.diag([0.01] * 2),
+        transient_covariance_process_noise=np.diag([0.01] * 3),
         asoh_covariance_process_noise=np.diag([1e-3] * 2),
         covariance_sensor_noise=np.diag([1e-3])
     )
-    assert est.state.get_covariance().shape == (4, 4)
+    assert est.state.get_covariance().shape == (5, 5)
 
     est_state, est_outputs = est.step(new_inputs, expected_v)
     assert np.isclose(est_outputs.get_mean(), expected_v.terminal_voltage)
     est_mean = est_state.get_mean()
-    assert np.allclose(expected_state.to_numpy(), est_mean[:2])  # SOC, T
-    assert np.allclose(asoh.r[0].t_coeffs, est_mean[2:])  # Temp dependence of R
+    assert np.allclose(expected_state.to_numpy(), est_mean[:3], rtol=1e-4)  # SOC, T, hyst
path: tests/models/test_thevenin.py
user: login: victorventuri
body: > I only intend this test to ensure that the interface between Thevenin and the estimator works plausibly, and rely on tests for the joint estimator to assure the estimator is correct.

I am confident the estimator is correct (we have tests for that), and certain `thevenin` is also correct (after using it and seeing Corey's numerous tests as well). However, due to the intricacies of how `thevenin` deals with transient and health variables, I fear a single step of the estimator is insufficient to demonstrate the connection between the codebases is robust. Perhaps the demonstration against an analytical solution of the ECM directly from `thevenin` would help put my mind at ease... 
created_at: 2025-04-21T22:03:53Z
author_association: COLLABORATOR
diff_hunk: @@ -178,19 +178,19 @@ def test_estimator():
         initial_asoh=asoh,
         initial_transients=state,
         initial_inputs=pre_inputs,
-        covariance_transient=np.diag([0.05, 0.1]),
+        covariance_transient=np.diag([0.05, 0.1, 1e-6]),
         covariance_asoh=np.diag([1e-3] * 2),
-        transient_covariance_process_noise=np.diag([0.01] * 2),
+        transient_covariance_process_noise=np.diag([0.01] * 3),
         asoh_covariance_process_noise=np.diag([1e-3] * 2),
         covariance_sensor_noise=np.diag([1e-3])
     )
-    assert est.state.get_covariance().shape == (4, 4)
+    assert est.state.get_covariance().shape == (5, 5)
 
     est_state, est_outputs = est.step(new_inputs, expected_v)
     assert np.isclose(est_outputs.get_mean(), expected_v.terminal_voltage)
     est_mean = est_state.get_mean()
-    assert np.allclose(expected_state.to_numpy(), est_mean[:2])  # SOC, T
-    assert np.allclose(asoh.r[0].t_coeffs, est_mean[2:])  # Temp dependence of R
+    assert np.allclose(expected_state.to_numpy(), est_mean[:3], rtol=1e-4)  # SOC, T, hyst
path: tests/models/test_thevenin.py
user: login: WardLT
body: What about 3-4 steps? If we're worried about the re-preparation. Let's shoot for something minimal
created_at: 2025-04-21T22:08:45Z
author_association: CONTRIBUTOR

PR comments:
user: login: WardLT
created_at: 2025-04-21T21:57:32Z
body: >Before merging, however, I think we need to make sure the estimator that is based on thevenin converges when initialized away from the ground truth over a certain number of cycles.

I do not think testing convergence here is appropriate for a handful of reasons:
- The test for thevenin should be limited to ensuring that thevenin works. That test around convergence feels like it's testing the reliability of the joint estimator, which should be done elsewhere
- I don't think the convergence tests we have now are that great of unit tests anyway. Many mistakes can be masked or at least convoluted if we're only testing the answer after thousands of iteration of an algorithm.

I'd be open to making a unit test where I compare the solution of a step of Joint Estimation against one done "by hand" (i.e., outside of Moirae), but otherwise believe these tests to be sufficient.
author_association: CONTRIBUTOR
user: login: victorventuri
created_at: 2025-04-21T22:15:32Z
body: > I don't think the convergence tests we have now are that great of unit tests anyway. Many mistakes can be masked or at least convoluted if we're only testing the answer after thousands of iteration of an algorithm.

I don't fully agree with this point. To be clear, I am not opposed to testing a single step and other small functionalities, quite on the contrary, I am all for those. However, the main point of the estimator is to be able to converge to an (user-)uncertain ground truth, and to track it thereafter in an online fashion. That's why I found it critical to testing this large-scaled functionality of both the joint and dual estimators we have, even if it may mask other mistakes (which is why I also believe the smaller functionalities, like a single `step` call, should also be tested, and it was my oversight in not doing so in the currently existing tests). 

> I'd be open to making a unit test where I compare the solution of a step of Joint Estimation against one done "by hand" (i.e., outside of Moirae), but otherwise believe these tests to be sufficient.

I'm 100% in favor of this! If you think these tests are sufficient, I trust you, and am good to merge. 
author_association: COLLABORATOR
user: login: victorventuri
created_at: 2025-04-21T22:16:16Z
body: (accidentally closed the PR, but just reopened it, my bad)
author_association: COLLABORATOR
user: login: WardLT
created_at: 2025-04-21T22:30:42Z
body: Sounds like we need a few little tiers of tests: 1 step, 4 steps, 10k steps. :)

I'll piece out the middle-sized number of steps with Thevenin tomorrow and see where that gets us.
author_association: CONTRIBUTOR
user: login: victorventuri
created_at: 2025-04-21T22:32:13Z
body: Sounds good! 
author_association: COLLABORATOR
user: login: WardLT
created_at: 2025-04-22T17:55:07Z
body: Alright, I've added a test where we run 500 steps (pardon the commit message stating 1000s) and show that the SOC is within 5% of correct even after perturbing the resistance from the true value.

I also added a test that highlights the batching.

Does that alleviate your concerns about the interface between moirae and thevenin? 
author_association: CONTRIBUTOR
user: login: victorventuri
created_at: 2025-04-22T20:01:01Z
body: Thanks @WardLT ! 

> Does that alleviate your concerns about the interface between moirae and thevenin?

For the most part, yes! I'm for merging it as is and dealing with minor things later if they come up as issues. 
author_association: COLLABORATOR