""" Toroid Winding Designer ======================= Determines the feasibility of winding a toroidal transformer given core geometry, wire gauge, and turn counts. For each winding segment (tap) it reports: - Whether the turns physically fit on the core - Number of complete layers required and turns per layer - Total wire length - DC resistance - Wire weight Wire gauge table ---------------- Diameters in the AWG table already include insulation (heavy-build magnet wire), so no additional insulation allowance is applied. Geometry assumptions (toroid cross-section is a rectangle w × h) ----------------------------------------------------------------- Winding builds simultaneously on ALL four faces of the rectangular cross-section: - Inner bore radius shrinks by d_wire per layer - Outer radius grows by d_wire per layer - Available winding height shrinks by 2*d_wire per layer For layer n (0-indexed) the per-turn path length is: L_turn(n) = π*(OD/2 + n*d + ID/2 - (n+1)*d) + 2*(h - 2*n*d) = π*((OD + ID)/2 - d) + 2*h - 4*n*d Turns that fit in layer n (minimum of inner-bore and height constraints): turns_inner(n) = floor( π*(ID - 2*(n+1)*d) / d ) turns_height(n) = floor( (h - 2*n*d) / d ) turns_per_layer(n) = min(turns_inner(n), turns_height(n)) Winding stops when either constraint hits zero (no more room). The fill_factor parameter (default 0.35) limits the fraction of the available window area that can be used. Window area = π*(ID/2)² for a toroid. Wire cross-section area per turn = π*(d/2)². Max turns from fill factor = floor(fill_factor * window_area / wire_area). """ from __future__ import annotations import math from dataclasses import dataclass, field from typing import Optional # --------------------------------------------------------------------------- # Wire gauge table # AWG: (diameter_mm including insulation, area_mm2 conductor, ohm_per_km) # --------------------------------------------------------------------------- AWG_TABLE: dict[int, tuple[float, float, float]] = { 20: (0.813, 0.518, 33.292), 21: (0.724, 0.410, 41.984), 22: (0.645, 0.326, 52.939), 23: (0.574, 0.258, 66.781), 24: (0.511, 0.205, 84.198), 25: (0.455, 0.162, 106.17), 26: (0.404, 0.129, 133.86), 27: (0.361, 0.102, 168.62), 28: (0.320, 0.081, 212.87), 29: (0.287, 0.064, 268.4), 30: (0.254, 0.051, 338.5), } # Copper density in g/mm³ _COPPER_DENSITY_G_MM3 = 8.96e-3 # --------------------------------------------------------------------------- # Wire cost table # AWG: (spool_length_m, cost_usd_per_spool, part_number) # --------------------------------------------------------------------------- WIRE_COST_TABLE: dict[int, tuple[float, float, str]] = { 20: (972, 230.00, "20SNS10"), 22: (1545, 238.23, "22SNS10"), 24: (2447, 222.53, "24SNS10"), 28: (6178, 224.00, "28SNS10"), } @dataclass class WireSpec: """Properties of a single AWG wire.""" awg: int diameter_mm: float # including insulation area_mm2: float # conductor cross-section ohm_per_km: float # DC resistance spool_length_m: Optional[float] = None # None if not in cost table cost_per_spool: Optional[float] = None # USD part_number: Optional[str] = None @property def radius_mm(self) -> float: return self.diameter_mm / 2.0 @property def cost_per_m(self) -> Optional[float]: """USD per metre, or None if cost data unavailable.""" if self.spool_length_m and self.cost_per_spool: return self.cost_per_spool / self.spool_length_m return None @classmethod def from_awg(cls, awg: int) -> "WireSpec": if awg not in AWG_TABLE: raise ValueError(f"AWG {awg} not in table. Available: {sorted(AWG_TABLE)}") d, a, r = AWG_TABLE[awg] spool_m = cost_usd = pn = None if awg in WIRE_COST_TABLE: spool_m, cost_usd, pn = WIRE_COST_TABLE[awg] return cls(awg=awg, diameter_mm=d, area_mm2=a, ohm_per_km=r, spool_length_m=spool_m, cost_per_spool=cost_usd, part_number=pn) @dataclass class ToroidCore: """ Toroidal core geometry and magnetic material parameters. Parameters ---------- ID_mm : float Inner diameter of the core in mm. OD_mm : float Outer diameter of the core in mm. height_mm : float Height (axial length) of the core in mm. Ae_mm2 : float, optional Effective magnetic cross-section area (mm²). Used for flux-density calculation. Defaults to cross_section_area_mm2 (geometric value). Ve_mm3 : float, optional Effective core volume (mm³). Used for core loss calculation. Defaults to Ae_mm2 * mean_path_length_mm. Set to 0 to disable core loss. Pv_func : callable, optional Core loss density function with signature:: Pv_func(f_hz: float, B_T: float) -> float # kW / m³ If None and Ve_mm3 > 0, the simulator falls back to the built-in ``core.core_loss_Pv`` interpolation (which also returns kW/m³). Pass ``lambda f, B: 0.0`` to disable core loss while keeping a non-zero volume. """ ID_mm: float OD_mm: float height_mm: float Ae_mm2: Optional[float] = None Ve_mm3: Optional[float] = None Pv_func: Optional[object] = None # Callable[[float, float], float] | None def __post_init__(self): if self.ID_mm <= 0 or self.OD_mm <= self.ID_mm or self.height_mm <= 0: raise ValueError("Require 0 < ID < OD and height > 0") if self.Ae_mm2 is not None and self.Ae_mm2 <= 0: raise ValueError("Ae_mm2 must be > 0") if self.Ve_mm3 is not None and self.Ve_mm3 < 0: raise ValueError("Ve_mm3 must be >= 0") @property def window_area_mm2(self) -> float: """Area of the inner bore (the winding window).""" return math.pi * (self.ID_mm / 2.0) ** 2 @property def cross_section_area_mm2(self) -> float: """Rectangular cross-section area of the core material.""" return ((self.OD_mm - self.ID_mm) / 2.0) * self.height_mm @property def mean_path_length_mm(self) -> float: """Mean magnetic path length through the core centre.""" return math.pi * (self.OD_mm + self.ID_mm) / 2.0 @dataclass class WindingSpec: """ Specification for one winding (primary or secondary). Parameters ---------- awg : int or list[int] Wire gauge(s). A single int applies to all segments. A list must have one entry per segment (i.e. len(taps) - 1 entries). Example: awg=26 or awg=[26, 28] for a 2-segment winding. taps : list[int] Incremental turns per segment, same convention as model.py. First element is always 0; each subsequent element is the additional turns added by that tap segment. Example: [0, 128, 23] → tap 1 = 128 turns, tap 2 = 128+23 = 151 turns. name : str Optional label (e.g. "primary", "secondary"). """ awg: "int | list[int]" taps: list[int] name: str = "winding" def __post_init__(self): if len(self.taps) < 2: raise ValueError("taps must have at least 2 elements (first is always 0)") if self.taps[0] != 0: raise ValueError("First element of taps must be 0") if any(t < 0 for t in self.taps): raise ValueError("All tap increments must be >= 0") n_segments = len(self.taps) - 1 if isinstance(self.awg, int): self.awg = [self.awg] * n_segments if len(self.awg) != n_segments: raise ValueError( f"awg list length ({len(self.awg)}) must match number of segments ({n_segments})" ) @property def total_turns(self) -> int: """Total turns for the highest tap.""" return sum(self.taps) def cumulative_turns(self) -> list[int]: """Cumulative turns at each tap point (1-indexed tap numbers).""" result = [] total = 0 for t in self.taps[1:]: total += t result.append(total) return result def segment_turns(self) -> list[int]: """Turns in each physical segment (same as taps[1:]).""" return list(self.taps[1:]) @dataclass class LayerResult: """Results for one winding layer.""" layer_index: int # 0-based turns_capacity: int # max turns that fit in this layer turns_used: int # turns actually placed in this layer L_turn_mm: float # mean turn length for this layer (mm) wire_length_mm: float # total wire length for turns in this layer (mm) @dataclass class SegmentResult: """Results for one tap segment.""" segment_index: int # 0-based awg: int turns: int layers: list[LayerResult] wire_length_m: float resistance_ohm: float weight_g: float fits: bool # True if all turns fit within fill factor and geometry @dataclass class WindingResult: """Full winding analysis result.""" spec: WindingSpec core: ToroidCore fill_factor: float max_turns_fill: int # turns allowed by fill factor max_turns_geometry: int # turns allowed by geometry alone segments: list[SegmentResult] total_wire_length_m: float total_resistance_ohm: float total_weight_g: float feasible: bool # True if all segments fit # Cost fields (None when AWG not in cost table) spools_required: Optional[int] = None cost_usd: Optional[float] = None # Internal winding position at end of this winding (for chaining) _end_layer: int = 0 _end_turns_in_layer: int = 0 _end_total_turns: int = 0 def summary(self) -> str: # Header: show AWG as single value if uniform, else show range awg_list = self.spec.awg # always a list after __post_init__ awg_str = str(awg_list[0]) if len(set(awg_list)) == 1 else str(awg_list) lines = [ f"=== Winding: {self.spec.name} (AWG {awg_str}) ===", f" Core: ID={self.core.ID_mm}mm OD={self.core.OD_mm}mm H={self.core.height_mm}mm", f" Fill factor: {self.fill_factor*100:.0f}% " f"(max turns by fill={self.max_turns_fill}, by geometry={self.max_turns_geometry})", f" Total turns: {self.spec.total_turns} | Feasible: {'YES' if self.feasible else 'NO -- DOES NOT FIT'}", "", ] cumulative = 0 for seg in self.segments: cumulative += seg.turns tap_num = seg.segment_index + 1 status = "OK" if seg.fits else "OVERFLOW" # Show AWG per tap only when mixed awg_tag = f" AWG {seg.awg}" if len(set(awg_list)) > 1 else "" lines.append( f" Tap {tap_num}: {seg.turns:>4} turns " f"(cumulative {cumulative:>4}){awg_tag} " f"{seg.wire_length_m:7.2f} m " f"{seg.resistance_ohm*1000:8.3f} mOhm " f"{seg.weight_g:7.3f} g [{status}]" ) for lr in seg.layers: lines.append( f" layer {lr.layer_index:2d}: capacity={lr.turns_capacity:3d} " f"used={lr.turns_used:3d} " f"MTL={lr.L_turn_mm:.1f}mm " f"wire={lr.wire_length_mm/1000:.3f}m" ) cost_str = "" if self.cost_usd is not None: cost_str = f" | ${self.cost_usd:.2f}" lines += [ "", f" TOTAL: {self.total_wire_length_m:.2f} m | " f"{self.total_resistance_ohm*1000:.3f} mOhm | " f"{self.total_weight_g:.2f} g" + cost_str, ] return "\n".join(lines) def _layer_geometry(core: ToroidCore, wire: WireSpec, layer_index: int ) -> tuple[int, float]: """ Calculate the capacity and mean turn length for a given layer index. Returns ------- (turns_capacity, L_turn_mm) turns_capacity : maximum turns that fit in this layer (0 if no room) L_turn_mm : mean path length of one turn in this layer (mm) """ n = layer_index d = wire.diameter_mm # Wire centres sit at ID/2 - (n+0.5)*d from the bore axis wire_centre_radius = core.ID_mm / 2.0 - (n + 0.5) * d # Available height for this layer (each layer adds one wire diameter # of build-up on top and bottom faces, reducing the straight section) avail_height = core.height_mm - 2 * n * d if wire_centre_radius <= 0 or avail_height <= 0: return 0, 0.0 # Turns per layer = how many wire diameters fit around the inner # circumference at the wire centre radius. The height only constrains # how many *layers* can be wound, not how many turns per layer. inner_circumference = 2 * math.pi * wire_centre_radius turns_capacity = int(inner_circumference / d) # floor if turns_capacity <= 0: return 0, 0.0 # Mean turn length: inner + outer semicircle + two straight sections outer_radius = core.OD_mm / 2.0 + (n + 0.5) * d L_turn_mm = math.pi * (wire_centre_radius + outer_radius) + 2 * avail_height return turns_capacity, L_turn_mm def analyse_winding( core: ToroidCore, spec: WindingSpec, fill_factor: float = 0.35, start_layer: int = 0, start_turns_in_layer: int = 0, start_total_turns: int = 0, ) -> WindingResult: """ Analyse feasibility and compute wire parameters for a winding. Parameters ---------- core : ToroidCore spec : WindingSpec fill_factor : float Maximum fraction of the bore window area that may be filled. Default 0.35 (35%). start_layer : int Layer index to begin winding on. Pass the previous winding's _end_layer to continue mid-layer across windings. start_turns_in_layer : int Turns already consumed in start_layer from a previous winding. start_total_turns : int Cumulative turns already placed (used for fill-factor accounting across windings). Returns ------- WindingResult """ # Resolve per-segment wire specs (spec.awg is always a list after __post_init__) seg_wires = [WireSpec.from_awg(a) for a in spec.awg] # For informational max_turns_fill / max_turns_geometry use the thickest # wire (largest diameter) as a conservative lower bound. wire_ref = max(seg_wires, key=lambda w: w.diameter_mm) wire_area_ref = math.pi * (wire_ref.diameter_mm / 2.0) ** 2 max_turns_fill = int(fill_factor * core.window_area_mm2 / wire_area_ref) max_turns_geometry = 0 layer_idx = 0 while True: cap, _ = _layer_geometry(core, wire_ref, layer_idx) if cap == 0: break max_turns_geometry += cap layer_idx += 1 effective_max = max_turns_fill # fill factor is the binding constraint segments: list[SegmentResult] = [] overall_feasible = True # Track position across segments: which layer we're on and how many # turns have been placed in the current layer already. current_layer = start_layer turns_in_current_layer = start_turns_in_layer total_turns_placed = start_total_turns current_layer_wire_diameter: Optional[float] = None # set on first use for seg_idx, (seg_turns, wire) in enumerate(zip(spec.segment_turns(), seg_wires)): seg_layers: list[LayerResult] = [] seg_wire_length_mm = 0.0 seg_fits = True turns_remaining = seg_turns while turns_remaining > 0: # If the wire gauge changed from what's already on this layer, # start a fresh layer so gauges are never mixed on one layer. if (current_layer_wire_diameter is not None and wire.diameter_mm != current_layer_wire_diameter and turns_in_current_layer > 0): current_layer += 1 turns_in_current_layer = 0 cap, L_turn = _layer_geometry(core, wire, current_layer) current_layer_wire_diameter = wire.diameter_mm if cap == 0: # No more geometry room at all seg_fits = False overall_feasible = False # Record overflow as a single "layer" with 0 capacity seg_layers.append(LayerResult( layer_index=current_layer, turns_capacity=0, turns_used=turns_remaining, L_turn_mm=0.0, wire_length_mm=0.0, )) turns_remaining = 0 break available_in_layer = cap - turns_in_current_layer if available_in_layer <= 0: # Layer is full, advance current_layer += 1 turns_in_current_layer = 0 continue # Check fill-factor cap if total_turns_placed >= effective_max: seg_fits = False overall_feasible = False seg_layers.append(LayerResult( layer_index=current_layer, turns_capacity=available_in_layer, turns_used=turns_remaining, L_turn_mm=L_turn, wire_length_mm=0.0, )) turns_remaining = 0 break turns_to_place = min(turns_remaining, available_in_layer, effective_max - total_turns_placed) wire_len = turns_to_place * L_turn seg_layers.append(LayerResult( layer_index=current_layer, turns_capacity=cap, turns_used=turns_to_place, L_turn_mm=L_turn, wire_length_mm=wire_len, )) seg_wire_length_mm += wire_len turns_remaining -= turns_to_place turns_in_current_layer += turns_to_place total_turns_placed += turns_to_place if turns_in_current_layer >= cap: current_layer += 1 turns_in_current_layer = 0 seg_wire_length_m = seg_wire_length_mm / 1000.0 seg_resistance = (wire.ohm_per_km / 1000.0) * seg_wire_length_m # Weight from conductor area × length × density seg_weight_g = wire.area_mm2 * seg_wire_length_mm * _COPPER_DENSITY_G_MM3 segments.append(SegmentResult( segment_index=seg_idx, awg=wire.awg, turns=seg_turns, layers=seg_layers, wire_length_m=seg_wire_length_m, resistance_ohm=seg_resistance, weight_g=seg_weight_g, fits=seg_fits, )) total_wire_m = sum(s.wire_length_m for s in segments) total_resistance = sum(s.resistance_ohm for s in segments) total_weight = sum(s.weight_g for s in segments) # Cost: pro-rated per segment (each may have different AWG/price) spools_required = None cost_usd = None seg_costs = [] for seg in segments: w = WireSpec.from_awg(seg.awg) if w.cost_per_m is not None: seg_costs.append(w.cost_per_m * seg.wire_length_m) if seg_costs: cost_usd = sum(seg_costs) return WindingResult( spec=spec, core=core, fill_factor=fill_factor, max_turns_fill=max_turns_fill, max_turns_geometry=max_turns_geometry, segments=segments, total_wire_length_m=total_wire_m, total_resistance_ohm=total_resistance, total_weight_g=total_weight, feasible=overall_feasible, spools_required=spools_required, cost_usd=cost_usd, _end_layer=current_layer, _end_turns_in_layer=turns_in_current_layer, _end_total_turns=total_turns_placed, ) def design_transformer( core: ToroidCore, windings: list[WindingSpec], fill_factor: float = 0.35, ) -> list[WindingResult]: """ Analyse multiple windings on the same core, packing them continuously mid-layer. Each winding picks up exactly where the previous one left off. The fill_factor budget is shared across all windings: once the total turns placed (regardless of AWG) reaches fill_factor * window_area / wire_area, no more turns can be added. Because different AWGs have different wire areas, the budget is tracked in terms of area consumed. Parameters ---------- core : ToroidCore windings : list[WindingSpec] Windings in winding order. fill_factor : float Maximum fraction of bore window area consumed by ALL windings combined. Returns ------- list[WindingResult] (one per winding, in order) """ results: list[WindingResult] = [] cur_layer = 0 cur_turns_in_layer = 0 # Track consumed window area (mm²) across all windings to enforce the # shared fill-factor budget regardless of AWG. consumed_area_mm2 = 0.0 budget_area_mm2 = fill_factor * core.window_area_mm2 for spec in windings: # Use the thickest wire in this spec for area-budget accounting seg_wires = [WireSpec.from_awg(a) for a in spec.awg] wire_ref = max(seg_wires, key=lambda w: w.diameter_mm) wire_area_mm2 = math.pi * (wire_ref.diameter_mm / 2.0) ** 2 # Express the remaining area budget as an equivalent turn count for # this winding's reference wire gauge, then back-calculate the # synthetic start offset so that analyse_winding's fill-factor # headroom check is correct. turns_already_equivalent = int(consumed_area_mm2 / wire_area_mm2) result = analyse_winding( core=core, spec=spec, fill_factor=fill_factor, start_layer=cur_layer, start_turns_in_layer=cur_turns_in_layer, start_total_turns=turns_already_equivalent, ) results.append(result) # Advance shared layer position cur_layer = result._end_layer cur_turns_in_layer = result._end_turns_in_layer # Update consumed area using actual turns placed in this winding turns_placed = result._end_total_turns - turns_already_equivalent consumed_area_mm2 = min(budget_area_mm2, consumed_area_mm2 + turns_placed * wire_area_mm2) return results # --------------------------------------------------------------------------- # Example / smoke-test # --------------------------------------------------------------------------- if __name__ == "__main__": core = ToroidCore(ID_mm=21.5, OD_mm=46.5, height_mm=22.8) primary = WindingSpec( awg=[22, 22], # tap 1 = AWG 22, tap 2 = AWG 24 taps=[0, 25, 50], name="primary", ) secondary = WindingSpec( awg=[22, 22, 22, 26], # uniform gauge taps=[0, 100, 50, 50, 50], name="secondary", ) results = design_transformer(core, [primary, secondary], fill_factor=0.35) for result in results: print(result.summary()) print()