369 lines
13 KiB
Python
369 lines
13 KiB
Python
"""
|
|
draw_toroid.py
|
|
==============
|
|
Top-down (axial) cross-section drawing of a toroidal transformer.
|
|
|
|
The view is looking straight down the axis of the toroid.
|
|
|
|
What you see in this view
|
|
-------------------------
|
|
- Grey annulus : the core material (ID to OD)
|
|
- White disc : the bore window (diameter = ID)
|
|
- Coloured dots : wire cross-sections packed against the inner bore wall,
|
|
one concentric ring per winding layer
|
|
|
|
Physical basis: each wire turn passes through the bore. Looking down the
|
|
axis you see the wire cross-sections arranged in rings inside the bore.
|
|
Layer 0 (first layer) sits closest to the bore wall; successive layers sit
|
|
inward. Wire centres for layer n are at:
|
|
|
|
r(n) = ID/2 - (n + 0.5) * d_wire
|
|
|
|
Turns are spaced by d_wire around the circumference of that ring. Up to
|
|
turns_capacity dots are drawn per ring (the physical fill of that layer).
|
|
|
|
Usage
|
|
-----
|
|
from designer import ToroidCore, WindingSpec, design_transformer
|
|
from draw_toroid import draw_toroid
|
|
|
|
core = ToroidCore(ID_mm=21.5, OD_mm=46.5, height_mm=22.8)
|
|
primary = WindingSpec(awg=22, taps=[0, 25, 50], name="primary")
|
|
sec = WindingSpec(awg=22, taps=[0, 100, 50], name="secondary")
|
|
results = design_transformer(core, [primary, secondary])
|
|
|
|
draw_toroid(core, results, "toroid.png")
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import math
|
|
from typing import List
|
|
|
|
import matplotlib
|
|
matplotlib.use("Agg")
|
|
import matplotlib.pyplot as plt
|
|
import matplotlib.patches as mpatches
|
|
from matplotlib.patches import Circle
|
|
import numpy as np
|
|
|
|
from designer import ToroidCore, WindingResult, WireSpec
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Colour scheme (one per winding)
|
|
# ---------------------------------------------------------------------------
|
|
_WINDING_COLORS = [
|
|
"#1565C0", # dark blue — winding 0
|
|
"#B71C1C", # dark red — winding 1
|
|
"#2E7D32", # dark green — winding 2
|
|
"#E65100", # dark orange— winding 3
|
|
"#6A1B9A", # dark purple— winding 4
|
|
"#00838F", # dark cyan — winding 5
|
|
]
|
|
|
|
_CORE_FACE_COLOR = "#9E9E9E" # core material
|
|
_CORE_EDGE_COLOR = "#212121"
|
|
_WIRE_EDGE_COLOR = "#000000"
|
|
_BG_COLOR = "white"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Public API
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def draw_toroid(
|
|
core: ToroidCore,
|
|
winding_results: List[WindingResult],
|
|
output_path: str = "toroid.png",
|
|
dpi: int = 180,
|
|
fig_size_mm: float = 180.0,
|
|
) -> None:
|
|
"""
|
|
Render a to-scale top-down cross-section of the transformer as a PNG.
|
|
|
|
Parameters
|
|
----------
|
|
core : ToroidCore
|
|
winding_results : list[WindingResult]
|
|
Output of design_transformer() or analyse_winding().
|
|
output_path : str
|
|
Destination file (PNG).
|
|
dpi : int
|
|
Render resolution.
|
|
fig_size_mm : float
|
|
Square canvas size in mm.
|
|
"""
|
|
OD = core.OD_mm
|
|
ID = core.ID_mm
|
|
|
|
# Canvas extent: just enough margin around the OD
|
|
margin = OD * 0.12
|
|
extent = OD / 2.0 + margin # mm from centre to canvas edge
|
|
|
|
fig_in = fig_size_mm / 25.4
|
|
fig, ax = plt.subplots(figsize=(fig_in, fig_in), facecolor=_BG_COLOR)
|
|
ax.set_facecolor(_BG_COLOR)
|
|
ax.set_aspect("equal")
|
|
ax.set_xlim(-extent, extent)
|
|
ax.set_ylim(-extent, extent)
|
|
ax.set_xlabel("mm", fontsize=8)
|
|
ax.set_ylabel("mm", fontsize=8)
|
|
ax.tick_params(labelsize=7)
|
|
ax.set_title(
|
|
f"Toroid cross-section "
|
|
f"ID={ID:.1f} mm OD={OD:.1f} mm H={core.height_mm:.1f} mm",
|
|
fontsize=9, pad=6,
|
|
)
|
|
|
|
# -----------------------------------------------------------------------
|
|
# Core annulus
|
|
# -----------------------------------------------------------------------
|
|
core_ring = mpatches.Annulus(
|
|
xy=(0, 0),
|
|
r=OD / 2.0,
|
|
width=(OD - ID) / 2.0,
|
|
facecolor=_CORE_FACE_COLOR,
|
|
edgecolor=_CORE_EDGE_COLOR,
|
|
linewidth=1.2,
|
|
zorder=2,
|
|
label=f"Core ID={ID:.1f} OD={OD:.1f} H={core.height_mm:.1f} mm",
|
|
)
|
|
ax.add_patch(core_ring)
|
|
|
|
# Bore window
|
|
bore = Circle(
|
|
(0, 0), ID / 2.0,
|
|
facecolor=_BG_COLOR,
|
|
edgecolor=_CORE_EDGE_COLOR,
|
|
linewidth=0.8,
|
|
zorder=3,
|
|
)
|
|
ax.add_patch(bore)
|
|
|
|
|
|
# -----------------------------------------------------------------------
|
|
# Build layer_index -> wire_centre_radius map (accounts for mixed gauges)
|
|
#
|
|
# Layers are wound in order from the bore wall inward. Each layer
|
|
# consumes its own wire diameter of radial space. We walk through all
|
|
# layers across all windings in ascending layer_index order and accumulate
|
|
# the actual radial depth so the centre radius is correct regardless of
|
|
# whether wire gauge changes between segments or windings.
|
|
# -----------------------------------------------------------------------
|
|
|
|
# Collect (layer_index, wire_diameter) for every active layer, all windings
|
|
_layer_d: dict[int, float] = {}
|
|
for wr in winding_results:
|
|
for seg in wr.segments:
|
|
wire = WireSpec.from_awg(seg.awg)
|
|
for lr in seg.layers:
|
|
if lr.turns_used > 0 and lr.turns_capacity > 0:
|
|
# First winding to touch a layer defines its wire gauge
|
|
if lr.layer_index not in _layer_d:
|
|
_layer_d[lr.layer_index] = wire.diameter_mm
|
|
|
|
# Walk layers in order to accumulate radial depth from the bore wall
|
|
layer_centre_r: dict[int, float] = {}
|
|
depth = 0.0
|
|
for n in sorted(_layer_d):
|
|
d = _layer_d[n]
|
|
layer_centre_r[n] = ID / 2.0 - depth - d / 2.0
|
|
depth += d
|
|
|
|
# -----------------------------------------------------------------------
|
|
# Pre-compute per-layer start angles (shared across all windings) so
|
|
# the total winding arc on each layer is centred at the top (π/2).
|
|
# -----------------------------------------------------------------------
|
|
_layer_total_used: dict[int, int] = {}
|
|
for _wr in winding_results:
|
|
for _seg in _wr.segments:
|
|
for _lr in _seg.layers:
|
|
if _lr.turns_used > 0 and _lr.turns_capacity > 0:
|
|
n_ = _lr.layer_index
|
|
_layer_total_used[n_] = _layer_total_used.get(n_, 0) + _lr.turns_used
|
|
|
|
# Step = d/r (touching circles), start at 0, wind continuously.
|
|
# All segments share the same running angle per layer — they just
|
|
# pick up where the previous segment left off and keep going around.
|
|
layer_angle_step: dict[int, float] = {}
|
|
layer_next_angle: dict[int, float] = {}
|
|
for n_ in _layer_total_used:
|
|
r_ = layer_centre_r.get(n_, 0.0)
|
|
d_ = _layer_d.get(n_, 1.0)
|
|
if r_ > 0:
|
|
layer_angle_step[n_] = d_ / r_
|
|
layer_next_angle[n_] = 0.0
|
|
|
|
legend_handles: list[mpatches.Patch] = []
|
|
|
|
for w_idx, wr in enumerate(winding_results):
|
|
base_color = _WINDING_COLORS[w_idx % len(_WINDING_COLORS)]
|
|
awg_list = wr.spec.awg
|
|
awg_str = str(awg_list[0]) if len(set(awg_list)) == 1 else f"{awg_list[0]}-{awg_list[-1]}"
|
|
n_segs = len(wr.spec.taps) - 1
|
|
feasible_str = "OK" if wr.feasible else "DOES NOT FIT"
|
|
legend_handles.append(mpatches.Patch(
|
|
facecolor=base_color,
|
|
edgecolor=_WIRE_EDGE_COLOR,
|
|
linewidth=0.8,
|
|
label=f"{wr.spec.name} AWG {awg_str} {n_segs} seg(s) "
|
|
f"{wr.spec.total_turns} turns [{feasible_str}]",
|
|
))
|
|
|
|
# Colour shading: outermost layers → light, innermost → dark.
|
|
for seg in wr.segments:
|
|
wire = WireSpec.from_awg(seg.awg)
|
|
d = wire.diameter_mm
|
|
|
|
# Shade by segment index: seg 0 → lightest, last seg → base colour.
|
|
# Use full 0..1 range across all segments for maximum separation.
|
|
frac = seg.segment_index / max(1, n_segs - 1)
|
|
seg_color = _lighten(base_color, 0.55 * (1.0 - frac))
|
|
|
|
for lr in seg.layers:
|
|
if lr.turns_used == 0 or lr.turns_capacity == 0:
|
|
continue
|
|
|
|
n = lr.layer_index
|
|
r = layer_centre_r.get(n, 0.0)
|
|
if r <= 0:
|
|
continue
|
|
|
|
angle_step = layer_angle_step.get(n, 2.0 * math.pi / max(lr.turns_used, 1))
|
|
start_angle = layer_next_angle.get(n, 0.0)
|
|
|
|
# Draw at true wire radius. Circles are evenly spaced over
|
|
# 360° so they tile the ring; they may overlap on outer
|
|
# layers (where arc spacing > wire diameter) or underlap on
|
|
# very dense inner layers, but the count and colour are correct.
|
|
draw_r = d / 2.0
|
|
|
|
for i in range(lr.turns_used):
|
|
a = start_angle + i * angle_step
|
|
x = r * math.cos(a)
|
|
y = r * math.sin(a)
|
|
ax.add_patch(Circle(
|
|
(x, y), draw_r,
|
|
facecolor=seg_color,
|
|
edgecolor=_WIRE_EDGE_COLOR,
|
|
linewidth=0.35,
|
|
alpha=0.90,
|
|
zorder=10 + n,
|
|
))
|
|
|
|
# Advance the angle for the next segment on this layer
|
|
layer_next_angle[n] = start_angle + lr.turns_used * angle_step
|
|
|
|
# Segment legend entry
|
|
awg_tag = f" AWG {seg.awg}" if len(set(awg_list)) > 1 else ""
|
|
n_active_layers = sum(
|
|
1 for lr in seg.layers if lr.turns_used > 0 and lr.turns_capacity > 0
|
|
)
|
|
legend_handles.append(mpatches.Patch(
|
|
facecolor=seg_color,
|
|
edgecolor=_WIRE_EDGE_COLOR,
|
|
linewidth=0.6,
|
|
label=(f" tap {seg.segment_index + 1}: {seg.turns} turns{awg_tag}"
|
|
f" {n_active_layers} layer(s)"
|
|
f" {seg.resistance_ohm * 1e3:.1f} mOhm"),
|
|
))
|
|
|
|
# -----------------------------------------------------------------------
|
|
# Scale bar (bottom-centre)
|
|
# -----------------------------------------------------------------------
|
|
bar_mm = _nice_bar(OD)
|
|
bx = -bar_mm / 2
|
|
by = -(extent * 0.87)
|
|
tick = extent * 0.012
|
|
ax.plot([bx, bx + bar_mm], [by, by], color="black", lw=1.5, zorder=20)
|
|
ax.plot([bx, bx], [by - tick, by + tick], color="black", lw=1.5, zorder=20)
|
|
ax.plot([bx + bar_mm, bx + bar_mm], [by - tick, by + tick], color="black", lw=1.5, zorder=20)
|
|
ax.text(bx + bar_mm / 2, by - tick * 1.5, f"{bar_mm:.0f} mm",
|
|
ha="center", va="top", fontsize=7)
|
|
|
|
# -----------------------------------------------------------------------
|
|
# Legend (right of axes)
|
|
# -----------------------------------------------------------------------
|
|
ax.legend(
|
|
handles=legend_handles,
|
|
loc="upper left",
|
|
bbox_to_anchor=(1.02, 1.0),
|
|
borderaxespad=0,
|
|
fontsize=7,
|
|
frameon=True,
|
|
title="Windings & taps",
|
|
title_fontsize=8,
|
|
)
|
|
|
|
fig.savefig(output_path, dpi=dpi, bbox_inches="tight", facecolor=_BG_COLOR)
|
|
plt.close(fig)
|
|
print(f"Saved: {output_path}")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Geometry helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _ring_positions(radius: float, n: int,
|
|
d_wire: float = 0.0) -> list[tuple[float, float]]:
|
|
"""
|
|
Place n wire centres uniformly around a full 360° circle.
|
|
|
|
n should equal turns_capacity (floor(2πr/d)), so the spacing between
|
|
adjacent centres is approximately d_wire — the circles are tangent with
|
|
no visible gap. Always covers the full circumference.
|
|
"""
|
|
if n <= 0 or radius <= 0:
|
|
return []
|
|
angles = np.linspace(0.0, 2 * math.pi, n, endpoint=False)
|
|
return [(radius * math.cos(a), radius * math.sin(a)) for a in angles]
|
|
|
|
|
|
|
|
def _nice_bar(OD_mm: float) -> float:
|
|
"""Round scale-bar length ≈ 20% of OD."""
|
|
raw = OD_mm * 0.2
|
|
for v in [1, 2, 5, 10, 20, 25, 50]:
|
|
if v >= raw:
|
|
return float(v)
|
|
return float(round(raw / 10) * 10)
|
|
|
|
|
|
def _lighten(hex_color: str, amount: float) -> str:
|
|
"""
|
|
Mix hex_color towards white by `amount` (0=unchanged, 1=white).
|
|
Used to shade tap segments within a winding.
|
|
"""
|
|
amount = max(0.0, min(1.0, amount))
|
|
h = hex_color.lstrip("#")
|
|
r, g, b = (int(h[i:i+2], 16) / 255.0 for i in (0, 2, 4))
|
|
r = r + (1 - r) * amount
|
|
g = g + (1 - g) * amount
|
|
b = b + (1 - b) * amount
|
|
return "#{:02X}{:02X}{:02X}".format(int(r * 255), int(g * 255), int(b * 255))
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Smoke-test
|
|
# ---------------------------------------------------------------------------
|
|
|
|
if __name__ == "__main__":
|
|
from designer import WindingSpec, design_transformer
|
|
|
|
core = ToroidCore(ID_mm=21.5, OD_mm=46.5, height_mm=22.8)
|
|
|
|
primary = WindingSpec(
|
|
awg=[22, 22],
|
|
taps=[0, 25, 50],
|
|
name="primary",
|
|
)
|
|
secondary = WindingSpec(
|
|
awg=[22, 22, 22, 26],
|
|
taps=[0, 100, 50, 50, 50],
|
|
name="secondary",
|
|
)
|
|
|
|
results = design_transformer(core, [primary, secondary])
|
|
draw_toroid(core, results, "toroid.png", dpi=180)
|