adding tordoidal simulator and drawing
This commit is contained in:
641
designer.py
Normal file
641
designer.py
Normal 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()
|
||||
Reference in New Issue
Block a user