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