971 lines
33 KiB
Python
971 lines
33 KiB
Python
"""
|
||
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 (0–1)
|
||
|
||
# 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")
|