"""Thin orchestrator for parameters, backends, and sweep specs."""
from __future__ import annotations
import os
from dataclasses import dataclass, field
from pathlib import Path
import numpy as np
from psc_sim.cell_metrics import (
compute_metrics,
extract_rs_from_sr_inv,
rs_extraction_series,
sr_vs_bias,
)
from psc_sim.eis_lumped import eis_default_sweep
from psc_sim.parameters import CellMetrics, CellParameters
from psc_sim.simulation.backends import (
EISBackend,
IVBackend,
PythonEISBackend,
PythonIVBackend,
)
from psc_sim.simulation.defaults import (
DEFAULT_IV_SWEEP,
DEFAULT_RS_SWEEP,
DEFAULT_SR_SWEEP,
DEFAULT_TRAN_SPEC,
)
from psc_sim.simulation.dispatcher import SimulationDispatcher
from psc_sim.simulation.specs import resolve_sweep
from psc_sim.simulation.types import (
IVResult,
MeasuredEIS,
RsFitMask,
SweepSpec,
TRANResult,
TranSpec,
)
[docs]
@dataclass
class SimulationSession:
"""Orchestrates cell parameters, sweep specs, Python/LTspice backends, and overlays.
Typical library usage::
from psc_sim import CellParameters, SimulationSession
session = SimulationSession(CellParameters())
V, I, P = session.iv_curve()
met = session.metrics()
f, zre, zim = session.eis_default_sweep()
"""
params: CellParameters
iv_backend: IVBackend = field(default_factory=PythonIVBackend)
eis_backend: EISBackend = field(default_factory=PythonEISBackend)
iv_spec: SweepSpec = DEFAULT_IV_SWEEP
sr_spec: SweepSpec = DEFAULT_SR_SWEEP
rs_spec: SweepSpec = DEFAULT_RS_SWEEP
tran_spec: TranSpec = DEFAULT_TRAN_SPEC
lt_iv_result: IVResult | None = None
lt_eis_result: tuple[np.ndarray, np.ndarray, np.ndarray] | None = None
lt_tran_result: TRANResult | None = None
measured_eis: MeasuredEIS | None = None
rs_fit_mask: RsFitMask | None = None
_dispatcher_cache: SimulationDispatcher | None = field(
default=None, repr=False, compare=False
)
_dispatcher_key: tuple[float, ...] | None = field(default=None, repr=False, compare=False)
def _dispatcher_fingerprint(self) -> tuple[float, ...]:
p = self.params
return (
p.I0,
p.n,
p.Iph,
p.Rs,
p.Rsh,
p.T,
p.Cj,
p.Rion,
p.Cion,
self.iv_spec.v_min,
self.iv_spec.v_max,
self.iv_spec.step,
float(id(self.iv_backend)),
float(id(self.eis_backend)),
)
@property
def dispatcher(self) -> SimulationDispatcher:
key = self._dispatcher_fingerprint()
if self._dispatcher_cache is None or self._dispatcher_key != key:
self._dispatcher_cache = SimulationDispatcher(
params=self.params,
iv_backend=self.iv_backend,
eis_backend=self.eis_backend,
iv_spec=self.iv_spec,
)
self._dispatcher_key = key
return self._dispatcher_cache
[docs]
def iv_result(self) -> IVResult:
return self.dispatcher.iv_result()
[docs]
def solve_i(
self,
V: float,
rs: float | None = None,
*,
i_hint: float | None = None,
) -> float:
return self.iv_backend.solve_point(V, self.params, rs=rs, i_hint=i_hint)
[docs]
def iv_curve(
self,
v_min: float | None = None,
v_max: float | None = None,
step: float | None = None,
rs: float | None = None,
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
spec = resolve_sweep(self.iv_spec, v_min, v_max, step)
result = self.iv_backend.sweep_iv(self.params, spec, rs=rs)
return result.V, result.I, result.P
[docs]
def metrics(self, pin_w_cm2: float = 0.1) -> CellMetrics:
return compute_metrics(self.params, pin_w_cm2, iv_backend=self.iv_backend)
[docs]
def sr_vs_bias(
self,
v_min: float | None = None,
v_max: float | None = None,
step: float | None = None,
rs: float | None = None,
) -> tuple[np.ndarray, np.ndarray]:
s = resolve_sweep(self.sr_spec, v_min, v_max, step)
return sr_vs_bias(
self.params,
s.v_min,
s.v_max,
s.step,
rs,
iv_backend=self.iv_backend,
)
[docs]
def rs_fit(
self,
i_mA_lo: float | None = None,
i_mA_hi: float | None = None,
*,
positive_generation: bool | None = None,
) -> float:
mask = self.rs_fit_mask
lo = i_mA_lo if i_mA_lo is not None else (mask.i_mA_lo if mask else 5.0)
hi = i_mA_hi if i_mA_hi is not None else (mask.i_mA_hi if mask else 28.0)
pos = (
positive_generation
if positive_generation is not None
else (mask.positive_generation if mask else True)
)
im, srinv = self.rs_extraction_series()
if pos:
lo_int, hi_int = -hi, -lo
else:
lo_int, hi_int = lo, hi
fit_mask = (im >= lo_int) & (im <= hi_int)
return extract_rs_from_sr_inv(
self.params,
im,
srinv,
mask=fit_mask,
iv_backend=self.iv_backend,
)
[docs]
def eis_impedance(self, freqs: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
return self.dispatcher.eis_impedance(freqs)
[docs]
def eis_default_sweep(self) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
return eis_default_sweep(self.params)
[docs]
def clear_lt_results(self) -> None:
self.lt_iv_result = None
self.lt_eis_result = None
self.lt_tran_result = None
[docs]
def clear_measured_eis(self) -> None:
self.measured_eis = None
[docs]
def run_ltspice_iv(
self,
ltspice_exe: str | Path | None = None,
workdir: str | Path | None = None,
*,
spec: SweepSpec | None = None,
) -> IVResult:
result = self.dispatcher.ltspice_iv(spec, ltspice_exe=ltspice_exe, workdir=workdir)
self.lt_iv_result = result
return result
[docs]
def run_ltspice_eis(
self,
ltspice_exe: str | Path | None = None,
workdir: str | Path | None = None,
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
result = self.dispatcher.ltspice_eis(ltspice_exe=ltspice_exe, workdir=workdir)
self.lt_eis_result = result
return result
[docs]
def run_ltspice_tran(
self,
ltspice_exe: str | Path | None = None,
workdir: str | Path | None = None,
*,
tran_spec: TranSpec | None = None,
) -> TRANResult:
spec = tran_spec if tran_spec is not None else self.tran_spec
result = self.dispatcher.ltspice_tran(
ltspice_exe=ltspice_exe,
workdir=workdir,
tran_spec=spec,
)
self.lt_tran_result = result
return result
[docs]
@staticmethod
def ltspice_available(exe: str | Path | None) -> bool:
if exe and Path(exe).is_file():
return True
env = os.environ.get("LTSPICE_EXE")
return bool(env and Path(env).is_file())