Files
toroid/app.py
2026-02-17 11:11:47 -06:00

465 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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=5000)