""" 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)