From 4df588e09cb83f1540a9a0d0d0f25d80c7981822 Mon Sep 17 00:00:00 2001 From: brentperteet Date: Tue, 17 Feb 2026 11:11:47 -0600 Subject: [PATCH] added toroid simulator --- app.py | 79 +++++++++++++++++++++ designer.py | 59 +++++++--------- draw_toroid.py | 156 ++++++++++++++++++++++++------------------ presets/sim.json | 6 +- presets/windings.json | 10 +-- templates/index.html | 121 ++++++++++++++++++++++++++++++++ 6 files changed, 323 insertions(+), 108 deletions(-) diff --git a/app.py b/app.py index 83ab02e..f1e35d4 100644 --- a/app.py +++ b/app.py @@ -203,6 +203,85 @@ def api_design(): return jsonify({"success": False, "error": str(exc)}), 400 +@app.route("/api/simulate", methods=["POST"]) +def api_simulate(): + """ + Compute a single operating point. + + Request body (JSON): + { + // Same core + windings as /api/design ... + "fill_factor": 0.35, + "primary_tap": 1, + "secondary_tap": 1, + "Vp_rms": 12.0, + "freq_hz": 1000.0, + "R_load": 100.0, + "X_load": 0.0, + "constraints": { ... } // optional + } + """ + try: + data = request.get_json(force=True) + core = _parse_core(data) + specs = _parse_windings(data) + fill_factor = float(data.get("fill_factor", 0.35)) + + results = design_transformer(core, specs, fill_factor=fill_factor) + + if len(results) < 2: + return jsonify({"success": False, "error": "Need at least 2 windings"}), 400 + + sim = ToroidSimulator(core=core, primary=results[0], secondary=results[1]) + + cdata = data.get("constraints", {}) + constraints = SimConstraints( + B_max_T=float(cdata.get("B_max_T", 0.3)), + Vp_max=float(cdata.get("Vp_max", float("inf"))), + Vs_max=float(cdata.get("Vs_max", float("inf"))), + Ip_max=float(cdata.get("Ip_max", float("inf"))), + Is_max=float(cdata.get("Is_max", float("inf"))), + P_out_max_W=float(cdata.get("P_out_max_W", float("inf"))), + ) if cdata else None + + r = sim.simulate( + Vp_rms=float(data["Vp_rms"]), + freq_hz=float(data["freq_hz"]), + primary_tap=int(data["primary_tap"]), + secondary_tap=int(data["secondary_tap"]), + Z_load=(float(data.get("R_load", 0.0)), float(data.get("X_load", 0.0))), + constraints=constraints, + ) + + def _f(v): + return None if (isinstance(v, float) and math.isnan(v)) else v + + return jsonify({ + "success": True, + "Np_eff": r.Np_eff, + "Ns_eff": r.Ns_eff, + "turns_ratio": _f(round(r.turns_ratio, 4)), + "B_peak_T": _f(round(r.B_peak_T, 4)), + "Vp_rms": _f(round(r.Vp_rms_applied, 4)), + "Vs_rms": _f(round(r.Vs_rms, 4)), + "Ip_rms": _f(round(r.Ip_rms, 4)), + "Is_rms": _f(round(r.Is_rms, 4)), + "P_out_W": _f(round(r.P_out_W, 4)), + "P_cu_W": _f(round(r.P_cu_W, 4)), + "P_cu_primary_W": _f(round(r.P_cu_primary_W, 4)), + "P_cu_secondary_W": _f(round(r.P_cu_secondary_W, 4)), + "P_core_W": _f(round(r.P_core_W, 4)), + "P_in_W": _f(round(r.P_in_W, 4)), + "efficiency_pct": _f(round(r.efficiency * 100, 2)), + "violations": r.violations, + }) + + except Exception as exc: + import traceback + return jsonify({"success": False, "error": str(exc), + "traceback": traceback.format_exc()}), 400 + + @app.route("/api/sweep", methods=["POST"]) def api_sweep(): """ diff --git a/designer.py b/designer.py index 4b09d35..d2e57a4 100644 --- a/designer.py +++ b/designer.py @@ -279,7 +279,7 @@ class WindingResult: # Internal winding position at end of this winding (for chaining) _end_layer: int = 0 _end_turns_in_layer: int = 0 - _end_total_turns: int = 0 + _end_consumed_area_mm2: float = 0.0 def summary(self) -> str: # Header: show AWG as single value if uniform, else show range @@ -372,7 +372,8 @@ def analyse_winding( fill_factor: float = 0.35, start_layer: int = 0, start_turns_in_layer: int = 0, - start_total_turns: int = 0, + consumed_area_mm2: float = 0.0, + budget_area_mm2: Optional[float] = None, ) -> WindingResult: """ Analyse feasibility and compute wire parameters for a winding. @@ -389,9 +390,13 @@ def analyse_winding( _end_layer to continue mid-layer across windings. start_turns_in_layer : int Turns already consumed in start_layer from a previous winding. - start_total_turns : int - Cumulative turns already placed (used for fill-factor accounting - across windings). + consumed_area_mm2 : float + Wire cross-section area (mm²) already consumed by previous windings. + Fill-factor budget is tracked in area so mixed gauges are handled + correctly. + budget_area_mm2 : float or None + Total allowed wire area (fill_factor * window_area). Computed from + fill_factor if not supplied. Returns ------- @@ -415,7 +420,8 @@ def analyse_winding( max_turns_geometry += cap layer_idx += 1 - effective_max = max_turns_fill # fill factor is the binding constraint + if budget_area_mm2 is None: + budget_area_mm2 = fill_factor * core.window_area_mm2 segments: list[SegmentResult] = [] overall_feasible = True @@ -424,7 +430,6 @@ def analyse_winding( # turns have been placed in the current layer already. current_layer = start_layer turns_in_current_layer = start_turns_in_layer - total_turns_placed = start_total_turns current_layer_wire_diameter: Optional[float] = None # set on first use for seg_idx, (seg_turns, wire) in enumerate(zip(spec.segment_turns(), seg_wires)): @@ -432,6 +437,7 @@ def analyse_winding( seg_wire_length_mm = 0.0 seg_fits = True turns_remaining = seg_turns + wire_area = math.pi * (wire.diameter_mm / 2.0) ** 2 while turns_remaining > 0: # If the wire gauge changed from what's already on this layer, @@ -449,7 +455,6 @@ def analyse_winding( # No more geometry room at all seg_fits = False overall_feasible = False - # Record overflow as a single "layer" with 0 capacity seg_layers.append(LayerResult( layer_index=current_layer, turns_capacity=0, @@ -467,8 +472,11 @@ def analyse_winding( turns_in_current_layer = 0 continue - # Check fill-factor cap - if total_turns_placed >= effective_max: + # Fill-factor check: how many more turns of this gauge fit in budget? + area_remaining = budget_area_mm2 - consumed_area_mm2 + turns_by_fill = int(area_remaining / wire_area) if wire_area > 0 else 0 + + if turns_by_fill <= 0: seg_fits = False overall_feasible = False seg_layers.append(LayerResult( @@ -481,9 +489,7 @@ def analyse_winding( turns_remaining = 0 break - turns_to_place = min(turns_remaining, - available_in_layer, - effective_max - total_turns_placed) + turns_to_place = min(turns_remaining, available_in_layer, turns_by_fill) wire_len = turns_to_place * L_turn seg_layers.append(LayerResult( @@ -497,7 +503,7 @@ def analyse_winding( seg_wire_length_mm += wire_len turns_remaining -= turns_to_place turns_in_current_layer += turns_to_place - total_turns_placed += turns_to_place + consumed_area_mm2 += turns_to_place * wire_area if turns_in_current_layer >= cap: current_layer += 1 @@ -549,7 +555,7 @@ def analyse_winding( cost_usd=cost_usd, _end_layer=current_layer, _end_turns_in_layer=turns_in_current_layer, - _end_total_turns=total_turns_placed, + _end_consumed_area_mm2=consumed_area_mm2, ) @@ -582,39 +588,24 @@ def design_transformer( results: list[WindingResult] = [] cur_layer = 0 cur_turns_in_layer = 0 - # Track consumed window area (mm²) across all windings to enforce the - # shared fill-factor budget regardless of AWG. consumed_area_mm2 = 0.0 budget_area_mm2 = fill_factor * core.window_area_mm2 for spec in windings: - # Use the thickest wire in this spec for area-budget accounting - seg_wires = [WireSpec.from_awg(a) for a in spec.awg] - wire_ref = max(seg_wires, key=lambda w: w.diameter_mm) - wire_area_mm2 = math.pi * (wire_ref.diameter_mm / 2.0) ** 2 - # Express the remaining area budget as an equivalent turn count for - # this winding's reference wire gauge, then back-calculate the - # synthetic start offset so that analyse_winding's fill-factor - # headroom check is correct. - turns_already_equivalent = int(consumed_area_mm2 / wire_area_mm2) - result = analyse_winding( core=core, spec=spec, fill_factor=fill_factor, start_layer=cur_layer, start_turns_in_layer=cur_turns_in_layer, - start_total_turns=turns_already_equivalent, + consumed_area_mm2=consumed_area_mm2, + budget_area_mm2=budget_area_mm2, ) results.append(result) - # Advance shared layer position cur_layer = result._end_layer cur_turns_in_layer = result._end_turns_in_layer - # Update consumed area using actual turns placed in this winding - turns_placed = result._end_total_turns - turns_already_equivalent - consumed_area_mm2 = min(budget_area_mm2, - consumed_area_mm2 + turns_placed * wire_area_mm2) + consumed_area_mm2 = result._end_consumed_area_mm2 return results @@ -633,7 +624,7 @@ if __name__ == "__main__": ) secondary = WindingSpec( - awg=[22, 22, 22, 26], # uniform gauge + awg=[22, 22, 22, 28], # uniform gauge taps=[0, 100, 50, 50, 50], name="secondary", ) diff --git a/draw_toroid.py b/draw_toroid.py index 3b967a0..7c19809 100644 --- a/draw_toroid.py +++ b/draw_toroid.py @@ -145,57 +145,86 @@ def draw_toroid( # ----------------------------------------------------------------------- - # Build layer_index -> wire_centre_radius map (accounts for mixed gauges) + # Drawing geometry. # - # 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. + # 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) # ----------------------------------------------------------------------- - # Collect (layer_index, wire_diameter) for every active layer, all windings - _layer_d: dict[int, float] = {} + # --- 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: - # 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 + _all_diameters.append(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 + _uniform_d = max(_all_diameters) if _all_diameters else 1.0 - # ----------------------------------------------------------------------- - # 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 + # --- 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) - # 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 + _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] = [] @@ -227,35 +256,28 @@ def draw_toroid( 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: + key = (w_idx, seg.segment_index, lr.layer_index) + if key not in _seg_layer_draw: 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 + 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 "" @@ -356,7 +378,7 @@ if __name__ == "__main__": core = ToroidCore(ID_mm=21.5, OD_mm=46.5, height_mm=22.8) primary = WindingSpec( - awg=[22, 22], + awg=[20, 22], taps=[0, 25, 50], name="primary", ) diff --git a/presets/sim.json b/presets/sim.json index c87918d..b81391b 100644 --- a/presets/sim.json +++ b/presets/sim.json @@ -1,12 +1,14 @@ { "points": { "frequencies": "256\n870\n3140\n8900\n12000\n16000\n22000\n33000\n45000", - "loads": "5\n10\n50\n100\n200\n600\n2000", + "loads": "10\n50\n100\n200\n600\n2000", "target_power_W": 25, "power_tol_pct": 2, "Vp_min": 1, "Vp_max": 50, - "Vp_steps": 100 + "Vp_steps": 100, + "T_ambient_C": 25, + "h_conv": 6 }, "_last": "points" } \ No newline at end of file diff --git a/presets/windings.json b/presets/windings.json index f1259db..3d05668 100644 --- a/presets/windings.json +++ b/presets/windings.json @@ -9,8 +9,8 @@ 50 ], "awg": [ - 22, - 22 + 20, + 20 ] }, { @@ -23,10 +23,10 @@ 50 ], "awg": [ + 20, 22, - 22, - 22, - 22 + 28, + 28 ] } ] diff --git a/templates/index.html b/templates/index.html index 5ed5bb9..112428b 100644 --- a/templates/index.html +++ b/templates/index.html @@ -535,6 +535,29 @@ + +
+
Operating Point
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+
+ @@ -552,6 +575,17 @@ + + +