393 lines
14 KiB
Python
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)
|