636 lines
22 KiB
Python
636 lines
22 KiB
Python
"""
|
||
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_consumed_area_mm2: float = 0.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,
|
||
consumed_area_mm2: float = 0.0,
|
||
budget_area_mm2: Optional[float] = None,
|
||
) -> 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.
|
||
consumed_area_mm2 : float
|
||
Wire cross-section area (mm²) already consumed by previous windings.
|
||
Fill-factor budget is tracked in area so mixed gauges are handled
|
||
correctly.
|
||
budget_area_mm2 : float or None
|
||
Total allowed wire area (fill_factor * window_area). Computed from
|
||
fill_factor if not supplied.
|
||
|
||
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
|
||
|
||
if budget_area_mm2 is None:
|
||
budget_area_mm2 = fill_factor * core.window_area_mm2
|
||
|
||
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
|
||
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
|
||
wire_area = math.pi * (wire.diameter_mm / 2.0) ** 2
|
||
|
||
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
|
||
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
|
||
|
||
# Fill-factor check: how many more turns of this gauge fit in budget?
|
||
area_remaining = budget_area_mm2 - consumed_area_mm2
|
||
turns_by_fill = int(area_remaining / wire_area) if wire_area > 0 else 0
|
||
|
||
if turns_by_fill <= 0:
|
||
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, turns_by_fill)
|
||
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
|
||
consumed_area_mm2 += turns_to_place * wire_area
|
||
|
||
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_consumed_area_mm2=consumed_area_mm2,
|
||
)
|
||
|
||
|
||
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
|
||
consumed_area_mm2 = 0.0
|
||
budget_area_mm2 = fill_factor * core.window_area_mm2
|
||
|
||
for spec in windings:
|
||
result = analyse_winding(
|
||
core=core,
|
||
spec=spec,
|
||
fill_factor=fill_factor,
|
||
start_layer=cur_layer,
|
||
start_turns_in_layer=cur_turns_in_layer,
|
||
consumed_area_mm2=consumed_area_mm2,
|
||
budget_area_mm2=budget_area_mm2,
|
||
)
|
||
results.append(result)
|
||
|
||
cur_layer = result._end_layer
|
||
cur_turns_in_layer = result._end_turns_in_layer
|
||
consumed_area_mm2 = result._end_consumed_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, 28], # 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()
|