from __future__ import annotations
from typing import Callable
import numpy as np
[docs]
class Experiment:
"""Experiment builder."""
__slots__ = ('_steps', '_kwargs', '_options',)
def __init__(self, **kwargs) -> None:
"""
A class to define an experimental protocol. Use the ``add_step()``
method sequential steps to run. Each step defines a control mode, a
constant or time-dependent load profile, a time span, and optional
limiting criteria to stop the step early if a specified event/state
is detected. Note that ``Experiment`` is designed to only interface
with the :class:`thevenin.Simulation` model wrapper.
Parameters
----------
**kwargs : dict, optional
IDASolver keyword arguments that span all steps.
See also
--------
~thevenin.solvers.IDASolver :
The solver class, with documentation for most keyword arguments
that you might want to adjust.
~thevenin.Simulation :
The correct model interface to use with the ``Experiment`` class.
"""
self._steps = []
self._kwargs = []
self._options = kwargs.copy()
def __repr__(self) -> str: # pragma: no cover
"""
Return a readable repr string.
Returns
-------
readable : str
A console-readable instance representation.
"""
keys = ['num_steps', 'options']
values = [self.num_steps, self._options]
summary = "\n ".join(f"{k}={v}," for k, v in zip(keys, values))
readable = f"Experiment(\n {summary}\n)"
return readable
@property
def steps(self) -> list[dict]:
"""
Return steps list.
Returns
-------
steps : list[dict]
List of the step dictionaries.
"""
return self._steps
@property
def num_steps(self) -> int:
"""
Return number of steps.
Returns
-------
num_steps : int
Number of steps.
"""
return len(self._steps)
[docs]
def print_steps(self) -> None:
"""
Prints a formatted/readable list of steps.
Returns
-------
None.
"""
with np.printoptions(threshold=6, edgeitems=2):
for i, step in enumerate(self.steps):
print(f"\nStep {i}\n" + "-"*20)
for key, value in step.items():
print(f"{key:<7} : {value!r}")
print(f"options : {self._kwargs[i]!r}")
[docs]
def add_step(self, mode: str, value: float | Callable, tspan: tuple,
limits: tuple[str, float] = None, **kwargs) -> None:
"""
Add a step to the experiment.
Parameters
----------
mode : str
Control mode, {'current_A', 'current_C', 'voltage_V', 'power_W'}.
value : float | Callable
Value of boundary contion mode, in the appropriate units.
tspan : tuple | 1D np.array
Relative times for recording solution [s]. Providing a tuple as
(t_max: float, Nt: int) or (t_max: float, dt: float) constructs
tspan using ``np.linspace`` or ``np.arange``, respectively. Given
an array uses the values supplied as the evaluation times. Arrays
must be monotonically increasing and start with zero. See the notes
for more information.
limits : tuple[str, float], optional
Stopping criteria for the new step, must be entered in sequential
name/value pairs. Allowable names are {'soc', 'temperature_K',
'current_A', 'current_C', 'voltage_V', 'power_W', 'capacity_Ah',
'time_s', 'time_min', 'time_h'}. Values for each limit should
immediately follow a corresponding name and match its units. Time
limits are in reference to total experiment time. The default is
None.
**kwargs : dict, optional
IDASolver keyword arguments specific to the new step only.
Returns
-------
None.
Raises
------
ValueError
'mode' is invalid.
ValueError
A 'limits' name is invalid.
ValueError
'tspan' tuple must be length 2.
TypeError
'tspan[1]' must be type int or float.
ValueError
'tspan' arrays must be one-dimensional.
ValueError
'tspan[0]' must be zero when given an array.
ValueError
'tspan' array length must be at least two.
ValueError
'tspan' arrays must be monotonically increasing.
See also
--------
~thevenin.solvers.IDASolver :
The solver class, with documentation for most keyword arguments
that you might want to adjust.
Notes
-----
For time-dependent loads, use a Callable for 'value' with a function
signature like ``def load(t: float) -> float``, where 't' is the step's
relative time, in seconds.
Solution times are constructed and saved depending on the 'tspan' input
types that were supplied:
* Given (float, int):
``tspan = np.linspace(0., tspan[0], tspan[1])``
* Given (float, float):
``tspan = np.arange(0., tspan[0], tspan[1])``
In this case, 't_max' is also appended to the end. This results
in the final 'dt' being different from the others if 't_max' is
not evenly divisible by the given 'dt'.
* Given 1D np.array:
When you provide a numpy array it is checked for compatibility.
If the array is not 1D, is not monotonically increasing, or starts
with a value other than zero then an error is raised.
"""
_check_mode(mode)
_check_limits(limits)
mode, units = mode.split('_')
if isinstance(tspan, tuple):
if not len(tspan) == 2:
raise ValueError("'tspan' tuple must be length 2.")
if isinstance(tspan[1], int):
t_max, Nt = tspan
tspan = np.linspace(0., t_max, Nt)
elif isinstance(tspan[1], float):
t_max, dt = tspan
tspan = np.arange(0., t_max, dt, dtype=float)
else:
raise TypeError("'tspan[1]' must be type int or float.")
if tspan[-1] != t_max:
tspan = np.hstack([tspan, t_max])
else:
tspan = np.asarray(tspan)
if tspan.ndim != 1:
raise ValueError("'tspan' must be one-dimensional.")
elif tspan[0] != 0.:
raise ValueError("'tspan[0]' must be zero.")
elif tspan.size < 2:
raise ValueError("'tspan' array length must be at least two.")
elif not all(np.diff(tspan) > 0.):
raise ValueError("'tspan' must be monotonically increasing.")
step = {}
step['mode'] = mode
step['value'] = value
step['units'] = units
step['tspan'] = tspan
step['limits'] = limits
self._steps.append(step)
self._kwargs.append({**self._options, **kwargs})
def _check_mode(mode: str) -> None:
"""
Check the operating mode.
Parameters
----------
mode : str
Operating mode and units.
Returns
-------
None.
Raises
------
ValueError
'mode' is invalid.
"""
valid = ['current_A', 'current_C', 'voltage_V', 'power_W']
if mode not in valid:
raise ValueError(f"{mode=} is invalid; valid values are {valid}.")
def _check_limits(limits: tuple[str, float]) -> None:
"""
Check the limit criteria.
Parameters
----------
limit : tuple[str, float]
Stopping criteria and limiting value.
Returns
-------
None.
Raises
------
ValueError
'limits' length must be even.
ValueError
A 'limits' name is invalid.
"""
valid = ['soc', 'temperature_K', 'current_A', 'current_C', 'voltage_V',
'power_W', 'capacity_Ah', 'time_s', 'time_min', 'time_h']
if limits is None:
pass
elif len(limits) % 2 != 0:
raise ValueError("'limits' length must be even.")
else:
for i in range(len(limits) // 2):
name = limits[2*i]
value = limits[2*i + 1]
if name not in valid:
raise ValueError(f"The limit name '{name}' is invalid; valid"
f" values are {valid}.")
elif not isinstance(value, (int, float)):
raise TypeError(f"Limit '{name}' value must be type float.")