Source code for psc_sim.simulation.backends

"""IV simulation backends: Python implicit solver and optional LTspice."""

from __future__ import annotations

from pathlib import Path
from typing import Protocol, runtime_checkable

import numpy as np

from psc_sim.eis_lumped import eis_impedance
from psc_sim.iv_solver import solve_current, sweep_iv_result
from psc_sim.ltspice_runner import run_ac_eis, run_dc_iv, run_tran
from psc_sim.parameters import CellParameters
from psc_sim.simulation.defaults import DEFAULT_IV_SWEEP
from psc_sim.simulation.eis_grid import default_eis_freqs
from psc_sim.simulation.types import IVResult, SweepSpec, TRANResult, TranSpec


[docs] @runtime_checkable class IVBackend(Protocol): def solve_point( self, V: float, params: CellParameters, *, rs: float | None = None, i_hint: float | None = None, ) -> float: ... def sweep_iv( self, params: CellParameters, spec: SweepSpec, *, rs: float | None = None, ) -> IVResult: ...
[docs] class PythonIVBackend: """Default backend: implicit 1-diode generation branch.""" def solve_point( self, V: float, params: CellParameters, *, rs: float | None = None, i_hint: float | None = None, ) -> float: return solve_current(V, params, rs, i_hint=i_hint) def sweep_iv( self, params: CellParameters, spec: SweepSpec, *, rs: float | None = None, ) -> IVResult: return sweep_iv_result(params, spec, rs)
[docs] class LTspiceIVBackend: """LTspice .dc sweep; point solve uses nearest sweep sample.""" def __init__( self, ltspice_exe: str | Path | None = None, workdir: str | Path | None = None, ) -> None: self.ltspice_exe = Path(ltspice_exe) if ltspice_exe else None self.workdir = Path(workdir) if workdir else None self._last_V: list[float] | None = None self._last_I: list[float] | None = None def solve_point( self, V: float, params: CellParameters, *, rs: float | None = None, i_hint: float | None = None, ) -> float: del rs, i_hint if self._last_V is None or self._last_I is None: self.sweep_iv(params, DEFAULT_IV_SWEEP) assert self._last_V is not None and self._last_I is not None arr_v = np.asarray(self._last_V, dtype=float) arr_i = np.asarray(self._last_I, dtype=float) idx = int(np.argmin(np.abs(arr_v - V))) return float(arr_i[idx]) def sweep_iv( self, params: CellParameters, spec: SweepSpec, *, rs: float | None = None, ) -> IVResult: del rs V_list, I_list = run_dc_iv( params, ltspice_exe=self.ltspice_exe, workdir=self.workdir, spec=spec, ) self._last_V, self._last_I = V_list, I_list V = np.asarray(V_list, dtype=float) I = np.asarray(I_list, dtype=float) P = V * I return IVResult(V=V, I=I, P=P, truncated=False)
[docs] @runtime_checkable class EISBackend(Protocol): def impedance( self, params: CellParameters, freqs: np.ndarray, ) -> tuple[np.ndarray, np.ndarray]: ...
[docs] class PythonEISBackend: """Lumped-element EIS from eis_lumped.py.""" def impedance( self, params: CellParameters, freqs: np.ndarray, ) -> tuple[np.ndarray, np.ndarray]: return eis_impedance(params, freqs)
[docs] class LTspiceEISBackend: """LTspice AC analysis; Z = V(v_out) / I(Vbias) from .raw.""" def __init__( self, ltspice_exe: str | Path | None = None, workdir: str | Path | None = None, ) -> None: self.ltspice_exe = Path(ltspice_exe) if ltspice_exe else None self.workdir = Path(workdir) if workdir else None self._last_freq: np.ndarray | None = None self._last_zre: np.ndarray | None = None self._last_zim: np.ndarray | None = None def _ensure_sweep(self, params: CellParameters) -> None: if self._last_freq is not None: return grid = default_eis_freqs() f, zre, zim = run_ac_eis( params, ltspice_exe=self.ltspice_exe, workdir=self.workdir, freqs=grid, ) self._last_freq, self._last_zre, self._last_zim = f, zre, zim def impedance( self, params: CellParameters, freqs: np.ndarray, ) -> tuple[np.ndarray, np.ndarray]: self._ensure_sweep(params) assert ( self._last_freq is not None and self._last_zre is not None and self._last_zim is not None ) f_req = np.asarray(freqs, dtype=float) if np.array_equal(f_req, self._last_freq): return self._last_zre.copy(), self._last_zim.copy() zre = np.interp(f_req, self._last_freq, self._last_zre, left=np.nan, right=np.nan) zim = np.interp(f_req, self._last_freq, self._last_zim, left=np.nan, right=np.nan) return zre, zim
[docs] class LTspiceTRANBackend: """LTspice transient analysis from .tran netlist.""" def __init__( self, ltspice_exe: str | Path | None = None, workdir: str | Path | None = None, tran_spec: TranSpec | None = None, ) -> None: self.ltspice_exe = Path(ltspice_exe) if ltspice_exe else None self.workdir = Path(workdir) if workdir else None self.tran_spec = tran_spec self._cached: TRANResult | None = None def run(self, params: CellParameters) -> TRANResult: if self._cached is not None: return self._cached t, v, i = run_tran( params, ltspice_exe=self.ltspice_exe, workdir=self.workdir, tran_spec=self.tran_spec, ) self._cached = TRANResult(t=t, V=v, I=i) return self._cached