Source code for psc_sim.iv_solver

"""Implicit 1-diode I–V solver (generation branch, Voc bracketing, IV sweep)."""

from __future__ import annotations

import math
from collections.abc import Callable

import numpy as np

from psc_sim.iv import residual_iv_implicit
from psc_sim.parameters import CellParameters
from psc_sim.simulation.types import IVResult, SweepSpec

IV_TRUNCATION_HINT = (
    "高電圧の一部では、DC の 1 ダイオード+Rs+Rsh の陰式に厳密な解がなく I–V を打ち切っています"
    "(回路図の C・Rion はこの I–V 計算に含まれません)。"
    "Rsh を大きくする・I₀ を下げる・電圧レンジを狭めると改善することがあります。"
)


def _bracket_root(
    f: Callable[[float], float],
    lo: float,
    hi: float,
    *,
    expand_hi: Callable[[float], float] | None = None,
    expand_lo: Callable[[float], float] | None = None,
    max_expand: int = 16,
    max_bisect: int = 80,
    tol_f: float = 1e-12,
    tol_x: float = 1e-13,
) -> float | None:
    """Bisection on [lo, hi] after optional bracket expansion. Returns None if no sign change."""
    if expand_lo is not None:
        for _ in range(max_expand):
            if f(lo) >= 0.0:
                break
            lo = expand_lo(lo)
        else:
            return None

    flo = f(lo)
    fhi = f(hi)
    if expand_hi is not None:
        for _ in range(max_expand):
            if flo == 0.0:
                return lo
            if flo * fhi <= 0.0:
                break
            hi = expand_hi(hi)
            fhi = f(hi)
        else:
            return None
    elif flo * fhi > 0.0:
        return None

    flo = f(lo)
    for _ in range(max_bisect):
        mid = 0.5 * (lo + hi)
        fm = f(mid)
        if abs(fm) < tol_f or abs(hi - lo) < tol_x:
            return mid
        if flo * fm <= 0.0:
            hi = mid
        else:
            lo = mid
            flo = fm
    return 0.5 * (lo + hi)


[docs] def terminal_voc(p: CellParameters, rs: float | None = None, *, v_hi: float = 2.0) -> float | None: """Return the I=0 terminal voltage for the diode + shunt branch.""" rs_use = p.Rs if rs is None else rs def f0(v: float) -> float: return residual_iv_implicit(0.0, v, p, rs_use) result = _bracket_root( f0, 0.0, max(v_hi, 0.1), expand_hi=lambda h: h * 1.5, tol_f=1e-13, tol_x=1e-10, ) return result
[docs] def solve_generation_current( V: float, p: CellParameters, rs: float, i_hint: float | None = None, ) -> float: """Solve the physical photovoltaic branch between generated current and Voc.""" f_hi = residual_iv_implicit(0.0, V, p, rs) if abs(f_hi) < 1e-13: return 0.0 if f_hi > 0.0: return float("nan") lo = -max(2.0 * abs(p.Iph), abs(i_hint or 0.0) * 1.4, 1e-9) def f_i(i: float) -> float: return residual_iv_implicit(i, V, p, rs) result = _bracket_root( f_i, lo, 0.0, expand_lo=lambda x: x * 1.8, max_expand=24, tol_f=1e-12, tol_x=1e-13, ) return float("nan") if result is None else result
[docs] def solve_current( V: float, p: CellParameters, rs: float | None = None, *, i_hint: float | None = None, ) -> float: """Single public API for terminal current at bias V (generation branch).""" rs_use = p.Rs if rs is None else rs return solve_generation_current(V, p, rs_use, i_hint)
[docs] def sweep_iv_curve( p: CellParameters, v_min: float = -0.3, v_max: float = 1.25, step: float = 0.01, rs: float | None = None, *, spec: SweepSpec | None = None, ) -> tuple[np.ndarray, np.ndarray, np.ndarray]: """Voltage sweep with Voc truncation beyond the physical open-circuit point.""" if spec is not None: v_min, v_max, step = spec.v_min, spec.v_max, spec.step result = sweep_iv_result(p, SweepSpec(v_min, v_max, step), rs) return result.V, result.I, result.P
[docs] def sweep_iv_result( p: CellParameters, spec: SweepSpec, rs: float | None = None, ) -> IVResult: """Voltage sweep returning IVResult with truncation flag.""" rs_use = p.Rs if rs is None else rs v_min, v_max, step = spec.v_min, spec.v_max, spec.step voc = terminal_voc(p, rs_use, v_hi=max(v_max, 0.2)) if rs is None or rs_use == p.Rs else None V = np.arange(v_min, v_max + step * 0.5, step, dtype=float) if ( voc is not None and v_min <= voc <= v_max and not np.any(np.isclose(V, voc, atol=1e-9, rtol=0.0)) ): V = np.sort(np.append(V, voc)) I = np.empty_like(V) prev: float | None = None for k in range(V.size): v_raw = float(V[k]) vq = round(v_raw, 6) voc_tol = max(1e-9, abs(step) * 1e-5) if voc is not None and v_raw > voc + voc_tol: I[k] = float("nan") continue if voc is not None and abs(v_raw - voc) <= voc_tol: i = 0.0 else: i = solve_generation_current(vq, p, rs_use, prev) if math.isfinite(i): I[k] = i prev = i else: I[k] = float("nan") P = V * I return IVResult.from_arrays(V, I, P)
def has_iv_truncation(I: np.ndarray) -> bool: return bool(np.any(~np.isfinite(I)))