"""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