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