0
mirror of https://github.com/incutec-hw/OpenESC_20X20.git synced 2026-08-09 22:06:04 +00:00
Files
incutec-OpenESC_20X20/hardware/tools/esc_thermal.py
JustStan d1614eee5c Fix contradictions and duplicated information
Contradictions, resolved against the design files:
- GitHub repo description corrected to KiCad 10 and 6S (schematic/PCB carry
  generator_version 10.0; DOC_INPUT is "+BATT 6S", no 3S floor is documented).
- DOC_NAME text variable now "OpenESC-20x20", matching the repo rename, so the
  old slug stops leaking into title blocks and fabrication exports.
- 20x20-ESC-QC/README.md no longer claims project-local libraries only: 44 of
  the 45 fixture footprints come from KiCad's stock TestPoint library. Also
  records that the fixture schematic is a stub, so the one ESC-QC symbol is
  unused.
- esc_thermal.py header and 20x20 profile name state that SP40N03GNJ is a
  superseded power stage and its numbers do not describe the shipping board
  (24x DOY180N03T). Parameters left untouched, re-parameterization needs the
  DOY180N03T datasheet.
- *.kicad_dru removed from .gitignore: hardware/4in1-mini.kicad_dru is a
  hand-authored DRC rule, a design input, not generated output.
- The "3D exports" ignore rule was malformed (**/.step matched nothing). Now
  hardware/*.step and 20x20-ESC-QC/*.step, which leaves the *.3dshapes/
  library assets tracked.

Duplication, each fact kept in one place:
- README specification table dropped, the full table lives in DESIGN.md.
- Production-export and flash-script statements kept in README Manufacturing;
  the layout rows and DESIGN.md now link there.
- licensing/README.md removed, its one unique sentence moved into the README
  license section. THIRD_PARTY.md and TRADEMARKS.md keep the real content.

Removed:
- hardware/datasheets/COMPONENT_REVIEW.md, a v0.3 audit describing a power
  stage, current sense and regulator that are not in the design files.
- hardware/scripts/v_beta_hygiene_fix.py, an applied migration whose target
  file and all three source strings no longer exist, and which byte-edits
  .kicad_sch against CONTRIBUTING.
- hardware/4in1-mini.step (18.3 MB) and 20x20-ESC-QC/QC-20x20-PCB.step,
  regenerable board exports now correctly gitignored.
- 20x20-ESC-QC/ESC-QC.pretty/.gitkeep, the directory holds a real footprint.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 10:50:09 +02:00

516 lines
22 KiB
Python
Raw Permalink 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.

#!/usr/bin/env python3
"""
4in1 ESC Power Loss & Thermal Calculator
Models AM32 trapezoidal 6-step commutation with steady-state and transient thermal.
MOSFET profiles:
20x20: SP40N03GNJ (Siliup Ver-1.2), Rg=15Ω. SUPERSEDED POWER STAGE. The
shipping OpenESC-20x20 uses 24x DOY180N03T (30 V, PowerDI3333-8, LCSC
C49441966, see hardware/docs/DESIGN.md). This profile has not been
re-parameterized from the DOY180N03T datasheet, so its loss and
temperature numbers do not describe the current board. Board geometry
(1030 mm², 6 layers) is current.
30x30: SP40N01GHNK (Siliup Ver-1.1), Rg=8Ω. Matches the OpenESC-30x30 board.
Gate driver for both: NSG2065Q (NSIC V1.0).
"""
import argparse
import math
# ═══════════════════════════════════════════════════════════════
# MOSFET profiles: all values from datasheets
# ═══════════════════════════════════════════════════════════════
PROFILES = {
"20x20": {
"name": "20×20 SP40N03GNJ (3×3mm, SUPERSEDED, not the shipping part)",
# ── SP40N03GNJ datasheet (Ver-1.2) ──
"rds_on": 2.9e-3, # Ω typ @ Vgs=10V, ID=20A, 25°C
"rds_on_max": 3.7e-3, # Ω max
"rds_tempco": 1.45, # normalized @ 100°C (p3 curve)
"rds_tempco_150": 1.70, # normalized @ 150°C
"qg_at_10v": 15e-9, # C (from gate charge curve p3 @ Vgs=10V)
"qgd": 5.5e-9, # C gate-drain (Miller) charge
"vgs": 10.0, # V
"rg": 15.0, # Ω external gate resistor
"v_plateau": 3.0, # V Miller plateau (transfer curve p3)
# Switching: Qgd / I_gate through Rg
# I_on = (10-3)/15 = 0.47A → tr = 5.5nC/0.47A = 11.7ns
# I_off = 3/15 = 0.20A → tf = 5.5nC/0.20A = 27.5ns
"tr": 11.7e-9,
"tf": 27.5e-9,
"vf_body_cold": 0.80, # V @ ~30A, 25°C (p4 curve extrapolated)
"vf_body_hot": 0.65, # V @ ~30A, 100°C
"trr": 14e-9, # s (p2)
"qrr": 23e-9, # C (p2)
"rth_jc": 2.27, # °C/W (p2)
"pd_max": 55.0, # W
"tj_max": 150.0, # °C
# Board
"board_mm": 20, # mm mounting pattern
"board_area": 1030.0, # mm² actual PCB (~31×33mm, from Edge.Cuts)
"r_copper": 3.2e-3, # Ω per phase trace
"r_shunt": 0.2e-3, # Ω
"layers": 6,
"oz_outer": 1, # oz copper
"oz_inner": 0.5,
},
"30x30": {
"name": "30×30 SP40N01GHNK (5×6mm)",
# ── SP40N01GHNK datasheet (Ver-1.1) ──
"rds_on": 1.2e-3, # Ω typ @ Vgs=10V, ID=20A, 25°C
"rds_on_max": 1.5e-3, # Ω max
"rds_tempco": 1.45, # normalized @ 100°C (p3 curve)
"rds_tempco_150": 1.70, # normalized @ 150°C
"qg_at_10v": 65e-9, # C (from gate charge curve p3 @ Vgs=10V)
"qgd": 15.5e-9, # C gate-drain (Miller) charge
"vgs": 10.0, # V
"rg": 8.0, # Ω external gate resistor
"v_plateau": 3.5, # V Miller plateau (transfer curve p3, higher Vth)
# Switching: Qgd / I_gate through Rg
# I_on = (10-3.5)/8 = 0.81A → tr = 15.5nC/0.81A = 19.1ns
# I_off = 3.5/8 = 0.44A → tf = 15.5nC/0.44A = 35.2ns
"tr": 19.1e-9,
"tf": 35.2e-9,
"vf_body_cold": 0.75, # V @ ~30A, 25°C (p3 source-drain diode curve)
"vf_body_hot": 0.60, # V @ ~30A, 100°C
"trr": 29e-9, # s (p2)
"qrr": 113e-9, # C (p2), much higher than SP40N03GNJ
"rth_jc": 0.96, # °C/W (p2), better, larger package
"pd_max": 130.0, # W
"tj_max": 150.0, # °C
# Board
"board_mm": 30, # mm mounting pattern
"board_area": 1770.0, # mm² actual PCB (~42×43mm, from Edge.Cuts)
"r_copper": 2.0e-3, # Ω per phase (wider traces, more area)
"r_shunt": 0.2e-3, # Ω
"layers": 6,
"oz_outer": 1,
"oz_inner": 0.5,
},
}
# ─── NSG2065Q gate driver (shared) ───
GATE_DRIVER = {
"dead_time_typ": 200e-9, # s hardware dead time (100-300ns, p6)
"dead_time_min": 100e-9,
"dead_time_max": 300e-9,
"io_source": 1.2, # A
"io_sink": 1.5, # A
}
# ─── Thermal scenarios ───
# h = convective heat transfer coefficient (W/m²·K)
# θ_board = 1 / (h × A_both_sides)
THERMAL_SCENARIOS = {
"still": {"h": 15, "label": "Still air (bench)"},
"light_flow": {"h": 50, "label": "Light prop wash (~3m/s)"},
"strong_flow": {"h": 100, "label": "Strong prop wash (~8m/s)"},
"direct_blast": {"h": 150, "label": "Direct motor blast (>10m/s)"},
}
# ─── PCB stackup options for thermal comparison ───
# Copper thickness: 1oz=35µm, 2oz=70µm, 0.5oz=17.5µm
# Thermal conductivity: Cu=385 W/(m·K), FR4=0.3 W/(m·K)
PCB_OPTIONS = {
"jlcpcb_6L_1oz": {
"label": "JLCPCB 6L 1oz/0.5oz (current)",
"oz_outer": 1.0, "oz_inner": 0.5, "layers": 6,
"notes": "Standard, free POFV via-in-pad",
},
"jlcpcb_6L_2oz": {
"label": "JLCPCB 6L 2oz/2oz (max multilayer)",
"oz_outer": 2.0, "oz_inner": 2.0, "layers": 6,
"notes": "Max Cu at JLCPCB for 6L, ~2x bare board cost",
},
"jlcpcb_4L_2oz": {
"label": "JLCPCB 4L 2oz/2oz",
"oz_outer": 2.0, "oz_inner": 2.0, "layers": 4,
"notes": "Cheaper than 6L, no free POFV",
},
"jlcpcb_6L_3oz": {
"label": "JLCPCB 6L 3oz/1oz (industry std ESC)",
"oz_outer": 3.0, "oz_inner": 1.0, "layers": 6,
"notes": "What commercial 35A ESCs use",
},
"pcbway_6L_4oz": {
"label": "PCBWay 6L 4oz/2oz (heavy Cu)",
"oz_outer": 4.0, "oz_inner": 2.0, "layers": 6,
"notes": "PCBWay supports >2oz on multilayer, premium cost",
},
}
def board_theta(area_mm2: float, h: float) -> float:
"""Board-to-ambient thermal resistance (°C/W), both sides convecting."""
area_m2 = area_mm2 * 2.0 * 1e-6
return 1.0 / (h * area_m2)
def board_thermal_mass(area_mm2: float, layers: int, oz_outer: float, oz_inner: float,
board_thickness_mm: float = 1.6) -> float:
"""Board thermal capacitance in J/K.
Accounts for FR-4 substrate and copper layers.
FR-4: ρ=1.85 g/cm³, cp=1.1 J/(g·K)
Cu: ρ=8.96 g/cm³, cp=0.385 J/(g·K)
"""
area_cm2 = area_mm2 * 1e-2 # mm² → cm²
# FR-4 volume (approximate: total thickness minus copper)
cu_outer_mm = oz_outer * 0.035 # mm per oz
cu_inner_mm = oz_inner * 0.035 if oz_inner else 0.0175
n_inner = max(layers - 2, 0)
total_cu_mm = 2 * cu_outer_mm + n_inner * cu_inner_mm
fr4_mm = board_thickness_mm - total_cu_mm
fr4_vol_cm3 = area_cm2 * fr4_mm * 0.1 # cm²×mm→cm³ needs ×0.1
fr4_mass = fr4_vol_cm3 * 1.85
fr4_cth = fr4_mass * 1.1
# Copper volume
cu_vol_cm3 = area_cm2 * total_cu_mm * 0.1
cu_mass = cu_vol_cm3 * 8.96
cu_cth = cu_mass * 0.385
return fr4_cth + cu_cth
def copper_resistance_scaled(r_base: float, oz_base: float, oz_new: float) -> float:
"""Scale copper trace resistance for different copper weight.
R ∝ 1/thickness, so doubling oz halves resistance.
"""
return r_base * oz_base / oz_new
def calc_phase_losses(i: float, v_bus: float, duty: float, f_pwm: float,
comp_pwm: bool, dead_time: float, p: dict,
r_copper_override: float = None) -> dict:
"""Calculate losses for one active phase pair."""
rds = p["rds_on"] * p["rds_tempco"] # hot Rds(on)
vf = p["vf_body_hot"]
r_cu = r_copper_override if r_copper_override else p["r_copper"]
# Conduction
p_cond_hs = i**2 * rds * duty
if comp_pwm:
p_cond_ls = i**2 * rds * (1.0 - duty)
p_body = 0.0
else:
p_cond_ls = 0.0
p_body = vf * i * (1.0 - duty)
p_cond_low = i**2 * rds # LOW-phase FET, 100% on
# Switching (Miller plateau crossing)
p_switch = 0.5 * v_bus * i * (p["tr"] + p["tf"]) * f_pwm
# Dead time (both edges per PWM cycle)
p_dead = vf * i * dead_time * 2.0 * f_pwm
# Reverse recovery (Qrr-based model)
p_rr = p["qrr"] * v_bus * f_pwm
# Gate drive (HS at f_pwm, LS at commutation freq ~200Hz)
p_gate = p["qg_at_10v"] * p["vgs"] * f_pwm + p["qg_at_10v"] * p["vgs"] * 200
# Copper (both phase traces in the current path)
p_copper = i**2 * r_cu * 2.0
# Shunt
p_shunt = i**2 * p["r_shunt"]
p_fet = p_cond_hs + p_cond_ls + p_body + p_cond_low + p_switch + p_dead + p_rr + p_gate
p_passive = p_copper + p_shunt
p_total = p_fet + p_passive
return {
"p_cond_hs": p_cond_hs, "p_cond_ls": p_cond_ls, "p_body": p_body,
"p_cond_low": p_cond_low, "p_switch": p_switch, "p_dead": p_dead,
"p_rr": p_rr, "p_gate": p_gate, "p_copper": p_copper, "p_shunt": p_shunt,
"p_fet": p_fet, "p_passive": p_passive, "p_total": p_total,
}
def transient_time_to_tj(p_board: float, theta_ba: float, c_th: float,
tj_max: float, t_ambient: float) -> float:
"""Time (seconds) from ambient to Tj_max under constant power.
Simple first-order RC model: ΔT(t) = ΔT_ss × (1 - e^(-t/τ))
τ = C_th × θ_ba
Returns float('inf') if steady-state ΔT < (Tj_max - T_ambient).
"""
dt_max = tj_max - t_ambient
dt_ss = p_board * theta_ba
if dt_ss <= dt_max:
return float('inf') # never reaches Tj_max
tau = c_th * theta_ba
return -tau * math.log(1.0 - dt_max / dt_ss)
def print_full_report(profile_key: str, i: float, v_bus: float, duty: float,
f_pwm: float, comp_pwm: bool, dead_time: float, n_active: int):
p = PROFILES[profile_key]
rds_hot = p["rds_on"] * p["rds_tempco"]
losses = calc_phase_losses(i, v_bus, duty, f_pwm, comp_pwm, dead_time, p)
p_board = losses["p_total"] * n_active
p_motor = v_bus * i * duty * n_active
eff = p_motor / (p_motor + p_board) * 100 if p_motor > 0 else 0
print("=" * 70)
print(f" 4in1 ESC Power Loss Report - {p['name']}")
print(f" AM32 6-Step Trapezoidal | NSG2065Q gate driver")
print("=" * 70)
print(f" Battery voltage: {v_bus:.1f} V")
print(f" Phase current: {i:.1f} A")
print(f" Duty cycle: {duty*100:.1f}%")
print(f" PWM frequency: {f_pwm/1e3:.0f} kHz")
print(f" Dead time: {dead_time*1e9:.0f} ns (NSG2065Q HW)")
print(f" Comp PWM: {'ON' if comp_pwm else 'OFF'}")
print(f" Gate resistor: {p['rg']:.0f}Ω → tr={p['tr']*1e9:.1f}ns tf={p['tf']*1e9:.1f}ns")
print(f" Rds(on): {p['rds_on']*1e3:.1f}mΩ cold → {rds_hot*1e3:.1f}mΩ hot (×{p['rds_tempco']})")
print(f" Active phases: {n_active}")
print("-" * 70)
print(f"\n Per phase pair (HS + LS MOSFET + copper):")
print(f" HS conduction: {losses['p_cond_hs']:7.3f} W (I²×Rds×D)")
if comp_pwm:
print(f" LS sync rect: {losses['p_cond_ls']:7.3f} W (I²×Rds×(1-D))")
else:
print(f" Body diode: {losses['p_body']:7.3f} W (Vf×I×(1-D))")
print(f" LOW-phase FET: {losses['p_cond_low']:7.3f} W (I²×Rds, 100%)")
print(f" Switching: {losses['p_switch']:7.3f} W")
print(f" Dead time: {losses['p_dead']:7.3f} W")
print(f" Reverse recovery: {losses['p_rr']:7.3f} W (Qrr={p['qrr']*1e9:.0f}nC)")
print(f" Gate drive: {losses['p_gate']:7.3f} W")
print(f" ────────────────────────────────────────")
print(f" FET subtotal: {losses['p_fet']:7.3f} W")
print(f" Copper (×2): {losses['p_copper']:7.3f} W ({p['r_copper']*1e3:.1f}mΩ/phase)")
print(f" Shunt: {losses['p_shunt']:7.3f} W")
print(f" ────────────────────────────────────────")
print(f" Phase total: {losses['p_total']:7.3f} W")
print(f"\n Board totals ({n_active} phases):")
print(f" Heat dissipation: {p_board:7.2f} W")
print(f" Motor output: {p_motor:7.1f} W")
print(f" Efficiency: {eff:7.1f}%")
# ─── Steady-state thermal ───
area = p["board_area"]
c_th = board_thermal_mass(area, p["layers"], p["oz_outer"], p["oz_inner"])
print(f"\n Steady-state thermal ({area:.0f}mm² board, {p['layers']}L "
f"{p['oz_outer']:.0f}oz/{p['oz_inner']}oz):")
print(f" {'Scenario':<28s} {'θ':>6s} {'ΔT':>6s} {'Tj@25°C':>7s} {'Tj@40°C':>7s}")
print(f" {'-'*62}")
for key, sc in THERMAL_SCENARIOS.items():
theta = board_theta(area, sc["h"])
dt = p_board * theta
tj25 = 25 + dt
tj40 = 40 + dt
w = " !! EXCEED" if tj25 > 150 else " ! HOT" if tj25 > 125 else ""
print(f" {sc['label']:<28s} {theta:5.1f}°/W +{dt:4.0f}°C {tj25:6.0f}°C {tj40:6.0f}°C{w}")
# ─── Transient thermal ───
print(f"\n Transient thermal (time to Tj=150°C from 25°C ambient):")
print(f" Board thermal mass: {c_th:.2f} J/K")
print(f" {'Scenario':<28s} {'τ':>6s} {'t to 150°C':>10s}")
print(f" {'-'*50}")
for key, sc in THERMAL_SCENARIOS.items():
theta = board_theta(area, sc["h"])
tau = c_th * theta
t_max = transient_time_to_tj(p_board, theta, c_th, 150, 25)
if t_max == float('inf'):
t_str = "never"
elif t_max < 1:
t_str = f"{t_max*1000:.0f} ms"
else:
t_str = f"{t_max:.1f} s"
print(f" {sc['label']:<28s} {tau:5.1f}s {t_str:>10s}")
# ─── MOSFET limits ───
p_worst_fet = max(
losses["p_cond_hs"] + losses["p_switch"] + losses["p_dead"]/2 + losses["p_rr"],
losses["p_cond_low"] + losses["p_body"] + losses["p_dead"]/2,
)
print(f"\n MOSFET: RθJC={p['rth_jc']:.2f}°C/W Pd_max={p['pd_max']:.0f}W Tj_max={p['tj_max']:.0f}°C")
print(f" Worst single FET: {p_worst_fet:.2f}W → ΔTjc={p_worst_fet*p['rth_jc']:.1f}°C above pad")
print("=" * 70)
def sweep(profile_key: str, currents: list, v_bus: float, duty: float,
f_pwm: float, comp_pwm: bool, dead_time: float, n_active: int):
p = PROFILES[profile_key]
area = p["board_area"]
c_th = board_thermal_mass(area, p["layers"], p["oz_outer"], p["oz_inner"])
theta_fly = board_theta(area, 100) # strong prop wash
print()
print(f" {p['name']} @ {v_bus:.0f}V {duty*100:.0f}% {f_pwm/1e3:.0f}kHz "
f"comp_pwm={'ON' if comp_pwm else 'OFF'}")
print(f" Board: {area:.0f}mm² θ_propwash={theta_fly:.1f}°/W C_th={c_th:.2f}J/K")
print(f" {'I':>6s} {'Phase':>7s} {'Board':>7s} {'Eff':>5s} "
f"{'Tj_fly':>6s} {'t→150°C':>8s} {'Rating':>12s}")
print(f" {'-'*66}")
for current in currents:
losses = calc_phase_losses(current, v_bus, duty, f_pwm, comp_pwm, dead_time, p)
p_board = losses["p_total"] * n_active
p_motor = v_bus * current * duty * n_active
eff = p_motor / (p_motor + p_board) * 100 if p_motor > 0 else 0
tj_fly = 25 + p_board * theta_fly
t_max = transient_time_to_tj(p_board, theta_fly, c_th, 150, 25)
if t_max == float('inf'):
t_str = ""
if tj_fly < 100:
rating = "continuous"
elif tj_fly < 125:
rating = "continuous*"
else:
rating = "marginal"
elif t_max > 10:
t_str = f"{t_max:.0f}s"
rating = "burst OK"
elif t_max > 3:
t_str = f"{t_max:.1f}s"
rating = "burst limit"
else:
t_str = f"{t_max:.1f}s"
rating = "DANGER"
print(f" {current:5.0f}A {losses['p_total']:6.2f}W {p_board:6.1f}W "
f"{eff:4.1f}% {tj_fly:5.0f}°C {t_str:>8s} {rating:>12s}")
print()
print(" continuous = Tj < 100°C steady-state in prop wash")
print(" continuous* = Tj 100-125°C (hot but survivable)")
print(" marginal = Tj 125-150°C (near limit)")
print(" burst OK = >10s to Tj_max (typical punch-out)")
print(" burst limit = 3-10s to Tj_max")
print()
def compare_boards(v_bus: float, duty: float, f_pwm: float, comp_pwm: bool,
dead_time: float, n_active: int):
"""Side-by-side comparison of both ESC variants."""
currents = [5, 10, 15, 20, 25, 30, 35, 40, 50]
print()
print("=" * 78)
print(" BOARD COMPARISON - 20×20 vs 30×30")
print(f" {v_bus:.0f}V {duty*100:.0f}% duty {f_pwm/1e3:.0f}kHz "
f"comp_pwm={'ON' if comp_pwm else 'OFF'} {n_active} phases")
print("=" * 78)
for key in ["20x20", "30x30"]:
p = PROFILES[key]
area = p["board_area"]
c_th = board_thermal_mass(area, p["layers"], p["oz_outer"], p["oz_inner"])
theta_fly = board_theta(area, 100)
rds_hot = p["rds_on"] * p["rds_tempco"]
print(f"\n {p['name']}")
print(f" Rds(on)={rds_hot*1e3:.1f}mΩ hot Rg={p['rg']:.0f}Ω "
f"Qrr={p['qrr']*1e9:.0f}nC RθJC={p['rth_jc']:.2f}°/W")
print(f" θ_fly={theta_fly:.1f}°/W C_th={c_th:.2f}J/K "
f"R_cu={p['r_copper']*1e3:.1f}mΩ/phase")
print(f" {'I':>6s} {'Board W':>7s} {'Eff':>5s} "
f"{'Tj_fly':>6s} {'t→150°C':>8s}")
print(f" {'-'*44}")
for current in currents:
losses = calc_phase_losses(current, v_bus, duty, f_pwm, comp_pwm, dead_time, p)
p_board = losses["p_total"] * n_active
p_motor = v_bus * current * duty * n_active
eff = p_motor / (p_motor + p_board) * 100 if p_motor > 0 else 0
tj_fly = 25 + p_board * theta_fly
t_max = transient_time_to_tj(p_board, theta_fly, c_th, 150, 25)
t_str = "" if t_max == float('inf') else f"{t_max:.1f}s" if t_max < 60 else f"{t_max/60:.0f}m"
warn = " !!" if tj_fly > 150 else " !" if tj_fly > 125 else ""
print(f" {current:5.0f}A {p_board:6.1f}W {eff:4.1f}% "
f"{tj_fly:5.0f}°C {t_str:>8s}{warn}")
print()
def compare_pcb_options(profile_key: str, i: float, v_bus: float, duty: float,
f_pwm: float, comp_pwm: bool, dead_time: float, n_active: int):
"""Show impact of different PCB stackups on thermal performance."""
p = PROFILES[profile_key]
print()
print(f" PCB stackup comparison for {p['name']} @ {i:.0f}A {v_bus:.0f}V {duty*100:.0f}%")
print("=" * 78)
print(f" {'Option':<38s} {'R_cu':>5s} {'Board W':>7s} {'Tj_fly':>6s} "
f"{'t→150':>6s} {'Notes'}")
print(f" {'-'*76}")
for opt_key, opt in PCB_OPTIONS.items():
r_cu = copper_resistance_scaled(p["r_copper"], p["oz_outer"], opt["oz_outer"])
losses = calc_phase_losses(i, v_bus, duty, f_pwm, comp_pwm, dead_time, p,
r_copper_override=r_cu)
p_board = losses["p_total"] * n_active
area = p["board_area"]
c_th = board_thermal_mass(area, opt["layers"], opt["oz_outer"], opt["oz_inner"])
theta_fly = board_theta(area, 100)
tj = 25 + p_board * theta_fly
t_max = transient_time_to_tj(p_board, theta_fly, c_th, 150, 25)
t_str = "" if t_max == float('inf') else f"{t_max:.1f}s"
print(f" {opt['label']:<38s} {r_cu*1e3:4.1f} {p_board:6.1f}W "
f"{tj:5.0f}°C {t_str:>5s} {opt['notes']}")
print()
def main():
parser = argparse.ArgumentParser(
description="4in1 ESC power loss calculator (AM32 6-step trapezoidal)",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""\
examples:
%(prog)s -p 20x20 -i 30 -v 16.8 -d 80
%(prog)s -p 30x30 -i 40 -v 25.2 -d 50 --comp-pwm
%(prog)s -p 20x20 --sweep 5,10,15,20,25,30,35,40
%(prog)s --compare -v 16.8 -d 80
%(prog)s --pcb-options -p 30x30 -i 30 -v 16.8 -d 80
""")
parser.add_argument("-p", "--profile", choices=["20x20", "30x30"], default="20x20",
help="ESC variant (default: 20x20)")
parser.add_argument("-i", "--current", type=float, default=30.0)
parser.add_argument("-v", "--voltage", type=float, default=16.8)
parser.add_argument("-d", "--duty", type=float, default=80.0)
parser.add_argument("-f", "--pwm-freq", type=float, default=24000)
parser.add_argument("--dead-time", type=float, default=200e-9)
parser.add_argument("--comp-pwm", action="store_true")
parser.add_argument("-n", "--num-active", type=int, default=4)
parser.add_argument("--sweep", type=str, default=None)
parser.add_argument("--compare", action="store_true",
help="compare 20x20 vs 30x30 side by side")
parser.add_argument("--pcb-options", action="store_true",
help="compare PCB stackup options")
args = parser.parse_args()
duty = args.duty / 100.0
if args.compare:
compare_boards(args.voltage, duty, args.pwm_freq, args.comp_pwm,
args.dead_time, args.num_active)
elif args.pcb_options:
compare_pcb_options(args.profile, args.current, args.voltage, duty,
args.pwm_freq, args.comp_pwm, args.dead_time, args.num_active)
elif args.sweep:
currents = [float(x) for x in args.sweep.split(",")]
sweep(args.profile, currents, args.voltage, duty, args.pwm_freq,
args.comp_pwm, args.dead_time, args.num_active)
else:
print_full_report(args.profile, args.current, args.voltage, duty,
args.pwm_freq, args.comp_pwm, args.dead_time, args.num_active)
if __name__ == "__main__":
main()