adding tordoidal simulator and drawing

This commit is contained in:
2026-02-13 11:32:37 -06:00
parent 5f3beeda8d
commit 1d8e60c5df
11 changed files with 2511 additions and 5 deletions

970
sim_toroid.py Normal file
View File

@@ -0,0 +1,970 @@
"""
sim_toroid.py
=============
Simulate a toroidal transformer designed with designer.py.
Given a pair of WindingResult objects (primary / secondary) produced by
designer.py, this module lets you evaluate any operating point and check
it against a set of constraints.
Typical usage
-------------
from designer import ToroidCore, WindingSpec, design_transformer
from sim_toroid import ToroidSimulator, SimConstraints
core = ToroidCore(
ID_mm=21.5, OD_mm=46.5, height_mm=22.8,
Ae_mm2=297.0, # effective cross-section (mm²)
Ve_mm3=18500.0, # effective volume (mm³)
Pv_func=my_pv_func, # Pv_func(f_hz, B_T) -> kW/m³ (or None)
)
primary = WindingSpec(awg=22, taps=[0, 25, 50], name="primary")
secondary = WindingSpec(awg=22, taps=[0, 100, 50], name="secondary")
pri_result, sec_result = design_transformer(core, [primary, secondary])
sim = ToroidSimulator(core=core, primary=pri_result, secondary=sec_result)
constraints = SimConstraints(
B_max_T=0.3,
Vp_max=50.0,
Vs_max=30.0,
Ip_max=5.0,
Is_max=3.0,
P_out_max_W=50.0,
)
result = sim.simulate(
Vp_rms=24.0,
freq_hz=50_000.0,
primary_tap=2,
secondary_tap=1,
Z_load=(10.0, 0.0), # 10 Ω purely resistive
constraints=constraints,
)
print(result)
Physics model
-------------
The model uses the standard ideal-transformer approximation with
first-order winding resistance correction:
* Flux density: B_peak = Vp / (4.44 · Np · Ae · f)
* Ideal secondary voltage: Vs_ideal = Vp · (Ns / Np)
* Secondary circuit: Vs_oc drives (Rs + Z_load) series circuit
Is = Vs_ideal / (Z_load + Rs)
Vs = Is · Z_load
* Primary current reflected from secondary plus magnetising (ignored):
Ip = Is · (Ns / Np) [for turns-ratio correction]
Then added primary drop:
Vp_actual ≈ Vp (Vp is the applied voltage, copper drop on primary
reduces flux slightly; accounted by reducing Vp seen
by the core by Ip·Rp)
* Core loss computed via core.Pv_func(f, B) [kW/m³] · core.Ve_mm3 [mm³]
"""
from __future__ import annotations
import cmath
import math
from dataclasses import dataclass, field
from typing import Callable, ClassVar, Optional, Tuple
from designer import ToroidCore, WindingResult
# ---------------------------------------------------------------------------
# Result dataclass
# ---------------------------------------------------------------------------
@dataclass
class SimResult:
"""Operating-point results from ToroidSimulator.simulate()."""
# Inputs (echoed)
Vp_rms: float
freq_hz: float
primary_tap: int
secondary_tap: int
# Turns
Np_eff: int
Ns_eff: int
turns_ratio: float # Ns / Np
# Magnetics
B_peak_T: float # peak flux density in core
# Voltages (RMS magnitudes)
Vs_rms: float # voltage across load
Vp_rms_applied: float # = Vp_rms (the applied primary voltage)
# Currents (RMS magnitudes)
Ip_rms: float # primary current
Is_rms: float # secondary current
# Phase angle of load current relative to secondary voltage (radians)
load_phase_rad: float
# Winding resistances (ohms)
Rp: float # primary winding resistance for active turns
Rs: float # secondary winding resistance for active turns
# Power breakdown (watts)
P_out_W: float # real power delivered to load
P_cu_primary_W: float # primary copper loss
P_cu_secondary_W: float # secondary copper loss
P_cu_W: float # total copper loss
P_core_W: float # core loss
P_in_W: float # total input power
efficiency: float # P_out / P_in (01)
# Constraint violations (empty list = all OK)
violations: list[str] = field(default_factory=list)
@property
def feasible(self) -> bool:
return len(self.violations) == 0
def __str__(self) -> str:
lines = [
f"=== Simulation result (tap P{self.primary_tap}/S{self.secondary_tap}, "
f"{self.freq_hz/1e3:.1f} kHz, Vp={self.Vp_rms:.2f} V) ===",
f" Turns : Np={self.Np_eff} Ns={self.Ns_eff} ratio={self.turns_ratio:.4f}",
f" Flux density : B_peak = {self.B_peak_T*1e3:.2f} mT",
f" Winding R : Rp = {self.Rp*1e3:.3f} mOhm Rs = {self.Rs*1e3:.3f} mOhm",
f" Voltages (RMS) : Vp = {self.Vp_rms_applied:.4f} V Vs = {self.Vs_rms:.4f} V",
f" Currents (RMS) : Ip = {self.Ip_rms:.4f} A Is = {self.Is_rms:.4f} A",
f" Load phase : {math.degrees(self.load_phase_rad):.1f} deg",
f" Power : P_out={self.P_out_W:.4f} W P_cu={self.P_cu_W:.4f} W "
f"P_core={self.P_core_W:.4f} W P_in={self.P_in_W:.4f} W",
f" Efficiency : {self.efficiency*100:.2f}%",
]
if self.violations:
lines.append(f" *** CONSTRAINT VIOLATIONS: {', '.join(self.violations)} ***")
else:
lines.append(" Constraints : all satisfied")
return "\n".join(lines)
# ---------------------------------------------------------------------------
# Constraints dataclass
# ---------------------------------------------------------------------------
@dataclass
class SimConstraints:
"""
Operating limits. Set any limit to float('inf') / None to disable it.
Parameters
----------
B_max_T : float
Maximum peak flux density (Tesla). Default 0.3 T.
Vp_max : float
Maximum primary RMS voltage.
Vs_max : float
Maximum secondary RMS voltage across load.
Ip_max : float
Maximum primary RMS current.
Is_max : float
Maximum secondary RMS current.
P_out_max_W : float
Maximum output power (W).
"""
B_max_T: float = 0.3
Vp_max: float = float('inf')
Vs_max: float = float('inf')
Ip_max: float = float('inf')
Is_max: float = float('inf')
P_out_max_W: float = float('inf')
# ---------------------------------------------------------------------------
# Simulator
# ---------------------------------------------------------------------------
class ToroidSimulator:
"""
Simulate an operating point of a toroidal transformer designed with
designer.py.
Parameters
----------
core : ToroidCore
Core geometry and magnetic material parameters. Magnetic fields
(Ae_mm2, Ve_mm3, Pv_func) are read directly from the core object;
if omitted they default to the geometric values.
primary : WindingResult
Output of analyse_winding / design_transformer for the primary.
secondary : WindingResult
Output of analyse_winding / design_transformer for the secondary.
"""
def __init__(
self,
core: ToroidCore,
primary: WindingResult,
secondary: WindingResult,
) -> None:
self.core = core
self.primary = primary
self.secondary = secondary
# ------------------------------------------------------------------
# Internal helpers
# ------------------------------------------------------------------
def _effective_turns(self, winding: WindingResult, tap: int) -> int:
"""Cumulative turns up to and including tap (1-indexed)."""
n_taps = len(winding.spec.taps) - 1 # number of valid tap numbers
if tap < 1 or tap > n_taps:
raise ValueError(
f"Tap {tap} out of range 1..{n_taps} for winding '{winding.spec.name}'"
)
return winding.spec.cumulative_turns()[tap - 1]
def _winding_resistance(self, winding: WindingResult, tap: int) -> float:
"""Total DC resistance for turns up to (and including) tap (ohms)."""
n_taps = len(winding.spec.taps) - 1
if tap < 1 or tap > n_taps:
raise ValueError(
f"Tap {tap} out of range 1..{n_taps} for winding '{winding.spec.name}'"
)
# Sum resistance across segments 0..tap-1
return sum(winding.segments[i].resistance_ohm for i in range(tap))
def _effective_Ae_mm2(self) -> float:
"""Ae_mm2: use core field if set, else geometric cross-section."""
if self.core.Ae_mm2 is not None:
return self.core.Ae_mm2
return self.core.cross_section_area_mm2
def _effective_Ve_mm3(self) -> float:
"""Ve_mm3: use core field if set, else Ae_mm2 * mean_path_length_mm."""
if self.core.Ve_mm3 is not None:
return self.core.Ve_mm3
return self._effective_Ae_mm2() * self.core.mean_path_length_mm
def _core_loss_W(self, B_peak_T: float, freq_hz: float) -> float:
"""Compute core loss in watts. Pv_func returns kW/m³; Ve in mm³."""
Ve_mm3 = self._effective_Ve_mm3()
if Ve_mm3 <= 0:
return 0.0
Ve_m3 = Ve_mm3 * 1e-9 # mm³ → m³
if self.core.Pv_func is not None:
# User-supplied function returns kW/m³
try:
Pv_kW_m3 = float(self.core.Pv_func(freq_hz, B_peak_T))
except Exception:
return 0.0
return Pv_kW_m3 * 1e3 * Ve_m3 # kW/m³ → W/m³, then × m³ = W
# Built-in fallback: core_loss_Pv also returns kW/m³
try:
from core import core_loss_Pv
Pv_kW_m3 = float(core_loss_Pv(B_peak_T, freq_hz))
return Pv_kW_m3 * 1e3 * Ve_m3
except Exception:
return 0.0
# ------------------------------------------------------------------
# Main simulate method
# ------------------------------------------------------------------
def simulate(
self,
Vp_rms: float,
freq_hz: float,
primary_tap: int,
secondary_tap: int,
Z_load: Tuple[float, float] = (0.0, 0.0),
constraints: Optional[SimConstraints] = None,
) -> SimResult:
"""
Compute one operating point.
Parameters
----------
Vp_rms : float
Applied primary RMS voltage (V).
freq_hz : float
Excitation frequency (Hz).
primary_tap : int
Primary tap number (1-indexed).
secondary_tap : int
Secondary tap number (1-indexed).
Z_load : (R, X) tuple
Complex load impedance in ohms. R is the resistive part,
X is the reactive part (positive = inductive, negative = capacitive).
For a purely resistive load use (R, 0.0).
For a complex load the simulator computes real output power as
P_out = |Is|² · R_load.
constraints : SimConstraints, optional
Operating limits. If None, no constraint checking is performed.
Returns
-------
SimResult
"""
if Vp_rms <= 0:
raise ValueError("Vp_rms must be > 0")
if freq_hz <= 0:
raise ValueError("freq_hz must be > 0")
R_load, X_load = float(Z_load[0]), float(Z_load[1])
Z_load_complex = complex(R_load, X_load)
if abs(Z_load_complex) == 0:
raise ValueError("Z_load magnitude must be > 0 (short circuit not supported)")
# --- Turns and resistance ------------------------------------------
Np = self._effective_turns(self.primary, primary_tap)
Ns = self._effective_turns(self.secondary, secondary_tap)
Rp = self._winding_resistance(self.primary, primary_tap)
Rs = self._winding_resistance(self.secondary, secondary_tap)
if Np == 0 or Ns == 0:
raise ValueError("Effective turns cannot be zero")
turns_ratio = Ns / Np # a = Ns/Np
# --- Flux density ---------------------------------------------------
# The primary voltage applied to the winding minus the resistive drop
# sets the flux. We iterate once: compute ideal Ip, correct Vp seen by
# core, then recompute.
#
# Iteration:
# Pass 0 (ideal): B from full Vp
# Pass 1: B from (Vp - Ip_0 * Rp) where Ip_0 is ideal primary current
#
Ae_m2 = self._effective_Ae_mm2() * 1e-6
def _compute_op(Vp_core: float):
"""Compute operating point given effective core voltage."""
B = Vp_core / (4.44 * Np * Ae_m2 * freq_hz)
# Ideal open-circuit secondary voltage
Vs_oc = complex(Vp_core * turns_ratio, 0.0)
# Secondary loop: Vs_oc = Is * (Rs + Z_load)
Z_sec_total = complex(Rs, 0.0) + Z_load_complex
Is_phasor = Vs_oc / Z_sec_total
# Voltage across load
Vs_phasor = Is_phasor * Z_load_complex
# Reflect secondary current to primary (ideal transformer)
Ip_reflected = Is_phasor / turns_ratio # phasor, Amperes
return B, Is_phasor, Vs_phasor, Ip_reflected
# Pass 0: ideal (ignore primary drop for now)
_, Is0, _, Ip0 = _compute_op(Vp_rms)
Ip0_mag = abs(Ip0)
# Pass 1: correct for primary winding voltage drop
# Primary drop is Ip * Rp (in phase with current; Rp is real)
# Vp_core ≈ Vp_rms - Ip0 * Rp (phasor subtraction)
# For simplicity treat Ip0 as real-valued magnitude for the correction
# (conservative: subtracts in phase with voltage)
Vp_core = max(Vp_rms - Ip0_mag * Rp, 0.0)
B_peak_T, Is_phasor, Vs_phasor, Ip_phasor = _compute_op(Vp_core)
Is_rms = abs(Is_phasor)
Vs_rms_out = abs(Vs_phasor)
Ip_rms = abs(Ip_phasor)
# Load phase angle (angle of Is relative to Vs across load)
load_phase_rad = cmath.phase(Is_phasor) - cmath.phase(Vs_phasor)
# --- Power -----------------------------------------------------------
# Real power out = |Is|² · R_load
P_out = Is_rms ** 2 * R_load
P_cu_p = Ip_rms ** 2 * Rp
P_cu_s = Is_rms ** 2 * Rs
P_cu = P_cu_p + P_cu_s
P_core = self._core_loss_W(B_peak_T, freq_hz)
P_in = P_out + P_cu + P_core
efficiency = P_out / P_in if P_in > 0 else 1.0
# --- Constraint checking --------------------------------------------
violations: list[str] = []
if constraints is not None:
if B_peak_T > constraints.B_max_T:
violations.append(
f"B_peak={B_peak_T*1e3:.1f}mT > B_max={constraints.B_max_T*1e3:.1f}mT"
)
if Vp_rms > constraints.Vp_max:
violations.append(
f"Vp={Vp_rms:.2f}V > Vp_max={constraints.Vp_max:.2f}V"
)
if Vs_rms_out > constraints.Vs_max:
violations.append(
f"Vs={Vs_rms_out:.2f}V > Vs_max={constraints.Vs_max:.2f}V"
)
if Ip_rms > constraints.Ip_max:
violations.append(
f"Ip={Ip_rms:.4f}A > Ip_max={constraints.Ip_max:.4f}A"
)
if Is_rms > constraints.Is_max:
violations.append(
f"Is={Is_rms:.4f}A > Is_max={constraints.Is_max:.4f}A"
)
if P_out > constraints.P_out_max_W:
violations.append(
f"P_out={P_out:.2f}W > P_out_max={constraints.P_out_max_W:.2f}W"
)
return SimResult(
Vp_rms=Vp_rms,
freq_hz=freq_hz,
primary_tap=primary_tap,
secondary_tap=secondary_tap,
Np_eff=Np,
Ns_eff=Ns,
turns_ratio=turns_ratio,
B_peak_T=B_peak_T,
Vs_rms=Vs_rms_out,
Vp_rms_applied=Vp_rms,
Ip_rms=Ip_rms,
Is_rms=Is_rms,
load_phase_rad=load_phase_rad,
Rp=Rp,
Rs=Rs,
P_out_W=P_out,
P_cu_primary_W=P_cu_p,
P_cu_secondary_W=P_cu_s,
P_cu_W=P_cu,
P_core_W=P_core,
P_in_W=P_in,
efficiency=efficiency,
violations=violations,
)
def optimize(
self,
freq_hz: float,
Z_load: Tuple[float, float],
target_power_W: float,
constraints: SimConstraints,
Vp_min: float = 1.0,
Vp_max: float = 50.0,
Vp_steps: int = 100,
power_tol_pct: float = 2.0,
) -> Optional["OptimizeResult"]:
"""
Find the tap combination and input voltage that best delivers
``target_power_W`` to the load while satisfying all constraints.
The search evaluates every (primary_tap, secondary_tap, Vp) triple.
A candidate is *feasible* if it passes all hard limits in
``constraints`` (B_max_T, Vp_max, Vs_max, Ip_max, Is_max).
``constraints.P_out_max_W`` is treated as a hard upper bound too.
Among feasible candidates:
1. If any candidate delivers power within ``power_tol_pct`` % of
``target_power_W``, the one with the highest efficiency is returned.
2. Otherwise (fallback), the candidate with power closest to
``target_power_W`` is returned; ties broken by highest efficiency.
Parameters
----------
freq_hz : float
Operating frequency (Hz).
Z_load : (R, X) tuple
Complex load impedance (ohms).
target_power_W : float
Desired output power (W).
constraints : SimConstraints
Hard operating limits. All fields are enforced.
Vp_min, Vp_max : float
Input voltage search range (V).
Vp_steps : int
Number of voltage steps (linearly spaced, inclusive of endpoints).
power_tol_pct : float
Acceptable power delivery error as % of target. Default 2 %.
Returns
-------
OptimizeResult, or None if no feasible point exists at all.
"""
import numpy as np
if target_power_W <= 0:
raise ValueError("target_power_W must be > 0")
if Vp_min >= Vp_max:
raise ValueError("Vp_min must be < Vp_max")
if Vp_steps < 2:
raise ValueError("Vp_steps must be >= 2")
voltages = np.linspace(Vp_min, Vp_max, Vp_steps)
n_ptaps = len(self.primary.spec.taps) - 1
n_staps = len(self.secondary.spec.taps) - 1
tol_abs = target_power_W * power_tol_pct / 100.0
# Best within tolerance
best_in_tol: Optional[SimResult] = None
best_in_tol_eff = -1.0
# Best fallback (closest power, then best efficiency)
best_fallback: Optional[SimResult] = None
best_fallback_err = float('inf')
best_fallback_eff = -1.0
for p_tap in range(1, n_ptaps + 1):
for s_tap in range(1, n_staps + 1):
for Vp in voltages:
try:
r = self.simulate(
Vp_rms=float(Vp),
freq_hz=freq_hz,
primary_tap=p_tap,
secondary_tap=s_tap,
Z_load=Z_load,
constraints=constraints,
)
except ValueError:
continue
# Skip if any hard constraint violated
if not r.feasible:
continue
err = abs(r.P_out_W - target_power_W)
if err <= tol_abs:
if r.efficiency > best_in_tol_eff:
best_in_tol_eff = r.efficiency
best_in_tol = r
else:
if (err < best_fallback_err or
(err == best_fallback_err and r.efficiency > best_fallback_eff)):
best_fallback_err = err
best_fallback_eff = r.efficiency
best_fallback = r
if best_in_tol is not None:
return OptimizeResult(result=best_in_tol, target_power_W=target_power_W,
power_tol_pct=power_tol_pct, met_target=True)
if best_fallback is not None:
return OptimizeResult(result=best_fallback, target_power_W=target_power_W,
power_tol_pct=power_tol_pct, met_target=False)
return None
# ---------------------------------------------------------------------------
# Optimize result dataclass
# ---------------------------------------------------------------------------
@dataclass
class OptimizeResult:
"""
Result of ToroidSimulator.optimize().
Attributes
----------
result : SimResult
The best operating point found.
target_power_W : float
The requested target output power.
power_tol_pct : float
The tolerance that was used (%).
met_target : bool
True if result.P_out_W is within power_tol_pct % of target_power_W.
False means the optimizer returned the closest feasible point it
could find (fallback mode).
"""
result: SimResult
target_power_W: float
power_tol_pct: float
met_target: bool
@property
def power_error_pct(self) -> float:
return abs(self.result.P_out_W - self.target_power_W) / self.target_power_W * 100.0
def __str__(self) -> str:
status = "TARGET MET" if self.met_target else "FALLBACK (closest feasible)"
lines = [
f"=== Optimize result [{status}] ===",
f" Target power : {self.target_power_W:.3f} W "
f"(tol={self.power_tol_pct:.1f}%, actual error={self.power_error_pct:.2f}%)",
str(self.result),
]
return "\n".join(lines)
# ---------------------------------------------------------------------------
# Multi-point sweep: optimise over a grid of frequencies × loads
# ---------------------------------------------------------------------------
@dataclass
class SweepEntry:
"""
One row of the operating-point sweep dataset.
Fields that come from the optimizer search inputs are prefixed with no
special marker. Fields derived from the best SimResult are included
directly so the object is self-contained and easy to serialise.
"""
# --- Search inputs -------------------------------------------------------
freq_hz: float
Z_load_R: float # resistive part of load (ohms)
Z_load_X: float # reactive part of load (ohms)
target_power_W: float
# --- Optimizer outcome ---------------------------------------------------
met_target: bool # True = within tolerance; False = fallback
power_error_pct: float # |P_out - target| / target * 100
# --- Best operating point (None fields mean no feasible solution) --------
primary_tap: Optional[int]
secondary_tap: Optional[int]
Vp_rms: Optional[float]
Np_eff: Optional[int]
Ns_eff: Optional[int]
turns_ratio: Optional[float]
B_peak_T: Optional[float]
Vs_rms: Optional[float]
Ip_rms: Optional[float]
Is_rms: Optional[float]
load_phase_deg: Optional[float]
Rp_ohm: Optional[float]
Rs_ohm: Optional[float]
P_out_W: Optional[float]
P_cu_W: Optional[float]
P_cu_primary_W: Optional[float]
P_cu_secondary_W: Optional[float]
P_core_W: Optional[float]
P_in_W: Optional[float]
efficiency: Optional[float]
# CSV column order (class-level constant)
_CSV_FIELDS: ClassVar[list[str]] = [
"freq_hz", "Z_load_R", "Z_load_X", "target_power_W",
"met_target", "power_error_pct",
"primary_tap", "secondary_tap", "Vp_rms",
"Np_eff", "Ns_eff", "turns_ratio",
"B_peak_T", "Vs_rms", "Ip_rms", "Is_rms", "load_phase_deg",
"Rp_ohm", "Rs_ohm",
"P_out_W", "P_cu_W", "P_cu_primary_W", "P_cu_secondary_W",
"P_core_W", "P_in_W", "efficiency",
]
def as_dict(self) -> dict:
return {f: getattr(self, f) for f in self._CSV_FIELDS}
@staticmethod
def _no_solution(
freq_hz: float,
Z_load: Tuple[float, float],
target_power_W: float,
) -> "SweepEntry":
"""Create a row representing an infeasible / no-solution point."""
return SweepEntry(
freq_hz=freq_hz, Z_load_R=Z_load[0], Z_load_X=Z_load[1],
target_power_W=target_power_W,
met_target=False, power_error_pct=float('nan'),
primary_tap=None, secondary_tap=None, Vp_rms=None,
Np_eff=None, Ns_eff=None, turns_ratio=None,
B_peak_T=None, Vs_rms=None, Ip_rms=None, Is_rms=None,
load_phase_deg=None, Rp_ohm=None, Rs_ohm=None,
P_out_W=None, P_cu_W=None, P_cu_primary_W=None,
P_cu_secondary_W=None, P_core_W=None, P_in_W=None,
efficiency=None,
)
@staticmethod
def _from_opt(
freq_hz: float,
Z_load: Tuple[float, float],
target_power_W: float,
opt: "OptimizeResult",
) -> "SweepEntry":
r = opt.result
return SweepEntry(
freq_hz=freq_hz, Z_load_R=Z_load[0], Z_load_X=Z_load[1],
target_power_W=target_power_W,
met_target=opt.met_target,
power_error_pct=opt.power_error_pct,
primary_tap=r.primary_tap,
secondary_tap=r.secondary_tap,
Vp_rms=r.Vp_rms,
Np_eff=r.Np_eff,
Ns_eff=r.Ns_eff,
turns_ratio=r.turns_ratio,
B_peak_T=r.B_peak_T,
Vs_rms=r.Vs_rms,
Ip_rms=r.Ip_rms,
Is_rms=r.Is_rms,
load_phase_deg=math.degrees(r.load_phase_rad),
Rp_ohm=r.Rp,
Rs_ohm=r.Rs,
P_out_W=r.P_out_W,
P_cu_W=r.P_cu_W,
P_cu_primary_W=r.P_cu_primary_W,
P_cu_secondary_W=r.P_cu_secondary_W,
P_core_W=r.P_core_W,
P_in_W=r.P_in_W,
efficiency=r.efficiency,
)
def sweep_operating_points(
sim: ToroidSimulator,
frequencies: list[float],
loads: list[Tuple[float, float]],
target_power_W: float,
constraints: SimConstraints,
Vp_min: float = 1.0,
Vp_max: float = 50.0,
Vp_steps: int = 100,
power_tol_pct: float = 2.0,
) -> list[SweepEntry]:
"""
Optimise over a grid of frequencies × complex loads.
For each (freq, load) combination, calls ``sim.optimize()`` to find the
tap combination and input voltage that best delivers ``target_power_W``
while satisfying all constraints.
Parameters
----------
sim : ToroidSimulator
frequencies : list[float]
Frequencies to sweep (Hz).
loads : list[(R, X)]
Complex load impedances to sweep (ohms).
target_power_W : float
Desired output power for every operating point.
constraints : SimConstraints
Hard limits applied at every point.
Vp_min, Vp_max : float
Input voltage search range (V).
Vp_steps : int
Number of voltage steps per optimize call.
power_tol_pct : float
Acceptable power delivery error (%).
Returns
-------
list[SweepEntry]
One entry per (freq, load) pair, in the order
frequencies × loads (freq varies slowest).
"""
entries: list[SweepEntry] = []
for freq in frequencies:
for Z in loads:
opt = sim.optimize(
freq_hz=freq,
Z_load=Z,
target_power_W=target_power_W,
constraints=constraints,
Vp_min=Vp_min,
Vp_max=Vp_max,
Vp_steps=Vp_steps,
power_tol_pct=power_tol_pct,
)
if opt is None:
entries.append(SweepEntry._no_solution(freq, Z, target_power_W))
else:
entries.append(SweepEntry._from_opt(freq, Z, target_power_W, opt))
return entries
def sweep_to_csv(entries: list[SweepEntry], path: str) -> None:
"""
Write a sweep dataset to a CSV file.
Parameters
----------
entries : list[SweepEntry]
Output of ``sweep_operating_points()``.
path : str
Destination file path (created or overwritten).
"""
import csv
if not entries:
return
fields = SweepEntry._CSV_FIELDS
with open(path, "w", newline="") as fh:
writer = csv.DictWriter(fh, fieldnames=fields)
writer.writeheader()
for e in entries:
writer.writerow(e.as_dict())
# ---------------------------------------------------------------------------
# Convenience: sweep taps at fixed Vp / freq
# ---------------------------------------------------------------------------
def sweep_taps(
sim: ToroidSimulator,
Vp_rms: float,
freq_hz: float,
Z_load: Tuple[float, float],
constraints: Optional[SimConstraints] = None,
) -> list[SimResult]:
"""
Evaluate all primary × secondary tap combinations for a fixed operating
point.
Returns a list of SimResult, sorted by descending efficiency (feasible
results first).
"""
n_ptaps = len(sim.primary.spec.taps) - 1
n_staps = len(sim.secondary.spec.taps) - 1
results = []
for p in range(1, n_ptaps + 1):
for s in range(1, n_staps + 1):
try:
r = sim.simulate(
Vp_rms=Vp_rms,
freq_hz=freq_hz,
primary_tap=p,
secondary_tap=s,
Z_load=Z_load,
constraints=constraints,
)
results.append(r)
except ValueError:
continue
# Sort: feasible first, then by descending efficiency
results.sort(key=lambda r: (not r.feasible, -r.efficiency))
return results
# ---------------------------------------------------------------------------
# Example / smoke-test
# ---------------------------------------------------------------------------
def pcv(f_hz, B_T):
pcv_ref = 25 # kW/m^3
a = 1.3
b = 2.8
ret = pcv_ref * (f_hz / 20000)**a * (B_T / 0.2)**b
return ret
if __name__ == "__main__":
from designer import WindingSpec, design_transformer
Ae_mm2 = 142.5
Le_mm = 106.8
Ve_mm3 = Ae_mm2 * Le_mm
core = ToroidCore(ID_mm=21.5, OD_mm=46.5, height_mm=22.8, Ae_mm2=Ae_mm2, Ve_mm3=Ve_mm3, Pv_func=pcv)
primary = WindingSpec(
awg=[22, 22],
taps=[0, 25, 50],
name="primary",
)
secondary = WindingSpec(
awg=[22, 22, 22, 26],
taps=[0, 100, 50, 50, 50],
name="secondary",
)
pri_result, sec_result = design_transformer(core, [primary, secondary])
# Ae_mm2 / Ve_mm3 / Pv_func not set on core → simulator uses geometric defaults
sim = ToroidSimulator(core=core, primary=pri_result, secondary=sec_result)
constraints = SimConstraints(
B_max_T=1.0,
Vp_max=50.0,
Vs_max=90.0,
Ip_max=5.0,
Is_max=2.0,
P_out_max_W=25.0,
)
print("=== Single operating point (10 ohm resistive load) ===")
result = sim.simulate(
Vp_rms=12.0,
freq_hz=256.0,
primary_tap=2,
secondary_tap=1,
Z_load=(100.0, 0.0),
constraints=constraints,
)
print(result)
print()
print("=== Complex load (8 ohm + 1 mH at 50 kHz -> X ~= 314 ohm) ===")
X = 2 * math.pi * 50_000.0 * 1e-3
result2 = sim.simulate(
Vp_rms=24.0,
freq_hz=50_000.0,
primary_tap=2,
secondary_tap=1,
Z_load=(8.0, X),
constraints=constraints,
)
print(result2)
print()
print("=== Tap sweep ===")
all_results = sweep_taps(sim, 24.0, 50_000.0, (10.0, 0.0), constraints)
for r in all_results:
feasible = "OK" if r.feasible else "VIOL"
print(
f" P{r.primary_tap}/S{r.secondary_tap} "
f"Np={r.Np_eff:3d} Ns={r.Ns_eff:3d} "
f"Vs={r.Vs_rms:.3f}V Is={r.Is_rms:.4f}A "
f"P_out={r.P_out_W:.3f}W eff={r.efficiency*100:.2f}% [{feasible}]"
)
print()
print("=== Optimize: find best taps + Vp to deliver ~10 W at 256 Hz ===")
opt = sim.optimize(
freq_hz=256.0,
Z_load=(10.0, 0.0),
target_power_W=25.0,
constraints=constraints,
Vp_min=1.0,
Vp_max=50.0,
Vp_steps=200,
power_tol_pct=2.0,
)
if opt is None:
print(" No feasible solution found.")
else:
print(opt)
print()
print("=== Operating-point sweep (3 freqs x 3 loads) ===")
freqs = [256.0, 870.0, 3140.0, 8900.0]
loads = [(50.0, 0.0), (100.0, 0.0), (200.0, 0.0), (600.0, 0.0)]
entries = sweep_operating_points(
sim=sim,
frequencies=freqs,
loads=loads,
target_power_W=25.0,
constraints=constraints,
Vp_min=1.0,
Vp_max=50.0,
Vp_steps=100,
power_tol_pct=2.0,
)
# Print a compact table
print(f" {'freq_hz':>8} {'R':>6} {'X':>6} {'met':>5} "
f"{'P_out':>7} {'err%':>5} {'eff%':>6} {'Vp':>6} {'tap':>5}")
for e in entries:
met = "YES" if e.met_target else "NO"
if e.P_out_W is not None:
print(f" {e.freq_hz:>8.1f} {e.Z_load_R:>6.1f} {e.Z_load_X:>6.1f} "
f"{met:>5} {e.P_out_W:>7.3f} {e.power_error_pct:>5.2f} "
f"{e.efficiency*100:>6.2f} {e.Vp_rms:>6.3f} "
f"P{e.primary_tap}/S{e.secondary_tap}")
else:
print(f" {e.freq_hz:>8.1f} {e.Z_load_R:>6.1f} {e.Z_load_X:>6.1f} "
f"{'NO SOL':>5}")
# Write to CSV
sweep_to_csv(entries, "sweep_results.csv")
print()
print(" CSV written to sweep_results.csv")