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

368
draw_toroid.py Normal file
View File

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