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