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