Source code for psc_sim.simulation.session

"""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 extract_rs_from_sr_inv( self, i_mA: np.ndarray | None = None, sr_inv: np.ndarray | None = None, mask: slice | np.ndarray | None = None, rs: float | None = None, ) -> float: return extract_rs_from_sr_inv( self.params, i_mA, sr_inv, mask, rs, iv_backend=self.iv_backend, )
[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_extraction_series( 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.rs_spec, v_min, v_max, step) return rs_extraction_series( 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())