"""Tests for runtime-complete simulation bindings (SimulationOptions/Simulator/YamlParser)."""

from __future__ import annotations

import gc
import math
import statistics
import tempfile
from pathlib import Path

import pulsim as ps
import pytest


def _build_rc_circuit() -> ps.Circuit:
    circuit = ps.Circuit()
    n_in = circuit.add_node("in")
    n_out = circuit.add_node("out")
    gnd = circuit.ground()

    circuit.add_voltage_source("V1", n_in, gnd, 5.0)
    circuit.add_resistor("R1", n_in, n_out, 1_000.0)
    circuit.add_capacitor("C1", n_out, gnd, 1e-6, 0.0)
    return circuit


def _build_rc_frequency_circuit() -> ps.Circuit:
    circuit = ps.Circuit()
    n_in = circuit.add_node("in")
    n_out = circuit.add_node("out")
    gnd = circuit.ground()

    circuit.add_resistor("R1", n_in, n_out, 1_000.0)
    circuit.add_capacitor("C1", n_out, gnd, 1e-6, 0.0)
    return circuit


def _build_sine_resistive_frequency_circuit() -> ps.Circuit:
    circuit = ps.Circuit()
    n_in = circuit.add_node("in")
    n_out = circuit.add_node("out")
    gnd = circuit.ground()

    circuit.add_sine_voltage_source("Vs", n_in, gnd, 1.0, 1_000.0, 0.0)
    circuit.add_resistor("R1", n_in, n_out, 1_000.0)
    circuit.add_resistor("R2", n_out, gnd, 500.0)
    return circuit


def _build_sine_rc_frequency_circuit() -> ps.Circuit:
    circuit = ps.Circuit()
    n_in = circuit.add_node("in")
    n_out = circuit.add_node("out")
    gnd = circuit.ground()

    circuit.add_sine_voltage_source("Vs", n_in, gnd, 1.0, 1_000.0, 0.0)
    circuit.add_resistor("R1", n_in, n_out, 1_000.0)
    circuit.add_capacitor("C1", n_out, gnd, 1e-6, 0.0)
    return circuit


def _build_buck_averaged_circuit() -> ps.Circuit:
    circuit = ps.Circuit()
    n_in = circuit.add_node("in")
    n_out = circuit.add_node("out")
    gnd = circuit.ground()

    circuit.add_voltage_source("Vin", n_in, gnd, 12.0)
    circuit.add_inductor("L1", n_in, n_out, 100e-6, 0.0)
    circuit.add_capacitor("C1", n_out, gnd, 220e-6, 0.0)
    circuit.add_resistor("Rload", n_out, gnd, 10.0)
    return circuit


def _build_boost_averaged_circuit(load_ohm: float = 20.0) -> ps.Circuit:
    circuit = ps.Circuit()
    n_in = circuit.add_node("in")
    n_out = circuit.add_node("out")
    gnd = circuit.ground()

    circuit.add_voltage_source("Vin", n_in, gnd, 12.0)
    circuit.add_inductor("L1", n_in, n_out, 100e-6, 0.0)
    circuit.add_capacitor("C1", n_out, gnd, 220e-6, 0.0)
    circuit.add_resistor("Rload", n_out, gnd, load_ohm)
    return circuit


def _build_buck_boost_averaged_circuit(load_ohm: float = 10.0) -> ps.Circuit:
    circuit = ps.Circuit()
    n_in = circuit.add_node("in")
    n_out = circuit.add_node("out")
    gnd = circuit.ground()

    circuit.add_voltage_source("Vin", n_in, gnd, 12.0)
    circuit.add_inductor("L1", n_in, n_out, 100e-6, 0.0)
    circuit.add_capacitor("C1", n_out, gnd, 220e-6, 0.0)
    circuit.add_resistor("Rload", n_out, gnd, load_ohm)
    return circuit


def test_simulator_with_simulation_options_runs_transient() -> None:
    circuit = _build_rc_circuit()

    opts = ps.SimulationOptions()
    opts.tstart = 0.0
    opts.tstop = 5e-4
    opts.dt = 1e-6
    opts.integrator = ps.Integrator.Trapezoidal
    opts.linear_solver.order = [ps.LinearSolverKind.KLU]
    opts.linear_solver.fallback_order = [ps.LinearSolverKind.SparseLU]

    sim = ps.Simulator(circuit, opts)
    result = sim.run_transient()

    assert result.success
    assert result.final_status == ps.SolverStatus.Success
    assert len(result.time) > 2
    assert result.total_steps > 0
    assert result.newton_iterations_total >= 0


def test_procedural_run_transient_remains_compatible_with_canonical_simulator_surface() -> None:
    circuit = _build_rc_circuit()

    opts = ps.SimulationOptions()
    opts.tstart = 0.0
    opts.tstop = 2e-4
    opts.dt = 1e-6
    opts.dt_min = opts.dt
    opts.dt_max = opts.dt
    opts.adaptive_timestep = False
    opts.newton_options.num_nodes = int(circuit.num_nodes())
    opts.newton_options.num_branches = int(circuit.num_branches())

    x0 = circuit.initial_state()
    canonical = ps.Simulator(circuit, opts).run_transient(x0)
    proc_time, proc_states, proc_ok, _ = ps.run_transient(
        circuit,
        opts.tstart,
        opts.tstop,
        opts.dt,
        x0,
        opts.newton_options,
        opts.linear_solver,
        robust=False,
        auto_regularize=False,
    )

    assert canonical.success is True
    assert proc_ok is True
    assert len(proc_time) > 2
    assert len(canonical.time) > 2
    assert abs(float(proc_time[-1]) - opts.tstop) < 1e-12
    assert abs(float(canonical.time[-1]) - opts.tstop) < 1e-12
    assert abs(float(proc_states[-1][1]) - float(canonical.states[-1][1])) < 5e-3


def test_yaml_parser_load_string_returns_circuit_and_options() -> None:
    content = """
schema: pulsim-v1
version: 1
simulation:
  tstart: 0
  tstop: 5e-4
  dt: 1e-6
  integrator: trapezoidal
  solver:
    order: [klu]
components:
  - type: voltage_source
    name: V1
    nodes: [in, 0]
    waveform: {type: dc, value: 5}
  - type: resistor
    name: R1
    nodes: [in, out]
    value: 1k
  - type: capacitor
    name: C1
    nodes: [out, 0]
    value: 1u
    ic: 0
"""
    parser = ps.YamlParser()
    circuit, options = parser.load_string(content)

    assert parser.errors == []
    assert circuit.num_devices() == 3
    assert options.tstop > options.tstart

    sim = ps.Simulator(circuit, options)
    result = sim.run_transient()
    assert result.success


def test_yaml_virtual_component_first_does_not_break_switched_convergence() -> None:
    content = """
schema: pulsim-v1
version: 1
simulation:
  tstart: 0.0
  tstop: 4e-4
  dt: 1e-6
  step_mode: variable
  enable_events: true
  enable_losses: false
  max_step_retries: 8
  formulation: projected_wrapper
  direct_formulation_fallback: true
  newton:
    max_iterations: 99
    enable_limiting: true
    max_voltage_step: 2.0
components:
  - type: voltage_probe
    name: Vghost
    nodes: [ghost_control_node, 0]
  - type: voltage_source
    name: Vin
    nodes: [vin, 0]
    waveform: {type: dc, value: 12.0}
  - type: voltage_source
    name: Vref
    nodes: [vref, 0]
    waveform: {type: dc, value: 6.0}
  - type: mosfet
    name: M1
    nodes: [0, vin, sw]
    vth: 3.0
    kp: 0.35
    lambda: 0.01
    g_off: 1e-8
  - type: diode
    name: D1
    nodes: [0, sw]
    g_on: 350.0
    g_off: 1e-9
  - type: inductor
    name: L1
    nodes: [sw, vout]
    value: 220u
    ic: 0.0
  - type: capacitor
    name: Cout
    nodes: [vout, 0]
    value: 220u
    ic: 0.0
  - type: resistor
    name: Rload
    nodes: [vout, 0]
    value: 8.0
  - type: pi_controller
    name: PI1
    nodes: [vref, vout, 0]
    kp: 0.08
    ki: 100.0
    output_min: 0.0
    output_max: 0.95
    anti_windup: 1.0
  - type: pwm_generator
    name: PWM1
    nodes: [0]
    frequency: 10000.0
    duty: 0.5
    duty_min: 0.0
    duty_max: 0.95
    duty_from_channel: PI1
    target_component: M1
"""
    parser = ps.YamlParser()
    circuit, options = parser.load_string(content)
    assert parser.errors == []

    result = ps.Simulator(circuit, options).run_transient(circuit.initial_state())

    assert result.success is True
    assert result.backend_telemetry.failure_reason == ""
    assert abs(float(result.time[-1]) - float(options.tstop)) <= 1e-12


def test_yaml_parser_strict_mode_reports_unknown_field() -> None:
    content = """
schema: pulsim-v1
version: 1
simulation:
  unknown_field: 123
components:
  - type: resistor
    name: R1
    nodes: [in, 0]
    value: 1k
"""
    options = ps.YamlParserOptions()
    options.strict = True
    parser = ps.YamlParser(options)
    parser.load_string(content)

    assert any("PULSIM_YAML_E_UNKNOWN_FIELD" in msg for msg in parser.errors)
    assert any("simulation.unknown_field" in msg for msg in parser.errors)


def test_yaml_parser_reports_type_mismatch_with_expected_and_received_classes() -> None:
    content = """
schema: pulsim-v1
version: 1
simulation:
  dt: {invalid: true}
components:
  - type: resistor
    name: R1
    nodes: [in, 0]
    value: 1k
"""
    parser = ps.YamlParser()
    parser.load_string(content)

    assert any("PULSIM_YAML_E_TYPE_MISMATCH" in msg for msg in parser.errors)
    assert any("simulation.dt" in msg for msg in parser.errors)
    assert any("expected number" in msg and "got map" in msg for msg in parser.errors)


def test_yaml_parser_warns_deprecated_adaptive_timestep_alias_in_non_strict_mode() -> None:
    content = """
schema: pulsim-v1
version: 1
simulation:
  adaptive_timestep: true
  tstop: 1e-4
  dt: 1e-6
components:
  - type: resistor
    name: R1
    nodes: [in, 0]
    value: 1k
"""
    options = ps.YamlParserOptions()
    options.strict = False
    parser = ps.YamlParser(options)
    _, parsed_options = parser.load_string(content)

    assert parser.errors == []
    assert parsed_options.adaptive_timestep is True
    assert any("PULSIM_YAML_W_DEPRECATED_FIELD" in msg for msg in parser.warnings)
    assert any("simulation.adaptive_timestep" in msg for msg in parser.warnings)
    assert any("simulation.step_mode" in msg for msg in parser.warnings)


def test_yaml_parser_maps_frequency_analysis_block() -> None:
    content = """
schema: pulsim-v1
version: 1
simulation:
  tstart: 0
  tstop: 1e-3
  dt: 1e-6
  frequency_analysis:
    enabled: true
    mode: open_loop_transfer
    anchor: dc
    sweep:
      scale: log
      f_start_hz: 1.0
      f_stop_hz: 100000.0
      points: 40
    injection_current_amplitude: 1.0
    perturbation:
      positive: in
      negative: 0
    output:
      positive: out
      negative: 0
components:
  - type: resistor
    name: R1
    nodes: [in, out]
    value: 1k
  - type: capacitor
    name: C1
    nodes: [out, 0]
    value: 1u
"""
    parser = ps.YamlParser()
    _, options = parser.load_string(content)

    assert parser.errors == []
    assert options.frequency_analysis.enabled is True
    assert options.frequency_analysis.mode == ps.FrequencyAnalysisMode.OpenLoopTransfer
    assert options.frequency_analysis.anchor_mode == ps.FrequencyAnchorMode.DC
    assert options.frequency_analysis.sweep_scale == ps.FrequencySweepScale.Logarithmic
    assert options.frequency_analysis.points == 40
    assert abs(options.frequency_analysis.f_start_hz - 1.0) < 1e-12
    assert abs(options.frequency_analysis.f_stop_hz - 100000.0) < 1e-12
    assert options.frequency_analysis.perturbation_port.positive_node == "in"
    assert options.frequency_analysis.output_port.positive_node == "out"


def test_yaml_parser_rejects_invalid_frequency_sweep_points() -> None:
    content = """
schema: pulsim-v1
version: 1
simulation:
  frequency_analysis:
    enabled: true
    sweep:
      f_start_hz: 10.0
      f_stop_hz: 1000.0
      points: 1
    injection_current_amplitude: 1.0
    perturbation:
      positive: in
      negative: 0
components:
  - type: resistor
    name: R1
    nodes: [in, 0]
    value: 1k
"""
    parser = ps.YamlParser()
    parser.load_string(content)
    assert any("PULSIM_YAML_E_FREQ_CONFIG_INVALID" in msg for msg in parser.errors)


def test_yaml_parser_maps_averaged_converter_block() -> None:
    content = """
schema: pulsim-v1
version: 1
simulation:
  tstart: 0
  tstop: 1e-4
  dt: 1e-6
  averaged_converter:
    enabled: true
    topology: boost
    operating_mode: dcm
    envelope_policy: warn
    vin_source: Vin
    inductor: L1
    capacitor: C1
    load_resistor: Rload
    output_node: out
    duty: 0.42
    duty_min: 0.1
    duty_max: 0.9
    switching_frequency_hz: 120000.0
    initial_inductor_current: 0.2
    initial_output_voltage: 4.5
    ccm_current_threshold: 0.0
components:
  - type: voltage_source
    name: Vin
    nodes: [in, 0]
    waveform: {type: dc, value: 12.0}
  - type: inductor
    name: L1
    nodes: [in, out]
    value: 100u
  - type: capacitor
    name: C1
    nodes: [out, 0]
    value: 220u
  - type: resistor
    name: Rload
    nodes: [out, 0]
    value: 10
"""
    parser = ps.YamlParser()
    _, options = parser.load_string(content)

    assert parser.errors == []
    assert options.averaged_converter.enabled is True
    assert options.averaged_converter.topology == ps.AveragedConverterTopology.Boost
    assert options.averaged_converter.operating_mode == ps.AveragedOperatingMode.DCM
    assert options.averaged_converter.envelope_policy == ps.AveragedEnvelopePolicy.Warn
    assert options.averaged_converter.vin_source == "Vin"
    assert options.averaged_converter.inductor == "L1"
    assert options.averaged_converter.capacitor == "C1"
    assert options.averaged_converter.load_resistor == "Rload"
    assert options.averaged_converter.output_node == "out"
    assert abs(options.averaged_converter.duty - 0.42) < 1e-12
    assert abs(options.averaged_converter.switching_frequency_hz - 120000.0) < 1e-12


def test_yaml_parser_rejects_invalid_averaged_duty_bounds() -> None:
    content = """
schema: pulsim-v1
version: 1
simulation:
  averaged_converter:
    enabled: true
    topology: buck
    vin_source: Vin
    inductor: L1
    capacitor: C1
    load_resistor: Rload
    output_node: out
    duty: 1.1
    duty_min: 0.0
    duty_max: 0.9
components:
  - type: voltage_source
    name: Vin
    nodes: [in, 0]
    waveform: {type: dc, value: 12.0}
  - type: inductor
    name: L1
    nodes: [in, out]
    value: 100u
  - type: capacitor
    name: C1
    nodes: [out, 0]
    value: 220u
  - type: resistor
    name: Rload
    nodes: [out, 0]
    value: 10
"""
    parser = ps.YamlParser()
    parser.load_string(content)

    assert any("PULSIM_YAML_E_AVERAGED_CONFIG_INVALID" in msg for msg in parser.errors)


def test_yaml_parser_rejects_invalid_averaged_switching_frequency() -> None:
    content = """
schema: pulsim-v1
version: 1
simulation:
  averaged_converter:
    enabled: true
    topology: buck_boost
    operating_mode: dcm
    vin_source: Vin
    inductor: L1
    capacitor: C1
    load_resistor: Rload
    output_node: out
    duty: 0.4
    duty_min: 0.0
    duty_max: 0.9
    switching_frequency_hz: 0.0
components:
  - type: voltage_source
    name: Vin
    nodes: [in, 0]
    waveform: {type: dc, value: 12.0}
  - type: inductor
    name: L1
    nodes: [in, out]
    value: 100u
  - type: capacitor
    name: C1
    nodes: [out, 0]
    value: 220u
  - type: resistor
    name: Rload
    nodes: [out, 0]
    value: 10
"""
    parser = ps.YamlParser()
    parser.load_string(content)
    assert any("PULSIM_YAML_E_AVERAGED_CONFIG_INVALID" in msg for msg in parser.errors)
    assert any("switching_frequency_hz" in msg for msg in parser.errors)


def test_frequency_analysis_rc_lowpass_behaves_physically() -> None:
    circuit = _build_rc_frequency_circuit()
    options = ps.SimulationOptions()
    options.frequency_analysis.enabled = True
    options.frequency_analysis.mode = ps.FrequencyAnalysisMode.OpenLoopTransfer
    options.frequency_analysis.anchor_mode = ps.FrequencyAnchorMode.DC
    options.frequency_analysis.sweep_scale = ps.FrequencySweepScale.Logarithmic
    options.frequency_analysis.f_start_hz = 1.0
    options.frequency_analysis.f_stop_hz = 100000.0
    options.frequency_analysis.points = 80
    options.frequency_analysis.injection_current_amplitude = 1.0
    options.frequency_analysis.perturbation_port.positive_node = "in"
    options.frequency_analysis.perturbation_port.negative_node = "0"
    options.frequency_analysis.output_port.positive_node = "out"
    options.frequency_analysis.output_port.negative_node = "0"

    result = ps.Simulator(circuit, options).run_frequency_analysis()

    assert result.success is True
    assert len(result.frequency_hz) == 80
    assert len(result.magnitude_db) == 80
    assert len(result.phase_deg) == 80

    # RC low-pass: near 0 dB at very low frequency, strong attenuation at high frequency.
    assert result.magnitude_db[0] > -0.5
    assert result.magnitude_db[-1] < -40.0
    assert result.phase_deg[-1] < -70.0

    f_cutoff = 1.0 / (2.0 * math.pi * 1_000.0 * 1e-6)
    idx = min(range(len(result.frequency_hz)), key=lambda i: abs(result.frequency_hz[i] - f_cutoff))
    assert abs(result.magnitude_db[idx] + 3.0) < 1.5


def test_averaged_buck_transient_runs_with_expected_channels() -> None:
    circuit = _build_buck_averaged_circuit()
    options = ps.SimulationOptions()
    options.tstart = 0.0
    options.tstop = 2e-4
    options.dt = 1e-6

    options.averaged_converter.enabled = True
    options.averaged_converter.topology = ps.AveragedConverterTopology.Buck
    options.averaged_converter.operating_mode = ps.AveragedOperatingMode.CCM
    options.averaged_converter.envelope_policy = ps.AveragedEnvelopePolicy.Warn
    options.averaged_converter.vin_source = "Vin"
    options.averaged_converter.inductor = "L1"
    options.averaged_converter.capacitor = "C1"
    options.averaged_converter.load_resistor = "Rload"
    options.averaged_converter.output_node = "out"
    options.averaged_converter.duty = 0.42
    options.averaged_converter.duty_min = 0.0
    options.averaged_converter.duty_max = 0.95
    options.averaged_converter.switching_frequency_hz = 100_000.0
    options.averaged_converter.initial_inductor_current = 0.0
    options.averaged_converter.initial_output_voltage = 0.0
    options.averaged_converter.ccm_current_threshold = 0.0

    result = ps.Simulator(circuit, options).run_transient(circuit.initial_state())

    assert result.success is True
    assert result.diagnostic.name == "None"
    assert result.backend_telemetry.solver_family == "averaged_converter"
    assert len(result.time) > 10
    assert "Iavg(L1)" in result.virtual_channels
    assert "Vavg(out)" in result.virtual_channels
    assert "Davg" in result.virtual_channels
    assert len(result.virtual_channels["Iavg(L1)"]) == len(result.time)
    assert len(result.virtual_channels["Vavg(out)"]) == len(result.time)
    assert len(result.virtual_channels["Davg"]) == len(result.time)
    assert result.virtual_channels["Vavg(out)"][-1] > 0.0


def test_averaged_buck_strict_envelope_reports_typed_diagnostic() -> None:
    circuit = _build_buck_averaged_circuit()
    options = ps.SimulationOptions()
    options.tstart = 0.0
    options.tstop = 5e-5
    options.dt = 1e-6

    options.averaged_converter.enabled = True
    options.averaged_converter.topology = ps.AveragedConverterTopology.Buck
    options.averaged_converter.operating_mode = ps.AveragedOperatingMode.CCM
    options.averaged_converter.envelope_policy = ps.AveragedEnvelopePolicy.Strict
    options.averaged_converter.vin_source = "Vin"
    options.averaged_converter.inductor = "L1"
    options.averaged_converter.capacitor = "C1"
    options.averaged_converter.load_resistor = "Rload"
    options.averaged_converter.output_node = "out"
    options.averaged_converter.duty = 0.2
    options.averaged_converter.duty_min = 0.0
    options.averaged_converter.duty_max = 0.95
    options.averaged_converter.switching_frequency_hz = 100_000.0
    options.averaged_converter.initial_inductor_current = 0.0
    options.averaged_converter.initial_output_voltage = 0.0
    options.averaged_converter.ccm_current_threshold = 5.0

    result = ps.Simulator(circuit, options).run_transient(circuit.initial_state())

    assert result.success is False
    assert result.diagnostic.name == "AveragedOutOfEnvelope"


def test_averaged_boost_ccm_produces_step_up_output() -> None:
    circuit = _build_boost_averaged_circuit(load_ohm=20.0)
    options = ps.SimulationOptions()
    options.tstart = 0.0
    options.tstop = 8e-4
    options.dt = 1e-6

    options.averaged_converter.enabled = True
    options.averaged_converter.topology = ps.AveragedConverterTopology.Boost
    options.averaged_converter.operating_mode = ps.AveragedOperatingMode.CCM
    options.averaged_converter.envelope_policy = ps.AveragedEnvelopePolicy.Warn
    options.averaged_converter.vin_source = "Vin"
    options.averaged_converter.inductor = "L1"
    options.averaged_converter.capacitor = "C1"
    options.averaged_converter.load_resistor = "Rload"
    options.averaged_converter.output_node = "out"
    options.averaged_converter.duty = 0.5
    options.averaged_converter.duty_min = 0.0
    options.averaged_converter.duty_max = 0.95
    options.averaged_converter.switching_frequency_hz = 100_000.0
    options.averaged_converter.initial_inductor_current = 0.0
    options.averaged_converter.initial_output_voltage = 0.0
    options.averaged_converter.ccm_current_threshold = 0.0

    result = ps.Simulator(circuit, options).run_transient(circuit.initial_state())

    assert result.success is True
    v_final = float(result.virtual_channels["Vavg(out)"][-1])
    assert v_final > 12.0
    assert v_final < 60.0


def test_averaged_buck_boost_dcm_produces_negative_output() -> None:
    circuit = _build_buck_boost_averaged_circuit(load_ohm=10.0)
    options = ps.SimulationOptions()
    options.tstart = 0.0
    options.tstop = 1.5e-3
    options.dt = 1e-6

    options.averaged_converter.enabled = True
    options.averaged_converter.topology = ps.AveragedConverterTopology.BuckBoost
    options.averaged_converter.operating_mode = ps.AveragedOperatingMode.DCM
    options.averaged_converter.envelope_policy = ps.AveragedEnvelopePolicy.Strict
    options.averaged_converter.vin_source = "Vin"
    options.averaged_converter.inductor = "L1"
    options.averaged_converter.capacitor = "C1"
    options.averaged_converter.load_resistor = "Rload"
    options.averaged_converter.output_node = "out"
    options.averaged_converter.duty = 0.35
    options.averaged_converter.duty_min = 0.0
    options.averaged_converter.duty_max = 0.95
    options.averaged_converter.switching_frequency_hz = 100_000.0
    options.averaged_converter.initial_inductor_current = 0.0
    options.averaged_converter.initial_output_voltage = 0.0
    options.averaged_converter.ccm_current_threshold = 5.0

    result = ps.Simulator(circuit, options).run_transient(circuit.initial_state())

    assert result.success is True
    v_final = float(result.virtual_channels["Vavg(out)"][-1])
    assert v_final < -0.5
    assert v_final > -8.0


def test_averaged_auto_mode_uses_dcm_without_envelope_failure() -> None:
    circuit = _build_boost_averaged_circuit(load_ohm=80.0)
    options = ps.SimulationOptions()
    options.tstart = 0.0
    options.tstop = 8e-4
    options.dt = 1e-6

    options.averaged_converter.enabled = True
    options.averaged_converter.topology = ps.AveragedConverterTopology.Boost
    options.averaged_converter.operating_mode = ps.AveragedOperatingMode.Auto
    options.averaged_converter.envelope_policy = ps.AveragedEnvelopePolicy.Strict
    options.averaged_converter.vin_source = "Vin"
    options.averaged_converter.inductor = "L1"
    options.averaged_converter.capacitor = "C1"
    options.averaged_converter.load_resistor = "Rload"
    options.averaged_converter.output_node = "out"
    options.averaged_converter.duty = 0.45
    options.averaged_converter.duty_min = 0.0
    options.averaged_converter.duty_max = 0.95
    options.averaged_converter.switching_frequency_hz = 100_000.0
    options.averaged_converter.initial_inductor_current = 0.0
    options.averaged_converter.initial_output_voltage = 0.0
    options.averaged_converter.ccm_current_threshold = 3.0

    result = ps.Simulator(circuit, options).run_transient(circuit.initial_state())

    assert result.success is True
    assert result.diagnostic.name == "None"
    assert float(result.virtual_channels["Vavg(out)"][-1]) > 0.0


def test_frequency_analysis_exposes_metadata_and_undefined_metric_reasons() -> None:
    circuit = _build_rc_frequency_circuit()
    options = ps.FrequencyAnalysisOptions()
    options.enabled = True
    options.mode = ps.FrequencyAnalysisMode.OpenLoopTransfer
    options.anchor_mode = ps.FrequencyAnchorMode.DC
    options.sweep_scale = ps.FrequencySweepScale.Logarithmic
    options.f_start_hz = 10.0
    options.f_stop_hz = 100000.0
    options.points = 40
    options.injection_current_amplitude = 1.0
    options.perturbation_port.positive_node = "in"
    options.output_port.positive_node = "out"

    result = ps.Simulator(circuit).run_frequency_analysis(options)

    assert result.success is True
    assert "frequency_hz" in result.channel_metadata
    assert "magnitude_db" in result.channel_metadata
    assert result.channel_metadata["frequency_hz"].unit == "Hz"
    assert result.channel_metadata["magnitude_db"].unit == "dB"
    assert result.channel_metadata["magnitude_db"].domain == "frequency"

    # Passive RC low-pass does not cross 0 dB or -180 deg in this range.
    assert math.isnan(result.gain_crossover_hz)
    assert math.isnan(result.phase_margin_deg)
    assert result.gain_crossover_reason == ps.FrequencyMetricUndefinedReason.NoGainCrossover
    assert result.phase_margin_reason == ps.FrequencyMetricUndefinedReason.NoGainCrossover
    assert result.phase_crossover_reason == ps.FrequencyMetricUndefinedReason.NoPhaseCrossover
    assert result.gain_margin_reason == ps.FrequencyMetricUndefinedReason.NoPhaseCrossover


def test_frequency_analysis_auto_anchor_selects_dc_without_periodic_excitation() -> None:
    circuit = _build_rc_frequency_circuit()
    options = ps.FrequencyAnalysisOptions()
    options.enabled = True
    options.mode = ps.FrequencyAnalysisMode.OpenLoopTransfer
    options.anchor_mode = ps.FrequencyAnchorMode.Auto
    options.f_start_hz = 10.0
    options.f_stop_hz = 1_000.0
    options.points = 10
    options.injection_current_amplitude = 1.0
    options.perturbation_port.positive_node = "in"
    options.output_port.positive_node = "out"

    result = ps.Simulator(circuit).run_frequency_analysis(options)

    assert result.success is True
    assert result.anchor_mode_selected == ps.FrequencyAnchorMode.DC


def test_frequency_analysis_auto_anchor_selects_periodic_with_sine_source() -> None:
    circuit = _build_sine_resistive_frequency_circuit()
    sim_options = ps.SimulationOptions()
    sim_options.dt = 1e-4
    sim_options.frequency_analysis.enabled = True
    sim_options.frequency_analysis.mode = ps.FrequencyAnalysisMode.InputImpedance
    sim_options.frequency_analysis.anchor_mode = ps.FrequencyAnchorMode.Auto
    sim_options.frequency_analysis.f_start_hz = 10.0
    sim_options.frequency_analysis.f_stop_hz = 1_000.0
    sim_options.frequency_analysis.points = 6
    sim_options.frequency_analysis.injection_current_amplitude = 1.0
    sim_options.frequency_analysis.perturbation_port.positive_node = "out"
    sim_options.periodic_options.max_iterations = 4
    sim_options.periodic_options.tolerance = 1e-6
    sim_options.periodic_options.relaxation = 1.0

    result = ps.Simulator(circuit, sim_options).run_frequency_analysis()

    assert result.success is True
    assert result.anchor_mode_selected == ps.FrequencyAnchorMode.Periodic


def test_frequency_analysis_periodic_anchor_requires_period_configuration() -> None:
    circuit = _build_rc_frequency_circuit()
    sim_options = ps.SimulationOptions()
    sim_options.frequency_analysis.enabled = True
    sim_options.frequency_analysis.mode = ps.FrequencyAnalysisMode.OpenLoopTransfer
    sim_options.frequency_analysis.anchor_mode = ps.FrequencyAnchorMode.Periodic
    sim_options.frequency_analysis.f_start_hz = 10.0
    sim_options.frequency_analysis.f_stop_hz = 1_000.0
    sim_options.frequency_analysis.points = 10
    sim_options.frequency_analysis.injection_current_amplitude = 1.0
    sim_options.frequency_analysis.perturbation_port.positive_node = "in"
    sim_options.frequency_analysis.output_port.positive_node = "out"
    sim_options.periodic_options.period = 0.0

    result = ps.Simulator(circuit, sim_options).run_frequency_analysis()

    assert result.success is False
    assert result.diagnostic.name == "FrequencyUnsupportedConfiguration"
    assert "periodic anchor period" in result.message.lower()


def test_frequency_analysis_explicit_averaged_anchor_runs() -> None:
    circuit = _build_sine_rc_frequency_circuit()
    sim_options = ps.SimulationOptions()
    sim_options.dt = 1e-3
    sim_options.frequency_analysis.enabled = True
    sim_options.frequency_analysis.mode = ps.FrequencyAnalysisMode.InputImpedance
    sim_options.frequency_analysis.anchor_mode = ps.FrequencyAnchorMode.Averaged
    sim_options.frequency_analysis.f_start_hz = 10.0
    sim_options.frequency_analysis.f_stop_hz = 1_000.0
    sim_options.frequency_analysis.points = 6
    sim_options.frequency_analysis.injection_current_amplitude = 1.0
    sim_options.frequency_analysis.perturbation_port.positive_node = "out"
    sim_options.periodic_options.max_iterations = 1
    sim_options.periodic_options.tolerance = 1e-12

    result = ps.Simulator(circuit, sim_options).run_frequency_analysis()

    assert result.success is True
    assert result.anchor_mode_selected == ps.FrequencyAnchorMode.Averaged
    assert "anchor=averaged" in result.message.lower()


def test_frequency_analysis_auto_falls_back_from_periodic_to_averaged() -> None:
    circuit = _build_sine_rc_frequency_circuit()
    sim_options = ps.SimulationOptions()
    sim_options.dt = 1e-3
    # Force periodic pre-anchor to fail deterministically in one iteration.
    sim_options.periodic_options.period = 7e-4
    sim_options.periodic_options.max_iterations = 1
    sim_options.periodic_options.tolerance = 1e-12
    sim_options.periodic_options.relaxation = 1.0

    sim_options.frequency_analysis.enabled = True
    sim_options.frequency_analysis.mode = ps.FrequencyAnalysisMode.InputImpedance
    sim_options.frequency_analysis.anchor_mode = ps.FrequencyAnchorMode.Auto
    sim_options.frequency_analysis.sweep_scale = ps.FrequencySweepScale.Linear
    sim_options.frequency_analysis.f_start_hz = 100.0
    sim_options.frequency_analysis.f_stop_hz = 1_000.0
    sim_options.frequency_analysis.points = 4
    sim_options.frequency_analysis.injection_current_amplitude = 1.0
    sim_options.frequency_analysis.perturbation_port.positive_node = "out"

    result = ps.Simulator(circuit, sim_options).run_frequency_analysis()

    assert result.success is True
    assert result.anchor_mode_selected == ps.FrequencyAnchorMode.Averaged
    assert "auto fallback applied" in result.message.lower()


def test_procedural_frequency_analysis_matches_simulator_entrypoint() -> None:
    circuit = _build_rc_frequency_circuit()
    options = ps.FrequencyAnalysisOptions()
    options.enabled = True
    options.mode = ps.FrequencyAnalysisMode.InputImpedance
    options.anchor_mode = ps.FrequencyAnchorMode.DC
    options.sweep_scale = ps.FrequencySweepScale.Linear
    options.f_start_hz = 100.0
    options.f_stop_hz = 10000.0
    options.points = 25
    options.injection_current_amplitude = 0.5
    options.perturbation_port.positive_node = "in"
    options.perturbation_port.negative_node = "0"

    class_result = ps.Simulator(circuit).run_frequency_analysis(options)
    proc_result = ps.run_frequency_analysis(circuit, options)

    assert class_result.success is True
    assert proc_result.success is True
    assert class_result.frequency_hz == proc_result.frequency_hz
    assert class_result.magnitude_db == proc_result.magnitude_db
    assert class_result.phase_deg == proc_result.phase_deg


def test_frequency_analysis_failure_context_is_structured() -> None:
    circuit = _build_rc_frequency_circuit()
    options = ps.FrequencyAnalysisOptions()
    options.enabled = True
    options.mode = ps.FrequencyAnalysisMode.OpenLoopTransfer
    options.anchor_mode = ps.FrequencyAnchorMode.DC
    options.f_start_hz = 0.0
    options.f_stop_hz = 1000.0
    options.points = 10
    options.injection_current_amplitude = 1.0
    options.perturbation_port.positive_node = "in"
    options.output_port.positive_node = "out"

    result = ps.Simulator(circuit).run_frequency_analysis(options)

    assert result.success is False
    assert result.diagnostic.name == "FrequencyInvalidConfiguration"
    assert result.failed_point_index == -1
    assert math.isnan(result.failed_frequency_hz)


def test_procedural_frequency_analysis_raise_on_failure_exposes_diagnostics() -> None:
    circuit = _build_rc_frequency_circuit()
    options = ps.FrequencyAnalysisOptions()
    options.enabled = True
    options.mode = ps.FrequencyAnalysisMode.OpenLoopTransfer
    options.anchor_mode = ps.FrequencyAnchorMode.DC
    options.f_start_hz = 0.0
    options.f_stop_hz = 1000.0
    options.points = 10
    options.injection_current_amplitude = 1.0
    options.perturbation_port.positive_node = "in"
    options.output_port.positive_node = "out"

    with pytest.raises(ps.FrequencyAnalysisError) as excinfo:
        ps.run_frequency_analysis(circuit, options, raise_on_failure=True)

    err = excinfo.value
    assert err.diagnostic.name == "FrequencyInvalidConfiguration"
    assert err.reason_code == "frequency_invalid_configuration"
    assert err.failed_point_index == -1
    assert math.isnan(err.failed_frequency_hz)
    assert "frequency_invalid_configuration" in str(err)


def test_procedural_frequency_analysis_default_does_not_raise() -> None:
    circuit = _build_rc_frequency_circuit()
    options = ps.FrequencyAnalysisOptions()
    options.enabled = True
    options.mode = ps.FrequencyAnalysisMode.OpenLoopTransfer
    options.anchor_mode = ps.FrequencyAnchorMode.DC
    options.f_start_hz = 0.0
    options.f_stop_hz = 1000.0
    options.points = 10
    options.injection_current_amplitude = 1.0
    options.perturbation_port.positive_node = "in"
    options.output_port.positive_node = "out"

    result = ps.run_frequency_analysis(circuit, options)
    assert result.success is False
    assert result.diagnostic.name == "FrequencyInvalidConfiguration"


def test_yaml_parser_supports_legacy_si_suffix_words() -> None:
    content = """
schema: pulsim-v1
version: 1
simulation:
  tstart: 0
  tstop: 2milli
  dt: 10micro
components:
  - type: voltage_source
    name: V1
    nodes: [in, 0]
    waveform: {type: dc, value: 12}
  - type: resistor
    name: R1
    nodes: [in, out]
    value: 2kilo
  - type: resistor
    name: R2
    nodes: [out, 0]
    value: 2K
  - type: capacitor
    name: C1
    nodes: [out, 0]
    value: 1uF
"""

    parser = ps.YamlParser()
    circuit, options = parser.load_string(content)

    assert parser.errors == []
    assert abs(options.tstop - 2e-3) < 1e-12
    assert abs(options.dt - 10e-6) < 1e-12

    dc = ps.dc_operating_point(circuit)
    assert dc.success
    # Divider 2k/2k with Vin=12V -> V(out)=6V
    assert abs(dc.newton_result.solution[1] - 6.0) < 1e-6


def test_yaml_parser_maps_thermal_fields_and_rejects_json() -> None:
    content = """
schema: pulsim-v1
version: 1
simulation:
  tstart: 0
  tstop: 1e-4
  dt: 1e-6
  thermal:
    enabled: true
    ambient: 30
    policy: loss_with_temperature_scaling
    default_rth: 1.2
    default_cth: 0.4
components:
  - type: voltage_source
    name: Vg
    nodes: [gate, 0]
    waveform: {type: dc, value: 10}
  - type: voltage_source
    name: Vd
    nodes: [drain, 0]
    waveform: {type: dc, value: 12}
  - type: mosfet
    name: M1
    nodes: [gate, drain, source]
    thermal:
      rth: 0.8
      cth: 0.1
      temp_init: 35
      temp_ref: 25
      alpha: 0.006
  - type: resistor
    name: Rload
    nodes: [source, 0]
    value: 5
"""
    parser = ps.YamlParser()
    _, options = parser.load_string(content)
    assert parser.errors == []
    assert options.thermal.enable
    assert abs(options.thermal.ambient - 30.0) < 1e-12
    assert options.thermal.policy == ps.ThermalCouplingPolicy.LossWithTemperatureScaling
    assert "M1" in options.thermal_devices
    assert abs(options.thermal_devices["M1"].rth - 0.8) < 1e-12

    parser_json = ps.YamlParser()
    parser_json.load_string('{"schema":"pulsim-v1","version":1,"components":[]}')
    assert any("JSON netlists are unsupported" in msg for msg in parser_json.errors)


def test_yaml_parser_strict_mode_requires_thermal_rth_cth() -> None:
    content = """
schema: pulsim-v1
version: 1
simulation:
  tstop: 1e-4
  dt: 1e-6
components:
  - type: mosfet
    name: M1
    nodes: [gate, drain, source]
    thermal:
      enabled: true
      cth: 0.1
"""
    parser_opts = ps.YamlParserOptions()
    parser_opts.strict = True
    parser = ps.YamlParser(parser_opts)
    parser.load_string(content)
    assert any("PULSIM_YAML_E_THERMAL_MISSING_REQUIRED" in msg for msg in parser.errors)


def test_yaml_parser_non_strict_defaults_missing_thermal_rth_cth() -> None:
    content = """
schema: pulsim-v1
version: 1
simulation:
  tstop: 1e-4
  dt: 1e-6
  thermal:
    default_rth: 1.8
    default_cth: 0.25
components:
  - type: mosfet
    name: M1
    nodes: [gate, drain, source]
    thermal:
      enabled: true
"""
    parser_opts = ps.YamlParserOptions()
    parser_opts.strict = False
    parser = ps.YamlParser(parser_opts)
    _, options = parser.load_string(content)
    assert parser.errors == []
    assert abs(options.thermal_devices["M1"].rth - 1.8) < 1e-12
    assert abs(options.thermal_devices["M1"].cth - 0.25) < 1e-12
    assert any("PULSIM_YAML_W_THERMAL_DEFAULT_APPLIED" in msg for msg in parser.warnings)


def test_yaml_parser_maps_foster_thermal_network() -> None:
    content = """
schema: pulsim-v1
version: 1
simulation:
  tstop: 1e-4
  dt: 1e-6
components:
  - type: resistor
    name: R1
    nodes: [in, 0]
    value: 10
    thermal:
      enabled: true
      network: foster
      rth_stages: [0.4, 0.6]
      cth_stages: [0.01, 0.05]
"""
    parser = ps.YamlParser()
    _, options = parser.load_string(content)

    assert parser.errors == []
    cfg = options.thermal_devices["R1"]
    assert cfg.network_kind == ps.ThermalNetworkKind.Foster
    assert list(cfg.stage_rth) == [0.4, 0.6]
    assert list(cfg.stage_cth) == [0.01, 0.05]
    assert abs(cfg.rth - 1.0) < 1e-12


def test_yaml_parser_maps_shared_sink_thermal_fields() -> None:
    content = """
schema: pulsim-v1
version: 1
simulation:
  tstop: 1e-4
  dt: 1e-6
components:
  - type: resistor
    name: R1
    nodes: [in, 0]
    value: 10
    thermal:
      enabled: true
      rth: 0.7
      cth: 0.02
      shared_sink_id: hs_main
      shared_sink_rth: 0.5
      shared_sink_cth: 0.08
"""
    parser = ps.YamlParser()
    _, options = parser.load_string(content)

    assert parser.errors == []
    cfg = options.thermal_devices["R1"]
    assert cfg.shared_sink_id == "hs_main"
    assert abs(cfg.shared_sink_rth - 0.5) < 1e-12
    assert abs(cfg.shared_sink_cth - 0.08) < 1e-12


def test_yaml_parser_rejects_inconsistent_shared_sink_parameters() -> None:
    content = """
schema: pulsim-v1
version: 1
simulation:
  tstop: 1e-4
  dt: 1e-6
components:
  - type: resistor
    name: R1
    nodes: [n1, 0]
    value: 10
    thermal:
      enabled: true
      rth: 0.7
      cth: 0.02
      shared_sink_id: hs_main
      shared_sink_rth: 0.5
      shared_sink_cth: 0.08
  - type: resistor
    name: R2
    nodes: [n2, 0]
    value: 20
    thermal:
      enabled: true
      rth: 0.8
      cth: 0.03
      shared_sink_id: hs_main
      shared_sink_rth: 0.4
      shared_sink_cth: 0.08
"""
    parser = ps.YamlParser()
    parser.load_string(content)

    assert any("PULSIM_YAML_E_THERMAL_RANGE_INVALID" in msg for msg in parser.errors)
    assert any("shared sink 'hs_main'" in msg for msg in parser.errors)


def test_yaml_parser_rejects_invalid_thermal_stage_dimensions() -> None:
    content = """
schema: pulsim-v1
version: 1
simulation:
  tstop: 1e-4
  dt: 1e-6
components:
  - type: resistor
    name: R1
    nodes: [in, 0]
    value: 10
    thermal:
      enabled: true
      network: cauer
      rth_stages: [0.2, 0.3]
      cth_stages: [0.01]
"""
    parser = ps.YamlParser()
    parser.load_string(content)

    assert any("PULSIM_YAML_E_THERMAL_DIMENSION_INVALID" in msg for msg in parser.errors)


def test_yaml_parser_rejects_unsupported_thermal_component_enablement() -> None:
    content = """
schema: pulsim-v1
version: 1
simulation:
  tstop: 1e-4
  dt: 1e-6
components:
  - type: voltage_source
    name: V1
    nodes: [in, 0]
    waveform: {type: dc, value: 5}
    thermal:
      enabled: true
      rth: 0.8
      cth: 0.1
"""
    parser = ps.YamlParser()
    parser.load_string(content)
    assert any("PULSIM_YAML_E_THERMAL_UNSUPPORTED_COMPONENT" in msg for msg in parser.errors)


def test_yaml_parser_rejects_invalid_loss_ranges() -> None:
    content = """
schema: pulsim-v1
version: 1
simulation:
  tstop: 1e-4
  dt: 1e-6
components:
  - type: mosfet
    name: M1
    nodes: [g, d, s]
    vth: 2.5
    kp: 0.1
    lambda: 0.0
    g_off: 1e-8
    loss:
      eon: -2e-6
      eoff: 3e-6
      err: -1e-6
  - type: resistor
    name: R1
    nodes: [s, 0]
    value: 10
"""
    parser = ps.YamlParser()
    parser.load_string(content)

    assert any("PULSIM_YAML_E_LOSS_RANGE_INVALID" in msg for msg in parser.errors)
    assert any("M1.loss.eon" in msg for msg in parser.errors)
    assert any("M1.loss.err" in msg for msg in parser.errors)


def test_yaml_parser_rejects_invalid_datasheet_loss_dimensions() -> None:
    content = """
schema: pulsim-v1
version: 1
simulation:
  tstop: 1e-4
  dt: 1e-6
components:
  - type: mosfet
    name: M1
    nodes: [g, d, s]
    vth: 2.5
    kp: 0.2
    lambda: 0.0
    g_off: 1e-8
    loss:
      model: datasheet
      axes:
        current: [0.0, 10.0]
        voltage: [0.0, 20.0]
        temperature: [25.0, 125.0]
      eon: [1e-6, 1e-6, 1e-6]
      eoff: [2e-6, 2e-6, 2e-6, 2e-6]
  - type: resistor
    name: R1
    nodes: [s, 0]
    value: 10
"""
    parser = ps.YamlParser()
    parser.load_string(content)

    assert any("PULSIM_YAML_E_LOSS_DIMENSION_INVALID" in msg for msg in parser.errors)
    assert any("M1.loss.eon" in msg for msg in parser.errors)


def test_yaml_parser_accepts_valid_datasheet_loss_in_strict_mode() -> None:
    content = """
schema: pulsim-v1
version: 1
simulation:
  tstop: 1e-4
  dt: 1e-6
components:
  - type: mosfet
    name: M1
    nodes: [g, d, s]
    vth: 2.5
    kp: 0.2
    lambda: 0.0
    g_off: 1e-8
    loss:
      model: datasheet
      axes:
        current: [0.0, 10.0]
        voltage: [0.0, 20.0]
        temperature: [25.0, 125.0]
      eon: [1e-6, 1e-6, 1e-6, 1e-6, 1e-6, 1e-6, 1e-6, 1e-6]
      eoff: [2e-6, 2e-6, 2e-6, 2e-6, 2e-6, 2e-6, 2e-6, 2e-6]
  - type: resistor
    name: R1
    nodes: [s, 0]
    value: 10
"""
    parser_opts = ps.YamlParserOptions()
    parser_opts.strict = True
    parser = ps.YamlParser(parser_opts)
    parser.load_string(content)
    assert parser.errors == []


def test_electro_thermal_coupling_emits_thermal_telemetry() -> None:
    circuit = ps.Circuit()
    n_gate = circuit.add_node("gate")
    n_drain = circuit.add_node("drain")
    n_source = circuit.add_node("source")
    gnd = circuit.ground()

    circuit.add_voltage_source("Vg", n_gate, gnd, 10.0)
    circuit.add_voltage_source("Vd", n_drain, gnd, 20.0)
    mparams = ps.MOSFETParams()
    mparams.vth = 2.5
    mparams.kp = 0.01
    mparams.lambda_ = 0.0
    mparams.g_off = 1e-8
    circuit.add_mosfet("M1", n_gate, n_drain, n_source, mparams)
    circuit.add_resistor("Rload", n_source, gnd, 100.0)

    opts = ps.SimulationOptions()
    opts.tstart = 0.0
    opts.tstop = 1e-4
    opts.dt = 1e-6
    opts.dt_min = opts.dt
    opts.dt_max = opts.dt
    opts.adaptive_timestep = False
    opts.enable_bdf_order_control = False
    opts.enable_losses = True
    opts.thermal.enable = True
    opts.thermal.ambient = 25.0
    opts.thermal.policy = ps.ThermalCouplingPolicy.LossWithTemperatureScaling
    tcfg = ps.ThermalDeviceConfig()
    tcfg.rth = 0.5
    tcfg.cth = 1e-4
    tcfg.temp_init = 25.0
    tcfg.temp_ref = 25.0
    tcfg.alpha = 0.004
    opts.thermal_devices = {"M1": tcfg}

    sim = ps.Simulator(circuit, opts)
    result = sim.run_transient()
    assert result.success
    assert result.thermal_summary.enabled
    assert result.thermal_summary.max_temperature >= result.thermal_summary.ambient
    assert any(item.device_name == "M1" for item in result.thermal_summary.device_temperatures)
    assert len(result.component_electrothermal) == circuit.num_devices()
    m1 = next(item for item in result.component_electrothermal if item.component_name == "M1")
    assert m1.thermal_enabled
    assert m1.total_energy > 0.0
    rload = next(item for item in result.component_electrothermal if item.component_name == "Rload")
    assert rload.thermal_enabled
    assert rload.final_temperature >= result.thermal_summary.ambient


def test_resistor_foster_network_generates_rising_temperature_trace() -> None:
    circuit = ps.Circuit()
    n_in = circuit.add_node("in")
    gnd = circuit.ground()
    circuit.add_voltage_source("V1", n_in, gnd, 12.0)
    circuit.add_resistor("R1", n_in, gnd, 10.0)

    opts = ps.SimulationOptions()
    opts.tstart = 0.0
    opts.tstop = 5e-4
    opts.dt = 1e-6
    opts.dt_min = opts.dt
    opts.dt_max = opts.dt
    opts.adaptive_timestep = False
    opts.enable_losses = True
    opts.thermal.enable = True
    opts.thermal.ambient = 25.0

    cfg = ps.ThermalDeviceConfig()
    cfg.enabled = True
    cfg.network_kind = ps.ThermalNetworkKind.Foster
    cfg.stage_rth = [0.3, 0.7]
    cfg.stage_cth = [0.01, 0.05]
    cfg.temp_init = 25.0
    cfg.temp_ref = 25.0
    cfg.alpha = 0.0
    opts.thermal_devices = {"R1": cfg}

    result = ps.Simulator(circuit, opts).run_transient(circuit.initial_state())
    assert result.success
    assert "T(R1)" in result.virtual_channels
    trace = [float(v) for v in result.virtual_channels["T(R1)"]]
    assert len(trace) == len(result.time)
    assert trace[-1] > trace[0]
    assert max(trace) == trace[-1]


def test_resistor_cauer_network_generates_rising_temperature_trace() -> None:
    circuit = ps.Circuit()
    n_in = circuit.add_node("in")
    gnd = circuit.ground()
    circuit.add_voltage_source("V1", n_in, gnd, 12.0)
    circuit.add_resistor("R1", n_in, gnd, 10.0)

    opts = ps.SimulationOptions()
    opts.tstart = 0.0
    opts.tstop = 5e-4
    opts.dt = 1e-6
    opts.dt_min = opts.dt
    opts.dt_max = opts.dt
    opts.adaptive_timestep = False
    opts.enable_losses = True
    opts.thermal.enable = True
    opts.thermal.ambient = 25.0

    cfg = ps.ThermalDeviceConfig()
    cfg.enabled = True
    cfg.network_kind = ps.ThermalNetworkKind.Cauer
    cfg.stage_rth = [0.2, 0.4]
    cfg.stage_cth = [0.01, 0.03]
    cfg.temp_init = 25.0
    cfg.temp_ref = 25.0
    cfg.alpha = 0.0
    opts.thermal_devices = {"R1": cfg}

    result = ps.Simulator(circuit, opts).run_transient(circuit.initial_state())
    assert result.success
    assert "T(R1)" in result.virtual_channels
    trace = [float(v) for v in result.virtual_channels["T(R1)"]]
    assert len(trace) == len(result.time)
    assert trace[-1] > trace[0]
    assert max(trace) == trace[-1]


def test_stiff_cauer_network_remains_finite_with_large_timestep() -> None:
    circuit = ps.Circuit()
    n_in = circuit.add_node("in")
    gnd = circuit.ground()
    circuit.add_voltage_source("V1", n_in, gnd, 12.0)
    circuit.add_resistor("R1", n_in, gnd, 10.0)

    opts = ps.SimulationOptions()
    opts.tstart = 0.0
    opts.tstop = 2e-3
    opts.dt = 1e-4
    opts.dt_min = opts.dt
    opts.dt_max = opts.dt
    opts.adaptive_timestep = False
    opts.enable_losses = True
    opts.thermal.enable = True
    opts.thermal.ambient = 25.0

    cfg = ps.ThermalDeviceConfig()
    cfg.enabled = True
    cfg.network_kind = ps.ThermalNetworkKind.Cauer
    cfg.stage_rth = [0.05, 0.10]
    cfg.stage_cth = [1e-6, 2e-6]
    cfg.temp_init = 25.0
    cfg.temp_ref = 25.0
    cfg.alpha = 0.0
    opts.thermal_devices = {"R1": cfg}

    result = ps.Simulator(circuit, opts).run_transient(circuit.initial_state())
    assert result.success
    trace = [float(v) for v in result.virtual_channels["T(R1)"]]
    assert all(math.isfinite(value) for value in trace)
    assert trace[-1] >= trace[0]


def test_shared_sink_coupling_heats_other_members() -> None:
    def run_case(with_shared_sink: bool) -> ps.TransientResult:
        circuit = ps.Circuit()
        n_in = circuit.add_node("in")
        gnd = circuit.ground()
        circuit.add_voltage_source("V1", n_in, gnd, 12.0)
        circuit.add_resistor("Rhot", n_in, gnd, 10.0)
        circuit.add_resistor("Rcool", n_in, gnd, 100.0)

        opts = ps.SimulationOptions()
        opts.tstart = 0.0
        opts.tstop = 3e-3
        opts.dt = 2e-5
        opts.dt_min = opts.dt
        opts.dt_max = opts.dt
        opts.adaptive_timestep = False
        opts.enable_losses = True
        opts.thermal.enable = True
        opts.thermal.ambient = 25.0

        cfg_hot = ps.ThermalDeviceConfig()
        cfg_hot.enabled = True
        cfg_hot.rth = 0.3
        cfg_hot.cth = 0.02
        cfg_hot.temp_init = 25.0
        cfg_hot.temp_ref = 25.0
        cfg_hot.alpha = 0.0

        cfg_cool = ps.ThermalDeviceConfig()
        cfg_cool.enabled = True
        cfg_cool.rth = 0.3
        cfg_cool.cth = 0.02
        cfg_cool.temp_init = 25.0
        cfg_cool.temp_ref = 25.0
        cfg_cool.alpha = 0.0

        if with_shared_sink:
            cfg_hot.shared_sink_id = "HS1"
            cfg_hot.shared_sink_rth = 0.25
            cfg_hot.shared_sink_cth = 0.04
            cfg_cool.shared_sink_id = "HS1"
            cfg_cool.shared_sink_rth = 0.25
            cfg_cool.shared_sink_cth = 0.04

        opts.thermal_devices = {"Rhot": cfg_hot, "Rcool": cfg_cool}
        result = ps.Simulator(circuit, opts).run_transient(circuit.initial_state())
        assert result.success
        return result

    isolated = run_case(with_shared_sink=False)
    coupled = run_case(with_shared_sink=True)

    t_cool_iso = [float(v) for v in isolated.virtual_channels["T(Rcool)"]]
    t_cool_cpl = [float(v) for v in coupled.virtual_channels["T(Rcool)"]]
    t_hot_iso = [float(v) for v in isolated.virtual_channels["T(Rhot)"]]
    t_hot_cpl = [float(v) for v in coupled.virtual_channels["T(Rhot)"]]

    assert len(t_cool_iso) == len(t_cool_cpl)
    assert len(t_hot_iso) == len(t_hot_cpl)
    assert t_cool_cpl[-1] > t_cool_iso[-1] + 0.25
    assert t_hot_cpl[-1] > t_hot_iso[-1] + 0.25


def test_datasheet_switching_surface_drives_event_loss_channels() -> None:
    content = """
schema: pulsim-v1
version: 1
simulation:
  tstart: 0
  tstop: 1e-3
  dt: 1e-6
  step_mode: fixed
  enable_events: true
  enable_losses: true
components:
  - type: voltage_source
    name: Vin
    nodes: [vin, 0]
    waveform: {type: dc, value: 20}
  - type: mosfet
    name: M1
    nodes: [0, vin, out]
    vth: 3.0
    kp: 0.35
    lambda: 0.0
    g_off: 1e-8
    loss:
      model: datasheet
      axes:
        current: [0.0, 10.0]
        voltage: [0.0, 20.0]
        temperature: [25.0, 125.0]
      eon: [2e-6, 2e-6, 2e-6, 2e-6, 2e-6, 2e-6, 2e-6, 2e-6]
      eoff: [3e-6, 3e-6, 3e-6, 3e-6, 3e-6, 3e-6, 3e-6, 3e-6]
      err: [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]
  - type: resistor
    name: Rload
    nodes: [out, 0]
    value: 10
  - type: pwm_generator
    name: PWM1
    nodes: [0]
    frequency: 10000.0
    duty: 0.4
    v_high: 10.0
    v_low: 0.0
    target_component: M1
"""
    parser = ps.YamlParser()
    circuit, options = parser.load_string(content)
    assert parser.errors == []

    options.newton_options.num_nodes = int(circuit.num_nodes())
    options.newton_options.num_branches = int(circuit.num_branches())
    result = ps.Simulator(circuit, options).run_transient(circuit.initial_state())

    assert result.success
    assert "Psw_on(M1)" in result.virtual_channels
    assert "Psw_off(M1)" in result.virtual_channels
    p_on = [float(v) for v in result.virtual_channels["Psw_on(M1)"]]
    p_off = [float(v) for v in result.virtual_channels["Psw_off(M1)"]]
    assert len(p_on) == len(result.time)
    assert len(p_off) == len(result.time)
    assert max(p_on) > 0.0
    assert max(p_off) > 0.0

    def integrate(power: list[float]) -> float:
        return sum(
            power[i] * (float(result.time[i]) - float(result.time[i - 1]))
            for i in range(1, len(result.time))
        )

    e_on = integrate(p_on)
    e_off = integrate(p_off)
    on_events = sum(1 for value in p_on if value > 0.0)
    off_events = sum(1 for value in p_off if value > 0.0)
    assert on_events > 0
    assert off_events > 0
    assert abs(e_on - on_events * 2e-6) < 1e-11
    assert abs(e_off - off_events * 3e-6) < 1e-11


def test_python_api_switching_energy_surface_override_is_used() -> None:
    example_path = (
        Path(__file__).resolve().parents[2]
        / "examples"
        / "09_buck_closed_loop_loss_thermal_validation_backend.yaml"
    )

    parser = ps.YamlParser()
    circuit, options = parser.load(str(example_path))
    assert parser.errors == []
    options.newton_options.num_nodes = int(circuit.num_nodes())
    options.newton_options.num_branches = int(circuit.num_branches())

    surface = ps.SwitchingEnergySurface3D()
    surface.current_axis = [0.0, 10.0]
    surface.voltage_axis = [0.0, 20.0]
    surface.temperature_axis = [25.0, 125.0]
    surface.eon_table = [1e-6] * 8
    surface.eoff_table = [1.5e-6] * 8
    assert surface.valid_shape()
    assert surface.expected_table_size() == 8
    assert abs(surface.evaluate_eon(4.0, 12.0, 60.0) - 1e-6) < 1e-18

    sim = ps.Simulator(circuit, options)
    sim.set_switching_energy_surface("M1", surface)
    result = sim.run_transient(circuit.initial_state())
    assert result.success

    p_on = [float(v) for v in result.virtual_channels["Psw_on(M1)"]]
    p_off = [float(v) for v in result.virtual_channels["Psw_off(M1)"]]

    def integrate(power: list[float]) -> float:
        return sum(
            power[i] * (float(result.time[i]) - float(result.time[i - 1]))
            for i in range(1, len(result.time))
        )

    e_on = integrate(p_on)
    e_off = integrate(p_off)
    on_events = sum(1 for value in p_on if value > 0.0)
    off_events = sum(1 for value in p_off if value > 0.0)
    assert on_events > 0
    assert off_events > 0
    assert abs(e_on / on_events - 1e-6) < 1e-11
    assert abs(e_off / off_events - 1.5e-6) < 1e-11


def test_closed_loop_buck_loss_channel_order_is_deterministic() -> None:
    example_path = (
        Path(__file__).resolve().parents[2]
        / "examples"
        / "09_buck_closed_loop_loss_thermal_validation_backend.yaml"
    )

    parser_opts = ps.YamlParserOptions()
    parser_opts.strict = False

    def run_once() -> list[str]:
        parser = ps.YamlParser(parser_opts)
        circuit, options = parser.load(str(example_path))
        assert parser.errors == []
        options.newton_options.num_nodes = int(circuit.num_nodes())
        options.newton_options.num_branches = int(circuit.num_branches())
        result = ps.Simulator(circuit, options).run_transient(circuit.initial_state())
        assert result.success
        return [
            name for name in result.virtual_channels.keys()
            if name.startswith("Pcond(")
            or name.startswith("Psw_on(")
            or name.startswith("Psw_off(")
            or name.startswith("Prr(")
            or name.startswith("Ploss(")
        ]

    order_a = run_once()
    order_b = run_once()
    assert order_a == order_b


def test_closed_loop_buck_thermal_example_validates_control_losses_and_thermal() -> None:
    example_path = (
        Path(__file__).resolve().parents[2]
        / "examples"
        / "09_buck_closed_loop_loss_thermal_validation_backend.yaml"
    )

    parser_opts = ps.YamlParserOptions()
    parser_opts.strict = False
    parser = ps.YamlParser(parser_opts)
    circuit, options = parser.load(str(example_path))

    assert parser.errors == []
    options.newton_options.num_nodes = int(circuit.num_nodes())
    options.newton_options.num_branches = int(circuit.num_branches())

    result = ps.Simulator(circuit, options).run_transient(circuit.initial_state())
    assert result.success
    assert len(result.time) > 10
    assert abs(float(result.time[-1]) - float(options.tstop)) < 1e-12

    assert "PI1" in result.virtual_channels
    assert "PWM1.duty" in result.virtual_channels
    assert "Xout" in result.virtual_channels
    assert "T(M1)" in result.virtual_channels
    assert "Pcond(M1)" in result.virtual_channels
    assert "Psw_on(M1)" in result.virtual_channels
    assert "Psw_off(M1)" in result.virtual_channels
    assert "Prr(M1)" in result.virtual_channels
    assert "Ploss(M1)" in result.virtual_channels
    pi = [float(v) for v in result.virtual_channels["PI1"]]
    duty = [float(v) for v in result.virtual_channels["PWM1.duty"]]
    vout = [float(v) for v in result.virtual_channels["Xout"]]
    m1_temp = [float(v) for v in result.virtual_channels["T(M1)"]]
    m1_pcond = [float(v) for v in result.virtual_channels["Pcond(M1)"]]
    m1_pon = [float(v) for v in result.virtual_channels["Psw_on(M1)"]]
    m1_poff = [float(v) for v in result.virtual_channels["Psw_off(M1)"]]
    m1_ptotal = [float(v) for v in result.virtual_channels["Ploss(M1)"]]

    assert len(pi) == len(duty) == len(vout) == len(m1_temp) == len(result.time)
    assert len(m1_pcond) == len(m1_pon) == len(m1_poff) == len(m1_ptotal) == len(result.time)
    assert all(0.0 <= value <= 0.95 + 1e-12 for value in duty)
    assert max(abs(a - b) for a, b in zip(pi, duty)) < 1e-12
    assert abs(vout[-1] - 6.0) < 0.5
    assert max(m1_pon) > 0.0
    assert max(m1_poff) > 0.0
    assert max(m1_ptotal) >= max(m1_pcond)

    def integrate(power: list[float]) -> float:
        return sum(
            power[i] * (float(result.time[i]) - float(result.time[i - 1]))
            for i in range(1, len(result.time))
        )

    loss_summary = result.loss_summary
    assert loss_summary.total_loss > 0.0
    loss_rows = list(loss_summary.device_losses)
    loss_by_name = {item.device_name: item for item in loss_rows}
    assert "M1" in loss_by_name
    assert "D1" in loss_by_name
    assert "Rload" in loss_by_name
    assert loss_by_name["M1"].total_energy > 0.0
    assert loss_by_name["Rload"].total_energy > 0.0
    assert (
        float(loss_by_name["M1"].breakdown.turn_on) +
        float(loss_by_name["M1"].breakdown.turn_off)
    ) > 0.0

    duration = float(result.time[-1]) - float(result.time[0])
    total_energy = sum(float(item.total_energy) for item in loss_rows)
    expected_energy = float(loss_summary.total_loss) * duration
    energy_denom = max(abs(total_energy), abs(expected_energy), 1e-12)
    assert abs(total_energy - expected_energy) / energy_denom < 1e-9

    m1_econd = integrate(m1_pcond)
    m1_eon = integrate(m1_pon)
    m1_eoff = integrate(m1_poff)
    m1_etotal = integrate(m1_ptotal)
    m1_loss = loss_by_name["M1"]
    m1_energy_denom = max(abs(m1_etotal), abs(float(m1_loss.total_energy)), 1e-12)
    assert abs(float(m1_loss.total_energy) - m1_etotal) / m1_energy_denom < 1e-9
    assert abs(float(m1_loss.breakdown.conduction) * duration - m1_econd) / max(abs(m1_econd), 1e-12) < 1e-9
    assert abs(float(m1_loss.breakdown.turn_on) * duration - m1_eon) / max(abs(m1_eon), 1e-12) < 1e-9
    assert abs(float(m1_loss.breakdown.turn_off) * duration - m1_eoff) / max(abs(m1_eoff), 1e-12) < 1e-9

    thermal_summary = result.thermal_summary
    assert thermal_summary.enabled
    assert thermal_summary.max_temperature >= thermal_summary.ambient
    assert result.virtual_channel_metadata["T(M1)"].domain == "thermal"
    assert result.virtual_channel_metadata["T(M1)"].component_type == "thermal_trace"
    assert result.virtual_channel_metadata["T(M1)"].source_component == "M1"
    assert result.virtual_channel_metadata["T(M1)"].unit == "degC"
    assert result.virtual_channel_metadata["Pcond(M1)"].domain == "loss"
    assert result.virtual_channel_metadata["Pcond(M1)"].source_component == "M1"
    assert result.virtual_channel_metadata["Pcond(M1)"].unit == "W"
    assert m1_temp[-1] >= thermal_summary.ambient
    thermal_by_name = {
        item.device_name: item for item in list(thermal_summary.device_temperatures)
    }
    assert "M1" in thermal_by_name
    assert "D1" in thermal_by_name
    assert "Rload" in thermal_by_name
    for item in thermal_by_name.values():
        assert item.final_temperature >= thermal_summary.ambient
        assert item.peak_temperature >= item.final_temperature
    assert thermal_by_name["M1"].final_temperature > thermal_summary.ambient
    assert thermal_by_name["Rload"].final_temperature > thermal_summary.ambient
    assert abs(
        thermal_summary.max_temperature
        - max(item.peak_temperature for item in thermal_by_name.values())
    ) < 1e-12
    assert abs(thermal_by_name["M1"].final_temperature - m1_temp[-1]) < 1e-12
    assert abs(thermal_by_name["M1"].peak_temperature - max(m1_temp)) < 1e-12
    assert abs(thermal_by_name["M1"].average_temperature - statistics.fmean(m1_temp)) < 1e-12

    component_rows = {item.component_name: item for item in result.component_electrothermal}
    assert len(component_rows) == circuit.num_devices()
    assert component_rows["M1"].thermal_enabled
    assert component_rows["Rload"].thermal_enabled
    assert not component_rows["L1"].thermal_enabled
    assert component_rows["M1"].total_energy > 0.0
    assert component_rows["Rload"].total_energy > 0.0
    assert abs(float(component_rows["M1"].total_energy) - m1_etotal) / max(abs(m1_etotal), 1e-12) < 1e-9
    assert (
        abs(float(component_rows["M1"].conduction) * duration - m1_econd)
        / max(abs(m1_econd), 1e-12)
        < 1e-9
    )
    assert (
        abs(float(component_rows["M1"].turn_on) * duration - m1_eon)
        / max(abs(m1_eon), 1e-12)
        < 1e-9
    )
    assert (
        abs(float(component_rows["M1"].turn_off) * duration - m1_eoff)
        / max(abs(m1_eoff), 1e-12)
        < 1e-9
    )
    assert abs(component_rows["M1"].final_temperature - m1_temp[-1]) < 1e-12
    assert abs(component_rows["M1"].peak_temperature - max(m1_temp)) < 1e-12
    assert abs(component_rows["M1"].average_temperature - statistics.fmean(m1_temp)) < 1e-12

    component_total_loss = sum(float(item.total_loss) for item in component_rows.values())
    component_loss_denom = max(abs(component_total_loss), abs(float(loss_summary.total_loss)), 1e-12)
    assert abs(component_total_loss - float(loss_summary.total_loss)) / component_loss_denom < 1e-9


def test_closed_loop_buck_gui_example_cblock_pi_matches_native_pi() -> None:
    example_path = (
        Path(__file__).resolve().parents[2]
        / "examples"
        / "09_buck_closed_loop_loss_thermal_validation_backend.yaml"
    )

    parser = ps.YamlParser()
    circuit_pi, options_pi = parser.load(str(example_path))
    assert parser.errors == []
    options_pi.newton_options.num_nodes = int(circuit_pi.num_nodes())
    options_pi.newton_options.num_branches = int(circuit_pi.num_branches())
    result_pi = ps.Simulator(circuit_pi, options_pi).run_transient(circuit_pi.initial_state())
    assert result_pi.success

    compiler = ps.detect_compiler()
    if compiler is None:
        pytest.skip("No C compiler available for C_BLOCK PI equivalence check")

    c_source = r"""
#include "pulsim/v1/cblock_abi.h"
#include <stdlib.h>

#define KP 0.08
#define KI 100.0
#define OUTPUT_MIN 0.0
#define OUTPUT_MAX 0.95
#define ANTI_WINDUP 1

struct PulsimCBlockCtx {
    double integral;
    double t_prev;
};

static double clamp(double v, double lo, double hi) {
    if (v < lo) return lo;
    if (v > hi) return hi;
    return v;
}

PULSIM_CBLOCK_EXPORT int pulsim_cblock_abi_version = PULSIM_CBLOCK_ABI_VERSION;

PULSIM_CBLOCK_EXPORT int pulsim_cblock_init(PulsimCBlockCtx** ctx_out, const PulsimCBlockInfo* info) {
    (void)info;
    struct PulsimCBlockCtx* s = (struct PulsimCBlockCtx*)malloc(sizeof(struct PulsimCBlockCtx));
    if (!s) return -1;
    s->integral = 0.0;
    s->t_prev = -1.0;
    *ctx_out = (PulsimCBlockCtx*)s;
    return 0;
}

PULSIM_CBLOCK_EXPORT int pulsim_cblock_step(
    PulsimCBlockCtx* ctx, double t, double dt, const double* in, double* out) {
    struct PulsimCBlockCtx* s = (struct PulsimCBlockCtx*)ctx;
    const double error = in[0] - in[1];
    const double effective_dt = (s->t_prev < 0.0) ? 0.0 : ((dt > 0.0) ? dt : 0.0);

    double integral = s->integral + error * effective_dt;
    double u = KP * error + KI * integral;
    const double limited = clamp(u, OUTPUT_MIN, OUTPUT_MAX);

    if (ANTI_WINDUP && KI != 0.0 && effective_dt > 0.0 && limited != u) {
        integral = (limited - KP * error) / KI;
        u = limited;
    } else {
        u = limited;
    }

    s->integral = integral;
    s->t_prev = t;
    out[0] = u;
    return 0;
}

PULSIM_CBLOCK_EXPORT void pulsim_cblock_destroy(PulsimCBlockCtx* ctx) {
    free(ctx);
}
"""

    with tempfile.TemporaryDirectory(prefix="pulsim_pi_cblock_test_") as temp_dir:
        lib_path = ps.compile_cblock(
            c_source,
            output_dir=Path(temp_dir),
            name="pi_backend_equiv",
            compiler=compiler,
        )

        text = example_path.read_text(encoding="utf-8")
        pi_start = text.find("  - type: pi_controller\n")
        assert pi_start >= 0
        pwm_start = text.find("  - type: pwm_generator\n", pi_start)
        assert pwm_start >= 0

        cblock_block = (
            "  - type: voltage_probe\n"
            "    name: VrefProbe\n"
            "    nodes: [vref, 0]\n\n"
            "  - type: c_block\n"
            "    name: CB1\n"
            "    nodes: []\n"
            "    n_inputs: 2\n"
            "    n_outputs: 1\n"
            "    inputs: [VrefProbe, Xout]\n"
            f"    lib_path: {Path(lib_path).as_posix()}\n\n"
        )
        cblock_yaml = text[:pi_start] + cblock_block + text[pwm_start:]
        cblock_yaml = cblock_yaml.replace("duty_from_channel: PI1", "duty_from_channel: CB1", 1)

        parser_cb = ps.YamlParser()
        circuit_cb, options_cb = parser_cb.load_string(cblock_yaml)
        assert parser_cb.errors == []
        options_cb.newton_options.num_nodes = int(circuit_cb.num_nodes())
        options_cb.newton_options.num_branches = int(circuit_cb.num_branches())
        result_cb = ps.Simulator(circuit_cb, options_cb).run_transient(circuit_cb.initial_state())
        assert result_cb.success

        # Copy data out and release C_BLOCK owning objects before temp-dir cleanup.
        # On Windows, loaded DLL files cannot be removed while any handle remains open.
        time_cb = [float(v) for v in result_cb.time]
        xout_cb = [float(v) for v in result_cb.virtual_channels["Xout"]]
        ctrl_cb = [float(v) for v in result_cb.virtual_channels["CB1"]]
        duty_cb = [float(v) for v in result_cb.virtual_channels["PWM1.duty"]]
        del result_cb
        del circuit_cb
        del options_cb
        del parser_cb
        gc.collect()

    assert len(result_pi.time) == len(time_cb)
    assert len(result_pi.virtual_channels["Xout"]) == len(xout_cb)
    assert len(result_pi.virtual_channels["PI1"]) == len(ctrl_cb)
    assert len(result_pi.virtual_channels["PWM1.duty"]) == len(duty_cb)

    vout_diff = max(
        abs(float(a) - b)
        for a, b in zip(result_pi.virtual_channels["Xout"], xout_cb)
    )
    ctrl_diff = max(
        abs(float(a) - b)
        for a, b in zip(result_pi.virtual_channels["PI1"], ctrl_cb)
    )
    duty_diff = max(
        abs(float(a) - b)
        for a, b in zip(result_pi.virtual_channels["PWM1.duty"], duty_cb)
    )

    assert vout_diff < 1e-9
    assert ctrl_diff < 1e-12
    assert duty_diff < 1e-12


def test_closed_loop_buck_electrothermal_avoids_output_reallocations() -> None:
    example_path = (
        Path(__file__).resolve().parents[2]
        / "examples"
        / "09_buck_closed_loop_loss_thermal_validation_backend.yaml"
    )

    parser_opts = ps.YamlParserOptions()
    parser_opts.strict = False
    parser = ps.YamlParser(parser_opts)
    circuit, options = parser.load(str(example_path))
    assert parser.errors == []

    options.newton_options.num_nodes = int(circuit.num_nodes())
    options.newton_options.num_branches = int(circuit.num_branches())
    result = ps.Simulator(circuit, options).run_transient(circuit.initial_state())
    assert result.success

    telemetry = result.backend_telemetry
    assert telemetry.reserved_output_samples >= len(result.time)
    assert telemetry.time_series_reallocations == 0
    assert telemetry.state_series_reallocations == 0
    assert telemetry.virtual_channel_reallocations == 0


def test_transient_exports_canonical_thermal_trace_for_simple_resistive_case() -> None:
    circuit = ps.Circuit()
    n_in = circuit.add_node("in")
    gnd = circuit.ground()
    circuit.add_voltage_source("V1", n_in, gnd, 12.0)
    circuit.add_resistor("R1", n_in, gnd, 10.0)

    opts = ps.SimulationOptions()
    opts.tstart = 0.0
    opts.tstop = 2e-4
    opts.dt = 1e-6
    opts.dt_min = opts.dt
    opts.dt_max = opts.dt
    opts.adaptive_timestep = False
    opts.enable_bdf_order_control = False
    opts.enable_losses = True
    opts.thermal.enable = True
    opts.thermal.ambient = 25.0
    opts.thermal.policy = ps.ThermalCouplingPolicy.LossWithTemperatureScaling

    tcfg = ps.ThermalDeviceConfig()
    tcfg.enabled = True
    tcfg.rth = 1.0
    tcfg.cth = 5e-4
    tcfg.temp_init = 25.0
    tcfg.temp_ref = 25.0
    tcfg.alpha = 0.004
    opts.thermal_devices = {"R1": tcfg}

    result = ps.Simulator(circuit, opts).run_transient(circuit.initial_state())
    assert result.success
    assert "T(R1)" in result.virtual_channels

    trace = [float(v) for v in result.virtual_channels["T(R1)"]]
    assert len(trace) == len(result.time)
    assert trace[-1] > trace[0]
    assert result.virtual_channel_metadata["T(R1)"].domain == "thermal"
    assert result.virtual_channel_metadata["T(R1)"].source_component == "R1"
    assert result.virtual_channel_metadata["T(R1)"].unit == "degC"

    rows = {item.component_name: item for item in result.component_electrothermal}
    thermal_rows = {
        item.device_name: item for item in list(result.thermal_summary.device_temperatures)
    }
    assert "R1" in thermal_rows
    assert abs(thermal_rows["R1"].final_temperature - trace[-1]) < 1e-12
    assert abs(thermal_rows["R1"].peak_temperature - max(trace)) < 1e-12
    assert abs(thermal_rows["R1"].average_temperature - statistics.fmean(trace)) < 1e-12
    assert "R1" in rows
    assert abs(rows["R1"].final_temperature - trace[-1]) < 1e-12
    assert abs(rows["R1"].peak_temperature - max(trace)) < 1e-12
    assert abs(rows["R1"].average_temperature - statistics.fmean(trace)) < 1e-12


def test_transient_does_not_export_thermal_trace_without_loss_and_thermal_enable() -> None:
    circuit = ps.Circuit()
    n_in = circuit.add_node("in")
    gnd = circuit.ground()
    circuit.add_voltage_source("V1", n_in, gnd, 5.0)
    circuit.add_resistor("R1", n_in, gnd, 10.0)

    opts = ps.SimulationOptions()
    opts.tstart = 0.0
    opts.tstop = 5e-5
    opts.dt = 1e-6
    opts.dt_min = opts.dt
    opts.dt_max = opts.dt
    opts.adaptive_timestep = False
    opts.enable_bdf_order_control = False
    opts.enable_losses = False
    opts.thermal.enable = True
    opts.thermal.ambient = 25.0

    tcfg = ps.ThermalDeviceConfig()
    tcfg.enabled = True
    tcfg.rth = 1.0
    tcfg.cth = 1e-3
    tcfg.temp_init = 25.0
    tcfg.temp_ref = 25.0
    tcfg.alpha = 0.004
    opts.thermal_devices = {"R1": tcfg}

    result = ps.Simulator(circuit, opts).run_transient(circuit.initial_state())
    assert result.success
    assert "T(R1)" not in result.virtual_channels


def test_transient_does_not_export_thermal_trace_when_component_thermal_disabled() -> None:
    circuit = ps.Circuit()
    n_in = circuit.add_node("in")
    gnd = circuit.ground()
    circuit.add_voltage_source("V1", n_in, gnd, 5.0)
    circuit.add_resistor("R1", n_in, gnd, 10.0)

    opts = ps.SimulationOptions()
    opts.tstart = 0.0
    opts.tstop = 5e-5
    opts.dt = 1e-6
    opts.dt_min = opts.dt
    opts.dt_max = opts.dt
    opts.adaptive_timestep = False
    opts.enable_bdf_order_control = False
    opts.enable_losses = True
    opts.thermal.enable = True
    opts.thermal.ambient = 25.0

    tcfg = ps.ThermalDeviceConfig()
    tcfg.enabled = False
    tcfg.rth = 1.0
    tcfg.cth = 1e-3
    tcfg.temp_init = 25.0
    tcfg.temp_ref = 25.0
    tcfg.alpha = 0.004
    opts.thermal_devices = {"R1": tcfg}

    result = ps.Simulator(circuit, opts).run_transient(circuit.initial_state())
    assert result.success
    assert "T(R1)" not in result.virtual_channels


def test_python_api_exposes_control_mode_surface_and_yaml_mapping() -> None:
    opts = ps.SimulationOptions()
    control_mode_enum = getattr(ps, "ControlUpdateMode", type(opts.control_mode))
    assert hasattr(control_mode_enum, "Auto")
    assert hasattr(control_mode_enum, "Continuous")
    assert hasattr(control_mode_enum, "Discrete")

    opts.control_mode = control_mode_enum.Continuous
    opts.control_sample_time = 2.5e-6
    assert opts.control_mode == control_mode_enum.Continuous
    assert abs(opts.control_sample_time - 2.5e-6) < 1e-18

    content = """
schema: pulsim-v1
version: 1
simulation:
  tstop: 1e-4
  dt: 1e-6
  control:
    mode: discrete
    sample_time: 5e-6
components:
  - type: resistor
    name: R1
    nodes: [in, 0]
    value: 1k
"""
    parser = ps.YamlParser()
    _, parsed = parser.load_string(content)
    assert parser.errors == []
    assert parsed.control_mode == control_mode_enum.Discrete
    assert abs(parsed.control_sample_time - 5e-6) < 1e-18


def test_yaml_parser_maps_fallback_controls() -> None:
    content = """
schema: pulsim-v1
version: 1
simulation:
  tstop: 1e-4
  dt: 1e-6
  max_step_retries: 4
  fallback:
    trace_retries: true
    enable_transient_gmin: true
    gmin_retry_threshold: 2
    gmin_initial: 1e-8
    gmin_max: 1e-4
    gmin_growth: 5
components:
  - type: resistor
    name: R1
    nodes: [in, 0]
    value: 1k
"""
    parser = ps.YamlParser()
    _, options = parser.load_string(content)
    assert parser.errors == []
    assert options.max_step_retries == 4
    assert options.fallback_policy.trace_retries
    assert options.fallback_policy.enable_transient_gmin
    assert options.fallback_policy.gmin_retry_threshold == 2
    assert abs(options.fallback_policy.gmin_initial - 1e-8) < 1e-16
    assert abs(options.fallback_policy.gmin_max - 1e-4) < 1e-12
    assert abs(options.fallback_policy.gmin_growth - 5.0) < 1e-12


def test_yaml_parser_ignores_removed_legacy_backend_keys_in_non_strict_mode() -> None:
    content = """
schema: pulsim-v1
version: 1
simulation:
  tstop: 1e-4
  dt: 1e-6
  backend: auto
  sundials:
    enabled: true
  fallback:
    enable_backend_escalation: true
    backend_escalation_threshold: 3
    enable_native_reentry: true
    sundials_recovery_window: 5e-6
components:
  - type: resistor
    name: R1
    nodes: [in, 0]
    value: 1k
"""
    parser_opts = ps.YamlParserOptions()
    parser_opts.strict = False
    parser = ps.YamlParser(parser_opts)
    parser.load_string(content)
    assert parser.errors == []
    assert parser.warnings == []


def test_yaml_parser_maps_canonical_step_mode_and_solver_overrides() -> None:
    content = """
schema: pulsim-v1
version: 1
simulation:
  tstop: 2e-5
  dt: 1e-6
  step_mode: fixed
  solver:
    order: [klu]
components:
  - type: resistor
    name: R1
    nodes: [in, 0]
    value: 1k
"""
    parser = ps.YamlParser()
    _, options = parser.load_string(content)
    assert parser.errors == []
    assert options.step_mode == ps.StepMode.Fixed
    assert options.adaptive_timestep is False
    assert options.linear_solver.order == [ps.LinearSolverKind.KLU]
    assert options.integrator == ps.Integrator.TRBDF2

    options.step_mode = ps.StepMode.Variable
    assert options.step_mode == ps.StepMode.Variable
    assert options.adaptive_timestep is True

    options.adaptive_timestep = False
    assert options.step_mode == ps.StepMode.Fixed
    assert options.adaptive_timestep is False


def test_yaml_parser_maps_formulation_mode_controls() -> None:
    content = """
schema: pulsim-v1
version: 1
simulation:
  tstop: 2e-5
  dt: 1e-6
  formulation: projected_wrapper
  direct_formulation_fallback: false
components:
  - type: resistor
    name: R1
    nodes: [in, 0]
    value: 1k
"""
    parser = ps.YamlParser()
    _, options = parser.load_string(content)
    assert parser.errors == []
    assert options.formulation_mode == ps.FormulationMode.ProjectedWrapper
    assert options.direct_formulation_fallback is False

    invalid = """
schema: pulsim-v1
version: 1
simulation:
  tstop: 2e-5
  dt: 1e-6
  formulation: unsupported_mode
components:
  - type: resistor
    name: R1
    nodes: [in, 0]
    value: 1k
"""
    parser_invalid = ps.YamlParser()
    parser_invalid.load_string(invalid)
    assert parser_invalid.errors
    assert any("simulation.formulation" in msg for msg in parser_invalid.errors)


def test_yaml_parser_reports_unknown_field_errors_for_removed_legacy_backend_keys_in_strict_mode() -> None:
    content = """
schema: pulsim-v1
version: 1
simulation:
  tstop: 1e-4
  dt: 1e-6
  backend: auto
  sundials:
    enabled: true
  fallback:
    enable_backend_escalation: true
components:
  - type: resistor
    name: R1
    nodes: [in, 0]
    value: 1k
"""
    parser = ps.YamlParser()
    parser.load_string(content)
    assert parser.errors
    assert any("simulation.backend" in msg for msg in parser.errors)
    assert any("simulation.sundials" in msg for msg in parser.errors)
    assert any("simulation.fallback.enable_backend_escalation" in msg for msg in parser.errors)
    assert any("PULSIM_YAML_E_UNKNOWN_FIELD" in msg for msg in parser.errors)


def test_backend_telemetry_binding_fields() -> None:
    telemetry = ps.BackendTelemetry()
    telemetry.formulation_mode = "native"
    telemetry.function_evaluations = 10
    assert telemetry.formulation_mode == "native"
    assert telemetry.function_evaluations == 10


def test_yaml_parser_gui_parity_slice_registers_virtual_components() -> None:
    content = """
schema: pulsim-v1
version: 1
components:
  - type: voltage_source
    name: V1
    nodes: [in, 0]
    waveform: {type: dc, value: 12}
  - type: BJT_PNP
    name: QP1
    nodes: [ctrl, in, out]
    beta: 80
  - type: THYRISTOR
    name: SCR1
    nodes: [gate, out, 0]
  - type: FUSE
    name: F1
    nodes: [out, load]
    initial_state: true
  - type: RELAY
    name: K1
    nodes: [coil_p, coil_n, com, no, nc]
  - type: OP_AMP
    name: A1
    nodes: [ref, fb, ctrl]
  - type: VOLTAGE_PROBE
    name: VP1
    nodes: [out, 0]
"""
    parser = ps.YamlParser()
    circuit, _ = parser.load_string(content)
    assert parser.errors == []
    assert circuit.num_devices() >= 4
    assert circuit.num_virtual_components() >= 3
    assert any("PULSIM_YAML_W_COMPONENT_SURROGATE" in msg for msg in parser.warnings)
    assert any("PULSIM_YAML_W_COMPONENT_VIRTUAL" in msg for msg in parser.warnings)


def test_yaml_parser_registers_fuse_event_controller() -> None:
    content = """
schema: pulsim-v1
version: 1
simulation:
  tstart: 0
  tstop: 1e-5
  dt: 1e-6
components:
  - type: voltage_source
    name: V1
    nodes: [in, 0]
    waveform: {type: dc, value: 10}
  - type: fuse
    name: F1
    nodes: [in, out]
    rating: 5
    blow_i2t: 2
  - type: resistor
    name: R1
    nodes: [out, 0]
    value: 10
"""
    parser = ps.YamlParser()
    circuit, _ = parser.load_string(content)
    assert parser.errors == []
    assert "F1" in circuit.virtual_component_names()
    metadata = circuit.virtual_channel_metadata()
    assert "F1.state" in metadata
    assert metadata["F1.state"].domain == "events"


def test_yaml_parser_registers_relay_dual_contact_controller() -> None:
    content = """
schema: pulsim-v1
version: 1
components:
  - type: voltage_source
    name: Vcoil
    nodes: [coil_p, coil_n]
    waveform: {type: dc, value: 12}
  - type: relay
    name: K1
    nodes: [coil_p, coil_n, com, no, nc]
    pickup_voltage: 8
    dropout_voltage: 3
    contact_resistance: 0.05
    off_resistance: 1e9
"""
    parser = ps.YamlParser()
    circuit, _ = parser.load_string(content)
    assert parser.errors == []
    assert "K1" in circuit.virtual_component_names()
    # Relay is implemented as two electrical switches + one event controller.
    assert circuit.num_devices() >= 3
    metadata = circuit.virtual_channel_metadata()
    assert "K1.state" in metadata
    assert "K1.no_state" in metadata
    assert "K1.nc_state" in metadata
    assert metadata["K1.no_state"].domain == "events"


def test_yaml_parser_registers_thyristor_event_controller() -> None:
    content = """
schema: pulsim-v1
version: 1
components:
  - type: voltage_source
    name: Vg
    nodes: [gate, 0]
    waveform: {type: dc, value: 2}
  - type: thyristor
    name: SCR1
    nodes: [gate, anode, 0]
    gate_threshold: 1.0
    holding_current: 0.1
    latch_current: 0.2
"""
    parser = ps.YamlParser()
    circuit, _ = parser.load_string(content)
    assert parser.errors == []
    assert "SCR1" in circuit.virtual_component_names()
    metadata = circuit.virtual_channel_metadata()
    assert "SCR1.state" in metadata
    assert metadata["SCR1.state"].domain == "events"
    assert any("PULSIM_YAML_W_COMPONENT_SURROGATE" in msg for msg in parser.warnings)


def test_yaml_parser_registers_saturable_inductor_controller() -> None:
    content = """
schema: pulsim-v1
version: 1
components:
  - type: saturable_inductor
    name: Lsat
    nodes: [in, 0]
    inductance: 1m
    saturation_current: 2
    saturation_inductance: 100u
"""
    parser = ps.YamlParser()
    circuit, _ = parser.load_string(content)
    assert parser.errors == []
    assert circuit.num_devices() == 1
    assert "Lsat" in circuit.virtual_component_names()
    metadata = circuit.virtual_channel_metadata()
    assert "Lsat.l_eff" in metadata
    assert metadata["Lsat.l_eff"].domain == "electrical"


def test_yaml_parser_registers_coupled_inductor_pair_controller() -> None:
    content = """
schema: pulsim-v1
version: 1
components:
  - type: coupled_inductor
    name: K1
    nodes: [p1, p2, s1, s2]
    l1: 1m
    l2: 4m
    coupling: 0.95
"""
    parser = ps.YamlParser()
    circuit, _ = parser.load_string(content)
    assert parser.errors == []
    # Coupled inductor expands to two inductor branches plus one virtual controller.
    assert circuit.num_devices() == 2
    assert "K1" in circuit.virtual_component_names()
    metadata = circuit.virtual_channel_metadata()
    assert "K1.mutual" in metadata
    assert metadata["K1.mutual"].domain == "electrical"


def test_virtual_probe_evaluation_from_state_vector() -> None:
    circuit = ps.Circuit()
    n_in = circuit.add_node("in")
    gnd = circuit.ground()
    circuit.add_voltage_source("V1", n_in, gnd, 5.0)
    circuit.add_virtual_component("voltage_probe", "VP", [n_in, gnd], {}, {})
    circuit.add_virtual_component("current_probe", "IP", [n_in, gnd], {}, {"target_component": "V1"})
    circuit.add_virtual_component("power_probe", "PP", [n_in, gnd], {}, {"target_component": "V1"})

    x = [0.0] * circuit.system_size()
    x[n_in] = 8.0
    x[circuit.num_nodes()] = 0.5

    values = circuit.evaluate_virtual_signals(x)
    assert values["VP"] == 8.0
    assert values["IP"] == 0.5
    assert values["PP"] == 4.0


def test_mixed_domain_scheduler_is_deterministic() -> None:
    circuit = ps.Circuit()
    n_ref = circuit.add_node("ref")
    n_fb = circuit.add_node("fb")
    n_out = circuit.add_node("out")
    gnd = circuit.ground()
    circuit.add_voltage_source("V1", n_ref, gnd, 3.0)
    circuit.add_resistor("Rfb", n_ref, n_fb, 10.0)
    circuit.add_virtual_component("pi_controller", "PI1", [n_ref, n_fb, n_out], {"gain": 2.0}, {})

    x = [0.0] * circuit.system_size()
    x[n_ref] = 3.0
    x[n_fb] = 1.0
    step = circuit.execute_mixed_domain_step(x, 1e-6)

    assert step.phase_order == ["electrical", "control", "events", "instrumentation"]
    assert "PI1" in step.channel_values


def test_component_sample_time_overrides_legacy_global_control_sampling() -> None:
    circuit = ps.Circuit()
    n_err = circuit.add_node("err")
    n_out = circuit.add_node("out")
    gnd = circuit.ground()

    circuit.add_voltage_source("Verr", n_err, gnd, 1.0)
    circuit.add_resistor("Rerr", n_err, gnd, 1e3)
    circuit.add_resistor("Rout", n_out, gnd, 1e3)
    circuit.add_virtual_component(
        "pi_controller",
        "PI1",
        [n_err, gnd, n_out],
        {"kp": 0.0, "ki": 10_000.0, "sample_time": 50e-6},
        {},
    )

    opts = ps.SimulationOptions()
    opts.tstart = 0.0
    opts.tstop = 120e-6
    opts.dt = 10e-6
    opts.dt_min = opts.dt
    opts.dt_max = opts.dt
    opts.adaptive_timestep = False
    opts.enable_bdf_order_control = False
    opts.control_mode = ps.ControlUpdateMode.Discrete
    opts.control_sample_time = 100e-6  # legacy global fallback; local Ts must win
    opts.newton_options.num_nodes = int(circuit.num_nodes())
    opts.newton_options.num_branches = int(circuit.num_branches())

    result = ps.Simulator(circuit, opts).run_transient(circuit.initial_state())
    assert result.success
    pi = result.virtual_channels["PI1"]
    assert len(pi) >= 11

    assert abs(float(pi[0])) < 1e-12
    assert abs(float(pi[4]) - float(pi[0])) < 1e-12
    assert float(pi[5]) > float(pi[4])  # update at 50 us
    assert abs(float(pi[9]) - float(pi[5])) < 1e-12
    assert float(pi[10]) > float(pi[9])  # update at 100 us (50 us cadence)


def test_simulation_result_includes_virtual_channels() -> None:
    circuit = ps.Circuit()
    n_in = circuit.add_node("in")
    gnd = circuit.ground()
    circuit.add_voltage_source("V1", n_in, gnd, 5.0)
    circuit.add_resistor("R1", n_in, gnd, 100.0)
    circuit.add_virtual_component("voltage_probe", "VP", [n_in, gnd], {}, {})

    opts = ps.SimulationOptions()
    opts.tstart = 0.0
    opts.tstop = 5e-6
    opts.dt = 1e-6
    opts.dt_min = opts.dt
    opts.dt_max = opts.dt
    opts.adaptive_timestep = False
    opts.enable_bdf_order_control = False

    sim = ps.Simulator(circuit, opts)
    result = sim.run_transient()
    assert result.success
    assert result.mixed_domain_phase_order == ["electrical", "control", "events", "instrumentation"]
    assert "VP" in result.virtual_channels
    assert len(result.virtual_channels["VP"]) == len(result.time)
    assert "VP" in result.virtual_channel_metadata
    assert result.virtual_channel_metadata["VP"].component_type == "voltage_probe"
    assert result.virtual_channel_metadata["VP"].domain == "instrumentation"


def test_collect_result_channels_merges_electrical_and_control_channels() -> None:
    circuit = ps.Circuit()
    n_ref = circuit.add_node("ref")
    n_fb = circuit.add_node("fb")
    n_out = circuit.add_node("out")
    gnd = circuit.ground()

    circuit.add_voltage_source("Vref", n_ref, gnd, 5.0)
    circuit.add_voltage_source("Vfb", n_fb, gnd, 3.0)
    circuit.add_resistor("Rout", n_out, gnd, 1_000.0)
    circuit.add_virtual_component(
        "pi_controller",
        "PI1",
        [n_ref, n_fb, n_out],
        {"kp": 0.4, "ki": 5_000.0, "anti_windup": 1.0, "output_min": 0.0, "output_max": 1.0},
        {},
    )

    opts = ps.SimulationOptions()
    opts.tstart = 0.0
    opts.tstop = 10e-6
    opts.dt = 1e-6
    opts.dt_min = opts.dt
    opts.dt_max = opts.dt
    opts.adaptive_timestep = False
    opts.enable_bdf_order_control = False

    result = ps.Simulator(circuit, opts).run_transient(circuit.initial_state())
    assert result.success
    assert "PI1" in result.virtual_channels

    channels, metadata = ps.collect_result_channels(circuit, result)
    assert "V(ref)" in channels
    assert "I(Vref)" in channels
    assert "PI1" in channels
    assert len(channels["V(ref)"]) == len(result.time)
    assert len(channels["I(Vref)"]) == len(result.time)
    assert len(channels["PI1"]) == len(result.time)
    assert metadata["V(ref)"].domain == "electrical"
    assert metadata["V(ref)"].unit == "V"
    assert metadata["I(Vref)"].unit == "A"
    assert metadata["PI1"].domain == "control"


def test_run_transient_streaming_emits_full_electrical_names_and_final_virtual_channels() -> None:
    circuit = ps.Circuit()
    n_ref = circuit.add_node("ref")
    n_fb = circuit.add_node("fb")
    n_out = circuit.add_node("out")
    gnd = circuit.ground()

    circuit.add_voltage_source("Vref", n_ref, gnd, 5.0)
    circuit.add_voltage_source("Vfb", n_fb, gnd, 3.0)
    circuit.add_resistor("Rout", n_out, gnd, 1_000.0)
    circuit.add_virtual_component(
        "pi_controller",
        "PI1",
        [n_ref, n_fb, n_out],
        {"kp": 0.4, "ki": 5_000.0, "anti_windup": 1.0, "output_min": 0.0, "output_max": 1.0},
        {},
    )

    streamed_samples: list[dict[str, float]] = []

    def on_data(_time: float, sample: dict[str, float]) -> None:
        streamed_samples.append(dict(sample))

    _, _, ok, _ = ps.run_transient_streaming(
        circuit,
        0.0,
        10e-6,
        1e-6,
        circuit.initial_state(),
        ps.NewtonOptions(),
        ps.LinearSolverStackConfig.defaults(),
        on_data,
        None,
        None,
        1,
    )

    assert ok
    assert streamed_samples
    assert "V(ref)" in streamed_samples[0]
    assert "I(Vref)" in streamed_samples[0]
    assert "PI1" in streamed_samples[-1]


def test_virtual_channel_metadata_includes_relay_state_channel() -> None:
    circuit = ps.Circuit()
    n_coil_p = circuit.add_node("coil_p")
    n_coil_n = circuit.add_node("coil_n")
    n_com = circuit.add_node("com")
    n_no = circuit.add_node("no")
    n_nc = circuit.add_node("nc")
    gnd = circuit.ground()
    circuit.add_voltage_source("V1", n_coil_p, gnd, 12.0)
    circuit.add_virtual_component(
        "relay",
        "K1",
        [n_coil_p, n_coil_n, n_com, n_no, n_nc],
        {"pickup_voltage": 6.0, "dropout_voltage": 3.0},
        {},
    )

    metadata = circuit.virtual_channel_metadata()
    assert "K1" in metadata
    assert metadata["K1"].component_type == "relay"
    assert metadata["K1.state"].domain == "events"
    assert metadata["K1.no_state"].domain == "events"
    assert metadata["K1.nc_state"].domain == "events"


def test_op_amp_saturates_to_output_rails() -> None:
    circuit = ps.Circuit()
    n_pos = circuit.add_node("v_plus")
    n_neg = circuit.add_node("v_minus")
    n_out = circuit.add_node("v_out")
    circuit.add_virtual_component(
        "op_amp",
        "A1",
        [n_pos, n_neg, n_out],
        {"open_loop_gain": 1e5, "rail_low": -2.0, "rail_high": 2.0},
        {},
    )

    x = [0.0] * circuit.system_size()
    x[n_pos] = 1.0
    high = circuit.execute_mixed_domain_step(x, 1e-6)
    assert high.channel_values["A1"] == 2.0

    x[n_pos] = -1.0
    low = circuit.execute_mixed_domain_step(x, 2e-6)
    assert low.channel_values["A1"] == -2.0


def test_comparator_hysteresis_is_stateful() -> None:
    circuit = ps.Circuit()
    n_in = circuit.add_node("in")
    n_ref = circuit.add_node("ref")
    circuit.add_virtual_component(
        "comparator",
        "CMP1",
        [n_in, n_ref],
        {"threshold": 0.0, "hysteresis": 2.0, "high": 5.0, "low": 0.0},
        {},
    )

    x = [0.0] * circuit.system_size()
    x[n_in] = 0.8
    below_upper = circuit.execute_mixed_domain_step(x, 1e-6)
    assert below_upper.channel_values["CMP1"] == 0.0

    x[n_in] = 1.2
    above_upper = circuit.execute_mixed_domain_step(x, 2e-6)
    assert above_upper.channel_values["CMP1"] == 5.0

    x[n_in] = 0.4
    hold_high = circuit.execute_mixed_domain_step(x, 3e-6)
    assert hold_high.channel_values["CMP1"] == 5.0

    x[n_in] = -1.2
    below_lower = circuit.execute_mixed_domain_step(x, 4e-6)
    assert below_lower.channel_values["CMP1"] == 0.0


def test_pi_controller_anti_windup_recovers_faster_than_unclamped_integrator() -> None:
    circuit = ps.Circuit()
    n_err = circuit.add_node("err")
    n_ref = circuit.add_node("ref")
    circuit.add_virtual_component(
        "pi_controller",
        "PI_AW",
        [n_err, n_ref],
        {
            "kp": 0.0,
            "ki": 1.0,
            "output_min": -1.0,
            "output_max": 1.0,
            "anti_windup": 1.0,
        },
        {},
    )
    circuit.add_virtual_component(
        "pi_controller",
        "PI_NO_AW",
        [n_err, n_ref],
        {
            "kp": 0.0,
            "ki": 1.0,
            "output_min": -1.0,
            "output_max": 1.0,
            "anti_windup": 0.0,
        },
        {},
    )

    x = [0.0] * circuit.system_size()
    x[n_err] = 2.0
    circuit.execute_mixed_domain_step(x, 0.0)
    circuit.execute_mixed_domain_step(x, 1.0)
    circuit.execute_mixed_domain_step(x, 2.0)

    x[n_err] = -2.0
    recovery = circuit.execute_mixed_domain_step(x, 3.0)
    assert recovery.channel_values["PI_AW"] <= 0.0
    assert recovery.channel_values["PI_NO_AW"] > 0.0


def test_control_node_cascade_pi_pwm_updates_duty() -> None:
    circuit = ps.Circuit()
    n_ref_src = circuit.add_node("ref_src")
    n_sense = circuit.add_node("sense")
    n_ref = circuit.add_node("ref")
    n_err = circuit.add_node("err")
    n_pi = circuit.add_node("pi_out")
    gnd = circuit.ground()

    circuit.add_voltage_source("Vref", n_ref_src, gnd, 1.0)
    circuit.add_sine_voltage_source("Vsense", n_sense, gnd, 0.5, 500.0, 0.0)

    circuit.add_virtual_component("gain", "REF", [n_ref_src, n_ref], {"gain": 1.0}, {})
    circuit.add_virtual_component("subtraction", "SUB", [n_sense, n_ref, n_err], {}, {})
    circuit.add_virtual_component(
        "pi_controller",
        "PI",
        [n_err, gnd, n_pi],
        {"kp": 0.2, "ki": 50.0, "output_min": 0.0, "output_max": 1.0},
        {},
    )
    circuit.add_virtual_component(
        "pwm_generator",
        "PWM",
        [n_pi],
        {"frequency": 5_000.0, "duty_from_input": 1.0, "duty_min": 0.0, "duty_max": 1.0},
        {},
    )

    opts = ps.SimulationOptions()
    opts.tstart = 0.0
    opts.tstop = 2e-3
    opts.dt = 2e-6
    opts.step_mode = ps.StepMode.Fixed
    opts.newton_options.num_nodes = int(circuit.num_nodes())
    opts.newton_options.num_branches = int(circuit.num_branches())

    result = ps.Simulator(circuit, opts).run_transient(circuit.initial_state())
    assert result.success, result.message
    assert "PWM.duty" in result.virtual_channels
    duty = [float(v) for v in result.virtual_channels["PWM.duty"]]
    assert len(duty) == len(result.time)
    assert max(duty) <= 1.0 + 1e-12
    assert min(duty) >= -1e-12
    assert max(duty) - min(duty) > 1e-3


def test_pid_controller_uses_error_derivative() -> None:
    circuit = ps.Circuit()
    n_err = circuit.add_node("err")
    n_ref = circuit.add_node("ref")
    circuit.add_virtual_component(
        "pid_controller",
        "PID1",
        [n_err, n_ref],
        {"kp": 0.0, "ki": 0.0, "kd": 1.0},
        {},
    )

    x = [0.0] * circuit.system_size()
    x[n_err] = 0.0
    first = circuit.execute_mixed_domain_step(x, 0.0)
    assert first.channel_values["PID1"] == 0.0

    x[n_err] = 1.0
    second = circuit.execute_mixed_domain_step(x, 1.0)
    assert abs(second.channel_values["PID1"] - 1.0) < 1e-12

    x[n_err] = 3.0
    third = circuit.execute_mixed_domain_step(x, 2.0)
    assert abs(third.channel_values["PID1"] - 2.0) < 1e-12


def test_math_block_operations_and_divide_by_zero_behavior() -> None:
    circuit = ps.Circuit()
    n_a = circuit.add_node("a")
    n_b = circuit.add_node("b")
    circuit.add_virtual_component("math_block", "M_ADD", [n_a, n_b], {}, {"operation": "add"})
    circuit.add_virtual_component("math_block", "M_SUB", [n_a, n_b], {}, {"operation": "sub"})
    circuit.add_virtual_component("math_block", "M_MUL", [n_a, n_b], {}, {"operation": "mul"})
    circuit.add_virtual_component("math_block", "M_DIV", [n_a, n_b], {}, {"operation": "div"})

    x = [0.0] * circuit.system_size()
    x[n_a] = 6.0
    x[n_b] = 2.0
    step = circuit.execute_mixed_domain_step(x, 1e-6)
    assert step.channel_values["M_ADD"] == 8.0
    assert step.channel_values["M_SUB"] == 4.0
    assert step.channel_values["M_MUL"] == 12.0
    assert step.channel_values["M_DIV"] == 3.0

    x[n_b] = 0.0
    div_zero = circuit.execute_mixed_domain_step(x, 2e-6)
    assert div_zero.channel_values["M_DIV"] == 0.0


def test_pwm_generator_supports_input_driven_duty_with_limits() -> None:
    circuit = ps.Circuit()
    n_ctrl = circuit.add_node("ctrl")
    circuit.add_virtual_component(
        "pwm_generator",
        "PWM1",
        [n_ctrl],
        {
            "frequency": 1.0,
            "duty_from_input": 1.0,
            "duty_gain": 0.5,
            "duty_offset": 0.1,
            "duty_min": 0.2,
            "duty_max": 0.8,
        },
        {},
    )

    metadata = circuit.virtual_channel_metadata()
    assert "PWM1.duty" in metadata
    assert metadata["PWM1.duty"].domain == "control"

    x = [0.0] * circuit.system_size()
    x[n_ctrl] = 2.0
    high_duty = circuit.execute_mixed_domain_step(x, 0.25)
    assert abs(high_duty.channel_values["PWM1.duty"] - 0.8) < 1e-12
    assert high_duty.channel_values["PWM1"] == 1.0

    x[n_ctrl] = -2.0
    low_duty = circuit.execute_mixed_domain_step(x, 0.75)
    assert abs(low_duty.channel_values["PWM1.duty"] - 0.2) < 1e-12
    assert low_duty.channel_values["PWM1"] == 0.0


def test_yaml_pulse_voltage_source_can_drive_target_switch_component() -> None:
    content = """
schema: pulsim-v1
version: 1
components:
  - type: voltage_source
    name: VDRV
    nodes: [drv, 0]
    target_component: Q1
    waveform:
      type: pulse
      v_initial: 0
      v_pulse: 10
      t_delay: 0
      t_rise: 1e-9
      t_fall: 1e-9
      t_width: 2e-6
      period: 4e-6
  - type: igbt
    name: Q1
    nodes: [gate, main, 0]
    vth: 5
    g_on: 2000
    g_off: 1e-9
"""
    parser = ps.YamlParser()
    circuit, _ = parser.load_string(content)
    assert parser.errors == []

    n_main = circuit.get_node("main")
    x = [0.0] * circuit.system_size()
    x[n_main] = 1.0

    circuit.execute_mixed_domain_step(x, 1e-6)  # high interval
    _, f_on = circuit.assemble_jacobian(x)
    g_on = abs(float(f_on[n_main])) / max(abs(float(x[n_main])), 1e-9)

    circuit.execute_mixed_domain_step(x, 3e-6)  # low interval
    _, f_off = circuit.assemble_jacobian(x)
    g_off = abs(float(f_off[n_main])) / max(abs(float(x[n_main])), 1e-9)

    assert g_on > g_off * 10.0


def test_integrator_respects_output_limits() -> None:
    circuit = ps.Circuit()
    n_in = circuit.add_node("in")
    n_ref = circuit.add_node("ref")
    circuit.add_virtual_component(
        "integrator",
        "INT1",
        [n_in, n_ref],
        {"output_min": -1.0, "output_max": 1.0},
        {},
    )

    x = [0.0] * circuit.system_size()
    x[n_in] = 2.0
    circuit.execute_mixed_domain_step(x, 0.0)
    step1 = circuit.execute_mixed_domain_step(x, 1.0)
    step2 = circuit.execute_mixed_domain_step(x, 2.0)
    assert step1.channel_values["INT1"] == 1.0
    assert step2.channel_values["INT1"] == 1.0


def test_differentiator_alpha_filter_smooths_output() -> None:
    circuit = ps.Circuit()
    n_in = circuit.add_node("in")
    n_ref = circuit.add_node("ref")
    circuit.add_virtual_component(
        "differentiator",
        "D1",
        [n_in, n_ref],
        {"alpha": 0.5},
        {},
    )

    x = [0.0] * circuit.system_size()
    x[n_in] = 0.0
    circuit.execute_mixed_domain_step(x, 0.0)

    x[n_in] = 10.0
    step_up = circuit.execute_mixed_domain_step(x, 1.0)
    assert abs(step_up.channel_values["D1"] - 5.0) < 1e-12

    hold = circuit.execute_mixed_domain_step(x, 2.0)
    assert abs(hold.channel_values["D1"] - 2.5) < 1e-12


def test_rate_limiter_caps_rising_and_falling_edges() -> None:
    circuit = ps.Circuit()
    n_in = circuit.add_node("in")
    n_ref = circuit.add_node("ref")
    circuit.add_virtual_component(
        "rate_limiter",
        "RL1",
        [n_in, n_ref],
        {"rising_rate": 1.0, "falling_rate": 2.0},
        {},
    )

    x = [0.0] * circuit.system_size()
    circuit.execute_mixed_domain_step(x, 0.0)

    x[n_in] = 10.0
    rise = circuit.execute_mixed_domain_step(x, 1.0)
    assert rise.channel_values["RL1"] == 1.0

    x[n_in] = -10.0
    fall = circuit.execute_mixed_domain_step(x, 2.0)
    assert fall.channel_values["RL1"] == -1.0


def test_hysteresis_block_uses_high_low_states() -> None:
    circuit = ps.Circuit()
    n_in = circuit.add_node("in")
    n_ref = circuit.add_node("ref")
    circuit.add_virtual_component(
        "hysteresis",
        "HYS1",
        [n_in, n_ref],
        {"threshold": 0.0, "hysteresis": 1.0, "high": 3.0, "low": -1.0},
        {},
    )

    x = [0.0] * circuit.system_size()
    x[n_in] = 0.3
    below_upper = circuit.execute_mixed_domain_step(x, 1e-6)
    assert below_upper.channel_values["HYS1"] == -1.0

    x[n_in] = 0.6
    above_upper = circuit.execute_mixed_domain_step(x, 2e-6)
    assert above_upper.channel_values["HYS1"] == 3.0

    x[n_in] = -0.6
    below_lower = circuit.execute_mixed_domain_step(x, 3e-6)
    assert below_lower.channel_values["HYS1"] == -1.0


def test_lookup_table_performs_linear_interpolation() -> None:
    circuit = ps.Circuit()
    n_in = circuit.add_node("in")
    n_ref = circuit.add_node("ref")
    circuit.add_virtual_component(
        "lookup_table",
        "LUT1",
        [n_in, n_ref],
        {},
        {"x": "[0, 1, 2]", "y": "[0, 10, 20]"},
    )

    x = [0.0] * circuit.system_size()
    x[n_in] = 0.5
    mid = circuit.execute_mixed_domain_step(x, 1e-6)
    assert abs(mid.channel_values["LUT1"] - 5.0) < 1e-12

    x[n_in] = 1.5
    high = circuit.execute_mixed_domain_step(x, 2e-6)
    assert abs(high.channel_values["LUT1"] - 15.0) < 1e-12


def test_transfer_function_supports_num_den_coefficients() -> None:
    circuit = ps.Circuit()
    n_in = circuit.add_node("in")
    n_ref = circuit.add_node("ref")
    circuit.add_virtual_component(
        "transfer_function",
        "TF1",
        [n_in, n_ref],
        {},
        {"num": "[0.5, 0.5]", "den": "[1.0, -0.5]"},
    )

    x = [0.0] * circuit.system_size()
    x[n_in] = 1.0
    first = circuit.execute_mixed_domain_step(x, 0.0)
    assert abs(first.channel_values["TF1"] - 0.5) < 1e-12

    second = circuit.execute_mixed_domain_step(x, 1.0)
    assert abs(second.channel_values["TF1"] - 1.25) < 1e-12


def test_delay_block_outputs_time_shifted_signal() -> None:
    circuit = ps.Circuit()
    n_in = circuit.add_node("in")
    n_ref = circuit.add_node("ref")
    circuit.add_virtual_component(
        "delay_block",
        "DL1",
        [n_in, n_ref],
        {"delay": 1.0},
        {},
    )

    x = [0.0] * circuit.system_size()
    x[n_in] = 1.0
    t0 = circuit.execute_mixed_domain_step(x, 0.0)
    assert t0.channel_values["DL1"] == 1.0

    x[n_in] = 3.0
    t05 = circuit.execute_mixed_domain_step(x, 0.5)
    assert t05.channel_values["DL1"] == 1.0

    x[n_in] = 5.0
    t15 = circuit.execute_mixed_domain_step(x, 1.5)
    assert abs(t15.channel_values["DL1"] - 3.0) < 1e-12


def test_sample_hold_updates_only_at_sample_period() -> None:
    circuit = ps.Circuit()
    n_in = circuit.add_node("in")
    n_ref = circuit.add_node("ref")
    circuit.add_virtual_component(
        "sample_hold",
        "SH1",
        [n_in, n_ref],
        {"sample_period": 1.0},
        {},
    )

    x = [0.0] * circuit.system_size()
    x[n_in] = 0.0
    t0 = circuit.execute_mixed_domain_step(x, 0.0)
    assert t0.channel_values["SH1"] == 0.0

    x[n_in] = 5.0
    t04 = circuit.execute_mixed_domain_step(x, 0.4)
    assert t04.channel_values["SH1"] == 0.0

    t11 = circuit.execute_mixed_domain_step(x, 1.1)
    assert t11.channel_values["SH1"] == 5.0

    x[n_in] = -2.0
    t16 = circuit.execute_mixed_domain_step(x, 1.6)
    assert t16.channel_values["SH1"] == 5.0


def test_state_machine_supports_set_reset_mode() -> None:
    circuit = ps.Circuit()
    n_set = circuit.add_node("set")
    n_reset = circuit.add_node("reset")
    circuit.add_virtual_component(
        "state_machine",
        "SM1",
        [n_set, n_reset],
        {"threshold": 0.5, "high": 10.0, "low": -10.0},
        {"mode": "set_reset"},
    )

    x = [0.0] * circuit.system_size()
    base = circuit.execute_mixed_domain_step(x, 0.0)
    assert base.channel_values["SM1"] == -10.0

    x[n_set] = 1.0
    set_state = circuit.execute_mixed_domain_step(x, 1.0)
    assert set_state.channel_values["SM1"] == 10.0

    x[n_set] = 0.0
    x[n_reset] = 1.0
    reset_state = circuit.execute_mixed_domain_step(x, 2.0)
    assert reset_state.channel_values["SM1"] == -10.0


def test_relay_event_controller_drives_no_and_nc_switches() -> None:
    circuit = ps.Circuit()
    n_coil_p = circuit.add_node("coil_p")
    n_coil_n = circuit.add_node("coil_n")
    n_com = circuit.add_node("com")
    n_no = circuit.add_node("no")
    n_nc = circuit.add_node("nc")

    circuit.add_switch("K1__no", n_com, n_no, False, 10.0, 1e-6)
    circuit.add_switch("K1__nc", n_com, n_nc, True, 10.0, 1e-6)
    circuit.add_virtual_component(
        "relay",
        "K1",
        [n_coil_p, n_coil_n, n_com, n_no, n_nc],
        {"pickup_voltage": 6.0, "dropout_voltage": 3.0, "initial_closed": 0.0},
        {"target_component_no": "K1__no", "target_component_nc": "K1__nc"},
    )

    x = [0.0] * circuit.system_size()
    x[n_coil_p] = 12.0
    x[n_coil_n] = 0.0
    on_step = circuit.execute_mixed_domain_step(x, 1e-6)
    assert on_step.channel_values["K1.state"] == 1.0
    assert on_step.channel_values["K1.no_state"] == 1.0
    assert on_step.channel_values["K1.nc_state"] == 0.0
    g_on, _ = circuit.assemble_dc()
    assert g_on[n_com][n_no] < -1.0
    assert g_on[n_com][n_nc] > -1e-3

    x[n_coil_p] = 0.0
    off_step = circuit.execute_mixed_domain_step(x, 2e-6)
    assert off_step.channel_values["K1.state"] == 0.0
    assert off_step.channel_values["K1.no_state"] == 0.0
    assert off_step.channel_values["K1.nc_state"] == 1.0
    g_off, _ = circuit.assemble_dc()
    assert g_off[n_com][n_nc] < -1.0
    assert g_off[n_com][n_no] > -1e-3


def test_scope_channels_emit_waveforms_with_metadata() -> None:
    circuit = ps.Circuit()
    n_in = circuit.add_node("in")
    gnd = circuit.ground()
    circuit.add_voltage_source("V1", n_in, gnd, 10.0)
    circuit.add_resistor("R1", n_in, gnd, 1_000.0)
    circuit.add_virtual_component("electrical_scope", "SCOPE_E", [n_in, gnd], {}, {})
    circuit.add_virtual_component("thermal_scope", "SCOPE_T", [n_in], {}, {})

    opts = ps.SimulationOptions()
    opts.tstart = 0.0
    opts.tstop = 3e-6
    opts.dt = 1e-6
    opts.dt_min = opts.dt
    opts.dt_max = opts.dt
    opts.adaptive_timestep = False
    opts.enable_bdf_order_control = False

    result = ps.Simulator(circuit, opts).run_transient()
    assert result.success
    assert "SCOPE_E" in result.virtual_channels
    assert "SCOPE_T" in result.virtual_channels
    assert len(result.virtual_channels["SCOPE_E"]) == len(result.time)
    assert result.virtual_channel_metadata["SCOPE_E"].domain == "instrumentation"
    assert result.virtual_channel_metadata["SCOPE_T"].domain == "thermal"


def test_signal_mux_demux_mapping_is_deterministic() -> None:
    circuit = ps.Circuit()
    n_a = circuit.add_node("a")
    n_b = circuit.add_node("b")
    n_c = circuit.add_node("c")
    circuit.add_virtual_component(
        "signal_mux",
        "MUX1",
        [n_a, n_b, n_c],
        {"select_index": 1.0},
        {},
    )
    circuit.add_virtual_component(
        "signal_demux",
        "DMX1",
        [n_c, n_a, n_b],
        {},
        {},
    )

    x = [0.0] * circuit.system_size()
    x[n_a] = 2.0
    x[n_b] = 5.0
    x[n_c] = -1.0

    step = circuit.execute_mixed_domain_step(x, 1e-6)
    assert step.channel_values["MUX1"] == 5.0
    assert step.channel_values["DMX1"] == -1.0


def test_fuse_event_controller_trips_by_i2t() -> None:
    circuit = ps.Circuit()
    n_in = circuit.add_node("in")
    gnd = circuit.ground()
    circuit.add_voltage_source("V1", n_in, gnd, 5.0)
    circuit.add_switch("F_SW", n_in, gnd, True, 1e3, 1e-9)
    circuit.add_virtual_component(
        "fuse",
        "F_EVT",
        [n_in, gnd],
        {"g_on": 1e3, "blow_i2t": 1.0, "initial_closed": 1.0},
        {"target_component": "F_SW"},
    )

    x = [0.0] * circuit.system_size()
    x[n_in] = 5.0
    circuit.execute_mixed_domain_step(x, 0.0)
    step = circuit.execute_mixed_domain_step(x, 1e-2)
    assert step.channel_values["F_EVT.i2t"] > 1.0
    assert step.channel_values["F_EVT.state"] == 0.0


def test_breaker_event_controller_trips_by_time_overcurrent() -> None:
    circuit = ps.Circuit()
    n_in = circuit.add_node("in")
    gnd = circuit.ground()
    circuit.add_voltage_source("V1", n_in, gnd, 5.0)
    circuit.add_switch("B_SW", n_in, gnd, True, 1e3, 1e-9)
    circuit.add_virtual_component(
        "circuit_breaker",
        "B_EVT",
        [n_in, gnd],
        {"g_on": 1e3, "trip_current": 1000.0, "trip_time": 5e-3, "initial_closed": 1.0},
        {"target_component": "B_SW"},
    )

    x = [0.0] * circuit.system_size()
    x[n_in] = 5.0
    circuit.execute_mixed_domain_step(x, 0.0)
    mid = circuit.execute_mixed_domain_step(x, 2e-3)
    end = circuit.execute_mixed_domain_step(x, 6e-3)
    assert mid.channel_values["B_EVT.state"] == 1.0
    assert end.channel_values["B_EVT.trip_timer"] >= 5e-3
    assert end.channel_values["B_EVT.state"] == 0.0


def test_thyristor_latches_and_releases_on_holding_current() -> None:
    circuit = ps.Circuit()
    n_gate = circuit.add_node("gate")
    n_anode = circuit.add_node("anode")
    n_cathode = circuit.ground()
    circuit.add_switch("SCR_SW", n_anode, n_cathode, False, 1e3, 1e-9)
    circuit.add_virtual_component(
        "thyristor",
        "SCR_EVT",
        [n_gate, n_anode, n_cathode],
        {
            "gate_threshold": 1.0,
            "holding_current": 100.0,
            "latch_current": 500.0,
            "g_on": 1e3,
        },
        {"target_component": "SCR_SW"},
    )

    x = [0.0] * circuit.system_size()
    x[n_gate] = 2.0
    x[n_anode] = 1.0  # 1V * 1e3S = 1000A estimated => latches
    trig = circuit.execute_mixed_domain_step(x, 1e-6)
    assert trig.channel_values["SCR_EVT.trigger"] == 1.0
    assert trig.channel_values["SCR_EVT.state"] == 1.0

    x[n_gate] = 0.0
    keep = circuit.execute_mixed_domain_step(x, 2e-6)
    assert keep.channel_values["SCR_EVT.state"] == 1.0

    x[n_anode] = 1e-4  # 0.1A estimated => below holding current
    release = circuit.execute_mixed_domain_step(x, 3e-6)
    assert release.channel_values["SCR_EVT.state"] == 0.0


def test_triac_triggers_both_polarities() -> None:
    circuit = ps.Circuit()
    n_gate = circuit.add_node("gate")
    n_t1 = circuit.add_node("t1")
    n_t2 = circuit.ground()
    circuit.add_switch("TRI_SW", n_t1, n_t2, False, 1e3, 1e-9)
    circuit.add_virtual_component(
        "triac",
        "TRI_EVT",
        [n_gate, n_t1, n_t2],
        {
            "gate_threshold": 1.0,
            "holding_current": 10.0,
            "latch_current": 100.0,
            "g_on": 1e3,
        },
        {"target_component": "TRI_SW"},
    )

    x = [0.0] * circuit.system_size()
    x[n_gate] = -2.0
    x[n_t1] = -1.0  # reverse polarity but should still trigger for triac
    trig = circuit.execute_mixed_domain_step(x, 1e-6)
    assert trig.channel_values["TRI_EVT.trigger"] == 1.0
    assert trig.channel_values["TRI_EVT.state"] == 1.0

    x[n_gate] = 0.0
    hold = circuit.execute_mixed_domain_step(x, 2e-6)
    assert hold.channel_values["TRI_EVT.state"] == 1.0

    x[n_t1] = 0.0
    off = circuit.execute_mixed_domain_step(x, 3e-6)
    assert off.channel_values["TRI_EVT.state"] == 0.0


def test_latching_regularization_clamps_virtual_parameters() -> None:
    circuit = ps.Circuit()
    n_gate = circuit.add_node("gate")
    n_anode = circuit.add_node("anode")
    n_cathode = circuit.ground()
    circuit.add_switch("SCR_SW", n_anode, n_cathode, False, 1e9, 1e-15)
    circuit.add_virtual_component(
        "thyristor",
        "SCR_EVT",
        [n_gate, n_anode, n_cathode],
        {
            "gate_threshold": 0.0,
            "holding_current": 0.0,
            "latch_current": 0.0,
            "g_on": 1e9,
            "g_off": 1e-15,
        },
        {"target_component": "SCR_SW"},
    )

    changed = circuit.apply_numerical_regularization()
    assert changed > 0
    params = {vc.name: vc for vc in circuit.virtual_components()}["SCR_EVT"].numeric_params
    assert params["gate_threshold"] >= 1e-3
    assert params["holding_current"] >= 1e-6
    assert params["latch_current"] >= params["holding_current"]
    assert params["g_on"] <= 5e5
    assert params["g_off"] >= 1e-9


def test_saturable_inductor_effective_inductance_decreases_with_current() -> None:
    circuit = ps.Circuit()
    n_in = circuit.add_node("in")
    gnd = circuit.ground()
    circuit.add_inductor("Lsat", n_in, gnd, 1e-3, 0.0)
    circuit.add_virtual_component(
        "saturable_inductor",
        "Lsat",
        [n_in, gnd],
        {
            "inductance": 1e-3,
            "saturation_current": 1.0,
            "saturation_inductance": 1e-4,
            "saturation_exponent": 2.0,
        },
        {"target_component": "Lsat"},
    )

    br = circuit.num_nodes()
    x_low = [0.0] * circuit.system_size()
    x_low[n_in] = 1.0
    x_low[br] = 0.1
    j_low, _ = circuit.assemble_jacobian(x_low)

    x_high = [0.0] * circuit.system_size()
    x_high[n_in] = 1.0
    x_high[br] = 10.0
    j_high, _ = circuit.assemble_jacobian(x_high)

    assert abs(j_high[br][br]) < abs(j_low[br][br])


def test_saturable_inductor_magnetic_core_exports_core_loss_channel() -> None:
    parser = ps.YamlParser()
    circuit, opts = parser.load_string(
        """
schema: pulsim-v1
version: 1
simulation:
  tstart: 0.0
  tstop: 1e-3
  dt: 2e-6
  step_mode: fixed
components:
  - type: voltage_source
    name: Vin
    nodes: [vin, 0]
    waveform: {type: dc, value: 10}
  - type: resistor
    name: R1
    nodes: [vin, n1]
    value: 2
  - type: saturable_inductor
    name: Lsat
    nodes: [n1, 0]
    inductance: 1m
    saturation_current: 1.0
    saturation_inductance: 200u
    magnetic_core:
      enabled: true
      model: saturation
      core_loss_k: 0.2
      core_loss_alpha: 2.0
"""
    )
    assert parser.errors == [], parser.errors

    opts.newton_options.num_nodes = int(circuit.num_nodes())
    opts.newton_options.num_branches = int(circuit.num_branches())
    result = ps.Simulator(circuit, opts).run_transient(circuit.initial_state())
    assert result.success

    assert "Lsat.core_loss" in result.virtual_channels
    core_loss = [float(v) for v in result.virtual_channels["Lsat.core_loss"]]
    assert len(core_loss) == len(result.time)
    assert max(core_loss) > 0.0
    assert min(core_loss) >= 0.0

    metadata = result.virtual_channel_metadata["Lsat.core_loss"]
    assert metadata.domain == "loss"
    assert metadata.unit == "W"
    assert metadata.source_component == "Lsat"


def test_coupled_inductor_magnetic_core_exports_core_loss_channel() -> None:
    parser = ps.YamlParser()
    circuit, opts = parser.load_string(
        """
schema: pulsim-v1
version: 1
simulation:
  tstart: 0.0
  tstop: 5e-5
  dt: 1e-6
  step_mode: fixed
components:
  - type: voltage_source
    name: Vin
    nodes: [vin, 0]
    waveform: {type: dc, value: 2}
  - type: resistor
    name: Rp
    nodes: [vin, p1]
    value: 100
  - type: coupled_inductor
    name: Kmag
    nodes: [p1, 0, s1, 0]
    l1: 50m
    l2: 50m
    coupling: 0.2
    magnetic_core:
      enabled: true
      model: saturation
      core_loss_k: 0.02
      core_loss_alpha: 2.0
  - type: resistor
    name: Rload
    nodes: [s1, 0]
    value: 100
"""
    )
    assert parser.errors == [], parser.errors

    opts.newton_options.num_nodes = int(circuit.num_nodes())
    opts.newton_options.num_branches = int(circuit.num_branches())
    result = ps.Simulator(circuit, opts).run_transient(circuit.initial_state())
    assert result.success

    assert "Kmag.core_loss" in result.virtual_channels
    core_loss = [float(v) for v in result.virtual_channels["Kmag.core_loss"]]
    assert len(core_loss) == len(result.time)
    assert max(core_loss) > 0.0
    assert min(core_loss) >= 0.0

    metadata = result.virtual_channel_metadata["Kmag.core_loss"]
    assert metadata.domain == "loss"
    assert metadata.unit == "W"
    assert metadata.source_component == "Kmag"


def test_transformer_magnetic_core_exports_core_loss_channel() -> None:
    parser = ps.YamlParser()
    circuit, opts = parser.load_string(
        """
schema: pulsim-v1
version: 1
simulation:
  tstart: 0.0
  tstop: 5e-4
  dt: 2e-6
  step_mode: fixed
components:
  - type: voltage_source
    name: Vin
    nodes: [vin, 0]
    waveform: {type: dc, value: 12}
  - type: resistor
    name: Rsrc
    nodes: [vin, p1]
    value: 2
  - type: transformer
    name: Tmag
    nodes: [p1, 0, s1, 0]
    turns_ratio: 2.0
    magnetic_core:
      enabled: true
      model: saturation
      core_loss_k: 0.08
      core_loss_alpha: 2.0
  - type: resistor
    name: Rload
    nodes: [s1, 0]
    value: 4
"""
    )
    assert parser.errors == [], parser.errors

    opts.newton_options.num_nodes = int(circuit.num_nodes())
    opts.newton_options.num_branches = int(circuit.num_branches())
    result = ps.Simulator(circuit, opts).run_transient(circuit.initial_state())
    assert result.success

    assert "Tmag.core_loss" in result.virtual_channels
    core_loss = [float(v) for v in result.virtual_channels["Tmag.core_loss"]]
    assert len(core_loss) == len(result.time)
    assert max(core_loss) > 0.0
    assert min(core_loss) >= 0.0

    metadata = result.virtual_channel_metadata["Tmag.core_loss"]
    assert metadata.domain == "loss"
    assert metadata.unit == "W"
    assert metadata.source_component == "Tmag"


def test_saturable_inductor_core_loss_freq_coefficient_increases_loss_with_frequency() -> None:
    def _average_core_loss(frequency_hz: float) -> float:
        parser = ps.YamlParser()
        circuit, opts = parser.load_string(
            f"""
schema: pulsim-v1
version: 1
simulation:
  tstart: 0.0
  tstop: 3e-3
  dt: 2e-6
  step_mode: fixed
components:
  - type: voltage_source
    name: Vin
    nodes: [vin, 0]
    waveform:
      type: sine
      amplitude: 5
      frequency: {frequency_hz}
      offset: 0
      phase: 0
  - type: resistor
    name: R1
    nodes: [vin, n1]
    value: 10
  - type: saturable_inductor
    name: Lsat
    nodes: [n1, 0]
    inductance: 100n
    saturation_current: 5.0
    saturation_inductance: 80n
    magnetic_core:
      enabled: true
      model: saturation
      core_loss_k: 0.1
      core_loss_alpha: 2.0
      core_loss_freq_coeff: 1e-4
"""
        )
        assert parser.errors == [], parser.errors
        opts.newton_options.num_nodes = int(circuit.num_nodes())
        opts.newton_options.num_branches = int(circuit.num_branches())
        result = ps.Simulator(circuit, opts).run_transient(circuit.initial_state())
        assert result.success

        series = [float(v) for v in result.virtual_channels["Lsat.core_loss"]]
        assert series
        tail = series[len(series) // 2 :]
        return float(sum(tail) / len(tail))

    avg_low = _average_core_loss(1e3)
    avg_high = _average_core_loss(20e3)
    assert avg_high > avg_low


def test_magnetic_core_loss_channel_is_deterministic_across_repeated_runs() -> None:
    netlist = """
schema: pulsim-v1
version: 1
simulation:
  tstart: 0.0
  tstop: 8e-4
  dt: 2e-6
  step_mode: fixed
components:
  - type: voltage_source
    name: Vin
    nodes: [vin, 0]
    waveform: {type: dc, value: 10}
  - type: resistor
    name: R1
    nodes: [vin, n1]
    value: 2
  - type: saturable_inductor
    name: Lsat
    nodes: [n1, 0]
    inductance: 1m
    saturation_current: 1.0
    saturation_inductance: 200u
    magnetic_core:
      enabled: true
      model: saturation
      core_loss_k: 0.2
      core_loss_alpha: 2.0
      core_loss_freq_coeff: 1e-4
"""

    parser_a = ps.YamlParser()
    circuit_a, opts_a = parser_a.load_string(netlist)
    assert parser_a.errors == [], parser_a.errors
    opts_a.newton_options.num_nodes = int(circuit_a.num_nodes())
    opts_a.newton_options.num_branches = int(circuit_a.num_branches())
    result_a = ps.Simulator(circuit_a, opts_a).run_transient(circuit_a.initial_state())
    assert result_a.success

    parser_b = ps.YamlParser()
    circuit_b, opts_b = parser_b.load_string(netlist)
    assert parser_b.errors == [], parser_b.errors
    opts_b.newton_options.num_nodes = int(circuit_b.num_nodes())
    opts_b.newton_options.num_branches = int(circuit_b.num_branches())
    result_b = ps.Simulator(circuit_b, opts_b).run_transient(circuit_b.initial_state())
    assert result_b.success

    series_a = [float(v) for v in result_a.virtual_channels["Lsat.core_loss"]]
    series_b = [float(v) for v in result_b.virtual_channels["Lsat.core_loss"]]
    assert len(series_a) == len(series_b)
    assert len(series_a) == len(result_a.time)
    assert len(series_b) == len(result_b.time)

    for a, b in zip(series_a, series_b):
        assert abs(a - b) <= 1e-12


def test_magnetic_core_loss_policy_loss_summary_exports_summary_row() -> None:
    parser = ps.YamlParser()
    circuit, opts = parser.load_string(
        """
schema: pulsim-v1
version: 1
simulation:
  tstart: 0.0
  tstop: 1e-3
  dt: 2e-6
  step_mode: fixed
  enable_losses: true
components:
  - type: voltage_source
    name: Vin
    nodes: [vin, 0]
    waveform: {type: dc, value: 10}
  - type: resistor
    name: R1
    nodes: [vin, n1]
    value: 2
  - type: saturable_inductor
    name: Lsat
    nodes: [n1, 0]
    inductance: 1m
    saturation_current: 1.0
    saturation_inductance: 200u
    magnetic_core:
      enabled: true
      model: saturation
      loss_policy: loss_summary
      core_loss_k: 0.2
      core_loss_alpha: 2.0
      core_loss_freq_coeff: 1e-4
      i_equiv_init: 0.0
"""
    )
    assert parser.errors == [], parser.errors
    opts.newton_options.num_nodes = int(circuit.num_nodes())
    opts.newton_options.num_branches = int(circuit.num_branches())

    result = ps.Simulator(circuit, opts).run_transient(circuit.initial_state())
    assert result.success, result.message
    assert "Lsat.core_loss" in result.virtual_channels

    loss_rows = {row.device_name: row for row in result.loss_summary.device_losses}
    assert "Lsat.core" in loss_rows
    core_row = loss_rows["Lsat.core"]
    assert core_row.total_energy > 0.0
    assert core_row.average_power > 0.0
    assert core_row.breakdown.conduction > 0.0
    assert result.loss_summary.total_loss >= core_row.average_power

    component_rows = {row.component_name: row for row in result.component_electrothermal}
    assert "Lsat.core" in component_rows
    assert component_rows["Lsat.core"].total_energy > 0.0
    assert component_rows["Lsat.core"].total_loss > 0.0


def test_magnetic_core_loss_summary_row_is_deterministic_across_repeated_runs() -> None:
    netlist = """
schema: pulsim-v1
version: 1
simulation:
  tstart: 0.0
  tstop: 1e-3
  dt: 2e-6
  step_mode: fixed
  enable_losses: true
components:
  - type: voltage_source
    name: Vin
    nodes: [vin, 0]
    waveform: {type: dc, value: 10}
  - type: resistor
    name: R1
    nodes: [vin, n1]
    value: 2
  - type: saturable_inductor
    name: Lsat
    nodes: [n1, 0]
    inductance: 1m
    saturation_current: 1.0
    saturation_inductance: 200u
    magnetic_core:
      enabled: true
      model: saturation
      loss_policy: loss_summary
      core_loss_k: 0.2
      core_loss_alpha: 2.0
      core_loss_freq_coeff: 1e-4
"""

    def _run() -> tuple[float, float]:
        parser = ps.YamlParser()
        circuit, opts = parser.load_string(netlist)
        assert parser.errors == [], parser.errors
        opts.newton_options.num_nodes = int(circuit.num_nodes())
        opts.newton_options.num_branches = int(circuit.num_branches())
        result = ps.Simulator(circuit, opts).run_transient(circuit.initial_state())
        assert result.success
        rows = {row.device_name: row for row in result.loss_summary.device_losses}
        assert "Lsat.core" in rows
        row = rows["Lsat.core"]
        return float(row.average_power), float(row.total_energy)

    avg_a, energy_a = _run()
    avg_b, energy_b = _run()
    assert abs(avg_a - avg_b) <= 1e-12
    assert abs(energy_a - energy_b) <= 1e-12


def test_magnetic_core_hysteresis_exports_deterministic_memory_state() -> None:
    netlist = """
schema: pulsim-v1
version: 1
simulation:
  tstart: 0.0
  tstop: 2e-3
  dt: 2e-6
  step_mode: fixed
components:
  - type: voltage_source
    name: Vin
    nodes: [vin, 0]
    waveform:
      type: sine
      amplitude: 8
      frequency: 1000
      offset: 0
      phase: 0
  - type: resistor
    name: R1
    nodes: [vin, n1]
    value: 1
  - type: saturable_inductor
    name: Lsat
    nodes: [n1, 0]
    inductance: 1m
    saturation_current: 1.0
    saturation_inductance: 200u
    magnetic_core:
      enabled: true
      model: hysteresis
      hysteresis_band: 0.05
      hysteresis_strength: 0.25
      hysteresis_loss_coeff: 0.2
      hysteresis_state_init: 1
      core_loss_k: 0.15
      core_loss_alpha: 2.0
"""

    def _run_once() -> tuple[list[float], list[float]]:
        parser = ps.YamlParser()
        circuit, opts = parser.load_string(netlist)
        assert parser.errors == [], parser.errors
        opts.newton_options.num_nodes = int(circuit.num_nodes())
        opts.newton_options.num_branches = int(circuit.num_branches())
        result = ps.Simulator(circuit, opts).run_transient(circuit.initial_state())
        assert result.success
        assert "Lsat.h_state" in result.virtual_channels
        assert "Lsat.l_eff" in result.virtual_channels
        h_state = [float(v) for v in result.virtual_channels["Lsat.h_state"]]
        l_eff = [float(v) for v in result.virtual_channels["Lsat.l_eff"]]
        assert len(h_state) == len(result.time)
        assert len(l_eff) == len(result.time)
        return h_state, l_eff

    h_a, l_a = _run_once()
    h_b, l_b = _run_once()
    assert len(h_a) == len(h_b)
    assert len(l_a) == len(l_b)

    # Hysteresis state must be deterministic and bounded.
    for va, vb in zip(h_a, h_b):
        assert abs(va - vb) <= 1e-12
        assert abs(va) <= 1.0 + 1e-12
    assert max(h_a) > 0.5
    assert min(h_a) < -0.5

    # Effective inductance trace should also be deterministic.
    for va, vb in zip(l_a, l_b):
        assert abs(va - vb) <= 1e-12


def test_magnetic_core_loss_couples_into_thermal_summary_when_enabled() -> None:
    parser = ps.YamlParser()
    circuit, opts = parser.load_string(
        """
schema: pulsim-v1
version: 1
simulation:
  tstart: 0.0
  tstop: 1e-3
  dt: 2e-6
  step_mode: fixed
  enable_losses: true
  thermal:
    enabled: true
    ambient: 25
    default_rth: 0.9
    default_cth: 0.05
components:
  - type: voltage_source
    name: Vin
    nodes: [vin, 0]
    waveform: {type: dc, value: 10}
  - type: resistor
    name: R1
    nodes: [vin, n1]
    value: 2
  - type: saturable_inductor
    name: Lsat
    nodes: [n1, 0]
    inductance: 1m
    saturation_current: 1.0
    saturation_inductance: 200u
    magnetic_core:
      enabled: true
      model: saturation
      loss_policy: loss_summary
      core_loss_k: 0.2
      core_loss_alpha: 2.0
      core_loss_freq_coeff: 1e-4
"""
    )
    assert parser.errors == [], parser.errors
    opts.newton_options.num_nodes = int(circuit.num_nodes())
    opts.newton_options.num_branches = int(circuit.num_branches())

    result = ps.Simulator(circuit, opts).run_transient(circuit.initial_state())
    assert result.success, result.message

    thermal_channel = "T(Lsat.core)"
    assert thermal_channel in result.virtual_channels
    assert thermal_channel in result.virtual_channel_metadata
    trace = [float(v) for v in result.virtual_channels[thermal_channel]]
    assert len(trace) == len(result.time)
    assert trace[-1] > 25.0

    metadata = result.virtual_channel_metadata[thermal_channel]
    assert metadata.domain == "thermal"
    assert metadata.unit == "degC"
    assert metadata.source_component == "Lsat.core"

    thermal_rows = {
        row.device_name: row for row in list(result.thermal_summary.device_temperatures)
    }
    assert "Lsat.core" in thermal_rows
    core_row = thermal_rows["Lsat.core"]
    assert core_row.final_temperature > result.thermal_summary.ambient
    assert abs(core_row.final_temperature - trace[-1]) <= 1e-12
    assert abs(core_row.peak_temperature - max(trace)) <= 1e-12
    assert abs(core_row.average_temperature - statistics.fmean(trace)) <= 1e-12

    component_rows = {row.component_name: row for row in result.component_electrothermal}
    assert "Lsat.core" in component_rows
    assert component_rows["Lsat.core"].thermal_enabled
    assert abs(component_rows["Lsat.core"].final_temperature - trace[-1]) <= 1e-12


def test_coupled_inductor_adds_mutual_terms_to_jacobian() -> None:
    circuit = ps.Circuit()
    n_p = circuit.add_node("p")
    n_s = circuit.add_node("s")
    gnd = circuit.ground()
    circuit.add_inductor("K1__L1", n_p, gnd, 1e-3, 0.0)
    circuit.add_inductor("K1__L2", n_s, gnd, 4e-3, 0.0)
    circuit.add_virtual_component(
        "coupled_inductor",
        "K1",
        [n_p, gnd, n_s, gnd],
        {"l1": 1e-3, "l2": 4e-3, "coupling": 0.9},
        {"target_component_1": "K1__L1", "target_component_2": "K1__L2"},
    )

    br1 = circuit.num_nodes()
    br2 = circuit.num_nodes() + 1
    x = [0.0] * circuit.system_size()
    x[n_p] = 2.0
    x[n_s] = -1.0
    x[br1] = 1.5
    x[br2] = -0.5

    j, f = circuit.assemble_jacobian(x)
    assert abs(j[br1][br2]) > 1e-6
    assert abs(j[br2][br1]) > 1e-6
    assert abs(f[br1]) > 1e-9
    assert abs(f[br2]) > 1e-9


def test_yaml_parser_rejects_invalid_control_block_parameters() -> None:
    invalid_op_amp = """
schema: pulsim-v1
version: 1
components:
  - type: op_amp
    name: A1
    nodes: [vp, vn, out]
    rail_low: 5
    rail_high: 2
"""
    parser = ps.YamlParser()
    parser.load_string(invalid_op_amp)
    assert any("rail_low must be < rail_high" in msg for msg in parser.errors)

    invalid_pwm = """
schema: pulsim-v1
version: 1
components:
  - type: pwm_generator
    name: PWM1
    nodes: [ctrl]
    frequency: 10k
    duty_min: 0.9
    duty_max: 0.2
"""
    parser = ps.YamlParser()
    parser.load_string(invalid_pwm)
    assert any("duty_min/duty_max must satisfy" in msg for msg in parser.errors)

    invalid_math = """
schema: pulsim-v1
version: 1
components:
  - type: math_block
    name: M1
    nodes: [a, b]
    operation: xor
"""
    parser = ps.YamlParser()
    parser.load_string(invalid_math)
    assert any("operation must be add/sub/mul/div" in msg for msg in parser.errors)


def test_yaml_parser_rejects_invalid_signal_processing_block_parameters() -> None:
    invalid_limiter = """
schema: pulsim-v1
version: 1
components:
  - type: limiter
    name: LIM1
    nodes: [a, b]
    min: 5
    max: 1
"""
    parser = ps.YamlParser()
    parser.load_string(invalid_limiter)
    assert any("min must be <= max" in msg for msg in parser.errors)

    invalid_differentiator = """
schema: pulsim-v1
version: 1
components:
  - type: differentiator
    name: D1
    nodes: [a, b]
    alpha: 1.5
"""
    parser = ps.YamlParser()
    parser.load_string(invalid_differentiator)
    assert any("alpha must be in [0, 1]" in msg for msg in parser.errors)

    invalid_rate_limiter = """
schema: pulsim-v1
version: 1
components:
  - type: rate_limiter
    name: RL1
    nodes: [a, b]
    rising_rate: -10
"""
    parser = ps.YamlParser()
    parser.load_string(invalid_rate_limiter)
    assert any("rising_rate must be >= 0" in msg for msg in parser.errors)


def test_yaml_parser_rejects_invalid_advanced_control_block_parameters() -> None:
    invalid_lookup = """
schema: pulsim-v1
version: 1
components:
  - type: lookup_table
    name: LUT1
    nodes: [in, ref]
    x: [0, 1]
    y: [0]
"""
    parser = ps.YamlParser()
    parser.load_string(invalid_lookup)
    assert any("lookup_table requires x and y arrays" in msg for msg in parser.errors)

    invalid_transfer = """
schema: pulsim-v1
version: 1
components:
  - type: transfer_function
    name: TF1
    nodes: [in, ref]
    num: [1, 1]
    den: [0, 1]
"""
    parser = ps.YamlParser()
    parser.load_string(invalid_transfer)
    assert any("den[0] must be non-zero" in msg for msg in parser.errors)

    invalid_state_machine = """
schema: pulsim-v1
version: 1
components:
  - type: state_machine
    name: SM1
    nodes: [in]
    mode: random
"""
    parser = ps.YamlParser()
    parser.load_string(invalid_state_machine)
    assert any("state_machine mode must be" in msg for msg in parser.errors)


def test_fallback_trace_records_retry_reasons() -> None:
    circuit = ps.Circuit()
    n_in = circuit.add_node("in")
    n_out = circuit.add_node("out")
    gnd = circuit.ground()
    circuit.add_voltage_source("V1", n_in, gnd, 5.0)
    circuit.add_resistor("R1", n_in, n_out, 1_000.0)
    circuit.add_capacitor("C1", n_out, gnd, 1e-6, 0.0)

    opts = ps.SimulationOptions()
    opts.tstart = 0.0
    opts.tstop = 1e-4
    opts.dt = 1e-6
    opts.dt_min = opts.dt
    opts.dt_max = opts.dt
    opts.adaptive_timestep = False
    opts.enable_bdf_order_control = False
    opts.max_step_retries = 3
    opts.linear_solver.order = [ps.LinearSolverKind.CG]
    opts.linear_solver.allow_fallback = False
    opts.linear_solver.auto_select = False
    opts.fallback_policy.trace_retries = True
    opts.fallback_policy.convergence_profile = ps.ConvergenceProfile.Balanced
    opts.fallback_policy.policy_dry_run = True
    opts.fallback_policy.anti_overfit_check = True
    opts.fallback_policy.anti_overfit_stable_budget = 0
    opts.fallback_policy.enable_transient_gmin = True
    opts.fallback_policy.gmin_retry_threshold = 1
    opts.fallback_policy.gmin_initial = 1e-8
    opts.fallback_policy.gmin_max = 1e-4

    sim = ps.Simulator(circuit, opts)
    x0 = [0.0] * (circuit.num_nodes() + circuit.num_branches())
    result = sim.run_transient(x0)
    assert not result.success
    assert len(result.fallback_trace) > 0
    reasons = {entry.reason for entry in result.fallback_trace}
    assert ps.FallbackReasonCode.NewtonFailure in reasons
    assert (
        ps.FallbackReasonCode.TransientGminEscalation in reasons
        or ps.FallbackReasonCode.StiffnessBackoff in reasons
    )
    assert ps.FallbackReasonCode.MaxRetriesExceeded in reasons
    assert result.backend_telemetry.classified_fallback_events == len(result.fallback_trace)
    assert result.backend_telemetry.policy_dry_run_events == len(result.fallback_trace)
    assert (
        result.backend_telemetry.policy_recommendation_matches
        + result.backend_telemetry.policy_recommendation_mismatches
        == len(result.fallback_trace)
    )
    assert result.backend_telemetry.anti_overfit_budget_exceeded == (
        result.backend_telemetry.anti_overfit_violations
        > opts.fallback_policy.anti_overfit_stable_budget
    )
    assert result.backend_telemetry.last_failure_class in (
        ps.ConvergenceFailureClass.RetryBudgetExhausted,
        ps.ConvergenceFailureClass.LinearBreakdown,
        ps.ConvergenceFailureClass.NewtonGlobalizationFailure,
        ps.ConvergenceFailureClass.EventBurstZeroCross,
        ps.ConvergenceFailureClass.Unknown,
    )
    assert result.backend_telemetry.last_policy_action in (
        ps.ConvergencePolicyAction.AbortStep,
        ps.ConvergencePolicyAction.DtBackoff,
        ps.ConvergencePolicyAction.Regularization,
        ps.ConvergencePolicyAction.StiffnessBackoff,
        ps.ConvergencePolicyAction.TransientGminEscalation,
        ps.ConvergencePolicyAction.ObserveOnly,
    )
    assert any(
        entry.recovery_stage != ps.RecoveryStage.None_
        for entry in result.fallback_trace
    )
    assert any(
        entry.recommended_policy_action != ps.ConvergencePolicyAction.None_
        for entry in result.fallback_trace
    )
    assert all(
        isinstance(entry.anti_overfit_violation, bool)
        for entry in result.fallback_trace
    )


def test_control_algebraic_loop_emits_typed_diagnostic() -> None:
    circuit = ps.Circuit()
    n_in = circuit.add_node("in")
    n_mid = circuit.add_node("mid")
    gnd = circuit.ground()

    circuit.add_voltage_source("Vin", n_in, gnd, 1.0)
    circuit.add_resistor("Rin", n_in, gnd, 1_000.0)
    circuit.add_virtual_component("gain", "GA", [n_in, n_mid], {"gain": 1.0}, {})
    circuit.add_virtual_component("gain", "GB", [n_mid, n_in], {"gain": 1.0}, {})

    opts = ps.SimulationOptions()
    opts.tstart = 0.0
    opts.tstop = 2e-6
    opts.dt = 1e-6
    opts.dt_min = 1e-9
    opts.dt_max = 1e-6
    opts.step_mode = ps.StepMode.Fixed
    opts.adaptive_timestep = False
    opts.fallback_policy.trace_retries = True

    sim = ps.Simulator(circuit, opts)
    result = sim.run_transient(circuit.initial_state())

    assert not result.success
    assert result.diagnostic == ps.SimulationDiagnosticCode.ControlAlgebraicLoopRisk
    assert result.final_status == ps.SolverStatus.NumericalError
    assert result.backend_telemetry.failure_reason == "control_algebraic_loop_risk"
    assert result.fallback_trace
    assert result.fallback_trace[-1].failure_class == (
        ps.ConvergenceFailureClass.ControlAlgebraicLoopRisk
    )
    assert result.fallback_trace[-1].policy_action == ps.ConvergencePolicyAction.AbortStep
    assert "Virtual control algebraic loop" in result.message


def test_recovery_ladder_stages_are_deterministic() -> None:
    circuit = ps.Circuit()
    n_in = circuit.add_node("in")
    n_out = circuit.add_node("out")
    gnd = circuit.ground()
    circuit.add_voltage_source("V1", n_in, gnd, 5.0)
    circuit.add_resistor("R1", n_in, n_out, 1_000.0)
    circuit.add_capacitor("C1", n_out, gnd, 1e-6, 0.0)

    opts = ps.SimulationOptions()
    opts.tstart = 0.0
    opts.tstop = 1e-4
    opts.dt = 1e-6
    opts.dt_min = opts.dt
    opts.dt_max = opts.dt
    opts.adaptive_timestep = False
    opts.enable_bdf_order_control = False
    opts.max_step_retries = 5
    opts.linear_solver.order = [ps.LinearSolverKind.CG]
    opts.linear_solver.allow_fallback = False
    opts.linear_solver.auto_select = False
    opts.fallback_policy.trace_retries = True
    opts.fallback_policy.enable_transient_gmin = True
    opts.fallback_policy.gmin_retry_threshold = 1
    opts.fallback_policy.gmin_initial = 1e-8
    opts.fallback_policy.gmin_max = 1e-4

    x0 = [0.0] * (circuit.num_nodes() + circuit.num_branches())
    result_a = ps.Simulator(circuit, opts).run_transient(x0)
    result_b = ps.Simulator(circuit, opts).run_transient(x0)

    assert not result_a.success
    assert not result_b.success

    stage_actions_a = [entry.action for entry in result_a.fallback_trace if entry.action.startswith("recovery_stage_")]
    stage_actions_b = [entry.action for entry in result_b.fallback_trace if entry.action.startswith("recovery_stage_")]
    assert stage_actions_a == stage_actions_b

    expected_prefixes = [
        "recovery_stage_dt_backoff",
        "recovery_stage_globalization",
        "recovery_stage_stiff_profile",
        "recovery_stage_regularization",
    ]

    first_index: dict[str, int] = {}
    for prefix in expected_prefixes:
        matches = [idx for idx, action in enumerate(stage_actions_a) if action.startswith(prefix)]
        assert matches, f"missing stage action prefix: {prefix}"
        first_index[prefix] = matches[0]

    assert first_index["recovery_stage_dt_backoff"] < first_index["recovery_stage_globalization"]
    assert first_index["recovery_stage_globalization"] < first_index["recovery_stage_stiff_profile"]
    assert first_index["recovery_stage_stiff_profile"] < first_index["recovery_stage_regularization"]


def test_linear_factor_cache_telemetry_is_exposed() -> None:
    circuit = ps.Circuit()
    n_in = circuit.add_node("in")
    n_out = circuit.add_node("out")
    gnd = circuit.ground()
    circuit.add_voltage_source("V1", n_in, gnd, 5.0)
    circuit.add_resistor("R1", n_in, n_out, 1_000.0)
    circuit.add_capacitor("C1", n_out, gnd, 1e-6, 0.0)

    opts = ps.SimulationOptions()
    opts.tstart = 0.0
    opts.tstop = 5e-6
    opts.dt = 1e-6
    opts.dt_min = 1e-12
    opts.dt_max = 1e-6
    opts.adaptive_timestep = False
    opts.enable_bdf_order_control = False

    x0 = [0.0] * (circuit.num_nodes() + circuit.num_branches())
    result = ps.Simulator(circuit, opts).run_transient(x0)
    assert result.success
    assert result.backend_telemetry.state_space_primary_steps >= 1
    assert result.backend_telemetry.linear_factor_cache_misses >= 1
    assert result.backend_telemetry.linear_factor_cache_hits >= 1
    assert (
        result.backend_telemetry.linear_factor_cache_hits
        + result.backend_telemetry.linear_factor_cache_misses
    ) >= result.backend_telemetry.state_space_primary_steps


def test_direct_formulation_runtime_telemetry_is_exposed() -> None:
    circuit = ps.Circuit()
    n_in = circuit.add_node("in")
    n_out = circuit.add_node("out")
    gnd = circuit.ground()
    circuit.add_voltage_source("V1", n_in, gnd, 5.0)
    circuit.add_resistor("R1", n_in, n_out, 1_000.0)
    circuit.add_capacitor("C1", n_out, gnd, 1e-6, 0.0)

    opts = ps.SimulationOptions()
    opts.tstart = 0.0
    opts.tstop = 5e-6
    opts.dt = 1e-6
    opts.dt_min = 1e-9
    opts.dt_max = 1e-6
    opts.adaptive_timestep = False
    opts.enable_bdf_order_control = False
    opts.formulation_mode = ps.FormulationMode.Direct
    opts.direct_formulation_fallback = False

    x0 = [0.0] * (circuit.num_nodes() + circuit.num_branches())
    result = ps.Simulator(circuit, opts).run_transient(x0)
    assert result.success
    assert result.backend_telemetry.formulation_mode == "direct"
    assert result.backend_telemetry.state_space_primary_steps == 0
    assert result.backend_telemetry.dae_fallback_steps >= 1
    assert result.backend_telemetry.segment_model_cache_hits == 0
    assert result.backend_telemetry.segment_model_cache_misses == 0


def test_invalid_time_window_sets_typed_diagnostic() -> None:
    circuit = ps.Circuit()
    n_in = circuit.add_node("in")
    n_out = circuit.add_node("out")
    gnd = circuit.ground()
    circuit.add_voltage_source("V1", n_in, gnd, 5.0)
    circuit.add_resistor("R1", n_in, n_out, 1_000.0)
    circuit.add_capacitor("C1", n_out, gnd, 1e-6, 0.0)

    opts = ps.SimulationOptions()
    opts.tstart = 0.0
    opts.tstop = -1.0
    opts.dt = 1e-6

    result = ps.Simulator(circuit, opts).run_transient()
    assert not result.success
    assert result.backend_telemetry.failure_reason == "invalid_time_window"


def test_variable_step_accept_reject_pattern_is_deterministic() -> None:
    root = Path(__file__).resolve().parents[2]
    case_path = root / "benchmarks" / "circuits" / "diode_rectifier.yaml"
    parser_opts = ps.YamlParserOptions()
    parser_opts.strict = False

    def run_once() -> ps.SimulationResult:
        parser = ps.YamlParser(parser_opts)
        circuit, opts = parser.load(str(case_path))
        assert parser.errors == []

        opts.adaptive_timestep = True
        opts.enable_bdf_order_control = False
        opts.fallback_policy.trace_retries = True
        opts.max_step_retries = max(opts.max_step_retries, 6)
        opts.dt_min = min(opts.dt_min, max(opts.dt * 0.02, 1e-9))
        opts.dt_max = max(opts.dt_max, opts.dt * 8.0)

        return ps.Simulator(circuit, opts).run_transient()

    result_a = run_once()
    result_b = run_once()

    assert result_a.success == result_b.success
    assert result_a.final_status == result_b.final_status
    assert result_a.total_steps == result_b.total_steps
    assert result_a.timestep_rejections == result_b.timestep_rejections
    assert result_a.timestep_rejections > 0

    trace_a = [(entry.reason, entry.action) for entry in result_a.fallback_trace]
    trace_b = [(entry.reason, entry.action) for entry in result_b.fallback_trace]
    assert trace_a == trace_b

    assert len(result_a.time) == len(result_b.time)
    for ta, tb in zip(result_a.time, result_b.time):
        assert ta == pytest.approx(tb, abs=1e-15)
