adding tordoidal simulator and drawing

This commit is contained in:
2026-02-13 11:32:37 -06:00
parent 5f3beeda8d
commit 1d8e60c5df
11 changed files with 2511 additions and 5 deletions

641
designer.py Normal file
View File

@@ -0,0 +1,641 @@
"""
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()