Source code for src.rfbzero.experiment

"""
Classes to define electrochemical cycling protocols.
"""
import copy
from abc import ABC, abstractmethod
from enum import Enum
from typing import Callable, Optional

from scipy.optimize import fsolve

from .crossover import Crossover
from .degradation import DegradationMechanism
from .redox_flow_cell import ZeroDModel


[docs]class CyclingResults: """ A container of the simulation result data. Parameters ---------- duration : float Simulation time (s). time_step : float Simulation time step (s). charge_first : bool, optional True if CLS charges first, False if CLS discharges first. Defaults to True. products_cls : list[str], optional The names of any additional product species in the CLS. products_ncls : list[str], optional The names of any additional product species in the NCLS. """ def __init__( self, duration: float, time_step: float, charge_first: bool = True, products_cls: list[str] = None, products_ncls: list[str] = None, ) -> None: self.duration = duration self.time_step = time_step self.charge_first = charge_first self.products_cls = products_cls or [] self.products_ncls = products_ncls or [] self.compute_soc = True #: The number of time steps that were desired from the simulation. self.max_steps: int = int(duration / time_step) #: The number of time steps that were actually performed before the simulation terminated. self.steps: int = 0 #: The simulation time (s), at each time step. self.step_time: list[float] = [0.0] * self.max_steps #: Whether the half cycle was charge (True) or discharge (False), at each time step. self.step_is_charge: list[bool] = [False] * self.max_steps #: The instantaneous current flowing (A), at each time step. self.current: list[float] = [0.0] * self.max_steps #: The cell voltage (V), at each time step. self.cell_v: list[float] = [0.0] * self.max_steps #: The cell open circuit voltage (V), at each time step. self.ocv: list[float] = [0.0] * self.max_steps #: The CLS concentration of oxidized species (M), at each time step. self.c_ox_cls: list[float] = [0.0] * self.max_steps #: The CLS concentration of reduced species (M), at each time step. self.c_red_cls: list[float] = [0.0] * self.max_steps #: The NCLS concentration of oxidized species (M), at each time step. self.c_ox_ncls: list[float] = [0.0] * self.max_steps #: The NCLS concentration of reduced species (M), at each time step. self.c_red_ncls: list[float] = [0.0] * self.max_steps #: The CLS concentrations of any product species (M), at each time step. self.c_products_cls: dict[str, list[float]] = { species: [0.0] * self.max_steps for species in self.products_cls } #: The NCLS concentrations of any product species (M), at each time step. self.c_products_ncls: dict[str, list[float]] = { species: [0.0] * self.max_steps for species in self.products_ncls } #: Oxidized species crossing (mols), at each time step. Only meaningful for symmetric cell. self.crossed_ox_mols: list[float] = [0.0] * self.max_steps #: Reduced species crossing (mols), at each time step. Only meaningful for symmetric cell. self.crossed_red_mols: list[float] = [0.0] * self.max_steps #: The CLS state of charge, at each time step. self.soc_cls: list[float] = [0.0] * self.max_steps #: The NCLS state of charge, at each time step. self.soc_ncls: list[float] = [0.0] * self.max_steps #: The combined (CLS+NCLS) activation overpotential (V), at each time step. self.act: list[float] = [0.0] * self.max_steps #: The combined (CLS+NCLS) mass transport overpotential (V), at each time step. self.mt: list[float] = [0.0] * self.max_steps #: The total cell overpotential (V), at each time step. self.total_overpotential: list[float] = [0.0] * self.max_steps # Total number of cycles is unknown at start, thus list sizes are undetermined self.capacity = 0.0 #: The number of complete half cycles performed during the simulation. self.half_cycles: int = 0 #: The cell capacity (C), for each half cycle. self.half_cycle_capacity: list[float] = [] #: The last time step, for each half cycle. self.half_cycle_time: list[float] = [] #: Whether the half cycle was charge (True) or discharge (False), for each half cycle. self.half_cycle_is_charge: list[bool] = [] #: The cell capacity (C), for each charge half cycle. self.charge_cycle_capacity: list[float] = [] #: The last time step, for each charge half cycle. self.charge_cycle_time: list[float] = [] #: The cell capacity (C), for each discharge half cycle. self.discharge_cycle_capacity: list[float] = [] #: The last time step, for each discharge half cycle. self.discharge_cycle_time: list[float] = [] #: The reason for the simulation's termination. self.end_status: CyclingStatus = CyclingStatus.NORMAL def _record_step( self, cell_model: ZeroDModel, c_products_cls: dict[str, float], c_products_ncls: dict[str, float], charge: bool, current: float, cell_v: float, ocv: float, n_act: float = 0.0, n_mt: float = 0.0, total_overpotential: float = 0.0, ) -> None: """Records simulation data at valid time steps.""" # Update capacity self.capacity += abs(current) * cell_model.time_step # Record current, voltages, and charge self.current[self.steps] = current self.cell_v[self.steps] = cell_v self.ocv[self.steps] = ocv self.step_is_charge[self.steps] = charge # Record overpotentials and total total_overpotential self.act[self.steps] = n_act self.mt[self.steps] = n_mt self.total_overpotential[self.steps] = total_overpotential # Record species concentrations cls_ox = cell_model.c_ox_cls cls_red = cell_model.c_red_cls ncls_ox = cell_model.c_ox_ncls ncls_red = cell_model.c_red_ncls self.c_ox_cls[self.steps] = cls_ox self.c_red_cls[self.steps] = cls_red self.c_ox_ncls[self.steps] = ncls_ox self.c_red_ncls[self.steps] = ncls_red for species in self.products_cls: self.c_products_cls[species][self.steps] = c_products_cls[species] for species in self.products_ncls: self.c_products_ncls[species][self.steps] = c_products_ncls[species] self.crossed_ox_mols[self.steps] = cell_model.crossed_ox_mols self.crossed_red_mols[self.steps] = cell_model.crossed_red_mols # Compute state-of-charge if self.compute_soc: if cls_ox + cls_red == 0.0 or ncls_ox + ncls_red == 0.0: self.compute_soc = False else: self.soc_cls[self.steps] = (cls_red / (cls_ox + cls_red)) * 100 self.soc_ncls[self.steps] = (ncls_red / (ncls_ox + ncls_red)) * 100 # Record time and increment the step self.step_time[self.steps] = self.time_step * (self.steps + 1) self.steps += 1 def _record_half_cycle(self, charge: bool) -> None: """Records charge and discharge half-cycle times and capacities, and resets capacity after each half-cycle.""" time = self.steps * self.time_step self.half_cycle_capacity.append(self.capacity) self.half_cycle_time.append(time) self.half_cycle_is_charge.append(charge) self.half_cycles += 1 if charge: self.charge_cycle_capacity.append(self.capacity) self.charge_cycle_time.append(time) else: self.discharge_cycle_capacity.append(self.capacity) self.discharge_cycle_time.append(time) self.capacity = 0.0 def _finalize(self) -> None: """Trims empty simulation values (initialized zeroes) if simulation ends earlier than desired.""" self.step_time = self.step_time[:self.steps] self.step_is_charge = self.step_is_charge[:self.steps] self.current = self.current[:self.steps] self.cell_v = self.cell_v[:self.steps] self.ocv = self.ocv[:self.steps] self.c_ox_cls = self.c_ox_cls[:self.steps] self.c_red_cls = self.c_red_cls[:self.steps] self.c_ox_ncls = self.c_ox_ncls[:self.steps] self.c_red_ncls = self.c_red_ncls[:self.steps] self.crossed_ox_mols = self.crossed_ox_mols[:self.steps] self.crossed_red_mols = self.crossed_red_mols[:self.steps] self.soc_cls = self.soc_cls[:self.steps] self.soc_ncls = self.soc_ncls[:self.steps] self.act = self.act[:self.steps] self.mt = self.mt[:self.steps] self.total_overpotential = self.total_overpotential[:self.steps]
[docs]class CyclingStatus(str, Enum): """Used for keeping track of cycling status throughout simulation, and to report how the simulation terminated.""" NORMAL = 'normal' #: NEGATIVE_CONCENTRATIONS = 'negative species concentrations' #: VOLTAGE_LIMIT_REACHED = 'voltage limits reached' #: CURRENT_CUTOFF_REACHED = 'current cutoffs reached' #: LIMITING_CURRENT_REACHED = 'current has exceeded the limiting currents for the cell concentrations' #: LOW_CAPACITY = 'capacity is less than 1% of initial CLS capacity' #: TIME_DURATION_REACHED = 'time duration reached' #:
class _CycleMode(ABC): """ Abstract class representing cycling modes (charge/discharge) for portions of a cycling protocol. Parameters ---------- charge : bool True if charging, False if discharging. cell_model : ZeroDModel Defined cell parameters for simulation. results : CyclingResults Container for the simulation result data. update_concentrations : Callable[[float], tuple[dict[str, float], dict[str, float]]] Performs coulomb counting, concentration updates via (optional) degradation and crossover mechanisms. current : float Desired initial current for cycling. current_lim_cls : float Limiting current of CLS (A). current_lim_ncls : float Limiting current of NCLS (A). """ def __init__( self, charge: bool, cell_model: ZeroDModel, results: CyclingResults, update_concentrations: Callable[[float], tuple[dict[str, float], dict[str, float]]], current: float, current_lim_cls: float = None, current_lim_ncls: float = None, ) -> None: self.charge = charge self.cell_model = cell_model self.results = results self.update_concentrations = update_concentrations self.current = current if not current_lim_cls or not current_lim_ncls: current_lim_cls, current_lim_ncls = self.cell_model._limiting_concentration(self.charge) self.current_lim_cls = current_lim_cls self.current_lim_ncls = current_lim_ncls @abstractmethod def validate(self) -> CyclingStatus: """Determines cycling status based on current/voltage limits, as required.""" raise NotImplementedError @abstractmethod def cycle_step(self) -> CyclingStatus: """Updates concentrations and step limits, as required.""" raise NotImplementedError def _check_capacity(self, cycling_status: CyclingStatus) -> CyclingStatus: """Ends the simulation early if capacity goes below 1% of initial CLS capacity.""" if self.results.capacity < 0.01 * self.cell_model.init_cls_capacity and self.results.half_cycles > 2: return CyclingStatus.LOW_CAPACITY return cycling_status def _check_time(self, cycling_status: CyclingStatus) -> CyclingStatus: """Ends the simulation if desired simulation duration is reached.""" if cycling_status != CyclingStatus.NORMAL: return cycling_status # End the simulation if the time limit is reached if self.results.steps >= self.results.max_steps: return CyclingStatus.TIME_DURATION_REACHED return CyclingStatus.NORMAL class _ConstantCurrentCycleMode(_CycleMode): """ Provides cycling modes (charge/discharge) for a constant current (CC) portion of a cycling protocol. Parameters ---------- charge : bool True if charging, False if discharging. cell_model : ZeroDModel Defined cell parameters for simulation. results : CyclingResults Container for the simulation result data. update_concentrations : Callable[[float], tuple[dict[str, float], dict[str, float]]] Performs coulomb counting, concentration updates via (optional) degradation and crossover mechanisms. current : float Desired current for CC cycling during cycling mode (A). voltage_limit : float Desired voltage limit for CC cycling during cycling mode (V). voltage_limit_capacity_check : bool True if CC mode, False if constant voltage (CV) mode. """ def __init__( self, charge: bool, cell_model: ZeroDModel, results: CyclingResults, update_concentrations: Callable[[float], tuple[dict[str, float], dict[str, float]]], current: float, voltage_limit: float, voltage_limit_capacity_check: bool = True, ) -> None: super().__init__(charge, cell_model, results, update_concentrations, current) self.voltage_limit = voltage_limit self.voltage_limit_capacity_check = voltage_limit_capacity_check def validate(self) -> CyclingStatus: """ If current exceeds lower of the CLS/NCLS limiting currents, returns cycling status indicating that the current limit has been reached. Otherwise, calculates cell voltage then checks if voltage limit has been reached. """ if abs(self.current) >= min(self.current_lim_cls, self.current_lim_ncls): return CyclingStatus.LIMITING_CURRENT_REACHED total_overpotential, *_ = self.cell_model._total_overpotential( self.current, self.current_lim_cls, self.current_lim_ncls ) ocv = self.cell_model._open_circuit_voltage() cell_v = self.cell_model._cell_voltage(ocv, total_overpotential, self.charge) if self.charge and cell_v >= self.voltage_limit or not self.charge and cell_v <= self.voltage_limit: return CyclingStatus.VOLTAGE_LIMIT_REACHED return CyclingStatus.NORMAL def cycle_step(self) -> CyclingStatus: """ Updates concentrations, checks if negative concentrations occurred. Calculates cell voltage and checks if voltage limits have been reached. Then updates simulation results. """ cycling_status = CyclingStatus.NORMAL # Calculate species' concentrations c_products_cls, c_products_ncls = self.update_concentrations(self.current) # Handle edge case where the voltage limits are never reached if self.cell_model._negative_concentrations(): self.cell_model._revert_concentrations() return self._check_capacity(CyclingStatus.NEGATIVE_CONCENTRATIONS) # Calculate overpotentials and the resulting cell voltage total_overpotential, n_act, n_mt = self.cell_model._total_overpotential( self.current, self.current_lim_cls, self.current_lim_ncls) ocv = self.cell_model._open_circuit_voltage() cell_v = self.cell_model._cell_voltage(ocv, total_overpotential, self.charge) # Check if the voltage limit is reached if self.charge and cell_v >= self.voltage_limit or not self.charge and cell_v <= self.voltage_limit: cycling_status = CyclingStatus.VOLTAGE_LIMIT_REACHED if self.voltage_limit_capacity_check: cycling_status = self._check_capacity(cycling_status) # Update results self.results._record_step( self.cell_model, c_products_cls, c_products_ncls, self.charge, self.current, cell_v, ocv, n_act, n_mt, total_overpotential, ) return self._check_time(cycling_status) class _ConstantVoltageCycleMode(_CycleMode): """ Provides cycling modes (charge/discharge) for a constant voltage (CV) portion of a cycling protocol. Parameters ---------- charge : bool True if charging, False if discharging. cell_model : ZeroDModel Defined cell parameters for simulation. results : CyclingResults Container for the simulation result data. update_concentrations : Callable[[float], tuple[dict[str, float], dict[str, float]]] Performs coulomb counting, concentration updates via (optional) degradation and crossover mechanisms. current_cutoff : float Current cutoff for CV mode. Below it, simulation switches from charge to discharge and vice versa (A). voltage_limit : float Desired voltage limit for CV cycling during cycling mode (V). current_estimate : float Guess for next step's current value, used for the solver (A). current_lim_cls : float Limiting current of CLS (A). current_lim_ncls : float Limiting current of NCLS (A). """ def __init__( self, charge: bool, cell_model: ZeroDModel, results: CyclingResults, update_concentrations: Callable[[float], tuple[dict[str, float], dict[str, float]]], current_cutoff: float, voltage_limit: float, current_estimate: float, current_lim_cls: float = None, current_lim_ncls: float = None, ) -> None: super().__init__(charge, cell_model, results, update_concentrations, current_estimate, current_lim_cls, current_lim_ncls) self.current_cutoff = current_cutoff self.voltage_limit = voltage_limit def validate(self) -> CyclingStatus: return CyclingStatus.NORMAL def cycle_step(self) -> CyclingStatus: if not self.current: # Set initial current guess as a function of the limiting currents, however, we want to ensure that the # guess is less than the limiting currents to avoid log errors in the overpotential calculations self.current = self.__current_direction() * 0.99 * min(self.current_lim_cls, self.current_lim_ncls) elif abs(self.current) >= min(self.current_lim_cls, self.current_lim_ncls): return CyclingStatus.LIMITING_CURRENT_REACHED ocv = self.cell_model._open_circuit_voltage() # Adapting the solver's guess to the updated current self.__find_min_current(ocv) c_products_cls, c_products_ncls = self.update_concentrations(self.current) # Check if any reactant remains if self.cell_model._negative_concentrations(): self.cell_model._revert_concentrations() return self._check_capacity(CyclingStatus.NEGATIVE_CONCENTRATIONS) # Update results self.results._record_step( self.cell_model, c_products_cls, c_products_ncls, self.charge, self.current, self.voltage_limit, ocv, ) if abs(self.current) <= abs(self.current_cutoff): return self._check_capacity(CyclingStatus.CURRENT_CUTOFF_REACHED) return self._check_time(CyclingStatus.NORMAL) def __current_direction(self) -> int: """Return 1 if charging, -1 if discharging.""" return 1 if self.charge else -1 def __find_min_current(self, ocv: float) -> None: """ Solves the current at a given time step of constant voltage cycling. Attempts to minimize the difference of voltage, OCV, and total overpotential (function of current). """ def solver(current) -> float: # current is passed in as a ndarray, we use .item() to get the scalar float value loss_solve, *_ = self.cell_model._total_overpotential( current.item(), self.current_lim_cls, self.current_lim_ncls, ) return self.voltage_limit - ocv - self.__current_direction() * loss_solve min_current, *_ = fsolve(solver, self.current, xtol=1e-5) self.current = min_current.item()
[docs]class CyclingProtocol(ABC): """ Abstract class representing a cycling protocol. Parameters ---------- voltage_limit_charge : float Voltage above which cell will switch to discharge (V). voltage_limit_discharge : float Voltage below which cell will switch to charge (V). charge_first : bool True if CLS charges first, False if CLS discharges first. """ def __init__(self, voltage_limit_charge: float, voltage_limit_discharge: float, charge_first: bool = True) -> None: self.voltage_limit_charge = voltage_limit_charge self.voltage_limit_discharge = voltage_limit_discharge self.charge_first = charge_first
[docs] @abstractmethod def run( self, duration: int, cell_model: ZeroDModel, degradation: DegradationMechanism = None, cls_degradation: DegradationMechanism = None, ncls_degradation: DegradationMechanism = None, crossover: Crossover = None, ) -> CyclingResults: """ Applies a cycling protocol and (optional) degradation/crossover mechanisms to a cell model. Parameters ---------- duration : int Simulation time (s). cell_model : ZeroDModel Defined cell parameters for simulation. degradation : DegradationMechanism, optional Degradation mechanism applied to CLS and NCLS. cls_degradation : DegradationMechanism, optional Degradation mechanism applied to CLS. ncls_degradation : DegradationMechanism, optional Degradation mechanism applied to NCLS. crossover : Crossover, optional Crossover mechanism applied to cell. """ raise NotImplementedError
@staticmethod def _validate_cycle_values( value: Optional[float], value_charge: Optional[float], value_discharge: Optional[float], name: str, ) -> tuple[float, float]: """Checks validity of user inputs for current limits and/or cutoffs.""" if value is not None and (value_charge is not None or value_discharge is not None): raise ValueError(f"Cannot specify both '{name}' and '{name}_(dis)charge'") if value is not None: if value <= 0.0: raise ValueError(f"'{name}' must be > 0.0") value_charge = value value_discharge = -value elif value_charge is None or value_discharge is None: raise ValueError(f"Must specify both '{name}_charge' and '{name}_discharge', cannot specify only one") if value_charge <= 0.0: raise ValueError(f"'{name}_charge' must be > 0.0") if value_discharge >= 0.0: raise ValueError(f"'{name}_discharge' must be < 0.0") return value_charge, value_discharge def _validate_protocol( self, duration: int, cell_model: ZeroDModel, degradation: Optional[DegradationMechanism], cls_degradation: Optional[DegradationMechanism], ncls_degradation: Optional[DegradationMechanism], crossover: Optional[Crossover], ) -> tuple[CyclingResults, Callable[[float], tuple[dict[str, float], dict[str, float]]]]: """Checks validity of user inputs for voltage limits and optional degradation and crossover mechanisms.""" if not self.voltage_limit_discharge < cell_model.ocv_50_soc < self.voltage_limit_charge: raise ValueError("Ensure that 'voltage_limit_discharge' < 'ocv_50_soc' < 'voltage_limit_charge'") if cell_model.ocv_50_soc > 0.0 > self.voltage_limit_discharge: raise ValueError("Ensure that 'voltage_limit_discharge' >= 0.0 for a full cell ('ocv_50_soc' > 0.0)") if crossover and cell_model.ocv_50_soc > 0.0: raise ValueError("Cannot use crossover mechanism for a full cell ('ocv_50_soc' > 0.0)") if degradation is not None and (cls_degradation is not None or ncls_degradation is not None): raise ValueError("Cannot specify both 'degradation' and '(n)cls_degradation'") if degradation is not None: cls_degradation = degradation ncls_degradation = degradation # Create copies of the degradation mechanisms, so that even if they maintain some internal state, # the passed in instances can be reused across protocol runs. cls_degradation = copy.deepcopy(cls_degradation) ncls_degradation = copy.deepcopy(ncls_degradation) c_products_cls = cls_degradation.c_products if cls_degradation else {} c_products_ncls = ncls_degradation.c_products if ncls_degradation else {} if cell_model._negative_concentrations(): raise ValueError('Negative concentration detected') def update_concentrations(i: float) -> tuple[dict[str, float], dict[str, float]]: # Performs coulomb counting, concentration updates via (optional) degradation and crossover mechanisms return cell_model._coulomb_counter(i, cls_degradation, ncls_degradation, crossover) # Initialize data results object to be sent to user results = CyclingResults(duration, cell_model.time_step, self.charge_first, list(c_products_cls.keys()), list(c_products_ncls.keys())) print(f'{duration} sec of cycling, time steps: {cell_model.time_step} sec') return results, update_concentrations @staticmethod def _end_protocol(results: CyclingResults, end_status: CyclingStatus) -> CyclingResults: """Records the status that ended the simulation and logs the time.""" print(f'Simulation stopped after {results.steps} time steps: {end_status.value}.') results.end_status = end_status results._finalize() return results
[docs]class ConstantCurrent(CyclingProtocol): """ Provides a constant current (CC) cycling method. Parameters ---------- voltage_limit_charge : float Voltage above which cell will switch to discharge (V). voltage_limit_discharge : float Voltage below which cell will switch to charge (V). current : float Current (A) value used for charging. The negative of this value is used for discharging current. current_charge : float Desired charging current for CC cycling (A). current_discharge : float Desired discharging current for CC cycling (A). Input must be a negative value. charge_first : bool True if CLS charges first, False if CLS discharges first. """ def __init__( self, voltage_limit_charge: float, voltage_limit_discharge: float, current: float = None, current_charge: float = None, current_discharge: float = None, charge_first: bool = True, ) -> None: super().__init__(voltage_limit_charge, voltage_limit_discharge, charge_first) self.current_charge, self.current_discharge = self._validate_cycle_values( current, current_charge, current_discharge, 'current', )
[docs] def run( self, duration: int, cell_model: ZeroDModel, degradation: DegradationMechanism = None, cls_degradation: DegradationMechanism = None, ncls_degradation: DegradationMechanism = None, crossover: Crossover = None, ) -> CyclingResults: """ Applies the constant current (CC) protocol and (optional) degradation/crossover mechanisms to a cell model. Parameters ---------- duration : int Simulation time (s). cell_model : ZeroDModel Defined cell parameters for simulation. degradation : DegradationMechanism, optional Degradation mechanism applied to CLS and NCLS. cls_degradation : DegradationMechanism, optional Degradation mechanism applied to CLS. ncls_degradation : DegradationMechanism, optional Degradation mechanism applied to NCLS. crossover : Crossover, optional Crossover mechanism applied to cell. Returns ------- results : CyclingResults Container of simulation results. """ results, update_concentrations = self._validate_protocol( duration, cell_model, degradation, cls_degradation, ncls_degradation, crossover, ) def get_cycle_mode(charge: bool) -> _ConstantCurrentCycleMode: """Returns constant current (CC) cycle mode.""" return _ConstantCurrentCycleMode( charge, cell_model, results, update_concentrations, self.current_charge if charge else self.current_discharge, self.voltage_limit_charge if charge else self.voltage_limit_discharge, ) cycle_mode = get_cycle_mode(self.charge_first) cycling_status = cycle_mode.validate() if cycling_status != CyclingStatus.NORMAL: cycle_mode = get_cycle_mode(not self.charge_first) new_cycling_status = cycle_mode.validate() if new_cycling_status != CyclingStatus.NORMAL: raise ValueError(cycling_status) cycle_name = 'charge' if self.charge_first else 'discharge' print(f'Skipping to {cycle_name} cycle: {cycling_status.value}') cycling_status = CyclingStatus.NORMAL while cycling_status == CyclingStatus.NORMAL: cycling_status = cycle_mode.cycle_step() if cycling_status in [CyclingStatus.NEGATIVE_CONCENTRATIONS, CyclingStatus.VOLTAGE_LIMIT_REACHED]: # Record info for the half cycle results._record_half_cycle(cycle_mode.charge) # Start the next half cycle cycle_mode = get_cycle_mode(not cycle_mode.charge) cycling_status = CyclingStatus.NORMAL return self._end_protocol(results, cycling_status)
[docs]class ConstantVoltage(CyclingProtocol): """ Provides a constant voltage (CV) cycling method. Parameters ---------- voltage_limit_charge : float Voltage the cell is held at during charge (V). voltage_limit_discharge : float Voltage the cell is held at during discharge (V). current_cutoff : float Current below which cell will switch to charge, and above (the negative of this value) which will switch to discharge (A). current_cutoff_charge : float Current below which cell will switch to discharge (A). current_cutoff_discharge : float Current above which cell will switch to charge (A). charge_first : bool True if CLS charges first, False if CLS discharges first. """ def __init__( self, voltage_limit_charge: float, voltage_limit_discharge: float, current_cutoff: float = None, current_cutoff_charge: float = None, current_cutoff_discharge: float = None, charge_first: bool = True, ) -> None: super().__init__(voltage_limit_charge, voltage_limit_discharge, charge_first) self.current_cutoff_charge, self.current_cutoff_discharge = self._validate_cycle_values( current_cutoff, current_cutoff_charge, current_cutoff_discharge, 'current_cutoff', )
[docs] def run( self, duration: int, cell_model: ZeroDModel, degradation: DegradationMechanism = None, cls_degradation: DegradationMechanism = None, ncls_degradation: DegradationMechanism = None, crossover: Crossover = None, ) -> CyclingResults: """ Applies the constant voltage (CV) cycling protocol and (optional) degradation/crossover mechanisms to a cell model. Parameters ---------- duration : int Simulation time (s). cell_model : ZeroDModel Defined cell parameters for simulation. degradation : DegradationMechanism, optional Degradation mechanism applied to CLS and NCLS. cls_degradation : DegradationMechanism, optional Degradation mechanism applied to CLS. ncls_degradation : DegradationMechanism, optional Degradation mechanism applied to NCLS. crossover : Crossover, optional Crossover mechanism applied to cell. Returns ------- results : CyclingResults Container of simulation results. """ results, update_concentrations = self._validate_protocol( duration, cell_model, degradation, cls_degradation, ncls_degradation, crossover, ) def get_cycle_mode(charge: bool) -> _ConstantVoltageCycleMode: return _ConstantVoltageCycleMode( charge, cell_model, results, update_concentrations, self.current_cutoff_charge if charge else self.current_cutoff_discharge, self.voltage_limit_charge if charge else self.voltage_limit_discharge, 0.0, ) cycle_mode = get_cycle_mode(self.charge_first) cycling_status = cycle_mode.validate() if cycling_status != CyclingStatus.NORMAL: raise ValueError(cycling_status) while cycling_status == CyclingStatus.NORMAL: cycling_status = cycle_mode.cycle_step() if cycling_status in [CyclingStatus.CURRENT_CUTOFF_REACHED, CyclingStatus.NEGATIVE_CONCENTRATIONS]: # Record info for the half cycle results._record_half_cycle(cycle_mode.charge) # Start the next half cycle cycle_mode = get_cycle_mode(not cycle_mode.charge) cycling_status = CyclingStatus.NORMAL return self._end_protocol(results, cycling_status)
[docs]class ConstantCurrentConstantVoltage(CyclingProtocol): """ Provides a constant current constant voltage (CCCV) cycling method which, in the limit of a high current demanded of a cell that it cannot maintain, becomes a constant voltage (CV) cycling method. Parameters ---------- voltage_limit_charge : float Voltage above which cell will switch to CV mode, charging (V). voltage_limit_discharge : float Voltage below which cell will switch to CV mode, discharging (V). current_cutoff : float Current below which CV charging will switch to CC portion of CCCV discharge (A), and above (the negative of this value) which CV discharging will switch to CC portion of CCCV charge (A). current_cutoff_charge : float Current below which CV charging will switch to CC portion of CCCV discharge (A). current_cutoff_discharge : float Current above which CV discharging will switch to CC portion of CCCV charge (A). current : float Current (A) value used for charging. The negative of this value is used for discharging current. current_charge : float Desired charging current for CC cycling (A). current_discharge : float Desired discharging current for CC cycling (A). Input must be a negative value. charge_first : bool True if CLS charges first, False if CLS discharges first. """ def __init__( self, voltage_limit_charge: float, voltage_limit_discharge: float, current_cutoff: float = None, current_cutoff_charge: float = None, current_cutoff_discharge: float = None, current: float = None, current_charge: float = None, current_discharge: float = None, charge_first: bool = True, ) -> None: super().__init__(voltage_limit_charge, voltage_limit_discharge, charge_first) self.current_cutoff_charge, self.current_cutoff_discharge = self._validate_cycle_values( current_cutoff, current_cutoff_charge, current_cutoff_discharge, 'current_cutoff', ) self.current_charge, self.current_discharge = self._validate_cycle_values( current, current_charge, current_discharge, 'current', )
[docs] def run( self, duration: int, cell_model: ZeroDModel, degradation: DegradationMechanism = None, cls_degradation: DegradationMechanism = None, ncls_degradation: DegradationMechanism = None, crossover: Crossover = None, ) -> CyclingResults: """ Applies the constant current constant voltage (CCCV) cycling protocol and (optional) degradation/crossover mechanisms to a cell model. Parameters ---------- duration : int Simulation time (s). cell_model : ZeroDModel Defined cell parameters for simulation. degradation : DegradationMechanism, optional Degradation mechanism applied to CLS and NCLS. cls_degradation : DegradationMechanism, optional Degradation mechanism applied to CLS. ncls_degradation : DegradationMechanism, optional Degradation mechanism applied to NCLS. crossover : Crossover, optional Crossover mechanism applied to cell. Returns ------- results : CyclingResults Container of simulation results. """ results, update_concentrations = self._validate_protocol( duration, cell_model, degradation, cls_degradation, ncls_degradation, crossover, ) def get_cc_cycle_mode(charge: bool) -> _ConstantCurrentCycleMode: return _ConstantCurrentCycleMode( charge, cell_model, results, update_concentrations, self.current_charge if charge else self.current_discharge, self.voltage_limit_charge if charge else self.voltage_limit_discharge, voltage_limit_capacity_check=False, ) def get_cv_cycle_mode( charge: bool, current_estimate: float, current_lim_cls: Optional[float] = None, current_lim_ncls: Optional[float] = None, ) -> _ConstantVoltageCycleMode: return _ConstantVoltageCycleMode( charge, cell_model, results, update_concentrations, self.current_cutoff_charge if charge else self.current_cutoff_discharge, self.voltage_limit_charge if charge else self.voltage_limit_discharge, current_estimate, current_lim_cls, current_lim_ncls, ) cycle_mode: _CycleMode = get_cc_cycle_mode(self.charge_first) # Check if cell needs to go straight to CV cycling_status = cycle_mode.validate() is_cc_mode = cycling_status == CyclingStatus.NORMAL if not is_cc_mode: print(f'Skipping to CV cycling: {cycling_status.value}') cv_current = self.current_charge if self.charge_first else self.current_discharge cycle_mode = get_cv_cycle_mode(self.charge_first, cv_current) cycling_status = cycle_mode.validate() while cycling_status == CyclingStatus.NORMAL: cycling_status = cycle_mode.cycle_step() if is_cc_mode: if cycling_status == CyclingStatus.VOLTAGE_LIMIT_REACHED: is_cc_mode = False cycle_mode = get_cv_cycle_mode(cycle_mode.charge, cycle_mode.current, cycle_mode.current_lim_cls, cycle_mode.current_lim_ncls, ) cycling_status = CyclingStatus.NORMAL elif cycling_status == CyclingStatus.NEGATIVE_CONCENTRATIONS: # Record info for the half cycle results._record_half_cycle(cycle_mode.charge) # Start the next half cycle cycle_mode = get_cc_cycle_mode(not cycle_mode.charge) cycling_status = CyclingStatus.NORMAL elif cycling_status in [CyclingStatus.CURRENT_CUTOFF_REACHED, CyclingStatus.NEGATIVE_CONCENTRATIONS]: # Record info for the half cycle results._record_half_cycle(cycle_mode.charge) cc_cycle_mode = get_cc_cycle_mode(not cycle_mode.charge) is_cc_mode = cc_cycle_mode.validate() == CyclingStatus.NORMAL cycle_mode = cc_cycle_mode if is_cc_mode else get_cv_cycle_mode(not cycle_mode.charge, 0.0) cycling_status = CyclingStatus.NORMAL return self._end_protocol(results, cycling_status)