Files
toroid/draw_toroid.py
2026-02-17 11:11:47 -06:00

393 lines
14 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,
actual_fill: float | None = None,
) -> 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)
fill_str = f" fill={actual_fill*100:.1f}%" if actual_fill is not None else ""
ax.set_title(
f"Toroid cross-section "
f"ID={ID:.1f} mm OD={OD:.1f} mm H={core.height_mm:.1f} mm{fill_str}",
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)
# -----------------------------------------------------------------------
# Drawing geometry.
#
# Layer radial positions use the largest wire diameter (_uniform_d) as
# a uniform pitch: visual layer n sits at ID/2 - (n+0.5)*_uniform_d.
# This keeps layer spacing uniform regardless of gauge.
#
# Angular packing uses each segment's actual wire diameter so thinner
# wires pack more tightly and are drawn to scale.
#
# designer.py bumps the layer index on a gauge change even when the
# current layer still has room. For drawing we replay the packing
# using actual wire diameters to determine visual layer assignments,
# so a gauge change mid-layer continues on the same visual ring.
#
# Result: _seg_layer_draw[(w_idx, seg_idx, designer_layer)] =
# (visual_layer_index, start_angle)
# -----------------------------------------------------------------------
# --- Largest wire diameter → uniform radial layer pitch ---
_all_diameters: list[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:
_all_diameters.append(wire.diameter_mm)
_uniform_d = max(_all_diameters) if _all_diameters else 1.0
# --- Replay packing with actual wire diameters ---
# Track arc consumed (radians) on the current visual layer. Each wire
# of diameter d at ring radius r consumes d/r radians. Advance to the
# next ring when the accumulated arc reaches 2π. This handles mixed
# gauges correctly: the total arc never exceeds one full circumference.
#
# A single designer layer entry may be split across two visual layers
# (when the layer wraps mid-segment), so we store a list of draw calls.
#
# Key: (w_idx, seg_idx, designer_layer_index) ->
# list of (vis_layer, start_angle, turns_to_draw)
_seg_layer_draw: dict[tuple[int, int, int], list[tuple[int, float, int]]] = {}
vis_layer = 0
vis_arc = 0.0 # arc consumed so far on current visual layer (radians)
for w_idx, wr in enumerate(winding_results):
for seg in wr.segments:
wire = WireSpec.from_awg(seg.awg)
d_actual = wire.diameter_mm
for lr in seg.layers:
if lr.turns_used == 0 or lr.turns_capacity == 0:
continue
turns_left = lr.turns_used
key = (w_idx, seg.segment_index, lr.layer_index)
while turns_left > 0:
r_ = ID / 2.0 - (vis_layer + 0.5) * _uniform_d
if r_ <= 0:
break # no more radial room
step = d_actual / r_ # arc per wire (radians)
available = int((2 * math.pi - vis_arc) / step)
if available <= 0:
vis_layer += 1
vis_arc = 0.0
continue
place = min(turns_left, available)
_seg_layer_draw.setdefault(key, []).append(
(vis_layer, vis_arc, place)
)
vis_arc += place * step
turns_left -= place
if vis_arc >= 2 * math.pi - step * 0.5:
vis_layer += 1
vis_arc = 0.0
# --- Visual layer centre radii ---
_vis_layers_used = set(v for draws in _seg_layer_draw.values() for v, _, _ in draws)
layer_centre_r: dict[int, float] = {
n: ID / 2.0 - (n + 0.5) * _uniform_d for n in _vis_layers_used
}
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
key = (w_idx, seg.segment_index, lr.layer_index)
if key not in _seg_layer_draw:
continue
draw_r = d / 2.0
for vis_n, start_angle, n_turns in _seg_layer_draw[key]:
r = layer_centre_r.get(vis_n, 0.0)
if r <= 0:
continue
angle_step = d / r
for i in range(n_turns):
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 + vis_n,
))
# 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=[20, 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)