""" Flask web interface for the toroidal transformer designer + simulator. Routes ------ GET / → main page POST /api/design → run design_transformer(), return winding info + drawing PNG (base64) POST /api/sweep → run sweep_operating_points(), return JSON dataset """ import base64 import json import math import os import tempfile from flask import Flask, render_template, request, jsonify from designer import ToroidCore, WindingSpec, WireSpec, design_transformer from sim_toroid import ( ToroidSimulator, SimConstraints, sweep_operating_points, SweepEntry, ) from draw_toroid import draw_toroid app = Flask(__name__) # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _parse_core(data: dict) -> ToroidCore: """Build ToroidCore from request dict.""" Ae = data.get("Ae_mm2") Ve = data.get("Ve_mm3") return ToroidCore( ID_mm=float(data["ID_mm"]), OD_mm=float(data["OD_mm"]), height_mm=float(data["height_mm"]), Ae_mm2=float(Ae) if Ae not in (None, "") else None, Ve_mm3=float(Ve) if Ve not in (None, "") else None, Pv_func=None, # not editable in the UI; uses built-in fallback ) def _parse_windings(data: dict) -> list[WindingSpec]: """ Parse winding list from request dict. Expected format: windings: [ { name: "primary", taps: [0, 25, 50], awg: [22, 22] }, { name: "secondary", taps: [0, 100, 50, 50, 50], awg: [22, 22, 22, 26] }, ] """ specs = [] for w in data["windings"]: taps = [int(t) for t in w["taps"]] awg = [int(a) for a in w["awg"]] specs.append(WindingSpec(awg=awg, taps=taps, name=str(w.get("name", "winding")))) return specs def _actual_fill_factor(core: ToroidCore, results) -> float: """ Compute actual fill factor = total wire cross-section area placed / window area. Sums wire area for every turn placed across all windings. """ window_area = core.window_area_mm2 if window_area <= 0: return 0.0 total_wire_area = 0.0 for wr in results: for seg in wr.segments: wire = WireSpec.from_awg(seg.awg) r = wire.diameter_mm / 2.0 total_wire_area += math.pi * r * r * seg.turns return total_wire_area / window_area def _drawing_b64(core: ToroidCore, results, actual_fill: float) -> str: """Render the toroid PNG and return it as a base64 data-URI string.""" with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as f: tmp_path = f.name try: draw_toroid(core, results, output_path=tmp_path, dpi=150, fig_size_mm=160, actual_fill=actual_fill) with open(tmp_path, "rb") as f: png_bytes = f.read() finally: try: os.unlink(tmp_path) except OSError: pass return "data:image/png;base64," + base64.b64encode(png_bytes).decode() def _winding_result_to_dict(wr) -> dict: """Serialise a WindingResult for JSON.""" segs = [] for seg in wr.segments: segs.append({ "segment_index": seg.segment_index, "tap_number": seg.segment_index + 1, "awg": seg.awg, "turns": seg.turns, "wire_length_m": round(seg.wire_length_m, 4), "resistance_mohm": round(seg.resistance_ohm * 1000, 3), "weight_g": round(seg.weight_g, 3), "fits": seg.fits, "layers": [ { "layer_index": lr.layer_index, "turns_capacity": lr.turns_capacity, "turns_used": lr.turns_used, "L_turn_mm": round(lr.L_turn_mm, 2), } for lr in seg.layers ], }) return { "name": wr.spec.name, "total_turns": wr.spec.total_turns, "feasible": wr.feasible, "total_wire_length_m": round(wr.total_wire_length_m, 4), "total_resistance_mohm": round(wr.total_resistance_ohm * 1000, 3), "total_weight_g": round(wr.total_weight_g, 3), "segments": segs, "n_taps": len(wr.spec.taps) - 1, } def _sweep_entry_to_dict(e: SweepEntry) -> dict: """Convert SweepEntry to a plain dict suitable for JSON.""" d = e.as_dict() # Replace NaN (not JSON-serialisable) with None for k, v in d.items(): if isinstance(v, float) and math.isnan(v): d[k] = None return d # --------------------------------------------------------------------------- # Routes # --------------------------------------------------------------------------- @app.route("/") def index(): return render_template("index.html") @app.route("/api/design", methods=["POST"]) def api_design(): """ Run design_transformer() and return winding info + PNG drawing. Request body (JSON): { "ID_mm": 21.5, "OD_mm": 46.5, "height_mm": 22.8, "Ae_mm2": 142.5, // optional "Ve_mm3": 15219, // optional "fill_factor": 0.35, "windings": [ { "name": "primary", "taps": [0, 25, 50], "awg": [22, 22] }, { "name": "secondary", "taps": [0, 100, 50, 50, 50],"awg": [22, 22, 22, 26] } ] } """ 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) actual_fill = _actual_fill_factor(core, results) drawing_b64 = _drawing_b64(core, results, actual_fill) windings_info = [_winding_result_to_dict(wr) for wr in results] # Thermal surface area of wound toroid (m²): # outer cylinder + top/bottom annular faces + inner bore cylinder OD_m = core.OD_mm * 1e-3 ID_m = core.ID_mm * 1e-3 H_m = core.height_mm * 1e-3 A_outer = math.pi * OD_m * H_m A_faces = 2 * math.pi / 4 * (OD_m**2 - ID_m**2) A_bore = math.pi * ID_m * H_m A_surface_m2 = A_outer + A_faces + A_bore return jsonify({ "success": True, "windings": windings_info, "drawing": drawing_b64, "fill_factor_target": fill_factor, "fill_factor_actual": round(actual_fill, 4), "window_area_mm2": round(core.window_area_mm2, 2), "A_surface_m2": round(A_surface_m2, 6), }) except Exception as exc: 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(): """ Run sweep_operating_points() over a grid of frequencies × loads. Request body (JSON): { // Same core + windings as /api/design ... "fill_factor": 0.35, "frequencies": [256, 870, 3140], "loads": [[10, 0], [50, 0], [100, 0]], // [R, X] pairs "target_power_W": 25.0, "Vp_min": 1.0, "Vp_max": 50.0, "Vp_steps": 100, "power_tol_pct": 2.0, "constraints": { "B_max_T": 0.3, "Vp_max": 50.0, "Vs_max": 120.0, "Ip_max": 3.0, "Is_max": 2.0, "P_out_max_W": 100.0 } } """ 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) # Expect exactly 2 windings: primary and secondary if len(results) < 2: return jsonify({"success": False, "error": "Need at least 2 windings"}), 400 # Use first winding as primary, second as secondary primary_result = results[0] secondary_result = results[1] sim = ToroidSimulator(core=core, primary=primary_result, secondary=secondary_result) # Constraints 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"))), ) frequencies = [float(f) for f in data.get("frequencies", [256.0])] loads = [(float(p[0]), float(p[1])) for p in data.get("loads", [[10.0, 0.0]])] target_power_W = float(data.get("target_power_W", 10.0)) Vp_min = float(data.get("Vp_min", 1.0)) Vp_max_sweep = float(data.get("Vp_max", 50.0)) Vp_steps = int(data.get("Vp_steps", 100)) power_tol_pct = float(data.get("power_tol_pct", 2.0)) entries = sweep_operating_points( sim=sim, frequencies=frequencies, loads=loads, target_power_W=target_power_W, constraints=constraints, Vp_min=Vp_min, Vp_max=Vp_max_sweep, Vp_steps=Vp_steps, power_tol_pct=power_tol_pct, ) rows = [_sweep_entry_to_dict(e) for e in entries] return jsonify({ "success": True, "rows": rows, "frequencies": frequencies, "loads": [[r, x] for r, x in loads], }) except Exception as exc: import traceback return jsonify({"success": False, "error": str(exc), "traceback": traceback.format_exc()}), 400 # --------------------------------------------------------------------------- # Preset persistence # --------------------------------------------------------------------------- PRESETS_DIR = os.path.join(os.path.dirname(__file__), "presets") os.makedirs(PRESETS_DIR, exist_ok=True) # Valid preset categories _PRESET_TYPES = {"core", "windings", "constraints", "sim"} def _preset_path(ptype: str) -> str: return os.path.join(PRESETS_DIR, f"{ptype}.json") def _load_store(ptype: str) -> dict: """Load the JSON store for a preset type; return {} on missing/corrupt.""" path = _preset_path(ptype) if not os.path.exists(path): return {} try: with open(path, "r", encoding="utf-8") as f: return json.load(f) except Exception: return {} def _save_store(ptype: str, store: dict) -> None: path = _preset_path(ptype) with open(path, "w", encoding="utf-8") as f: json.dump(store, f, indent=2) @app.route("/api/presets/", methods=["GET"]) def presets_list(ptype: str): """Return list of saved preset names and the last-used name.""" if ptype not in _PRESET_TYPES: return jsonify({"success": False, "error": f"Unknown type '{ptype}'"}), 400 store = _load_store(ptype) names = [k for k in store if not k.startswith("_")] last = store.get("_last", None) return jsonify({"success": True, "names": sorted(names), "last": last}) @app.route("/api/presets//", methods=["GET"]) def presets_load(ptype: str, name: str): """Load a named preset and mark it as last-used.""" if ptype not in _PRESET_TYPES: return jsonify({"success": False, "error": f"Unknown type '{ptype}'"}), 400 store = _load_store(ptype) if name not in store: return jsonify({"success": False, "error": f"Preset '{name}' not found"}), 404 store["_last"] = name _save_store(ptype, store) return jsonify({"success": True, "name": name, "data": store[name]}) @app.route("/api/presets//", methods=["POST"]) def presets_save(ptype: str, name: str): """Save (create or overwrite) a named preset and mark it as last-used.""" if ptype not in _PRESET_TYPES: return jsonify({"success": False, "error": f"Unknown type '{ptype}'"}), 400 if not name or name.startswith("_"): return jsonify({"success": False, "error": "Invalid preset name"}), 400 data = request.get_json(force=True) store = _load_store(ptype) store[name] = data store["_last"] = name _save_store(ptype, store) return jsonify({"success": True, "name": name}) @app.route("/api/presets//", methods=["DELETE"]) def presets_delete(ptype: str, name: str): """Delete a named preset.""" if ptype not in _PRESET_TYPES: return jsonify({"success": False, "error": f"Unknown type '{ptype}'"}), 400 store = _load_store(ptype) if name not in store: return jsonify({"success": False, "error": f"Preset '{name}' not found"}), 404 del store[name] if store.get("_last") == name: remaining = [k for k in store if not k.startswith("_")] store["_last"] = remaining[-1] if remaining else None _save_store(ptype, store) return jsonify({"success": True}) if __name__ == "__main__": app.run(debug=True, host="0.0.0.0", port=5000)