diff --git a/app.py b/app.py index fb3bb8c..d7519a5 100644 --- a/app.py +++ b/app.py @@ -1,197 +1,263 @@ +""" +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 math +import os +import tempfile + from flask import Flask, render_template, request, jsonify -from model import TransformerModel -from optimizer import TransformerOptimizer + +from designer import ToroidCore, WindingSpec, design_transformer +from sim_toroid import ( + ToroidSimulator, SimConstraints, + sweep_operating_points, SweepEntry, +) +from draw_toroid import draw_toroid app = Flask(__name__) -# Default transformer configuration -# You can modify this to match your actual transformer -def get_default_transformer(): - primary_taps = [0, 75, 75] - secondary_taps = [0, 100, 150, 150, 150] +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- - # Resistance per turn for each segment (example values in ohms/turn) - # These would be calculated based on wire gauge, length per turn, etc. - primary_Rp_per_turn = [ - 0.01, - 0.01, - ] - - secondary_Rs_per_turn = [ - 0.004, - 0.024, - 0.024, - 0.024, - ] - - - - tf = TransformerModel( - Ae_mm2=354.0, - Ve_mm3=43900.0, - use_core_loss_model=True, - Np_total=150, - Ns_total=250, - primary_taps=primary_taps, - secondary_taps=secondary_taps, - primary_Rp_per_turn=primary_Rp_per_turn, - secondary_Rs_per_turn=secondary_Rs_per_turn, +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 ) - return tf -@app.route('/') -def index(): - return render_template('index.html') +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 -@app.route('/manual') -def manual(): - return render_template('manual.html') - - -@app.route('/api/simulate', methods=['POST']) -def simulate(): - """Run manual simulation with specified tap and voltage""" +def _drawing_b64(core: ToroidCore, results) -> 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: - data = request.json + draw_toroid(core, results, output_path=tmp_path, dpi=150, fig_size_mm=160) + 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() - # Extract parameters - primary_tap = int(data.get('primary_tap', 1)) - secondary_tap = int(data.get('secondary_tap', 1)) - Vp_rms = float(data.get('Vp_rms', 12)) - freq_hz = float(data.get('freq_hz', 2000)) - load_ohms = float(data.get('load_ohms', 100)) - # Create transformer - tf = get_default_transformer() +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, + } - # Run simulation (core loss is calculated automatically) - result = tf.simulate( - primary_tap=primary_tap, - secondary_tap=secondary_tap, - Vp_rms=Vp_rms, - freq_hz=freq_hz, - load_ohms=load_ohms, - core_loss_W=0.0, # Will be calculated by model - ) + +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) + + drawing_b64 = _drawing_b64(core, results) + windings_info = [_winding_result_to_dict(wr) for wr in results] return jsonify({ - 'success': True, - 'result': { - 'primary_tap': result['primary_tap'], - 'secondary_tap': result['secondary_tap'], - 'Np_eff': result['Np_eff'], - 'Ns_eff': result['Ns_eff'], - 'Vp_rms': round(result['Vp_rms'], 2), - 'Vs_rms': round(result['Vs_rms'], 2), - 'Ip_rms': round(result['Ip_rms'], 3), - 'Is_rms': round(result['Is_rms'], 3), - 'turns_ratio': round(result['turns_ratio'], 3), - 'P_out_W': round(result['P_out_W'], 2), - 'P_in_W': round(result['P_in_W'], 2), - 'P_cu_W': round(result['P_cu_W'], 2), - 'P_cu_primary_W': round(result['P_cu_primary_W'], 3), - 'P_cu_secondary_W': round(result['P_cu_secondary_W'], 3), - 'P_core_W': round(result['P_core_W'], 2), - 'efficiency': round(result['efficiency'] * 100, 2), - 'B_peak_T': round(result['B_peak_T'], 4), - } + "success": True, + "windings": windings_info, + "drawing": drawing_b64, }) - except Exception as e: - return jsonify({ - 'success': False, - 'error': str(e) - }), 400 + except Exception as exc: + return jsonify({"success": False, "error": str(exc)}), 400 -@app.route('/api/optimize', methods=['POST']) -def optimize(): +@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.json + data = request.get_json(force=True) + core = _parse_core(data) + specs = _parse_windings(data) + fill_factor = float(data.get("fill_factor", 0.35)) - # Extract parameters - load_ohms = float(data.get('load_ohms', 100)) - target_power_W = float(data.get('target_power_W', 10)) - freq_hz = float(data.get('freq_hz', 2000)) - Vp_min = float(data.get('Vp_min', 5)) - Vp_max = float(data.get('Vp_max', 36)) - Vp_step = float(data.get('Vp_step', 0.5)) - B_max_T = float(data.get('B_max_T', 0.3)) - Vs_max = float(data.get('Vs_max', 200)) - Is_max = float(data.get('Is_max', 1.5)) - power_tolerance_percent = float(data.get('power_tolerance_percent', 2.0)) + results = design_transformer(core, specs, fill_factor=fill_factor) - # Create transformer and optimizer - tf = get_default_transformer() - opt = TransformerOptimizer(tf) + # Expect exactly 2 windings: primary and secondary + if len(results) < 2: + return jsonify({"success": False, "error": "Need at least 2 windings"}), 400 - # Run optimization (core loss is calculated automatically) - result = opt.optimize( - load_ohms=load_ohms, - target_power_W=target_power_W, - freq_hz=freq_hz, - Vp_min=Vp_min, - Vp_max=Vp_max, - Vp_step=Vp_step, - B_max_T=B_max_T, - Vs_max=Vs_max, - Is_max=Is_max, - core_loss_W=0.0, # Will be calculated by model - power_tolerance_percent=power_tolerance_percent, + # 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"))), ) - if result: - return jsonify({ - 'success': True, - 'result': { - 'primary_tap': result.primary_tap, - 'secondary_tap': result.secondary_tap, - 'Np_eff': result.Np_eff, - 'Ns_eff': result.Ns_eff, - 'Vp_rms': round(result.Vp_rms, 2), - 'Vs_rms': round(result.Vs_rms, 2), - 'Ip_rms': round(result.Ip_rms, 3), - 'Is_rms': round(result.Is_rms, 3), - 'turns_ratio': round(result.turns_ratio, 3), - 'P_out_W': round(result.P_out_W, 2), - 'P_in_W': round(result.P_in_W, 2), - 'P_cu_W': round(result.P_cu_W, 2), - 'P_cu_primary_W': round(result.P_cu_primary_W, 3), - 'P_cu_secondary_W': round(result.P_cu_secondary_W, 3), - 'P_core_W': round(result.P_core_W, 2), - 'efficiency': round(result.efficiency * 100, 2), - 'B_peak_T': round(result.B_peak_T, 4), - 'power_error_percent': round(result.power_error_percent, 2), - } - }) - else: - return jsonify({ - 'success': False, - 'error': 'No valid configuration found within constraints' - }) + 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] - except Exception as e: return jsonify({ - 'success': False, - 'error': str(e) - }), 400 + "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 -@app.route('/api/transformer_info', methods=['GET']) -def transformer_info(): - """Return transformer configuration information""" - tf = get_default_transformer() - return jsonify({ - 'primary_taps': tf.primary_taps, - 'secondary_taps': tf.secondary_taps, - 'Ae_mm2': tf.Ae_mm2, - 'Np_total': tf.Np_total, - 'Ns_total': tf.Ns_total, - }) - - -if __name__ == '__main__': - app.run(debug=True, host='0.0.0.0', port=5000) +if __name__ == "__main__": + app.run(debug=True, host="0.0.0.0", port=5000) diff --git a/designer.py b/designer.py index 9cdb2de..4b09d35 100644 --- a/designer.py +++ b/designer.py @@ -169,6 +169,9 @@ class ToroidCore: @property def cross_section_area_mm2(self) -> float: """Rectangular cross-section area of the core material.""" + if self.Ae_mm2 != None: + return self.Ae_mm2 + return ((self.OD_mm - self.ID_mm) / 2.0) * self.height_mm @property diff --git a/sim_toroid.py b/sim_toroid.py index 44e62dd..6e0f13c 100644 --- a/sim_toroid.py +++ b/sim_toroid.py @@ -329,43 +329,34 @@ class ToroidSimulator: turns_ratio = Ns / Np # a = Ns/Np - # --- Flux density --------------------------------------------------- - # The primary voltage applied to the winding minus the resistive drop - # sets the flux. We iterate once: compute ideal Ip, correct Vp seen by - # core, then recompute. - # - # Iteration: - # Pass 0 (ideal): B from full Vp - # Pass 1: B from (Vp - Ip_0 * Rp) where Ip_0 is ideal primary current - # + # --- Circuit solution (exact voltage divider) ----------------------- Ae_m2 = self._effective_Ae_mm2() * 1e-6 - def _compute_op(Vp_core: float): - """Compute operating point given effective core voltage.""" - B = Vp_core / (4.44 * Np * Ae_m2 * freq_hz) - # Ideal open-circuit secondary voltage - Vs_oc = complex(Vp_core * turns_ratio, 0.0) - # Secondary loop: Vs_oc = Is * (Rs + Z_load) - Z_sec_total = complex(Rs, 0.0) + Z_load_complex - Is_phasor = Vs_oc / Z_sec_total - # Voltage across load - Vs_phasor = Is_phasor * Z_load_complex - # Reflect secondary current to primary (ideal transformer) - Ip_reflected = Is_phasor / turns_ratio # phasor, Amperes - return B, Is_phasor, Vs_phasor, Ip_reflected + # Exact voltage divider: solve for Vp_core analytically. + # + # Circuit: Vp --[Rp]-- Vp_core --[ideal xfmr]-- [Rs + Z_load] + # + # Secondary impedance reflected to primary: + # Z_sec_ref = (Rs + Z_load) / turns_ratio² + # Voltage divider: + # Vp_core = Vp * Z_sec_ref / (Rp + Z_sec_ref) + # Then: + # Is = Vp_core * turns_ratio / (Rs + Z_load) + # Ip = Is * turns_ratio (= Vp_core / Z_sec_ref / turns_ratio ... simplified) - # Pass 0: ideal (ignore primary drop for now) - _, Is0, _, Ip0 = _compute_op(Vp_rms) - Ip0_mag = abs(Ip0) + Z_sec_total = complex(Rs, 0.0) + Z_load_complex + Z_sec_ref = Z_sec_total / (turns_ratio ** 2) # reflected to primary + Z_total_primary = complex(Rp, 0.0) + Z_sec_ref - # Pass 1: correct for primary winding voltage drop - # Primary drop is Ip * Rp (in phase with current; Rp is real) - # Vp_core ≈ Vp_rms - Ip0 * Rp (phasor subtraction) - # For simplicity treat Ip0 as real-valued magnitude for the correction - # (conservative: subtracts in phase with voltage) - Vp_core = max(Vp_rms - Ip0_mag * Rp, 0.0) + Vp_core_phasor = complex(Vp_rms, 0.0) * Z_sec_ref / Z_total_primary + Vp_core = abs(Vp_core_phasor) - B_peak_T, Is_phasor, Vs_phasor, Ip_phasor = _compute_op(Vp_core) + B_peak_T = Vp_core / (4.44 * Np * Ae_m2 * freq_hz) + + Vs_oc = Vp_core_phasor * turns_ratio + Is_phasor = Vs_oc / Z_sec_total + Vs_phasor = Is_phasor * Z_load_complex + Ip_phasor = Is_phasor * turns_ratio Is_rms = abs(Is_phasor) Vs_rms_out = abs(Vs_phasor) @@ -876,69 +867,69 @@ if __name__ == "__main__": constraints = SimConstraints( B_max_T=1.0, Vp_max=50.0, - Vs_max=90.0, - Ip_max=5.0, + Vs_max=120.0, + Ip_max=3.0, Is_max=2.0, P_out_max_W=25.0, ) - print("=== Single operating point (10 ohm resistive load) ===") - result = sim.simulate( - Vp_rms=12.0, - freq_hz=256.0, - primary_tap=2, - secondary_tap=1, - Z_load=(100.0, 0.0), - constraints=constraints, - ) - print(result) - print() + # print("=== Single operating point (10 ohm resistive load) ===") + # result = sim.simulate( + # Vp_rms=12.0, + # freq_hz=256.0, + # primary_tap=2, + # secondary_tap=1, + # Z_load=(100.0, 0.0), + # constraints=constraints, + # ) + # print(result) + # print() - print("=== Complex load (8 ohm + 1 mH at 50 kHz -> X ~= 314 ohm) ===") - X = 2 * math.pi * 50_000.0 * 1e-3 - result2 = sim.simulate( - Vp_rms=24.0, - freq_hz=50_000.0, - primary_tap=2, - secondary_tap=1, - Z_load=(8.0, X), - constraints=constraints, - ) - print(result2) - print() + # print("=== Complex load (8 ohm + 1 mH at 50 kHz -> X ~= 314 ohm) ===") + # X = 2 * math.pi * 50_000.0 * 1e-3 + # result2 = sim.simulate( + # Vp_rms=24.0, + # freq_hz=50_000.0, + # primary_tap=2, + # secondary_tap=1, + # Z_load=(8.0, X), + # constraints=constraints, + # ) + # print(result2) + # print() - print("=== Tap sweep ===") - all_results = sweep_taps(sim, 24.0, 50_000.0, (10.0, 0.0), constraints) - for r in all_results: - feasible = "OK" if r.feasible else "VIOL" - print( - f" P{r.primary_tap}/S{r.secondary_tap} " - f"Np={r.Np_eff:3d} Ns={r.Ns_eff:3d} " - f"Vs={r.Vs_rms:.3f}V Is={r.Is_rms:.4f}A " - f"P_out={r.P_out_W:.3f}W eff={r.efficiency*100:.2f}% [{feasible}]" - ) + # print("=== Tap sweep ===") + # all_results = sweep_taps(sim, 24.0, 50_000.0, (10.0, 0.0), constraints) + # for r in all_results: + # feasible = "OK" if r.feasible else "VIOL" + # print( + # f" P{r.primary_tap}/S{r.secondary_tap} " + # f"Np={r.Np_eff:3d} Ns={r.Ns_eff:3d} " + # f"Vs={r.Vs_rms:.3f}V Is={r.Is_rms:.4f}A " + # f"P_out={r.P_out_W:.3f}W eff={r.efficiency*100:.2f}% [{feasible}]" + # ) - print() - print("=== Optimize: find best taps + Vp to deliver ~10 W at 256 Hz ===") - opt = sim.optimize( - freq_hz=256.0, - Z_load=(10.0, 0.0), - target_power_W=25.0, - constraints=constraints, - Vp_min=1.0, - Vp_max=50.0, - Vp_steps=200, - power_tol_pct=2.0, - ) - if opt is None: - print(" No feasible solution found.") - else: - print(opt) + # print() + # print("=== Optimize: find best taps + Vp to deliver ~10 W at 256 Hz ===") + # opt = sim.optimize( + # freq_hz=256.0, + # Z_load=(10.0, 0.0), + # target_power_W=25.0, + # constraints=constraints, + # Vp_min=1.0, + # Vp_max=50.0, + # Vp_steps=200, + # power_tol_pct=2.0, + # ) + # if opt is None: + # print(" No feasible solution found.") + # else: + # print(opt) print() print("=== Operating-point sweep (3 freqs x 3 loads) ===") freqs = [256.0, 870.0, 3140.0, 8900.0] - loads = [(50.0, 0.0), (100.0, 0.0), (200.0, 0.0), (600.0, 0.0)] + loads = [(10.0, 0.0), (50.0, 0.0), (100.0, 0.0), (200.0, 0.0), (600.0, 0.0), (2000.0, 0.0)] entries = sweep_operating_points( sim=sim, frequencies=freqs, diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..1ba730f --- /dev/null +++ b/templates/index.html @@ -0,0 +1,1034 @@ + + + + + + Toroid Transformer Designer + + + + + +
+

Toroid Transformer Designer

+
+
+ +
+ + +
+ + +
+
Core Parameters
+
+
+ + + +
+
+ + +
+
+ +
+
+
+ + +
+
Windings
+
+
+
+ +
+
+
+ + +
+
Design
+
+
+ +
+
+ +
+
+ + +
+
Simulation
+
+
+ +
+
+ +
+
+ + +
+
+ + +
+
+ +
+ +
+ Constraints (expand) +
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+ +
+
+
+
+ +
+ + + +
+ + +
+
Cross-section Drawing
+
+
+ Run Design to see drawing +
+ Toroid cross-section +
+
+ + + + + + + +
+ +
+ + + + + diff --git a/templates/manual.html b/templates/manual.html new file mode 100644 index 0000000..a736e1c --- /dev/null +++ b/templates/manual.html @@ -0,0 +1,562 @@ + + + + + + Manual Simulation - Transformer + + + +
+
+

🔧 Manual Simulation

+ ⚡ Go to Optimizer +
+

Directly specify tap settings and input voltage to simulate transformer performance

+ +
+
+

Tap Selection

+
+ + +
+
+ + +
+
+ +
+

Input Parameters

+
+ + +
+
+ + +
+
+ +
+

Load Conditions

+
+ + +
+
+
+ +
+ +
+ + + + + + +
+ + + +