465 lines
16 KiB
Python
465 lines
16 KiB
Python
"""
|
||
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/<ptype>", 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/<ptype>/<name>", 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/<ptype>/<name>", 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/<ptype>/<name>", 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=5010)
|