Files
toroid/designer.py
2026-02-13 14:20:30 -06:00

645 lines
23 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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."""
if self.Ae_mm2 != None:
return self.Ae_mm2
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()