Solar Hop universal charging pod — design simulations¶
Twelve sections test the design reference: seven simulations, three engineering analyses, a final integrated mission with a doc-to-code audit, and a stress test of weather, power and failures. Revision 3 incorporates two independent adversarial reviews. Every parameter is in podsim/params.py, each marked as a datasheet value, a design choice, or an assumption to replace with measurement. The pass/fail suite is tests/test_design.py (run python -m pytest -q).
| # | Question | Model |
|---|---|---|
| 1 | Do both solar kits stay inside the controller's voltage limits from −40 to +45 °C? | NEC 690.7(A)(1) coefficient method |
| 2 | How long does a drone charge take, how much energy does it draw, and can a cell ever exceed its limit? | CC/CV into a 6S pack with OCV curve, R0 and one RC branch |
| 3 | How many charges per day does each configuration deliver, and does storage charge current stay under 25 A? | One-minute energy balance over days |
| 4 | Can the sense check be fooled by water or debris? | Nonlinear circuit solve: source, film, body diodes, pack |
| 5 | Does every landing put a contact on each polarity without shorting the pad? | Monte Carlo and exhaustive grid over yaw and offset |
| 6 | Do the protections stop a failed converter, an overheated pad, a pad short and a liftoff? | 1 kHz controller state machine against a plant model |
| 7 | Does the sense circuit, built from real part values, still work across tolerances and temperature? | Component-level solve, 600-build Monte Carlo |
| 8 | Does the sealed case stay under the 45 °C charging limit? | Steady-state lumped thermal model |
| 9 | Wind, snow, pressure, contact heating, magnetic field, wiring drop | Engineering calculations |
| 10 | Can the camera see the tags to touchdown, and what does the drone kit cost in range? | Geometry and scaling laws |
| 11 | Does everything work together, in sequence, over three days of mixed events? Does the doc match the code? | Integrated mission simulation; doc-to-code audit |
| 12 | Does it stay safe under any weather, power condition and failure, including failures of the protection hardware? | 400-campaign randomized stress test, named extremes, FMEA |
Results are bounded by the models. Panel coefficients, pack resistances, the OCV curve, the 75 % solar derate and the sun profile are representative values, not measurements of the parts that will be bought. The thermal, wind and contact analyses use stated coefficients that a sun test, anchor pull test and contact cycling must confirm.
import numpy as np, matplotlib.pyplot as plt
from podsim import params as P, pv, charge, energy as E, sense as SN, geometry as G, controller as K
plt.rcParams.update({'figure.dpi': 110, 'axes.grid': True, 'grid.alpha': .3, 'font.size': 10})
S, M, X, PATH, SEN = P.Storage(), P.MPPT(), P.Site(), P.ChargePath(), P.Sense()
1. PV string voltage limits¶
Cold open-circuit voltage is taken at cell temperature equal to ambient (dawn). Hot operating voltage uses ambient plus the panel's NOCT rise at 1,000 W/m² (31 °C rigid, 35 °C folding on the ground). The controller must see more than battery voltage + 1 V while running; at the end of a LiFePO4 charge that is 28.8 + 1 = 29.8 V.
rows = []
for panel, ctl in [(P.LARGE_PANEL, M), (P.LARGE_PANEL_72, M), (P.LARGE_PANEL_72, P.MPPT_100_30), (P.LARGE_PANEL, P.MPPT_100_30)]:
r = pv.check_string(panel, 2, ctl, S.v_full_charge); rows.append(r)
print(f"{r['panel']:30s} on {r['controller']}: cold Voc {r['voc_cold_V']:6.1f} V vs limit {r['limit_abs_V']:.0f} V -> {'PASS' if r['cold_ok'] else 'FAIL'} (margin {r['cold_margin_V']:+.1f} V)")
P20 = P.Panel('100 W folding (Vmp 20.0 V)', 100, 24.0, 20.0, 5.5, 5.0, -0.0030, -0.0042, 35.0)
for panel in (P.SMALL_PANEL_18, P.SMALL_PANEL, P20):
r = pv.check_string(panel, 2, M, S.v_full_charge)
print(f"{r['panel']:30s} hot Vmp at 45 C ambient {r['vmp_hot_V']:5.1f} V vs needed {r['run_needed_V']:.1f} V -> {'PASS' if r['hot_run_ok'] else 'FAIL'}")
t = np.linspace(-40, 45, 200)
fig, ax = plt.subplots(1, 2, figsize=(11, 3.8))
for panel, ls in [(P.LARGE_PANEL, '-'), (P.LARGE_PANEL_72, '--')]:
voc, _ = pv.sweep(panel, 2, t); ax[0].plot(t, voc, ls, label=f'2 x {panel.name}')
ax[0].axhline(150, color='k', lw=1); ax[0].text(-38, 152, 'MPPT 150/35 absolute max 150 V', fontsize=8)
ax[0].axhline(100, color='r', lw=1); ax[0].text(-38, 102, 'MPPT 100/30 max 100 V', fontsize=8, color='r')
ax[0].set(xlabel='Ambient temperature (°C)', ylabel='String open-circuit voltage (V)', title='Large kit: cold-morning voltage', ylim=(60, 160)); ax[0].legend(fontsize=8)
for panel in (P.SMALL_PANEL_18, P.SMALL_PANEL, P20):
_, vmp = pv.sweep(panel, 2, t); ax[1].plot(t, vmp, label=f'2 x {panel.name}')
ax[1].axhline(29.8, color='r', lw=1); ax[1].text(-38, 30.1, 'needed to finish a charge: 29.8 V', fontsize=8, color='r')
ax[1].set(xlabel='Ambient temperature (°C)', ylabel='String operating voltage (V)', title='Small kit: hot-day operating voltage'); ax[1].legend(fontsize=8)
plt.tight_layout(); plt.savefig('figures/1_pv.png'); plt.show()
300 W, 60-cell (Voc 40.0 V) on MPPT 150/35: cold Voc 95.6 V vs limit 150 V -> PASS (margin +54.4 V) 300 W, 72-cell (Voc 45.6 V) on MPPT 150/35: cold Voc 109.0 V vs limit 150 V -> PASS (margin +41.0 V) 300 W, 72-cell (Voc 45.6 V) on MPPT 100/30: cold Voc 109.0 V vs limit 100 V -> FAIL (margin -9.0 V) 300 W, 60-cell (Voc 40.0 V) on MPPT 100/30: cold Voc 95.6 V vs limit 100 V -> PASS (margin +4.4 V) 100 W folding (Vmp 18.0 V) hot Vmp at 45 C ambient 27.7 V vs needed 29.8 V -> FAIL 100 W folding (Vmp 19.0 V) hot Vmp at 45 C ambient 29.2 V vs needed 29.8 V -> FAIL 100 W folding (Vmp 20.0 V) hot Vmp at 45 C ambient 30.8 V vs needed 29.8 V -> PASS
Result. The 150 V controller holds with either large panel type (95.6 V and 109.0 V at −40 °C). A 100 V controller would be overvolted by 72-cell panels (109 V) and has only 4.4 V of margin even with 60-cell panels. For the small kit, 19 V panels fall to 29.2 V at a 45 °C ambient and stop charging near the top of charge; the small-kit requirement is raised from Vmp ≥ 19 V to Vmp ≥ 20 V (30.8 V hot). The failure mode at 19 V is reduced output in extreme heat, not damage.
2. Drone charge: time, energy and cell voltage¶
The charger regulates its own output. Contact (20 mΩ) and rectifier (10 mΩ) resistance sit between it and the pack, so the pack sees less than the setpoint while current flows. "Internal cell voltage" is OCV plus polarization, the quantity that governs overcharge. The CV setpoint is also run at +0.5 %, the worst case of the specified accuracy.
cases = [(P.QUAD, 0.25, 24.6), (P.VTOL, 0.25, 24.6), (P.QUAD, 0.0, 25.2), (P.VTOL, 0.0, 25.2)]
res = {}
for pack, soc0, cv in cases:
r = charge.simulate_charge(pack, PATH, soc0=soc0, cv=cv); res[(pack.name, soc0, cv)] = r
print(f"{pack.name:14s} from {soc0*100:3.0f}% at CV {cv} V: {r['time_h']:.2f} h to {r['soc_final']*100:.1f}% "
f"energy from storage {r['e_storage_wh']:.0f} Wh max internal cell {r['max_v_cell_internal']:.3f} V")
for pack in (P.QUAD, P.VTOL):
for cv in (24.6, 25.2):
r = charge.simulate_charge(pack, PATH, soc0=0.0, cv=cv, cv_error=+0.005)
print(f" +0.5% setpoint error, {pack.name}, CV {cv}: max internal cell {r['max_v_cell_internal']:.4f} V")
fig, ax = plt.subplots(1, 3, figsize=(12, 3.5))
for pack in (P.QUAD, P.VTOL):
r = res[(pack.name, 0.25, 24.6)]; th = r['t_s'] / 3600
ax[0].plot(th, r['i_a'], label=pack.name); ax[1].plot(th, r['v_cell_internal'], label=pack.name); ax[2].plot(th, r['soc'] * 100, label=pack.name)
ax[0].set(xlabel='h', ylabel='A', title='Charge current'); ax[1].set(xlabel='h', ylabel='V per cell', title='Internal cell voltage'); ax[1].axhline(4.10, color='r', lw=1)
ax[2].set(xlabel='h', ylabel='%', title='State of charge')
for a in ax: a.legend(fontsize=8)
plt.tight_layout(); plt.savefig('figures/2_charge.png'); plt.show()
Quad 6S 10 Ah from 25% at CV 24.6 V: 1.08 h to 91.6% energy from storage 171 Wh max internal cell 4.092 V VTOL 6S 22 Ah from 25% at CV 24.6 V: 1.29 h to 91.3% energy from storage 378 Wh max internal cell 4.089 V Quad 6S 10 Ah from 0% at CV 25.2 V: 1.31 h to 99.3% energy from storage 251 Wh max internal cell 4.192 V VTOL 6S 22 Ah from 0% at CV 25.2 V: 1.51 h to 99.0% energy from storage 557 Wh max internal cell 4.189 V +0.5% setpoint error, Quad 6S 10 Ah, CV 24.6: max internal cell 4.1130 V
+0.5% setpoint error, Quad 6S 10 Ah, CV 25.2: max internal cell 4.2057 V +0.5% setpoint error, VTOL 6S 22 Ah, CV 24.6: max internal cell 4.1095 V +0.5% setpoint error, VTOL 6S 22 Ah, CV 25.2: max internal cell 4.2045 V
Result. From a typical 25 % arrival, the quad reaches 91.6 % in 1.08 h and the VTOL 91.3 % in 1.29 h. The doc's "roughly an hour" is right for the quad and short by about 20 minutes for the VTOL; corrected to about 1.1 h (quad) and 1.3 h (VTOL). A full charge from empty to 25.2 V draws 251 Wh (quad; the doc used 250) and 557 Wh (VTOL; the doc used 540, corrected). No case exceeds the per-cell setpoint. At +0.5 % error the 24.6 V setting peaks at 4.12 V per cell, well inside 4.20; the 25.2 V full-charge setting reaches 4.21 V, so full-charge mode requires ±0.25 % setpoint accuracy, or a 25.1 V setpoint.
3. Daily energy balance and charge-current cap¶
Capacity mode: a drone is always waiting, each arriving empty and charged to 25.2 V (the doc's conservative basis). Solar-only pods start a charge only if storage can finish it without future sun. Grid pods credit 80 % of the grid charger's output over the charge, and switch the grid on when that credit is needed. Storage discharge efficiency is 98 %, idle draw 2 W.
cfgs = [E.Config('Large kit', 600), E.Config('Small kit', 200), E.Config('Grid only', 0, True), E.Config('Large kit + grid', 600, True)]
table = []
for pack in (P.QUAD, P.VTOL):
for c in cfgs:
row = [pack.name, c.name]
for psh, dl in ((4.0, 12), (1.5, 9)):
r = E.run(c, pack, PATH, S, M, X, days=14, psh=psh, day_len_h=dl, cv=25.2)
row += [r['mean_charges_steady'], r['min_soc'], r['max_i_charge']]
table.append(row)
print(f"{'pack':14s} {'configuration':17s} | {'4 PSH: /day':>11s} {'min SOC':>7s} {'max A':>6s} | {'1.5 PSH: /day':>13s} {'min SOC':>7s} {'max A':>6s}")
for r in table:
print(f"{r[0]:14s} {r[1]:17s} | {r[2]:11.1f} {r[3]:7.2f} {r[4]:6.1f} | {r[5]:13.1f} {r[6]:7.2f} {r[7]:6.1f}")
pack configuration | 4 PSH: /day min SOC max A | 1.5 PSH: /day min SOC max A Quad 6S 10 Ah Large kit | 6.8 0.18 9.3 | 2.5 0.19 4.7 Quad 6S 10 Ah Small kit | 2.2 0.18 3.1 | 0.7 0.18 1.6 Quad 6S 10 Ah Grid only | 16.2 0.80 12.0 | 16.2 0.80 12.0 Quad 6S 10 Ah Large kit + grid | 16.2 0.80 19.5 | 16.2 0.80 16.5 VTOL 6S 22 Ah Large kit | 3.1 0.21 9.3 | 1.1 0.19 4.7 VTOL 6S 22 Ah Small kit | 1.0 0.28 3.1 | 0.3 0.18 1.6 VTOL 6S 22 Ah Grid only | 12.7 0.20 12.0 | 12.7 0.20 12.0 VTOL 6S 22 Ah Large kit + grid | 14.2 0.37 21.0 | 14.0 0.20 16.7
fig, ax = plt.subplots(figsize=(11, 3.6))
for c in cfgs:
r = E.run(c, P.QUAD, PATH, S, M, X, days=5, psh=4.0, cv=25.2)
ax.plot(np.arange(len(r['log']['soc'])) / 1440, r['log']['soc'] * 100, label=c.name)
ax.axhline(20, color='r', lw=1); ax.text(0.05, 21.5, '20 % reserve', fontsize=8, color='r')
ax.set(xlabel='Day', ylabel='Storage state of charge (%)', title='Storage over five clear days, quad drones always waiting (4 PSH)'); ax.legend(fontsize=8, ncol=4)
plt.tight_layout(); plt.savefig('figures/3_energy.png'); plt.show()
# stochastic month: random daily clearness, mean 4 PSH
for c in cfgs[:2]:
r = E.run(c, P.QUAD, PATH, S, M, X, days=30, psh=4.0, clearness_seed=11, cv=25.2)
cpd = r['charges_per_day']
print(f"{c.name:10s} 30 random days (mean 4 PSH): mean {cpd[2:].mean():.1f}/day, worst day {cpd[2:].min()}, best {cpd[2:].max()}")
Large kit 30 random days (mean 4 PSH): mean 6.8/day, worst day 2, best 9 Small kit 30 random days (mean 4 PSH): mean 2.1/day, worst day 1, best 3
# Worst-case storage charge current (not reachable with the derated sun profile above)
i_solar_peak = 600 * 1.10 * M.efficiency / 24.0 # nameplate x 10 % cold-bright over-irradiance, at 24 V
print(f'Large-kit solar peak into storage: {i_solar_peak:.1f} A (cap 25 A) -> set MPPT max charge current to 25 A')
print(f'Lockout edge: solar {X.grid_solar_lockout_a} A + grid {X.grid_charger_a} A = {X.grid_solar_lockout_a + X.grid_charger_a} A (cap 25 A)')
Large-kit solar peak into storage: 26.7 A (cap 25 A) -> set MPPT max charge current to 25 A Lockout edge: solar 13.0 A + grid 12.0 A = 25.0 A (cap 25 A)
Result. The doc's solar figures hold: large kit 6.8 quad or 3.1 VTOL charges per clear 4-PSH day (doc: 7 and 3), small kit 2.2 quad or 1.0 VTOL (doc: 2 and 1). In 1.5-PSH winter sun the large kit drops to 2.5 quad / 1.1 VTOL and the small kit to 0.7 quad / 0.3 VTOL, confirming that remote winter sites must be sized on the worst month. Grid-only throughput is limited by charge time for the quad (16 per day) and by grid power for the VTOL (12.7 per day, one per 1.9 h, not the doc's 1.6 h, because the grid charger delivers about 300 W at bulk-charge voltage, not 340 W).
Two control findings:
- The original rule "start only if storage alone can finish" starved grid pods: with grid switching on at 40 % and a VTOL charge needing 41 % of storage, the pod sat idle. The start rule now credits 80 % of the grid energy over the charge, and the grid switches on whenever a drone is waiting that storage alone cannot finish. With a 90 % credit the reserve dipped to 16 %; at 80 % it holds at 20 % with no loss of throughput.
- The 13 A solar lockout holds total charge current to 25 A, but the large kit alone can reach about 26.7 A in cold, bright conditions. The MPPT's maximum charge current must be set to 25 A.
4. Sense check against water and debris¶
A single-level check (accept if pad voltage is steady inside the window) is compared with a two-level check. The source and window here are the final 29.5 V and 17.6–27.6 V from simulation 7; with the doc's original 19–26.5 V window the single-level check was fooled by films from about 18 to 102 kΩ. The two-level check works (measure at about 3 mA and about 1 mA; a battery's voltage barely moves, a resistor's voltage scales with the source).
R = np.logspace(1, 8, 1500)
vhi = np.array([SN.node_voltage({'kind': 'resistor', 'r': r}, SEN.r_high, SEN) for r in R])
vlo = np.array([SN.node_voltage({'kind': 'resistor', 'r': r}, SEN.r_low, SEN) for r in R])
fool1 = R[(vhi >= SEN.window_lo) & (vhi <= SEN.window_hi)]
fool2 = [r for r in R if SN.classify({'kind': 'resistor', 'r': r}, SEN, True)[0]]
print(f'Single-level check energizes the pad for any film from {fool1.min()/1e3:.1f} to {fool1.max()/1e3:.1f} kOhm')
print(f'Two-level check false accepts: {len(fool2)} of {len(R)} film values')
vp = np.linspace(17, 26.5, 60)
acc = [SN.classify({'kind': 'pack', 'v_pack': v}, SEN)[0] for v in vp]
print(f'Packs accepted from {vp[np.argmax(acc)]:.2f} V to {vp[len(acc)-1-np.argmax(acc[::-1])]:.2f} V')
for v in (18.0, 22.0, 25.2):
ok, a, b = SN.classify({'kind': 'pack', 'v_pack': v}, SEN); print(f' pack {v} V: pad reads {a:.2f} V / {b:.2f} V (delta {a-b:.3f} V) -> {ok}')
print(f'Maximum sense current {SN.max_source_current(SEN)*1e3:.2f} mA at {SEN.v_source} V')
fig, ax = plt.subplots(figsize=(10, 3.6))
ax.semilogx(R, vhi, label='pad voltage, ~3 mA source'); ax.semilogx(R, vlo, label='pad voltage, ~1 mA source')
ax.axhspan(SEN.window_lo, SEN.window_hi, color='orange', alpha=.2, label='accept window')
ax.axvspan(fool1.min(), fool1.max(), color='red', alpha=.08, label='films that fool the single-level check')
ax.set(xlabel='Film resistance across the strips (Ω)', ylabel='V', title='A resistive film can sit inside the window; it cannot hold its voltage between two source levels'); ax.legend(fontsize=8)
plt.tight_layout(); plt.savefig('figures/4_sense.png'); plt.show()
Single-level check energizes the pad for any film from 14.5 to 140.2 kOhm Two-level check false accepts: 0 of 1500 film values Packs accepted from 17.00 V to 26.50 V pack 18.0 V: pad reads 19.08 V / 18.99 V (delta 0.083 V) -> True pack 22.0 V: pad reads 23.04 V / 22.96 V (delta 0.083 V) -> True pack 25.2 V: pad reads 26.19 V / 26.11 V (delta 0.082 V) -> True Maximum sense current 3.03 mA at 29.5 V
Result. This is a real error in the doc: a single-level check powers the pad for any water or debris film between about 14.5 and 140 kΩ with the final source and window (18–102 kΩ with the original ones). The two-level check rejects every film from 1 Ω to 1 GΩ and accepts every 6S pack from 17.0 to 26.5 V (pad voltage changes by about 0.08 V between levels, against a 0.30 V limit). A drone sitting in a heavy wet film (under about 100 kΩ) is refused, which fails safe; charging may be unavailable in heavy rain. Packs below about 17 V are refused; packs between 17 and 18 V are pre-charged at C/10 (see section 7).
5. Contact geometry (revision 2: nine contacts)¶
The strips are 15 mm apart and the contacts 10 mm across, so no contact can bridge two strips; a contact counts only with at least 2 mm of overlap. Each landing has a random heading and a random offset. Revision 1 used five contacts. An independent review showed that layout fails when one contact fails, so it is compared here with alternatives, including tolerance to contact loss and placement error and a finite pad with landing scatter.
cands = {'5: square + centre (rev 1)': (np.r_[G.square(300), [[0, 0]]], 156.5),
'7: hexagon + centre': (G.hex_centre(170), 88.0),
'8: octagon': (G.ring(170, 8), 70.0),
'9: 3 x 3 grid, 150 mm spacing (rev 2)': (G.grid3(300), 110.0)}
print(f"{'layout':40s} {'strip':>6s} {'grid fail':>9s} {'1 contact lost':>15s} {'+/-10 mm build':>15s}")
for name, (L, w) in cands.items():
f, b, _ = G.worst_case_grid(L, w, 15, 10, 361, 361)
sl = G.single_loss_worst(L, w, 15, 10, n=20000)
t10 = G.tolerance_worst(L, w, 15, 10, 10, builds=60)
print(f"{name:40s} {w:6.1f} {f*100:8.3f}% {sl*100:14.2f}% {t10*100:14.3f}%")
L9 = G.grid3(300)
band = [w for w in np.arange(100, 121, 0.5) if G.worst_case_grid(L9, w, 15, 10, 361, 361)[0] == 0]
print(f'9-contact grid: zero failures for strip widths {min(band)}-{max(band)} mm around the 110 mm design point')
two = min(G.success_rate(np.delete(L9, [i, j], axis=0), 110.0, 15, 10, n=8000)[0] for i in range(9) for j in range(i+1, 9))
print(f'9-contact grid with any two contacts failed: worst success {two*100:.2f} %')
for sig in (50, 100, 150, 200):
print(f'finite pad (8 strips, 0.985 m), landing scatter sigma {sig} mm: {G.finite_field_rate(L9, 110.0, 15, 10, 8, 985.0, sig)*100:.3f} % succeed')
W = np.arange(60, 180, 1.5)
fig, ax = plt.subplots(figsize=(10, 3.4))
for name, (L, w) in cands.items():
ax.plot(W, [G.success_rate(L, x, 15, 10, n=12000)[0] * 100 for x in W], label=name)
ax.set(xlabel='Strip width (mm), gap 15 mm', ylabel='Landings with both polarities (%)', ylim=(60, 101), title='Success rate against strip width'); ax.legend(fontsize=8)
plt.tight_layout(); plt.savefig('figures/5_geometry.png'); plt.show()
layout strip grid fail 1 contact lost +/-10 mm build
5: square + centre (rev 1) 156.5 0.000% 79.41% 0.015%
7: hexagon + centre 88.0 0.000% 100.00% 0.495%
8: octagon 70.0 0.000% 100.00% 0.075%
9: 3 x 3 grid, 150 mm spacing (rev 2) 110.0 0.000% 100.00% 0.000%
9-contact grid: zero failures for strip widths 100.0-120.5 mm around the 110 mm design point
9-contact grid with any two contacts failed: worst success 100.00 % finite pad (8 strips, 0.985 m), landing scatter sigma 50 mm: 100.000 % succeed
finite pad (8 strips, 0.985 m), landing scatter sigma 100 mm: 99.997 % succeed finite pad (8 strips, 0.985 m), landing scatter sigma 150 mm: 99.903 % succeed
finite pad (8 strips, 0.985 m), landing scatter sigma 200 mm: 99.001 % succeed
Result. The rev 1 five-contact layout never fails when intact, but losing one contact drops it to about 79 % of landings. Revision 2 uses nine contacts on a 3 × 3 grid at 150 mm spacing, with strips 110 mm wide and 15 mm apart (pitch 125 mm; eight strips span 0.985 m). It shows:
- zero failures at every heading and offset;
- 100 % success with any two contacts failed;
- zero failures with ±10 mm placement error;
- on the finite pad, at least 99.997 % of landings succeed if landing scatter is within σ = 100 mm.
That scatter becomes the precision-landing requirement. The cost is mass, covered in section 10.
6. Protection: fault injection (revision 3 controller)¶
Two independent protection paths.
Hardware. A latched comparator pair opens the pad-side MOSFET switch within 10 µs:
- overvoltage at a nominal 25.05 V. Its trip band of 24.89–25.21 V is derived from part tolerances, drift over −20 to 60 °C and comparator offset.
- overcurrent at 30 A.
Firmware, at 1 kHz. Revision 3 added the items a second independent review found missing:
- turn-on with a pre-biased converter and a 100 ms current ramp;
- a 10 s C/10 probe and pre-charge;
- a two-level sense re-check every 1 s, which catches a conductor bridging the strips while a drone sits on them;
- WAIT that drops to IDLE if the drone leaves;
- an "already full" refusal;
- lockout counting only electrical faults;
- a reserve abort conditioned on grid current actually flowing.
The plant runs at 10 µs steps, or 100 µs for multi-second cases.
import collections
agg = collections.defaultdict(list)
for f in ['converter_runaway', 'short_on_pad', 'cc_fail_short', 'pad_overheat', 'hot_ambient', 'liftoff', 'none']:
for pack in (P.QUAD, P.VTOL):
for soc0 in (0.05, 0.20, 0.50, 0.85):
for ov in (24.89, 25.21):
r = K.run_case(pack, PATH, f, soc0=soc0, ov_hw=ov)
agg[(f, pack.name)].append(r)
print(f"{'case':18s} {'pack':14s} {'end states / reasons':48s} {'worst open (ms)':>15s} {'peak A':>7s}")
for (f, pk), rs in agg.items():
tags = sorted(set(f"{r['state']}:{r['reason'][:22]}" for r in rs)); opens = [r['open_after_s'] for r in rs if r['open_after_s'] is not None]
print(f"{f:18s} {pk:14s} {'; '.join(tags)[:48]:48s} {max(opens)*1e3 if opens else float('nan'):15.2f} {max(r['peak_i_after'] for r in rs):7.1f}")
print()
print('Hardware path alone (firmware disabled), converter runaway, comparator at the top of its band:')
for pack in (P.QUAD, P.VTOL):
for soc0 in (0.05, 0.20, 0.50, 0.85):
r = K.run_case(pack, PATH, 'converter_runaway', soc0=soc0, ov_hw=25.21, firmware_off=True)
print(f" {pack.name:14s} SOC {soc0:.2f}: {r['reason']:22s} open {r['open_after_s']*1e3:.2f} ms, peak {r['peak_i_after']:.1f} A")
print()
print('Conductor bridging the strips while a drone charges (appears at 0.5 s):')
for pack in (P.QUAD, P.VTOL):
for rb in (1.2, 2.0, 5.0, 50.0, 1000.0):
r = K.run_case(pack, PATH, 'bridge', soc0=0.5, t_fault=0.5, t_end=2.5, dt=1e-4, r_bridge=rb)
print(f" {pack.name:14s} {rb:7.1f} ohm: {r['state']:7s} {r['reason'][:36]:36s} after {r['open_after_s']:.2f} s, energy into bridge {r['bridge_energy_j']:.0f} J")
print()
for pack in (P.QUAD, P.VTOL):
r = K.run_case(pack, PATH, 'none', soc0=0.3, from_start=True, skip_precharge=False, t_end=14.0, dt=1e-4)
print(f"Turn-on from START, {pack.name}: {r['state']}, peak turn-on current {r['peak_i_turnon']:.1f} A, pre-charge ended: {not r['ctl'].pre}, setpoint {r['ctl'].i_set} A")
r = K.run_case(pack, PATH, 'none', soc0=0.02, from_start=True, skip_precharge=False, t_end=12.0, dt=1e-4)
print(f"Deeply discharged {pack.name}: still in pre-charge at {r['ctl'].i_set:.1f} A")
r = K.run_case(pack, PATH, 'none', soc0=0.995, from_start=True, skip_precharge=False, t_end=3.0, dt=1e-4)
print(f"Full {pack.name} lands: {r['state']} ({r['reason']}), faults logged {len(r['ctl'].fault_log)}")
print('WAIT then drone leaves:', K.wait_then_drone_leaves(P.QUAD, PATH), '| lockout:', K.lockout_sequence(P.QUAD, PATH),
'| hot ambient x4 locks out?', K.environment_ends_do_not_lock_out(P.QUAD, PATH))
print('Reserve abort, no grid current:', K.reserve_abort(P.QUAD, PATH, False), '| grid flowing:', K.reserve_abort(P.QUAD, PATH, True))
print('Comparator band from parts:', [round(x, 2) for x in K.ov_band()], '| with 1 % / 100 ppm divider:', [round(x, 2) for x in K.ov_band(div_tol=0.01, tc_ppm=220)])
r = K.run_case(P.VTOL, PATH, 'converter_runaway', soc0=0.85, ov_hw=25.21, firmware_off=True)
sl = (r['series']['t'] > 0.0100) & (r['series']['t'] < 0.0115); tt = (r['series']['t'][sl] - 0.01049) * 1000
fig, ax = plt.subplots(1, 2, figsize=(11, 3.2))
ax[0].plot(tt, r['series']['v_pad'][sl]); ax[0].axhline(25.21, color='r', lw=1); ax[0].set(xlabel='ms after fault', ylabel='V', title='Pad voltage, hardware path only')
ax[1].plot(tt, r['series']['i'][sl]); ax[1].axhline(30, color='r', lw=1); ax[1].set(xlabel='ms after fault', ylabel='A', title='Current until the MOSFET switch opens')
plt.tight_layout(); plt.savefig('figures/6_faults.png'); plt.show()
case pack end states / reasons worst open (ms) peak A converter_runaway Quad 6S 10 Ah FAULT:hardware overcurrent; FAULT:hardware overv 0.33 53.8 converter_runaway VTOL 6S 22 Ah FAULT:hardware overcurrent; FAULT:hardware overv 0.33 80.7 short_on_pad Quad 6S 10 Ah FAULT:undervoltage: short or 0.51 10.0 short_on_pad VTOL 6S 22 Ah FAULT:undervoltage: short or 0.51 20.0 cc_fail_short Quad 6S 10 Ah FAULT:overcurrent 0.51 25.0 cc_fail_short VTOL 6S 22 Ah FAULT:hardware overcurrent 0.02 50.0 pad_overheat Quad 6S 10 Ah FAULT:pad overheat 0.51 10.0 pad_overheat VTOL 6S 22 Ah FAULT:pad overheat 0.51 20.0 hot_ambient Quad 6S 10 Ah DONE:environment 0.51 10.0 hot_ambient VTOL 6S 22 Ah DONE:environment 0.51 20.0 liftoff Quad 6S 10 Ah IDLE: 0.51 0.0 liftoff VTOL 6S 22 Ah IDLE: 0.51 0.0 none Quad 6S 10 Ah CHARGE: nan 10.0 none VTOL 6S 22 Ah CHARGE: nan 20.0 Hardware path alone (firmware disabled), converter runaway, comparator at the top of its band: Quad 6S 10 Ah SOC 0.05: hardware overcurrent open 0.02 ms, peak 53.8 A Quad 6S 10 Ah SOC 0.20: hardware overcurrent open 0.02 ms, peak 37.1 A Quad 6S 10 Ah SOC 0.50: hardware overcurrent open 0.29 ms, peak 30.4 A Quad 6S 10 Ah SOC 0.85: hardware overvoltage open 0.33 ms, peak 12.3 A VTOL 6S 22 Ah SOC 0.05: hardware overcurrent open 0.02 ms, peak 80.7 A VTOL 6S 22 Ah SOC 0.20: hardware overcurrent open 0.02 ms, peak 55.7 A VTOL 6S 22 Ah SOC 0.50: hardware overcurrent open 0.02 ms, peak 36.7 A VTOL 6S 22 Ah SOC 0.85: hardware overvoltage open 0.33 ms, peak 18.5 A Conductor bridging the strips while a drone charges (appears at 0.5 s): Quad 6S 10 Ah 1.2 ohm: FAULT overcurrent after 0.00 s, energy into bridge 0 J Quad 6S 10 Ah 2.0 ohm: FAULT re-sense failed: parallel path after 0.50 s, energy into bridge 126 J
Quad 6S 10 Ah 5.0 ohm: FAULT re-sense failed: parallel path after 0.50 s, energy into bridge 52 J Quad 6S 10 Ah 50.0 ohm: FAULT re-sense failed: parallel path after 0.50 s, energy into bridge 5 J Quad 6S 10 Ah 1000.0 ohm: FAULT re-sense failed: parallel path after 0.50 s, energy into bridge 0 J VTOL 6S 22 Ah 1.2 ohm: FAULT re-sense failed: parallel path after 0.50 s, energy into bridge 210 J
VTOL 6S 22 Ah 2.0 ohm: FAULT re-sense failed: parallel path after 0.50 s, energy into bridge 131 J VTOL 6S 22 Ah 5.0 ohm: FAULT re-sense failed: parallel path after 0.50 s, energy into bridge 54 J VTOL 6S 22 Ah 50.0 ohm: FAULT re-sense failed: parallel path after 0.50 s, energy into bridge 6 J VTOL 6S 22 Ah 1000.0 ohm: FAULT re-sense failed: parallel path after 0.50 s, energy into bridge 0 J
Turn-on from START, Quad 6S 10 Ah: CHARGE, peak turn-on current 10.0 A, pre-charge ended: True, setpoint 10.0 A
Deeply discharged Quad 6S 10 Ah: still in pre-charge at 1.0 A Full Quad 6S 10 Ah lands: DONE (pack already full), faults logged 0
Turn-on from START, VTOL 6S 22 Ah: CHARGE, peak turn-on current 20.0 A, pre-charge ended: True, setpoint 20.0 A
Deeply discharged VTOL 6S 22 Ah: still in pre-charge at 2.2 A
Full VTOL 6S 22 Ah lands: DONE (pack already full), faults logged 0
WAIT then drone leaves: ('IDLE', False) | lockout: LOCKOUT | hot ambient x4 locks out? IDLE
Reserve abort, no grid current: ('DONE', 'storage reserve') | grid flowing: ('CHARGE', '')
Comparator band from parts: [24.91, 25.19] | with 1 % / 100 ppm divider: [24.32, 25.78]
Result.
- Electrical faults. Every case ends with the pad unpowered within 1 ms, at every state of charge and at both ends of the comparator band. The hardware path alone does it with firmware disabled.
- Bridges. A conductor bridging the strips while a drone charges is caught by the 1 s sense re-check, with at most about 0.2 kJ delivered into it. A bridge low enough in resistance to pull current over the setpoint trips at once. Revision 2 missed this case entirely, and its 5 s re-check would have allowed up to 1.9 kJ.
- Turn-on. Turn-on never exceeds the setpoint and never trips falsely. A deeply discharged pack stays in pre-charge.
- Full packs. A full pack is refused without a fault: powering it would hold the pad inside the overvoltage trip band.
- Non-electrical ends. Environment and timer ends do not count toward lockout.
Why the overvoltage trip moved to 25.05 V. Revision 2's 25.30–25.50 V band sat at 4.22–4.25 V per cell, above Li-ion's 4.20 V limit, so a drifting converter between 24.8 and 25.3 V was caught only by the drone BMS. The new band tops out at 4.20 V per cell and sits 0.17 V above the CV maximum (24.72 V). With 1 % parts the band would span 24.3–25.8 V, which fails both ways.
7. Sense circuit at component level, with tolerances (revision 2)¶
Revision 2 parts:
- a 29.5 V ±1 % boost with a true output disconnect;
- R_A 28.7 kΩ and R_B 14.7 kΩ (1 %);
- a Schottky blocking diode;
- a 1.00 MΩ / 82.0 kΩ divider in 0.1 % parts, buffered into a 12-bit ADC with an external reference.
Revision 2 also gives each drone contact its own ideal-diode pair. These may conduct actively (tens of millivolts) or through MOSFET body diodes (about 0.5–0.7 V each), and the Monte Carlo draws both modes, because the reviewer showed the rev 1 model assumed only one.
Each of 600 builds draws a random tolerance on every part:
- resistors ±1 % (divider ±0.1 %);
- boost ±1 %;
- ADC gain ±0.5 %;
- diode spreads, with a −2 mV/K temperature coefficient;
- temperature −20, 25 or 60 °C.
from podsim import sense_hw as H
hw = H.HW()
print(f'Levels: {hw.v_boost/hw.r_high*1e3:.2f} mA and {hw.v_boost/hw.r_a*1e3:.2f} mA into a short; window {hw.window_lo}-{hw.window_hi} V; max delta {hw.max_delta_v} V')
for seed in (5, 11):
w = H.monte_carlo(n_builds=600, seed=seed)
print(f"seed {seed}: {w['cases']} cases: packs rejected {w['pack_rejected']}, films accepted {w['film_accepted']}, open accepted {w['open_accepted']}, short accepted {w['short_accepted']}")
print(f" pack readings {w['min_pack_reading_v']:.2f}-{w['max_pack_reading_v']:.2f} V; lowest open-pad reading {w['min_open_reading_v']:.2f} V; largest pack delta {w['max_pack_delta_v']:.3f} V; smallest in-window film delta {w['min_film_delta_in_window_v']:.2f} V")
print(f" full or partly charged 5S packs (17.5-21.0 V) accepted: {w['five_s_accepted']} of {4*600}")
Levels: 3.03 mA and 1.03 mA into a short; window 17.6-27.6 V; max delta 0.3 V
seed 5: 245400 cases: packs rejected 0, films accepted 0, open accepted 0, short accepted 0 pack readings 17.93-26.87 V; lowest open-pad reading 28.53 V; largest pack delta 0.106 V; smallest in-window film delta 2.03 V full or partly charged 5S packs (17.5-21.0 V) accepted: 2160 of 2400
seed 11: 245400 cases: packs rejected 0, films accepted 0, open accepted 0, short accepted 0 pack readings 17.94-26.87 V; lowest open-pad reading 28.59 V; largest pack delta 0.117 V; smallest in-window film delta 2.13 V full or partly charged 5S packs (17.5-21.0 V) accepted: 2183 of 2400
Result. No build accepted a film, an open pad or a short, and every build accepted every 6S pack from 18.0 to 25.4 V.
- Pack readings span 17.93–26.87 V, at least 0.33 V inside the 17.6–27.6 V window.
- The open pad now reads at least 28.5 V, outside the window by itself. The reviewer found rev 1's open pad could read 27.48 V, inside the window.
- Cell count cannot be told by voltage. Nearly every 5S pack (and a 6S pack with a shorted cell) is accepted, because its voltage overlaps a discharged 6S pack. The pod therefore requires a per-cell BMS on every drone pack: overvoltage 4.25 V, an NTC thermistor charge window of 0–45 °C, and a charge FET that opens before take-off.
8. Case thermal analysis¶
A steady-state lumped model.
Wall node:
- direct sun on one side;
- diffuse and reflected sun on four sides (150 W/m²);
- long-wave radiation from the pad underside;
- heat lost by convection (h = 5.7 + 3.8 v) and radiation to ambient, with sky cooling ignored (conservative).
Interior node: internal dissipation crosses the wall by natural convection (h = 3.5 W/m²K) unless it leaves through a through-wall heat-sink plate (0.40 K/W).
Worst internal load: a VTOL charge at full sun, which is about 64 W:
- 38 W converter;
- 18 W solar controller;
- 6 W battery I²R;
- 2 W controller.
The case is 0.60 × 0.45 × 0.25 m; wind 1 m/s.
from podsim import analysis as A
q = A.internal_losses(); print('internal losses (W):', {k: round(v, 1) for k, v in q.items()})
print(f"{'ambient':>7s} | {'dark, no sink':>14s} {'white, no sink':>15s} {'white + sink + shaded sides':>28s} | {'pad':>5s}")
amb = np.arange(20, 47, 1.0); curves = {}
f_plate = A.heatsink_fraction(q); f_no_mppt = A.heatsink_fraction(q, ('converter', 'pad_switch'))
print(f'Heat leaving through the plate (converter, MPPT and pad switch mounted on it): {f_plate*100:.1f} %; with the MPPT inside: {f_no_mppt*100:.1f} %')
for label, kw in [('dark case, no heat sink', dict(alpha_case=0.9)), ('white case, no heat sink', dict(alpha_case=0.25)),
('white, plate without MPPT', dict(alpha_case=0.25, frac_to_heatsink=f_no_mppt, r_heatsink_kw=0.25, sides_shaded=True)),
('white, heat sink, shaded sides', dict(alpha_case=0.25, frac_to_heatsink=f_plate, r_heatsink_kw=0.25, sides_shaded=True))]:
curves[label] = [A.case_thermal(t, q_internal_w=q['total'], **kw)['t_interior'] for t in amb]
for t in (25, 30, 35, 40, 45):
i = int(t - 20)
tp = A.case_thermal(t, q_internal_w=q['total'], frac_to_heatsink=f_plate, r_heatsink_kw=0.25, sides_shaded=True)
print(f"{t:7.0f} | {curves['dark case, no heat sink'][i]:14.1f} {curves['white case, no heat sink'][i]:15.1f} {curves['white, heat sink, shaded sides'][i]:28.1f} | {tp['t_pad']:5.1f} plate {tp['t_heatsink']:.1f}")
fig, ax = plt.subplots(figsize=(9, 3.4))
for k, v in curves.items(): ax.plot(amb, v, label=k)
ax.plot(amb, [A.case_thermal(t)['t_pad'] for t in amb], '--', label='pad surface')
ax.axhline(45, color='r', lw=1); ax.text(20.5, 45.8, 'LiFePO4 / Li-ion charge limit 45 °C', fontsize=8, color='r')
ax.set(xlabel='Ambient (°C), full sun, VTOL charging', ylabel='°C', title='Interior temperature: only the heat-sunk, shaded case stays under 42 °C to 34 °C ambient'); ax.legend(fontsize=8)
plt.tight_layout(); plt.savefig('figures/8_thermal.png'); plt.show()
internal losses (W): {'converter': 37.9, 'mppt': 18.0, 'pad_switch': 1.5, 'controller': 2.0, 'battery_i2r': 6.2, 'total': 65.7}
ambient | dark, no sink white, no sink white + sink + shaded sides | pad
Heat leaving through the plate (converter, MPPT and pad switch mounted on it): 87.4 %; with the MPPT inside: 60.0 %
25 | 62.4 53.9 32.1 | 45.4 plate 39.4
30 | 67.1 58.8 37.1 | 50.4 plate 44.4
35 | 71.9 63.8 42.2 | 55.3 plate 49.4
40 | 76.6 68.7 47.2 | 60.3 plate 54.4
45 | 81.4 73.6 52.2 | 65.3 plate 59.4
Result. This is a new finding. A sealed case with the converter inside reaches 54 °C at only 25 °C ambient (white) or 62 °C (dark), so the rev 1 thermal design does not work. Revision 2 requires:
- the converter, the solar controller and the pad switch all on a through-wall heat-sink plate of 0.25 K/W or better. The fraction of heat leaving through the plate is derived from where parts are mounted (87 %), not chosen. With the solar controller left inside, the interior reaches 48 °C at 35 °C ambient, as the second review found;
- a white case;
- side shading from the pad overhang.
With these, the interior holds about ambient + 7 K and reaches the 42 °C derate point at 34.8 °C ambient (42.2 °C at 35 °C): full-rate charging to 34 °C ambient in full sun. Above that the firmware halves drone current; in full sun the interior passes 45 °C at about 40 °C ambient, so there is no charging above about 40 °C (section 11). These are steady-state results; the 12 kg battery's heat capacity slows the rise by hours.
The plate itself stays under 50 °C at 35 °C ambient. With low-emissivity plated strips (ε 0.05), the pad surface reaches 50–65 °C in sun, so pad temperature cannot gate drone charging (rev 1 did that; rev 2's code still did, and rev 3 removes it). Charging is gated by the drone pack's own NTC and the pod's shaded ambient sensor. A measured sun test must confirm the model.
9. Wind, snow, enclosure pressure, contacts, magnetic field, wiring¶
pad = 1.385 ** 2
print(f'Pad area {pad:.2f} m2, pod mass 45 kg (estimate)')
for v in (20, 25, 30, 35):
r = A.anchor_requirement(v, pad, 45); r2 = A.anchor_requirement(v, pad, 45, windward_share=1.0)
print(f' wind {v} m/s ({v*2.237:.0f} mph): uplift {r["uplift_n"]:.0f} N vs weight {r["weight_n"]:.0f} N; net with SF 1.5 {r["net_with_sf_n"]:.0f} N; per anchor {r["per_anchor_n"]:.0f} N uniform, {r2["per_anchor_n"]:.0f} N if the windward pair carries it all')
for v in (20, 25, 30):
print(f' panels at {v} m/s: large kit {A.panel_wind(v, 3.26):.0f} N, small kit {A.panel_wind(v, 1.30):.0f} N')
print(f'Snow on pad: {A.snow_load(1.0, pad):.0f} N at 1.0 kPa, {A.snow_load(2.0, pad):.0f} N at 2.0 kPa')
sp = A.sealed_case_pressure(); print(f'Sealed case, 40 K daily swing: {sp["dp_pa"]/1000:.1f} kPa, {sp["lid_force_n"]:.0f} N on the lid')
for F in (1, 2, 4):
r = A.contact_resistance(F); print(f'Gold contact at {F} N: constriction {r*1e3:.2f} mOhm; with films assume 5 mOhm -> {A.contact_heating(20, 0.005):.1f} W at 20 A through one contact')
print(f'Field from 20 A in a strip: {A.strip_field_ut(20, 0.15):.0f} uT at 0.15 m, {A.strip_field_ut(20, 0.3):.0f} uT at 0.3 m (Earth about 50 uT)')
for name, args in [('PV large, 10 m 10 AWG', (10, 10, 9.15, 65.6)), ('PV small, 5 m 10 AWG', (10, 5, 5.0, 40)), ('battery, 0.5 m 6 AWG', (6, 0.5, 25, 25.6)),
('pad output, 1 m 10 AWG', (10, 1, 20, 24.6)), ('grid lead, 3 m 10 AWG', (10, 3, 12, 27))]:
d = A.drop(*args); print(f' {name}: {d["v_drop"]:.2f} V ({d["pct"]:.2f} %), {d["loss_w"]:.1f} W')
Pad area 1.92 m2, pod mass 45 kg (estimate) wind 20 m/s (45 mph): uplift 564 N vs weight 441 N; net with SF 1.5 404 N; per anchor 101 N uniform, 202 N if the windward pair carries it all wind 25 m/s (56 mph): uplift 881 N vs weight 441 N; net with SF 1.5 880 N; per anchor 220 N uniform, 440 N if the windward pair carries it all wind 30 m/s (67 mph): uplift 1269 N vs weight 441 N; net with SF 1.5 1462 N; per anchor 365 N uniform, 731 N if the windward pair carries it all wind 35 m/s (78 mph): uplift 1727 N vs weight 441 N; net with SF 1.5 2149 N; per anchor 537 N uniform, 1075 N if the windward pair carries it all panels at 20 m/s: large kit 1038 N, small kit 414 N panels at 25 m/s: large kit 1622 N, small kit 647 N panels at 30 m/s: large kit 2336 N, small kit 932 N Snow on pad: 1918 N at 1.0 kPa, 3836 N at 2.0 kPa Sealed case, 40 K daily swing: 13.8 kPa, 3733 N on the lid Gold contact at 1 N: constriction 0.76 mOhm; with films assume 5 mOhm -> 2.0 W at 20 A through one contact Gold contact at 2 N: constriction 0.53 mOhm; with films assume 5 mOhm -> 2.0 W at 20 A through one contact Gold contact at 4 N: constriction 0.38 mOhm; with films assume 5 mOhm -> 2.0 W at 20 A through one contact Field from 20 A in a strip: 27 uT at 0.15 m, 13 uT at 0.3 m (Earth about 50 uT) PV large, 10 m 10 AWG: 0.60 V (0.91 %), 5.5 W PV small, 5 m 10 AWG: 0.16 V (0.41 %), 0.8 W battery, 0.5 m 6 AWG: 0.03 V (0.13 %), 0.8 W pad output, 1 m 10 AWG: 0.13 V (0.53 %), 2.6 W grid lead, 3 m 10 AWG: 0.24 V (0.87 %), 2.8 W
Results.
- Wind. Uplift on the 1.92 m² pad passes the pod's weight at about 18 m/s (40 mph). Four anchors rated at least 1.5 kN each cover 35 m/s (78 mph) with a 1.5 safety factor, even if off-centre pressure puts the whole uplift on the windward pair (the second review's point). Panels carry 1–2.3 kN at 20–30 m/s, so the large-kit frame must be anchored, and folding panels must be staked at every corner or taken in before storms.
- Snow. 1.9–3.8 kN on the pad, which the pad and its standoffs must carry. Snow also covers the contacts, so charging stops.
- Pressure. A sealed case sees about 14 kPa (3.7 kN on the lid) over a 40 K daily swing; the pressure-equalizing vent is required, as specified.
- Contacts. Constriction resistance is under 1 mΩ; surface films dominate. At 5 mΩ, one contact carrying the full 20 A dissipates 2 W. Each contact is rated at least 25 A, because all current can pass through one.
- Magnetic field. During charging the strip field (13–27 µT at 0.15–0.3 m) is comparable to Earth's, so the drone's compass is disturbed only while current flows. It must not align heading until the charge has ended.
- Wiring. Every run drops under 1 %.
10. Landing-tag visibility and drone mass¶
off = 0.985 / 2 + 0.10
print('Tag centre 0.59 m from pad centre (200 mm border). Altitude band in which a tag is both in view and decodable (>= 20 px):')
for tag in (0.12, 0.18):
for fov, px in ((60, 1280), (90, 1280), (120, 1280), (120, 1920)):
print(f' tag {tag*1000:.0f} mm, {fov} deg HFOV, {px} px: decodable below {A.max_alt_to_detect_tag(tag, fov, px):.2f} m; any tag in view down to {A.min_alt_any_tag_worst_heading(off, tag, fov, 4/3):.2f} m (4:3 sensor, worst heading), {A.min_alt_any_tag_worst_heading(off, tag, fov, 16/9):.2f} m (16:9)')
print('Added drone-side mass 0.30 kg (9-contact plate, rectifier, BMS upgrade, camera): endurance factor')
for m0 in (2, 3, 4, 6):
print(f' {m0} kg drone: multirotor x{A.endurance_factor_multirotor(m0, 0.3):.3f}, fixed-wing x{A.endurance_factor_fixedwing(m0, 0.3):.3f}')
print('Storage cycling, from the energy-balance logs (capacity mode, 4 PSH, drones always waiting):')
for c in (E.Config('Large kit', 600), E.Config('Grid only', 0, True), E.Config('Large kit + grid', 600, True)):
for pack in (P.QUAD, P.VTOL):
r = E.run(c, pack, PATH, S, M, X, days=10, psh=4.0, cv=25.2)
d = np.diff(r['log']['soc']); thr = -d[d < 0].sum() * S.energy_wh / 1000 / 10
yrs, efc = A.storage_life_years(thr)
print(f' {c.name:17s} {pack.name:14s}: {thr:.2f} kWh/day from storage, {efc:.2f} cycles/day -> {yrs:.1f} y at 3,000 cycles, {2*yrs:.1f} y at 6,000')
Tag centre 0.59 m from pad centre (200 mm border). Altitude band in which a tag is both in view and decodable (>= 20 px): tag 120 mm, 60 deg HFOV, 1280 px: decodable below 6.65 m; any tag in view down to 1.16 m (4:3 sensor, worst heading), 1.55 m (16:9) tag 120 mm, 90 deg HFOV, 1280 px: decodable below 3.84 m; any tag in view down to 0.67 m (4:3 sensor, worst heading), 0.90 m (16:9) tag 120 mm, 120 deg HFOV, 1280 px: decodable below 2.22 m; any tag in view down to 0.39 m (4:3 sensor, worst heading), 0.52 m (16:9) tag 120 mm, 120 deg HFOV, 1920 px: decodable below 3.33 m; any tag in view down to 0.39 m (4:3 sensor, worst heading), 0.52 m (16:9) tag 180 mm, 60 deg HFOV, 1280 px: decodable below 9.98 m; any tag in view down to 1.26 m (4:3 sensor, worst heading), 1.68 m (16:9)
tag 180 mm, 90 deg HFOV, 1280 px: decodable below 5.76 m; any tag in view down to 0.73 m (4:3 sensor, worst heading), 0.97 m (16:9) tag 180 mm, 120 deg HFOV, 1280 px: decodable below 3.33 m; any tag in view down to 0.42 m (4:3 sensor, worst heading), 0.56 m (16:9) tag 180 mm, 120 deg HFOV, 1920 px: decodable below 4.99 m; any tag in view down to 0.42 m (4:3 sensor, worst heading), 0.56 m (16:9) Added drone-side mass 0.30 kg (9-contact plate, rectifier, BMS upgrade, camera): endurance factor 2 kg drone: multirotor x0.811, fixed-wing x0.870 3 kg drone: multirotor x0.867, fixed-wing x0.909 4 kg drone: multirotor x0.897, fixed-wing x0.930 6 kg drone: multirotor x0.929, fixed-wing x0.952 Storage cycling, from the energy-balance logs (capacity mode, 4 PSH, drones always waiting): Large kit Quad 6S 10 Ah : 0.71 kWh/day from storage, 0.56 cycles/day -> 14.8 y at 3,000 cycles, 29.6 y at 6,000 Large kit VTOL 6S 22 Ah : 1.11 kWh/day from storage, 0.87 cycles/day -> 9.4 y at 3,000 cycles, 18.9 y at 6,000
Grid only Quad 6S 10 Ah : 1.05 kWh/day from storage, 0.82 cycles/day -> 10.0 y at 3,000 cycles, 20.0 y at 6,000 Grid only VTOL 6S 22 Ah : 2.57 kWh/day from storage, 2.01 cycles/day -> 4.1 y at 3,000 cycles, 8.2 y at 6,000 Large kit + grid Quad 6S 10 Ah : 1.14 kWh/day from storage, 0.89 cycles/day -> 9.2 y at 3,000 cycles, 18.5 y at 6,000 Large kit + grid VTOL 6S 22 Ah : 2.13 kWh/day from storage, 1.66 cycles/day -> 4.9 y at 3,000 cycles, 9.9 y at 6,000
Results.
- Tags. This is a new finding. Revision 1's 120 mm border tags leave a 60° camera's view below 1.1 m and are too small to decode above about 2 m with a wide lens. Revision 2 specifies 180 mm tags on a 200 mm border (pad 1.39 m square) and a camera of at least 120° horizontal field of view with at least 1,920 pixels across, with a 4:3 sensor. At the worst heading, a tag is then decodable below 5 m and stays in view down to 0.42 m; a 16:9 sensor would lose them at 0.56 m. The last 0.42 m is flown on the held estimate, which the landing tests must confirm.
- Mass. The drone-side kit adds about 0.30 kg. That costs a 3 kg multirotor about 13 % of its endurance and a 3 kg fixed-wing about 9 % of its range. Pod spacing must use the reduced range.
- Storage life. Counted in cycles only, with drones always waiting, the storage battery lasts about 9–15 years at solar-only sites. At a grid site serving VTOLs continuously it lasts about 4 years with 3,000-cycle cells, or 8 with 6,000-cycle cells, so grid sites specify 6,000-cycle cells. Calendar ageing and heat shorten these.
11. Final integrated verification: every model together, in sequence¶
Three days at one pod with the large solar kit and grid kit, at 1 s steps. In each step the models run in this order:
- irradiance and ambient;
- solar controller (25 A cap) and grid rules;
- storage energy balance;
- case thermal, from the live internal losses;
- drone arrival;
- landing on the finite pad, with σ = 100 mm scatter and random heading;
- the component-level sense circuit, using one randomly drawn build of the pod;
- the same reference controller used in the fault simulations;
- CC/CV pack physics, with 0.25 A of avionics draw;
- the drone's BMS.
The days are mild (15–28 °C), hot (27–43 °C) and cold (−4 to 12 °C). Fifteen scripted events each carry an expected outcome. Global invariants are checked on every step. The 10 µs electrical transients are covered by section 6. Here the controller runs at 1 s, with its 1 s sense re-check evaluated every step and the 2 % pause it costs applied to charge current.
from podsim import mission as MS, audit as AU
mr = MS.run()
print(f"{'':4s} {'event':36s} {'expected':16s} {'outcome':42s} {'start h':>7s} {'pack end':>8s} {'max cell V':>10s}")
for e in mr['events']:
print(f"{'PASS' if e['pass'] else 'FAIL':4s} {e['event']:36s} {e['expect']:16s} {(e['end_state'] + ': ' + (e['end_reason'] or ''))[:42]:42s} "
+ (('-' if e['start_h'] is None else format(e['start_h'], '.2f')).rjust(7)) + f" {e['soc_end']*100:7.1f}% {e['max_cell']:10.3f}"
+ (f" relands {e['relands']}" if e['relands'] else '') + (f" bridge {e['bridge_J']:.0f} J" if e['bridge_J'] else '')
+ (f" waited {e['waited_for_temp_min']:.0f} min below 0 C" if e['waited_for_temp_min'] else '') + (' BMS opened' if e['bms_opened'] else ''))
inv = mr['invariants']
print()
print('Invariants over 259,200 one-second steps:')
for k, v in inv.items():
print(f' {k:34s} {v if not isinstance(v, float) else round(v, 3)}')
print(f"\nEvents passed: {sum(e['pass'] for e in mr['events'])} of {len(mr['events'])}")
lg = mr['logs']; th = lg['t'] / 3600
fig, ax = plt.subplots(3, 1, figsize=(11, 7.5), sharex=True)
ax[0].plot(th, lg['soc_st'] * 100); ax[0].axhline(20, color='r', lw=1); ax[0].set(ylabel='Storage %', title='Three days at one pod: storage, temperature and pad current')
ax[1].plot(th, lg['t_amb'], label='ambient'); ax[1].plot(th, lg['t_int'], label='interior'); ax[1].axhline(45, color='r', lw=1); ax[1].set(ylabel='°C'); ax[1].legend(fontsize=8)
ax[2].plot(th, lg['i_pad']); ax[2].set(ylabel='Pad A', xlabel='hours from start')
for e in mr['events']:
ax[2].annotate(e['event'].split(' ')[0], (e['arrive_h'], 21), fontsize=7, ha='center')
ax[2].set_ylim(0, 23)
plt.tight_layout(); plt.savefig('figures/11_mission.png'); plt.show()
event expected outcome start h pack end max cell V PASS 1 normal quad taper DONE: taper 8.00 91.1% 4.088 PASS 2 full pack lands already full DONE: pack already full - 95.0% 4.130 PASS 3 wet pad, then dries taper DONE: taper 11.33 91.1% 4.088 PASS 4 metal bridge mid-charge fault:re-sense FAULT: re-sense failed: parallel path 13.00 45.6% 3.763 bridge 255 J PASS 5 bad first landing, re-land taper DONE: taper 14.01 91.1% 4.088 relands 1 PASS 6a 5S pack (40 %) on 6S route safe DONE: timer 16.00 61.9% 3.850 PASS 6b 5S pack (80 %) on 6S route bms DONE: taper 19.50 92.7% 4.250 BMS opened PASS 7 normal VTOL taper DONE: taper 33.00 91.0% 4.086 PASS 8 VTOL at 41 C ambient, full sun interior CHARGE: drone gave up 42.35 57.5% 3.837 PASS 9 converter fault fault:overvoltage FAULT: overvoltage (hardware) 44.00 61.8% 3.884 PASS 10 VTOL after two faults taper DONE: taper 45.00 91.0% 4.086 PASS 11 VTOL below 0 C, waits taper DONE: taper 55.00 91.0% 4.086 waited 120 min below 0 C PASS 12 second fault within 24 h fault:re-sense LOCKOUT: re-sense failed: parallel path 58.00 37.1% 3.711 bridge 107 J PASS 12b third fault within 24 h fault:re-sense LOCKOUT: re-sense failed: parallel path - 29.9% 3.620 PASS 13 drone at locked-out pod not powered LOCKOUT: re-sense failed: parallel path - 29.9% 3.620 Invariants over 259,200 one-second steps: pad_powered_without_drone_s 0 pad_powered_without_sense_s 0 max_v_pad_fet_on 24.6 max_cell_6s_internal 4.13 max_cell_5s_terminal 4.25 min_storage_soc 0.397 max_storage_soc 1.0 max_storage_charge_a 20.876 max_interior_c 47.337 max_interior_while_charging_c 42.972 derate_seconds 2426 lockout_after_faults 3 max_interior_no_fault_c 46.841 storage_charge_blocked_hot_s 19045 Events passed: 15 of 15
res, stale = AU.run(open('design_reference_rev3.md').read())
print(f"Doc-to-code audit: {sum(r['ok'] for r in res)} of {len(res)} design values match; superseded values outside historical sentences: {len(stale)}")
for r in res:
print(f" {'OK ' if r['ok'] else 'MISS'} {r['check']:30s} code: {r['code_value']}")
lo, hi = K.ov_band(); print(f"Derived OV band {lo:.2f}-{hi:.2f} V lies inside the specified 24.89-25.21 V band: {24.89 <= lo and hi <= 25.21}")
Doc-to-code audit: 38 of 38 design values match; superseded values outside historical sentences: 0 OK CV setpoint code: 24.6 OK Firmware OV trip code: 25.05 OK Hardware OV band (derived) code: (24.91, 25.19) OK Hardware OC trip code: 30.0 OK OC factor code: 1.25 OK UV absolute code: 15.0 OK UV drop code: 2.0 OK Already-full threshold code: 24.4 OK Pre-charge probe / exit code: (10.0, 19.5) OK Taper code: (0.05, 0.3) OK Timer code: 3.0 OK Re-check period / pause code: (1.0, 0.02) OK Interior derate / stop code: (42.0, 45.0) OK Interior start limit code: 43.0 OK Watchdog / relay break / contact short-time code: (0.1, 150, 150) OK Pad alarm code: 80.0 OK Lockout count code: 3 OK Sense boost code: 29.5 OK Sense R_A / R_B code: (28700.0, 14700.0) OK Sense divider code: (1000000.0, 82000.0) OK Sense window code: (17.6, 27.6) OK Sense max change code: 0.3 OK Strips code: (110, 15, 8) OK Contacts code: 9 OK Pad size code: 1.39 OK Tags / camera code: (180, 120, 1920) OK Storage code: (25.6, 50.0) OK Storage charge rating code: 40 OK MPPT code: (150, 35, 25) OK Grid thresholds code: (0.4, 0.8, 13.0) OK Grid credit code: 0.8 OK Heat sink code: (0.25, 87) OK Anchors code: 1.5 OK Full-rate thermal envelope code: 34 OK OV reference tolerance code: 0.001 OK Small kit Vmp code: 20 OK Load disconnect code: 0.1 OK Grid force-on code: 0.15 Derived OV band 24.91-25.19 V lies inside the specified 24.89-25.21 V band: True
Result. All 15 events end as expected:
- normal charges;
- a full pack refused without a fault;
- a wet pad left unpowered until it dried;
- metal bridging the strips caught by the 1 s re-check;
- a bad first landing followed by a good one;
- a 5S pack at 80 % stopped by its own BMS at 4.25 V per cell and ended without a fault (at 40 % it stays in pre-charge until the timer ends it safely);
- charging refused at 41 °C ambient in full sun because the interior passed 45 °C;
- a converter fault cut by the hardware comparator;
- a cold-morning drone that waited two hours for 0 °C;
- lockout on the third electrical fault within 24 h, after which the next drone is not powered.
Invariants held on every step:
- the pad was never powered without a drone, or without a passing sense check immediately before power;
- storage never fell below 20 %;
- no 6S cell exceeded 4.13 V (the full pack that arrived at that level was not charged; charged packs peaked at 4.09 V).
What the integrated run found that the separate simulations had not:
- Drone BMS cutoffs were faults. A drone BMS opening the pack (or a drone lifting off) showed up on the re-check as an open pad and was counted as an electrical fault toward lockout. Revision 3 ends the charge without a fault when the re-check sees an open pad, and keeps the fault for a parallel path.
- The heat envelope was overstated. The thermal envelope is narrower than stated: on a 43 °C day, the interior passed 45 °C from sun alone at about 40 °C ambient, so no charge could start, and the storage BMS blocked solar charging for about 5 hours. The design reference now states full rate to 34 °C, half rate to about 40 °C, and no charging above about 40 °C in full sun.
The doc-to-code audit confirms that every design value the code uses appears in the design reference with the same number, and that no superseded value remains outside a sentence that says it was superseded.
12. Stress test: weather, power and failures¶
Randomized campaign. 400 random two-day scenarios (800 pod-days), loaded from stress_results.json (reproduce with python run_stress.py, about 12 minutes on two cores). Each scenario draws:
- weather: temperature from −25 to 48 °C, cloud cover, rain films and snow cover;
- power: starting storage, grid kit and outages, failed panels or solar controller;
- drones: arrivals, charge level, avionics draw, misloaded 5S packs, lift-offs;
- failures: bridges, converter runaways, pad shorts, a stuck ambient sensor;
- a fresh random build of the sense circuit.
Safety rules are checked on every simulated second, and every visit that ended without a charge must have a valid physical reason.
Live sample. Eight more scenarios are run live below.
Named extremes. Five worst cases.
Protection-component failures (FMEA). Each is run against the worst electrical fault at 10 µs resolution.
import json, collections
from podsim import stress as STR
J = json.load(open('stress_results.json'))
res = J['campaigns']
F = lambda k: [r['features'][k] for r in res]
print(f"Campaigns {len(res)} ({2*len(res)} pod-days); temperatures {min(F('min_t')):.1f} to {max(F('max_t')):.1f} C")
print(f"Rain {sum(F('rain'))}, snow {sum(F('snow'))}, grid kit {sum(F('grid'))}, outages {sum(F('outage'))}, solar failed {sum(1 for x in F('solar') if float(x)==0)}, one panel failed {sum(1 for x in F('solar') if float(x)==0.5)}, stuck ambient sensor {sum(F('stuck_sensor'))}")
print(f"Injected electrical faults {sum(F('faults'))}, lift-offs {sum(F('liftoffs'))}, misloaded 5S packs {sum(F('wrong5s'))}")
V = sum(r['visits'] for r in res); Sv = sum(r['served'] for r in res)
print(f"Visits {V}, served {Sv}; SAFETY VIOLATIONS {sum(len(r['violations']) for r in res)}; UNEXPLAINED REFUSALS {sum(len(r['gaps']) for r in res)}")
mx = lambda k, f=max: f(float(r['stats'][k]) for r in res)
print(f"Worst: 6S cell {mx('max_cell_6s'):.3f} V; storage charge {mx('max_i_storage'):.1f} A; interior while charging {mx('max_t_int_charging'):.1f} C; energy into a bridge {mx('max_bridge_j'):.0f} J; lowest storage {mx('min_soc_st', min)*100:.1f} %")
c = collections.Counter()
for r in res:
for reason, conds, fi, lo, pk in r['refusals']:
c['injected fault' if fi else 'drone lifted off' if lo else 'misloaded 5S pack (BMS stopped it)' if str(pk).startswith('5S') else
('cold below 0 C' if 'cell temperature' in conds else 'no stored energy' if 'no energy' in conds else 'snow on pad' if 'snow' in conds else
'wet pad' if 'wet pad' in conds else 'interior hot' if 'interior hot' in conds else 'other: ' + str(conds))] += 1
print('Why the unserved visits were not charged (first valid cause):')
for k, v in c.most_common(): print(f' {v:5d} {k}')
print()
print('Live sample of 8 further random scenarios:')
for seed in range(900, 908):
r = STR.run(STR.random_scenario(seed))
print(f" seed {seed}: visits {len(r['visits'])}, served {sum(v['served'] for v in r['visits'])}, violations {len(r['violations'])}, unexplained refusals {len(r['gaps'])}")
Campaigns 400 (800 pod-days); temperatures -24.9 to 46.5 C
Rain 257, snow 131, grid kit 242, outages 173, solar failed 77, one panel failed 86, stuck ambient sensor 25
Injected electrical faults 202, lift-offs 56, misloaded 5S packs 155
Visits 2699, served 1646; SAFETY VIOLATIONS 0; UNEXPLAINED REFUSALS 0
Worst: 6S cell 4.199 V; storage charge 25.0 A; interior while charging 45.0 C; energy into a bridge 467 J; lowest storage 9.9 %
Why the unserved visits were not charged (first valid cause):
594 cold below 0 C
165 injected fault
123 no stored energy
98 misloaded 5S pack (BMS stopped it)
46 drone lifted off
18 wet pad
6 interior hot
2 snow on pad
1 other: ['already full', 'lockout']
Live sample of 8 further random scenarios:
seed 900: visits 5, served 0, violations 0, unexplained refusals 0
seed 901: visits 8, served 6, violations 0, unexplained refusals 0
seed 902: visits 8, served 6, violations 0, unexplained refusals 0
seed 903: visits 6, served 0, violations 0, unexplained refusals 0
seed 904: visits 9, served 8, violations 0, unexplained refusals 0
seed 905: visits 4, served 3, violations 0, unexplained refusals 0
seed 906: visits 7, served 4, violations 0, unexplained refusals 0
seed 907: visits 9, served 6, violations 0, unexplained refusals 0
print('Named extremes:')
for e in J['extremes']:
st = e['stats']
print(f" {e['label']:52s} violations {len(e['violations'])}, unexplained {e['gaps']}, served {e['served']}/{e['visits']}, lowest storage {float(st['min_soc_st'])*100:.0f} %, peak interior charging {float(st['max_t_int_charging']):.1f} C")
print()
print('Protection-component failures with a converter runaway (worst over 5/50/85 % charge, both drones):')
cases = [('hardware comparators dead', dict(hw_disabled=True)), ('MOSFET switch stuck closed', dict(switch_stuck=True)),
('switch stuck + comparators dead', dict(switch_stuck=True, hw_disabled=True)), ('firmware hung', dict(fw_hang_at=0.0105)),
('firmware hung + comparators dead', dict(fw_hang_at=0.0100, hw_disabled=True)),
('hung + comparators dead + switch stuck', dict(fw_hang_at=0.0100, hw_disabled=True, switch_stuck=True))]
for name, kw in cases:
o = pk_i = dq = 0.0
for pack in (P.QUAD, P.VTOL):
for soc0 in (0.05, 0.5, 0.85):
r = K.run_case(pack, PATH, 'converter_runaway', soc0=soc0, t_end=0.3, dt=1e-5, **kw)
o, pk_i, dq = max(o, r['open_after_s']), max(pk_i, r['peak_i_after']), max(dq, r['charge_after_mAh'])
print(f' {name:42s} pad unpowered within {o*1e3:6.1f} ms, peak {pk_i:5.0f} A, charge added {dq:.3f} mAh')
Named extremes: 10 dark days, grid down, then sun violations 0, unexplained 0, served 5/24, lowest storage 29 %, peak interior charging 20.0 C heat wave, 30-48 C, full sun, no grid violations 0, unexplained 0, served 7/11, lowest storage 40 %, peak interior charging 43.3 C deep freeze -25 to -5 C, snow cover, grid kit violations 0, unexplained 0, served 0/6, lowest storage 39 %, peak interior charging -99.0 C monsoon rain, low films, three injected faults violations 0, unexplained 0, served 1/5, lowest storage 79 %, peak interior charging 31.6 C ambient sensor stuck at 20 C on a -15 C day violations 0, unexplained 0, served 0/4, lowest storage 72 %, peak interior charging -99.0 C Protection-component failures with a converter runaway (worst over 5/50/85 % charge, both drones):
hardware comparators dead pad unpowered within 0.5 ms, peak 97 A, charge added 0.013 mAh
MOSFET switch stuck closed pad unpowered within 15.3 ms, peak 150 A, charge added 0.606 mAh
switch stuck + comparators dead pad unpowered within 15.5 ms, peak 150 A, charge added 0.626 mAh
firmware hung pad unpowered within 0.3 ms, peak 81 A, charge added 0.002 mAh
firmware hung + comparators dead pad unpowered within 98.5 ms, peak 150 A, charge added 4.083 mAh
hung + comparators dead + switch stuck pad unpowered within 113.5 ms, peak 150 A, charge added 4.707 mAh
Result.
- Randomized campaign. Zero safety violations and zero unexplained refusals across 400 random campaigns, the live sample and the five extremes. Every unserved visit had a physical cause: cold, no energy, a wet or snow-covered pad, a hot case, an injected fault, a lift-off, or a misloaded pack stopped by its BMS.
- Protection-hardware failures. With any single or double failure, and even the triple failure tried (switch stuck, comparators dead, firmware hung), the pad is unpowered within 114 ms and the drone gains at most 4.7 mAh.
The stress test changed the design in two places:
- Interior start limit. A 43 °C interior start limit, because a drone arriving into a hot case had started and was stopped a second later.
- Backup hardware ratings. A 150 A relay break rating, a fast pad fuse, a 150 A / 20 ms contact rating and a 100 ms watchdog, so the backups can handle the failure cases above.
It cannot make the pod charge below 0 °C, above about 40 °C in full sun, without energy, or on a snow-covered pad. In those conditions the correct behaviour is to stay safe and wait, and that is what it did.
13. On-board solar charging: both airframes¶
The drone carries its own panel and a 4-switch buck-boost MPPT charger (LT8490-class: 6–80 V in, CV 24.6 V ±0.5 %, latched OVP at 25.05 V). drone_solar.py models the sun, the cells, a transient thermal network (panel sections, airframe interior, a two-node pack: the face nearest the panel and the core), the 6S pack with a separate-port BMS, the charger logic, the pod's charge logic, and flight. drone_verify.py runs the integrated missions and the stress campaign.
| Quad (3.0 kg, 6S 10 Ah) | VTOL (4.5 kg, 6S 22 Ah) | |
|---|---|---|
| Fixed kit | 24 sixth-cut cells on the body top, 0.061 m², 12 W STC, 0.135 kg | 26 cells in the wing skin, 0.398 m², 80 W STC, 0.30 kg |
| Alternative | Deployable flaps, 0.245 m², 49 W STC, 0.495 kg (stowed in flight) | — |
| Mounting compared | Pack directly under the panel vs a ≥15 mm vented gap with reflective (ε ≤ 0.05) faces | same, over the battery bay |
All airframe numbers are representative assumptions (no airframe data was supplied). Every value sits in drone_solar.py for replacement.
from podsim import drone_solar as D, drone_verify as V
import json
R = json.load(open('drone_results.json'))
print('MPPT input check (charger 6-80 V):')
for r in R['strings']:
print(f" {r['kit']:34s} Voc at -30 C {r['voc_-30C_V']:5.1f} V Vmp at 70 C, 200 W/m2 {r['vmp_70C_200W_V']:5.1f} V P_STC {r['p_stc_W']:5.1f} W")
print()
print('Pod consistency (both airframes):')
for af, kit in ((D.QUAD_AF, D.QUAD_DEPLOY), (D.VTOL_AF, D.VTOL_WING)):
bad = [c for c in D.pod_consistency(af, kit) if not c[1]]
print(f" {af.name}: {len(D.pod_consistency(af, kit)) - len(bad)}/{len(D.pod_consistency(af, kit))} pass", bad)
for c in D.pod_consistency(D.VTOL_AF, D.VTOL_WING):
print(f" {'PASS' if c[1] else 'FAIL'} {c[0]} ({c[2]})")
MPPT input check (charger 6-80 V): Quad fixed body panel, 0.061 m2 Voc at -30 C 18.9 V Vmp at 70 C, 200 W/m2 10.4 V P_STC 12.2 W Quad deployable, 0.245 m2 Voc at -30 C 25.2 V Vmp at 70 C, 200 W/m2 13.9 V P_STC 49.0 W VTOL wing-integrated, 0.398 m2 Voc at -30 C 20.5 V Vmp at 70 C, 200 W/m2 11.3 V P_STC 79.6 W Pod consistency (both airframes): Quad, 3.0 kg, 6S 10 Ah: 16/16 pass [] VTOL, 4.5 kg, 6S 22 Ah: 16/16 pass [] PASS Solar CV equals pod CV (4.10 V/cell) (24.6 V) PASS Solar CV + tolerance below pod hardware OV band (24.723 < 24.89 V) PASS Solar CV + tolerance below pod firmware OV (24.723 < 25.05 V) PASS Solar own OVP equals pod firmware OV (25.05 V) PASS Solar OVP below BMS 4.25 V/cell (25.05 < 25.5 V) PASS Solar refuses below pod UV floor (15 V) (15.0 V) PASS Solar can lift 15-17.6 V packs into the pod sense window (15.0-17.6 V) PASS Solar stops (44 C) before BMS NTC (45 C) (44.0 < 45.0 C) PASS Solar stops (2 C) before BMS NTC (0 C) (2.0 > 0.0 C) PASS Sleep + charger off-state draw within pod's 0.3 A taper budget (29 mA) PASS Worst solar step inside a sense window stays below 0.30 V (0.252 V) PASS Interlock re-enable (5 s) longer than pod pause (20 ms) (5.0 s) PASS Interlock latency within pod watchdog (100 ms) (100 ms) PASS Solar current limit below pod OC trip (4.4 A) PASS Pack presented to the pod only at 10-35 C (no 1 C charge below 10 C) (10.0-35.0 C) PASS Awake draw exceeds 0.3 A, so the drone must sleep during a pod charge (awake 0.39 A, sleep 29 mA)
lat, doy, w = D.CLIMATES['Hot summer (33 N)']
fig, axs = plt.subplots(1, 2, figsize=(12, 3.8), sharey=True)
for ax, (af, kit) in zip(axs, ((D.QUAD_AF, D.QUAD_FIXED), (D.VTOL_AF, D.VTOL_WING))):
for m, ls in ((D.DIRECT, '-'), (D.VENTED, '--')):
S, s = D.simulate(af, kit, m, D.Site(lat, doy, [w, w]), soc0=0.2, dt=10)
ax.plot(S['t_h'] - 24, S['t_pack'], ls, label=f"pack, {m.name.lower()} ({s['wh_solar']:.0f} Wh in 2 days)")
ax.plot(S['t_h'] - 24, S['t_a'], ':', c='grey', label='ambient')
ax.axhline(44, c='r', lw=0.8); ax.axhline(60, c='k', lw=0.8)
ax.set_xlim(0, 24); ax.set_title(af.name + ', hot day 22-38 C'); ax.set_xlabel('hour (day 2)'); ax.legend(fontsize=7)
axs[0].set_ylabel('deg C'); plt.tight_layout(); plt.show()
print('Highest daily maximum with no heat stop, and pack peak on a 45 C day on sand:')
for k, v in R['hottest'].items():
print(f" {k:80s} {v:5.1f} C {R['storage45'][k]:5.1f} C")
Highest daily maximum with no heat stop, and pack peak on a 45 C day on sand: quad|Quad fixed body panel, 0.061 m2|Pack directly under panel 27.1 C 69.6 C quad|Quad fixed body panel, 0.061 m2|Vented gap, reflective underside 38.5 C 52.1 C quad|Quad deployable, 0.245 m2|Vented gap, reflective underside 39.7 C 52.1 C vtol|VTOL wing-integrated, 0.398 m2|Pack directly under panel 26.3 C 68.5 C vtol|VTOL wing-integrated, 0.398 m2|Vented gap, reflective underside 37.3 C 53.7 C
Result: mounting. With the pack directly under the panel, the face cells sit about 20 K above ambient; solar charging is cut by heat on any day above about 27 °C, and the pack reaches 68–70 °C on a 45 °C day, above the 60 °C storage limit for Li-ion. A single low-reading face sensor then lets the face cells charge above 45 °C while the core BMS reads in range (named extreme below). Direct mounting is rejected. The vented gap with reflective faces holds charging to a 37–40 °C daily maximum and the pack to 52–54 °C on a 45 °C day; the panel also shades the body, so the pack runs no hotter than on the same airframe with no panel.
import pandas as pd
df = pd.DataFrame(R['daily'])
df['day 1 Wh'] = df['wh_day1'].round(0); df['% of pack'] = (100 * df['pack_frac_day1']).round(0)
df['hot-blocked min'] = df['min_hot'].round(0); df['cold-blocked min'] = df['min_cold'].round(0); df['heater min'] = df['min_heating'].round(0)
df['mount'] = df['mount'].str.replace('Pack directly under panel', 'direct').str.replace('Vented gap, reflective underside', 'vented')
df['kit'] = df['kit'].str.split(',').str[0]
print('Solar energy into the pack on day 1 from 20 % (second-day blocking figures over 2 days):')
display(df[['climate', 'airframe', 'kit', 'mount', 'day 1 Wh', '% of pack', 't_pack_max', 'hot-blocked min', 'cold-blocked min', 'heater min']].round(1))
Solar energy into the pack on day 1 from 20 % (second-day blocking figures over 2 days):
| climate | airframe | kit | mount | day 1 Wh | % of pack | t_pack_max | hot-blocked min | cold-blocked min | heater min | |
|---|---|---|---|---|---|---|---|---|---|---|
| 0 | Desert summer (33 N) | quad | Quad fixed body panel | direct | 11.0 | 5.0 | 65.7 | 1157.0 | 0.0 | 0.0 |
| 1 | Desert summer (33 N) | quad | Quad fixed body panel | vented | 31.0 | 14.0 | 51.2 | 871.0 | 0.0 | 0.0 |
| 2 | Desert summer (33 N) | quad | Quad deployable | vented | 100.0 | 45.0 | 51.2 | 481.0 | 0.0 | 0.0 |
| 3 | Desert summer (33 N) | vtol | VTOL wing-integrated | direct | 67.0 | 14.0 | 64.9 | 1245.0 | 0.0 | 0.0 |
| 4 | Desert summer (33 N) | vtol | VTOL wing-integrated | vented | 152.0 | 31.0 | 52.5 | 1010.0 | 0.0 | 0.0 |
| 5 | Hot summer (33 N) | quad | Quad fixed body panel | direct | 20.0 | 9.0 | 58.2 | 1025.0 | 0.0 | 0.0 |
| 6 | Hot summer (33 N) | quad | Quad fixed body panel | vented | 82.0 | 37.0 | 43.4 | 0.0 | 0.0 | 0.0 |
| 7 | Hot summer (33 N) | quad | Quad deployable | vented | 169.0 | 76.0 | 43.5 | 0.0 | 0.0 | 0.0 |
| 8 | Hot summer (33 N) | vtol | VTOL wing-integrated | direct | 117.0 | 24.0 | 57.3 | 1116.0 | 0.0 | 0.0 |
| 9 | Hot summer (33 N) | vtol | VTOL wing-integrated | vented | 353.0 | 72.0 | 44.8 | 0.0 | 0.0 | 0.0 |
| 10 | Temperate summer (45 N) | quad | Quad fixed body panel | direct | 85.0 | 38.0 | 42.4 | 0.0 | 0.0 | 0.0 |
| 11 | Temperate summer (45 N) | quad | Quad fixed body panel | vented | 84.0 | 38.0 | 31.2 | 0.0 | 0.0 | 0.0 |
| 12 | Temperate summer (45 N) | quad | Quad deployable | vented | 169.0 | 76.0 | 31.2 | 0.0 | 0.0 | 0.0 |
| 13 | Temperate summer (45 N) | vtol | VTOL wing-integrated | direct | 369.0 | 75.0 | 44.5 | 0.0 | 0.0 | 0.0 |
| 14 | Temperate summer (45 N) | vtol | VTOL wing-integrated | vented | 369.0 | 75.0 | 32.2 | 0.0 | 0.0 | 0.0 |
| 15 | Dark roof, summer (40 N) | quad | Quad fixed body panel | direct | 27.0 | 12.0 | 55.0 | 932.0 | 0.0 | 0.0 |
| 16 | Dark roof, summer (40 N) | quad | Quad fixed body panel | vented | 83.0 | 38.0 | 40.5 | 0.0 | 0.0 | 0.0 |
| 17 | Dark roof, summer (40 N) | quad | Quad deployable | vented | 169.0 | 76.0 | 40.6 | 0.0 | 0.0 | 0.0 |
| 18 | Dark roof, summer (40 N) | vtol | VTOL wing-integrated | direct | 158.0 | 32.0 | 54.4 | 975.0 | 0.0 | 0.0 |
| 19 | Dark roof, summer (40 N) | vtol | VTOL wing-integrated | vented | 369.0 | 76.0 | 42.6 | 0.0 | 0.0 | 0.0 |
| 20 | Temperate spring (45 N) | quad | Quad fixed body panel | direct | 50.0 | 23.0 | 25.5 | 0.0 | 4.0 | 40.0 |
| 21 | Temperate spring (45 N) | quad | Quad fixed body panel | vented | 50.0 | 23.0 | 19.0 | 0.0 | 0.0 | 43.0 |
| 22 | Temperate spring (45 N) | quad | Quad deployable | vented | 170.0 | 77.0 | 19.5 | 0.0 | 7.0 | 45.0 |
| 23 | Temperate spring (45 N) | vtol | VTOL wing-integrated | direct | 335.0 | 69.0 | 27.6 | 0.0 | 20.0 | 65.0 |
| 24 | Temperate spring (45 N) | vtol | VTOL wing-integrated | vented | 336.0 | 69.0 | 20.6 | 0.0 | 15.0 | 60.0 |
| 25 | Tropical, cloudy (5 N) | quad | Quad fixed body panel | direct | 43.0 | 20.0 | 42.6 | 0.0 | 0.0 | 0.0 |
| 26 | Tropical, cloudy (5 N) | quad | Quad fixed body panel | vented | 43.0 | 20.0 | 34.4 | 0.0 | 0.0 | 0.0 |
| 27 | Tropical, cloudy (5 N) | quad | Quad deployable | vented | 171.0 | 77.0 | 35.0 | 0.0 | 0.0 | 0.0 |
| 28 | Tropical, cloudy (5 N) | vtol | VTOL wing-integrated | direct | 289.0 | 59.0 | 43.7 | 0.0 | 0.0 | 0.0 |
| 29 | Tropical, cloudy (5 N) | vtol | VTOL wing-integrated | vented | 288.0 | 59.0 | 35.2 | 0.0 | 0.0 | 0.0 |
| 30 | Temperate winter (45 N) | quad | Quad fixed body panel | direct | 13.0 | 6.0 | 8.0 | 0.0 | 151.0 | 192.0 |
| 31 | Temperate winter (45 N) | quad | Quad fixed body panel | vented | 13.0 | 6.0 | 8.0 | 0.0 | 157.0 | 198.0 |
| 32 | Temperate winter (45 N) | quad | Quad deployable | vented | 65.0 | 29.0 | 8.1 | 0.0 | 115.0 | 140.0 |
| 33 | Temperate winter (45 N) | vtol | VTOL wing-integrated | direct | 107.0 | 22.0 | 8.4 | 0.0 | 139.0 | 154.0 |
| 34 | Temperate winter (45 N) | vtol | VTOL wing-integrated | vented | 106.0 | 22.0 | 8.1 | 0.0 | 134.0 | 153.0 |
| 35 | Cold winter, snow (55 N) | quad | Quad fixed body panel | direct | 0.0 | 0.0 | -5.5 | 0.0 | 380.0 | 380.0 |
| 36 | Cold winter, snow (55 N) | quad | Quad fixed body panel | vented | 0.0 | 0.0 | -4.7 | 0.0 | 380.0 | 380.0 |
| 37 | Cold winter, snow (55 N) | quad | Quad deployable | vented | 4.0 | 2.0 | 8.0 | 0.0 | 420.0 | 607.0 |
| 38 | Cold winter, snow (55 N) | vtol | VTOL wing-integrated | direct | 0.0 | 0.0 | 5.9 | 0.0 | 639.0 | 712.0 |
| 39 | Cold winter, snow (55 N) | vtol | VTOL wing-integrated | vented | 4.0 | 1.0 | 8.0 | 0.0 | 423.0 | 667.0 |
Result: daily yield. The VTOL refills three quarters of its 488 Wh pack in one clear summer or roof day and about 70 % in spring; the quad's fixed panel returns 38 % of its 222 Wh pack per clear summer day, 23 % in spring, 6 % in a temperate winter. The deployable quad kit returns 76–77 %. Winter at 55 °N yields almost nothing on either airframe: the sun is too low to power the heater and the pack stays below 5 °C. In the desert the VTOL returns 31 % (vented) because charging stops for heat through the middle of the day.
print(f"{'configuration':70s} {'mass kg':>7s} {'min':>6s} {'change':>7s}")
for r in R['endurance']:
print(f"{r['airframe'][:4]} {r['config'][:65]:65s} {r['mass_kg']:7.3f} {r['endurance_min']:6.1f} {r['change_pct']:+6.1f}%")
print()
for k, (rise, c35, c40) in R['landing'].items():
print(f"Landing pack rise {k}: +{rise:.1f} K; parked cool-down to 40 C {c40:.0f} min, to 35 C {c35:.0f} min")
print(f"Quad with half the pack resistance (high-power cells): +{R['quad_lowR_rise']:.1f} K after 18 min at 30 C")
configuration mass kg min change Quad Pod kit only 3.300 21.8 +0.0% Quad Quad fixed body panel, 0.061 m2, no sun 3.435 20.4 -6.1% Quad Quad fixed body panel, 0.061 m2, noon, 45 N, June (9 W) 3.435 20.9 -4.0% Quad Quad fixed body panel, 0.061 m2, 09-15 h, 45 N, June (9 W) 3.435 20.9 -4.2% Quad Quad fixed body panel, 0.061 m2, noon, 45 N, December (3 W) 3.435 20.6 -5.5% Quad Quad deployable, 0.245 m2, no sun 3.795 17.4 -19.9% Quad Quad deployable, 0.245 m2, noon, 45 N, June (0 W) 3.795 17.4 -19.9% Quad Quad deployable, 0.245 m2, 09-15 h, 45 N, June (0 W) 3.795 17.4 -19.9% Quad Quad deployable, 0.245 m2, noon, 45 N, December (0 W) 3.795 17.4 -19.9% VTOL Pod kit only 4.800 102.9 +0.0% VTOL VTOL wing-integrated, 0.398 m2, no sun 5.100 96.1 -6.6% VTOL VTOL wing-integrated, 0.398 m2, noon, 45 N, June (63 W) 5.100 139.6 +35.6% VTOL VTOL wing-integrated, 0.398 m2, 09-15 h, 45 N, June (58 W) 5.100 134.3 +30.5% VTOL VTOL wing-integrated, 0.398 m2, noon, 45 N, December (20 W) 5.100 106.8 +3.8% Landing pack rise quad|20: +30.4 K; parked cool-down to 40 C 33 min, to 35 C 54 min Landing pack rise quad|30: +30.3 K; parked cool-down to 40 C 84 min, to 35 C 133 min Landing pack rise vtol|20: +6.5 K; parked cool-down to 40 C 0 min, to 35 C 0 min Landing pack rise vtol|30: +6.5 K; parked cool-down to 40 C 0 min, to 35 C 21 min Quad with half the pack resistance (high-power cells): +14.9 K after 18 min at 30 C
Result: flight. In-flight solar runs through a separate ideal-diode output onto the discharge bus, enabled only when armed, inside the charger's temperature window, and limited to the load (it never net-charges in flight). On the VTOL it supplies about 60 W at midday against about 200 W cruise: +36 % endurance at noon in June, +30 % averaged over 09–15 h, +4 % at noon in December, −7 % at night (mass). On the quad the fixed panel's 9 W against 450 W hover cannot repay its 0.135 kg: −4 to −6 %. The deployable quad kit produces nothing in flight (stowed) and costs −20 %.
Flight heating sets the quad's pack temperature, not the sun: an 18-minute hover at about 2 C lands the pack +30 K over ambient (+6.5 K for the VTOL). At 30 °C ambient the quad pack needs 84 minutes parked before solar can start (40 °C) and 133 minutes before the pod will be given the pack (35 °C). Halving pack resistance (high-power cells) halves the rise.
fig, axs = plt.subplots(2, 1, figsize=(12, 6.5), sharex=True)
for ax, kind in zip(axs, ('vtol', 'quad')):
r = V.integrated_mission(kind)
S = r['series']
ax.plot(S['t_h'], S['t_pack'], label='pack (hottest node)'); ax.plot(S['t_h'], S['t_a'], ':', label='ambient')
ax2 = ax.twinx(); ax2.plot(S['t_h'], S['soc'], 'k', lw=0.8, label='SOC')
ax2.fill_between(S['t_h'], 0, S['i_solar'] / max(S['i_solar'].max(), 1e-6) * 0.15, color='orange', alpha=0.5, label='solar current (scaled)')
ax2.fill_between(S['t_h'], 0, S['i_pod'] / max(S['i_pod'].max(), 1e-6) * 0.25, color='g', alpha=0.4, label='pod current (scaled)')
ax2.set_ylim(0, 1.05); ax.set_ylabel('deg C'); ax.set_title(r['airframe'] + ' - three-day integrated mission')
ax.legend(loc='upper left', fontsize=7); ax2.legend(loc='upper right', fontsize=7)
print(r['airframe'])
for e in r['events']:
print(f" {'PASS' if e['pass'] else 'FAIL'} {e['event']}\n {e['detail']}")
axs[1].set_xlabel('hour'); plt.tight_layout(); plt.show()
VTOL, 4.5 kg, 6S 22 Ah
PASS 1. First flight completes; solar used in load-follow, never charging in flight
in-flight solar 100.6 Wh of 797 Wh flown
PASS 2. Warm landing: solar charge waits until the pack is inside the start window
pack 21.3 C at landing (+5.2 K over ambient); charge began at 8.52 h with pack 21.3 C
PASS 3. Day 1 wilderness parking: pack recharged by sun
SOC 0.34 at landing -> 0.91 at midnight
PASS 4. No solar charging with any pack node outside 2-44 C
pack while charging 5.1 to 32.4 C
PASS 5. Cell voltage never above CV (4.10 V + 0.5 %)
max internal cell voltage 4.0968 V
PASS 6. Pod visit: pod starts, ends on taper or already-full, never faults
pod events [(34.0, 'start'), (34.929, 'taper end')]
PASS 7. Interlock: solar off while the pod charges
steps with both chargers on: 0
PASS 8. Solar resumes after the pod (unless the pack is full)
SOC after pod 0.91
PASS 9. Cloud spell reduces output without false stops
unexplained refusals 0
PASS 10. Cold front: heater runs from solar before any charge; pack never charged below 2 C
heater 102 min; pack min -2.4 C; first charge 7.68 h
PASS 11. 50 % shade: bypass diodes keep the unshaded substrings producing
PV 63.7 W -> 33.2 W under shade
PASS 12. Parked load never forces the avionics off; no flight skipped
lowest SOC 0.34
PASS 13. Pack storage temperature below 60 C throughout
max pack 33.3 C, max panel 67.6 C
PASS 14. No safety invariant violated
0 violations
Quad, 3.0 kg, 6S 10 Ah
PASS 1. First flight completes; solar used in load-follow, never charging in flight
in-flight solar 2.4 Wh of 292 Wh flown
PASS 2. Warm landing: solar charge waits until the pack is inside the start window
pack 38.4 C at landing (+24.5 K over ambient); charge began at 7.27 h with pack 38.1 C
PASS 3. Day 1 wilderness parking: pack recharged by sun
SOC 0.42 at landing -> 0.67 at midnight
PASS 4. No solar charging with any pack node outside 2-44 C
pack while charging 6.4 to 39.9 C
PASS 5. Cell voltage never above CV (4.10 V + 0.5 %)
max internal cell voltage 4.0961 V
PASS 6. Pod visit: pod starts, ends on taper or already-full, never faults
pod events [(33.2, 'drone holds FET open (pack outside 10-35 C)'), (33.846, 'start'), (34.32, 'pack disconnected'), (35.328, 'start'), (35.82, 'taper end')]
PASS 7. Interlock: solar off while the pod charges
steps with both chargers on: 0
PASS 8. Solar resumes after the pod (unless the pack is full)
SOC after pod 0.92
PASS 9. Cloud spell reduces output without false stops
unexplained refusals 0
PASS 10. Cold front: heater runs from solar before any charge; pack never charged below 2 C
heater 106 min; pack min -2.2 C; first charge 9.38 h
PASS 11. 50 % shade: bypass diodes keep the unshaded substrings producing
PV 9.9 W -> 5.1 W under shade
PASS 12. Parked load never forces the avionics off; no flight skipped
lowest SOC 0.28
PASS 13. Pack storage temperature below 60 C throughout
max pack 50.6 C, max panel 61.0 C
PASS 14. No safety invariant violated
0 violations
Result: integrated missions. All 14 events pass on both airframes. The pod visit shows the hand-off: the quad lands with a hot pack, the drone holds its charge FET open (the pod sees an empty pad and keeps sensing), the pod starts when both sensors read 10–35 °C, the 45 °C pack limit interrupts the charge once in the sun, the pod resumes on the next sense check and ends on taper; solar stays off throughout and resumes 5 s after pod current stops.
S = json.load(open('drone_stress_results.json'))
camp, ext = S['campaigns'], S['extremes']
import collections
fc = collections.Counter()
for r in camp:
for k, v in r['faults'].items():
if (k == 'ovp_ok' and not v) or (k != 'ovp_ok' and v not in (None, False, 0.0)): fc[k] += 1
print(f"{len(camp)} random two-day scenarios; airframes {dict(collections.Counter(r['airframe'] for r in camp))}")
print(f"ambient {min(r['t_min'] for r in camp):.0f} to {max(r['t_max'] for r in camp):.0f} C, latitude 0-{max(r['lat'] for r in camp):.0f} deg, all seasons")
print('injected faults:', dict(fc))
print('safety faults per scenario:', dict(collections.Counter(r['n_safety_faults'] for r in camp)))
print('violations with <=1 safety fault:', sum(len(r['violations']) for r in camp if r['n_safety_faults'] <= 1),
'| with 2-3:', sum(len(r['violations']) for r in camp if r['n_safety_faults'] >= 2))
print('refusals with no physical reason and no fault:', sum(r['unexplained'] for r in camp))
print('max cell voltage while charging (no CV fault, 6S):', round(max(r['v_cell_max_charging'] for r in camp if not r['faults']['cv_runaway'] and not r['faults']['pack_swap']), 4))
print('max cell voltage while charging (CV runaway):', round(max(r['v_cell_max_charging'] for r in camp if r['faults']['cv_runaway']), 4))
for m in ('Pack directly under panel', 'Vented gap, reflective underside'):
st = [r['t_pack_max_settled'] for r in camp if r['mount'] == m and r['t_max'] <= 45 and r['t_pack_max_settled'] > -90]
print(f"{m}: parked pack max (ambient <= 45 C, 3 h after any flight) {max(st):.1f} C; above 60 C in {sum(x > 60 for x in st)} scenarios")
print()
print('Named extremes:')
for e in ext:
print(f" {'PASS' if e['pass'] else 'FAIL'} {e['label']}")
400 random two-day scenarios; airframes {'quad': 241, 'vtol': 159}
ambient -30 to 48 C, latitude 0-60 deg, all seasons
injected faults: {'solar_on_open_fet': 43, 'ntc_charger': 32, 'pack_swap': 31, 'interlock_fail': 32, 'ntc_bms_offset': 22, 'shade': 25, 'cv_runaway': 42, 'ovp_ok': 24, 'covered': 34, 'load_follow_fail': 25, 'mppt_dead': 23}
safety faults per scenario: {0: 236, 1: 125, 3: 5, 2: 34}
violations with <=1 safety fault: 0 | with 2-3: 0
refusals with no physical reason and no fault: 0
max cell voltage while charging (no CV fault, 6S): 4.0995
max cell voltage while charging (CV runaway): 4.2476
Pack directly under panel: parked pack max (ambient <= 45 C, 3 h after any flight) 61.6 C; above 60 C in 2 scenarios
Vented gap, reflective underside: parked pack max (ambient <= 45 C, 3 h after any flight) 53.4 C; above 60 C in 0 scenarios
Named extremes:
PASS quad: 48 C dark roof, direct mount, 0.5 m/s wind
PASS quad: 48 C dark roof, vented mount
PASS quad: -30 C snow, heater fitted
PASS quad: -6 to 4 C snow, March, heater fitted
PASS quad: -6 to 4 C snow, March, no heater
PASS quad: CV runaway, charger OVP works (latches, resets at night, locks out at 3)
PASS quad: CV runaway and OVP failed: BMS 4.25 V cut-off
PASS quad: charger NTC open (reads -40 C): no charge, no heat
PASS quad: charger NTC reads 10 K low, hot day, DIRECT mount: face cells charged above 45 C (expected; why direct is rejected)
PASS quad: charger NTC reads 10 K low, hot day, vented mount: core BMS still stops charge
PASS quad: charger NTC reads 20 K low, cold day: heater stopped by BMS-read 15 C
PASS quad: interlock failed during a 3 h pod visit (both chargers at 24.6 V)
PASS quad: double fault, interlock failed + CV runaway on a pod: pod tapers out or reads full, solar OVP latches, cells below 4.25 V
PASS quad: 5S pack fitted by mistake, solar only: BMS 4.25 V governs
PASS quad: panel covered all day (snow or debris)
PASS quad: empty pack (0 % SOC), clear day: precharge then recovery
PASS vtol: 48 C dark roof, direct mount, 0.5 m/s wind
PASS vtol: 48 C dark roof, vented mount
PASS vtol: -30 C snow, heater fitted
PASS vtol: -6 to 4 C snow, March, heater fitted
PASS vtol: -6 to 4 C snow, March, no heater
PASS vtol: CV runaway, charger OVP works (latches, resets at night, locks out at 3)
PASS vtol: CV runaway and OVP failed: BMS 4.25 V cut-off
PASS vtol: charger NTC open (reads -40 C): no charge, no heat
PASS vtol: charger NTC reads 10 K low, hot day, DIRECT mount: face cells charged above 45 C (expected; why direct is rejected)
PASS vtol: charger NTC reads 10 K low, hot day, vented mount: core BMS still stops charge
PASS vtol: charger NTC reads 20 K low, cold day: heater stopped by BMS-read 15 C
PASS vtol: interlock failed during a 3 h pod visit (both chargers at 24.6 V)
PASS vtol: double fault, interlock failed + CV runaway on a pod: pod tapers out or reads full, solar OVP latches, cells below 4.25 V
PASS vtol: 5S pack fitted by mistake, solar only: BMS 4.25 V governs
PASS vtol: panel covered all day (snow or debris)
PASS vtol: empty pack (0 % SOC), clear day: precharge then recovery
Result: stress test. 400 random two-day scenarios (−30 to 48 °C, latitudes to 60°, all seasons, every ground type, random flights and pod visits, start charge 0–100 %, warm starts) with 11 fault types injected singly or in pairs: no safety violation with one fault, none with two or three, and no refusal without a physical reason. Cells never exceeded 4.10 V per cell while charging without a CV fault, and 4.248 V with one (the BMS 4.25 V cut-off). All 32 named extremes behave as expected, including the two that are expected to fail and show why direct mounting is rejected.
What this establishes, and what it does not. The control and protection logic for on-board solar is consistent with the pod and safe under every single and double fault tried. The yields, temperatures and endurance figures depend on the assumed airframes, panel coupling coefficients, pack resistance and cell data; a sun test of the real airframe (thermocouples on the face and core cells), a flight-heating test, and the cell maker's charge-temperature limits must replace them.
14. The L1 aircraft¶
The L1-Q (quadcopter) and L1-W (fixed-wing VTOL) from the L1 design reference v2.2.1, both with a 6S2P Molicel P45B pack (9.0 Ah, about 194 Wh). L1 values are the published engineering estimates; cell values are from the Molicel P45B data sheet (standard charge 4.5 A, maximum continuous charge 9 A per cell, charge 0–60 °C, AC impedance ≤ 8 mΩ). podsim/l1.py holds every value and marks assumptions; run_l1.py writes l1_results.json, which this section reads.
R1 = json.load(open('l1_results.json'))
print('Pack:', R1['pack'])
print()
print(f"{'pod setting':38s} {'C-rate':>6s} {'<=1C':>5s} {'<=maker':>7s} {'30->85 %':>9s} {'to taper':>9s} {'heat W':>7s}")
for r in R1['pod_charge']:
print(f"{r['setting']:38s} {r['c_rate']:6.2f} {str(r['within_1C']):>5s} {str(r['within_maker_max']):>7s} {r['time_to_85pct_h']:8.2f}h {r['time_to_taper_h']:8.2f}h {r['pack_heat_W']:7.1f}")
print()
print('Charges per pod per day, arriving at 30 %:')
for r in R1['pod_sizing']:
print(f" {r['config']:17s} clear 4 PSH {r['clear_4psh']:5.1f} winter 1.5 PSH {r['winter_1p5psh']:5.1f}")
print()
print('Protection with the L1 pack:', int(sum(bool(r['pass']) for r in R1['protection'])), 'of', len(R1['protection']), 'cases pass')
for r in R1['protection']:
if not r['pass']: print(' FAIL', r['case'])
print('Pod thermal:', {k: round(v['full_rate_to_C'], 1) for k, v in R1['pod_thermal'].items()}, '(full-rate ambient limit, C)')
Pack: {'capacity_ah': 9.0, 'r0': 0.041999999999999996, 'r1': 0.012, 'i_1C': 9.0, 'i_maker_max': 18.0, 'i_pod': 9.0}
pod setting C-rate <=1C <=maker 30->85 % to taper heat W
L1 6S2P Molicel P45B 1.00 True True 0.57h 0.86h 4.4
L1 pack at the quad setting (10 A) 1.11 False True 0.52h 0.81h 5.4
L1 pack at the VTOL setting (20 A) 2.22 False False 0.34h 0.63h 21.6
Charges per pod per day, arriving at 30 %:
Large kit clear 4 PSH 10.7 winter 1.5 PSH 3.8
Small kit clear 4 PSH 3.3 winter 1.5 PSH 1.1
Grid only clear 4 PSH 22.5 winter 1.5 PSH 22.5
Large kit + grid clear 4 PSH 22.5 winter 1.5 PSH 22.5
Protection with the L1 pack: 20 of 20 cases pass
Pod thermal: {'L1 at 9 A': 34.8, 'reference VTOL at 20 A': 34.8} (full-rate ambient limit, C)
print(f"{'aircraft':8s} {'headwind':>8s} {'published km':>12s} {'with kit km':>11s} {'design spacing km':>17s} {'transit min':>11s} {'in air min':>10s}")
for r in R1['spacing']:
print(f"{r['aircraft']:8s} {r['headwind_ms']:6.0f}m/s {r['published_range_km']:12.1f} {r['range_with_kit_km']:11.1f} {r['design_spacing_km']:17.1f} {r['transit_min']:11.1f} {r['air_min']:10.1f}")
print()
for r in R1['solar_endurance']:
print(f" {r['aircraft']} in-flight solar, {r['case']:22s} {r['solar_W']:5.1f} W -> transit {r['transit_min_no_solar']:.1f} -> {r['transit_min_with_solar']:.1f} min, {r['range_km_no_solar']:.1f} -> {r['range_km_with_solar']:.1f} km ({r['gain_pct']:+.0f} %)")
print()
for k, v in R1['thermal_limits'].items():
print(f" {k:42s} hottest day with no heat stop {v['hottest_full_charge_day_C']:5.1f} C; pack peak on a 45 C day {v['pack_peak_45C_day']:5.1f} C")
print('Flight heating at 30 C:', R1['flight_heating'])
import pandas as pd
df = pd.DataFrame(R1['daily'])
df['mount'] = df['mount'].str.replace('Pack directly under panel', 'direct').str.replace('Vented gap, reflective underside', 'vented')
display(df[['climate', 'aircraft', 'mount', 'wh_day1', 'wh_available_day1', 'soc_end_day1', 't_pack_max', 'min_hot', 'min_cold', 'violations', 'unexplained']].round(2))
aircraft headwind published km with kit km design spacing km transit min in air min
L1-Q 0m/s 8.9 7.2 6.5 10.0 10.8
L1-Q 5m/s 8.9 4.2 3.8 10.0 10.8
L1-W 0m/s 36.3 29.5 26.5 27.3 29.3
L1-W 5m/s 36.3 21.3 19.2 27.3 29.3
L1-Q in-flight solar, noon, June 40.2 W -> transit 10.0 -> 10.8 min, 7.2 -> 7.7 km (+7 %)
L1-Q in-flight solar, 09-15 h average, June 36.8 W -> transit 10.0 -> 10.7 min, 7.2 -> 7.7 km (+7 %)
L1-Q in-flight solar, noon, December 13.1 W -> transit 10.0 -> 10.3 min, 7.2 -> 7.4 km (+2 %)
L1-W in-flight solar, noon, June 69.8 W -> transit 27.3 -> 48.3 min, 29.5 -> 52.2 km (+77 %)
L1-W in-flight solar, 09-15 h average, June 63.7 W -> transit 27.3 -> 45.3 min, 29.5 -> 48.9 km (+66 %)
L1-W in-flight solar, noon, December 22.5 W -> transit 27.3 -> 31.8 min, 29.5 -> 34.3 km (+16 %)
L1-Q | Pack directly under panel hottest day with no heat stop 28.8 C; pack peak on a 45 C day 69.3 C
L1-Q | Vented gap, reflective underside hottest day with no heat stop 41.4 C; pack peak on a 45 C day 52.2 C
L1-W | Pack directly under panel hottest day with no heat stop 33.0 C; pack peak on a 45 C day 69.1 C
L1-W | Vented gap, reflective underside hottest day with no heat stop 42.7 C; pack peak on a 45 C day 54.3 C
Flight heating at 30 C: {'L1-Q': {'flight_min': 11.0, 'rise_K': 22.377064198853596, 'skipped': 0}, 'L1-W': {'flight_min': 30.0, 'rise_K': 7.53975963609539, 'skipped': 0}}
| climate | aircraft | mount | wh_day1 | wh_available_day1 | soc_end_day1 | t_pack_max | min_hot | min_cold | violations | unexplained | |
|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | Desert summer (33 N) | L1-Q | direct | 43.69 | 330.25 | 0.46 | 65.45 | 1234.83 | 0.00 | 0 | 0 |
| 1 | Desert summer (33 N) | L1-Q | vented | 109.27 | 331.37 | 0.78 | 51.23 | 481.00 | 0.00 | 0 | 0 |
| 2 | Desert summer (33 N) | L1-W | direct | 56.14 | 564.29 | 0.50 | 65.53 | 1297.33 | 0.00 | 0 | 0 |
| 3 | Desert summer (33 N) | L1-W | vented | 126.71 | 565.67 | 0.84 | 53.08 | 0.00 | 0.00 | 0 | 0 |
| 4 | Hot summer (33 N) | L1-Q | direct | 75.37 | 340.53 | 0.62 | 57.95 | 554.17 | 0.00 | 0 | 0 |
| 5 | Hot summer (33 N) | L1-Q | vented | 134.22 | 341.61 | 0.89 | 43.48 | 0.00 | 0.00 | 0 | 0 |
| 6 | Hot summer (33 N) | L1-W | direct | 104.33 | 577.66 | 0.73 | 57.85 | 564.67 | 0.00 | 0 | 0 |
| 7 | Hot summer (33 N) | L1-W | vented | 135.73 | 578.31 | 0.88 | 45.13 | 0.00 | 0.00 | 0 | 0 |
| 8 | Temperate summer (45 N) | L1-Q | direct | 134.03 | 358.51 | 0.89 | 45.12 | 0.00 | 0.00 | 0 | 0 |
| 9 | Temperate summer (45 N) | L1-Q | vented | 134.03 | 358.05 | 0.89 | 31.20 | 0.00 | 0.00 | 0 | 0 |
| 10 | Temperate summer (45 N) | L1-W | direct | 135.47 | 599.28 | 0.88 | 44.91 | 0.00 | 0.00 | 0 | 0 |
| 11 | Temperate summer (45 N) | L1-W | vented | 135.47 | 599.10 | 0.88 | 32.49 | 0.00 | 0.00 | 0 | 0 |
| 12 | Dark roof, summer (40 N) | L1-Q | direct | 104.22 | 347.87 | 0.76 | 54.81 | 474.17 | 0.00 | 0 | 0 |
| 13 | Dark roof, summer (40 N) | L1-Q | vented | 134.12 | 348.67 | 0.89 | 40.56 | 0.00 | 0.00 | 0 | 0 |
| 14 | Dark roof, summer (40 N) | L1-W | direct | 135.57 | 592.73 | 0.88 | 55.03 | 0.00 | 0.00 | 0 | 0 |
| 15 | Dark roof, summer (40 N) | L1-W | vented | 135.57 | 592.77 | 0.88 | 42.79 | 0.00 | 0.00 | 0 | 0 |
| 16 | Temperate spring (45 N) | L1-Q | direct | 135.04 | 220.95 | 0.90 | 27.45 | 0.00 | 0.00 | 0 | 0 |
| 17 | Temperate spring (45 N) | L1-Q | vented | 135.05 | 220.83 | 0.90 | 19.09 | 0.00 | 0.00 | 0 | 0 |
| 18 | Temperate spring (45 N) | L1-W | direct | 136.76 | 369.09 | 0.88 | 28.04 | 0.00 | 21.33 | 0 | 0 |
| 19 | Temperate spring (45 N) | L1-W | vented | 136.74 | 369.03 | 0.88 | 20.43 | 0.00 | 0.00 | 0 | 0 |
| 20 | Tropical, cloudy (5 N) | L1-Q | direct | 135.44 | 187.52 | 0.90 | 44.57 | 0.00 | 0.00 | 0 | 0 |
| 21 | Tropical, cloudy (5 N) | L1-Q | vented | 135.34 | 187.39 | 0.90 | 34.52 | 0.00 | 0.00 | 0 | 0 |
| 22 | Tropical, cloudy (5 N) | L1-W | direct | 136.93 | 315.81 | 0.88 | 44.17 | 0.00 | 0.00 | 0 | 0 |
| 23 | Tropical, cloudy (5 N) | L1-W | vented | 136.96 | 315.74 | 0.88 | 35.06 | 0.00 | 0.00 | 0 | 0 |
| 24 | Temperate winter (45 N) | L1-Q | direct | 46.98 | 75.36 | 0.48 | 7.71 | 0.00 | 391.67 | 0 | 0 |
| 25 | Temperate winter (45 N) | L1-Q | vented | 18.70 | 75.13 | 0.33 | 5.67 | 0.00 | 629.33 | 0 | 0 |
| 26 | Temperate winter (45 N) | L1-W | direct | 86.10 | 126.56 | 0.64 | 8.85 | 0.00 | 305.17 | 0 | 0 |
| 27 | Temperate winter (45 N) | L1-W | vented | 66.11 | 126.40 | 0.55 | 7.08 | 0.00 | 423.67 | 0 | 0 |
| 28 | Cold winter, snow (55 N) | L1-Q | direct | 0.00 | 27.62 | 0.24 | -8.54 | 0.00 | 697.33 | 0 | 0 |
| 29 | Cold winter, snow (55 N) | L1-Q | vented | 0.00 | 27.62 | 0.24 | -8.85 | 0.00 | 697.33 | 0 | 0 |
| 30 | Cold winter, snow (55 N) | L1-W | direct | 0.00 | 46.29 | 0.21 | -7.68 | 0.00 | 712.00 | 0 | 0 |
| 31 | Cold winter, snow (55 N) | L1-W | vented | 0.00 | 46.29 | 0.21 | -7.99 | 0.00 | 712.00 | 0 | 0 |
c = R1['camera']
print(f"Listed camera: IMX296, {c['px_across']} px across, 6 mm lens -> {c['hfov_deg_6mm']:.1f} deg HFOV, aspect {c['aspect']:.3f}")
print(f" pass FOV >= 120: {c['pass_fov']}; pass >= 1,920 px: {c['pass_px']}; pass 4:3: {c['pass_aspect']}")
print(f" tags in view down to {c['listed_in_view_down_to_m']:.2f} m (required 0.42 m); same sensor with a 120 deg lens decodes below {c['same_sensor_120deg_decodable_below_m']:.2f} m (required {c['required_decodable_below_m']:.2f} m)")
print('Smaller contact grid on scaled strips:', R1['contact_grid_200'])
print('Universal 300 mm grid on those strips:', R1['contact_grid_300_on_200_strips'])
print()
print('L1 boost strings (assumed; L1 strings not published):')
for b in R1['boost_strings']: print(' ', b)
print()
print('Genasun GVB-8-Li against the on-board charger requirements:')
for req, has, fix in R1['charger_gaps']: print(f' {req}: {has} -> {fix}')
Listed camera: IMX296, 1456 px across, 6 mm lens -> 45.4 deg HFOV, aspect 1.338
pass FOV >= 120: False; pass >= 1,920 px: False; pass 4:3: True
tags in view down to 1.75 m (required 0.42 m); same sensor with a 120 deg lens decodes below 3.78 m (required 4.99 m)
Smaller contact grid on scaled strips: {'grid_mm': 200.0, 'plate_mm': 220.0, 'strip_w_mm': 73.33333333333333, 'gap_mm': 10.0, 'n_strips': 12, 'field_mm': 990.0, 'worst_case_fail_fraction': 0.0, 'can_bridge': False, 'finite_pad_rate': 1.0}
Universal 300 mm grid on those strips: {'grid_mm': 300.0, 'strip_w_mm': 73.33333333333333, 'gap_mm': 10.0, 'worst_case_fail_fraction': 0.07126986905611521, 'can_bridge': False, 'finite_pad_rate': 0.93048}
L1 boost strings (assumed; L1 strings not published):
{'kit': 'L1-Q top panel, thin-film, 0.25 m2, ~50 W', 'voc_-30C': 23.63115768351664, 'vmp_-10C': 18.688, 'vmp_75C_300W': 10.957953552160985, 'vmp_below_low_battery': True, 'voc_below_cv': True, 'vmp_above_5V': True}
{'kit': 'L1-W wing-skin Maxeon cells, 0.45 m2, ~85 W', 'voc_-30C': 22.822236650686953, 'vmp_-10C': 18.676, 'vmp_75C_300W': 12.656645406645891, 'vmp_below_low_battery': True, 'voc_below_cv': True, 'vmp_above_5V': True}
Genasun GVB-8-Li against the on-board charger requirements:
Output gated by the BMS charge-allow signal: No enable input -> Add a BMS-controlled output switch
Disabled within 100 ms of pod current > 0.2 A, re-enabled after 5 s: No enable input -> Same switch, driven by a comparator on the contact-rail shunt
Own latched OVP at 25.05 V: Not specified -> Add a latched comparator on the output switch
Charger temperature window (face and core NTC): No battery temperature input on lithium models -> Gate the output switch on both NTCs
Off-state drain <= 0.1 mA: About 6 mA at 24 V (night tare) -> Accept (about 3.5 Wh/day) or disconnect the charger at night
CV 24.6 V +/-0.5 %: Custom CV; tolerance not published -> Confirm with Genasun
Boost: panel Vmp below battery voltage: Required by the data sheet -> Choose strings with Vmp below about 19.8 V at -10 C and Voc below 24 V at -30 C
Result. The pod charges the L1 pack safely at 9 A (1C, inside the maker's 18 A maximum): 30 % to 85 % in 0.57 h, to taper in 0.86 h, every protection case passing and the pod's thermal envelope unchanged. The existing 10 A (1.1C) and 20 A (2.2C, above even the maker's maximum) settings must not be used on it; the drone-type switch needs an "L1: 9 A" position. With the 0.30 kg pod kit and a 0.05 kg BMS, still-air legs fall to 7.2 km (L1-Q) and 29.5 km (L1-W) (10.8 and 29.3 min in the air, including hover or vertical flight); design spacing in a 5 m/s headwind is 3.8 km and 19.2 km. On-board solar refills either aircraft from 30 % in one clear summer day when the battery is vented from the panel; directly under the panel the pack passes 60 °C on a 45 °C day, as for the reference drones. The L1 as listed fails three pod requirements outright (no per-cell BMS, no charge FET, landing camera at 45° and 1,456 px), leaves two unconfirmed (panel gap, contact-plate fit) and has no pod software. The design reference section "Applying the design to the L1 aircraft" lists what must change.
Summary of changes¶
First independent review and new analyses (revision 1 → revision 2)¶
| # | Finding | Change |
|---|---|---|
| 1 | A 5S pack or a 6S pack with a shorted cell is accepted and charged to 4.92 V per cell | Per-cell drone BMS is a binding requirement (OV 4.25 V, NTC 0–45 °C, charge FET opens before take-off) |
| 2 | OV comparator on a 1 % divider trips anywhere from 24.8 to 26.0 V | Own 0.1 % divider and 0.2 % reference: 25.30–25.50 V; 25.2 V full-charge mode removed |
| 3 | Relay would break up to 125 A in a converter runaway | Pad-side MOSFET switch, hardware OV/OC, opens within 1 ms, 150 A interrupt rating; relay isolates only |
| 4 | Pad short undetected while the current limit works | Undervoltage trip |
| 5 | Doc logic not in code | Pre-charge, START/WAIT, LOCKOUT, relative OC, UV, timer, reserve abort implemented and tested |
| 6 | Type mismatch "detection" claimed | Claim removed; drone BMS covers it |
| 7 | Rectifier topology and sense physics unverified | One ideal-diode pair per contact; Monte Carlo covers active and body-diode modes |
| 8 | Dead storage battery can strand the pod | Load disconnect at 10 %, charge-path-open BMS, latching grid relay forced on by hardware below 15 % |
| 9 | Pad temperature used as cell temperature | Drone NTC plus shaded ambient sensor |
| 10 | Thermal design asserted | Analysed: heat sink, white case, side shading required; derate above 34 °C ambient |
| 11 | Peak-current case not exercised | Bounded: solar ≤ 25 A (MPPT limit); grid lockout latency ≤ 2 s gives ≤ 37 A transient, under BMS and fuse ratings |
| 12 | Open pad could read inside window | 29.5 V boost, 0.1 % divider; open reads ≥ 28.5 V |
| 13 | Five contacts not redundant | Nine contacts, 3 × 3 at 150 mm; strips 110 mm; tolerates two failed contacts |
| 14–15 | Per-cell figures, gate drive, ADC buffer, boost disconnect, termination, grid port, lift-off, two drones | Corrected or specified in the design reference |
| new | Tags too small and out of view low down | 180 mm tags, 200 mm border, ≥120° and ≥1,920 px camera |
| new | Wind uplift exceeds weight at 18 m/s | Four ≥1.5 kN anchors; panel anchoring |
Second independent review (revision 2 → revision 3)¶
| # | Finding | Change |
|---|---|---|
| C1 | WAIT could power the pad after the drone left | WAIT drops to IDLE on an empty pad; a fresh sense check is required immediately before power |
| C2 | A conductor bridging the strips while a drone charges went undetected | Two-level sense re-check every 1 s (20 ms pause); ≤ 0.2 kJ into any bridge |
| M1 | Pad temperature still gated charging in code | Removed; 80 °C pad alarm and 42 °C interior derate implemented |
| M2 | Heat-sink fraction was a chosen input | Derived from mounting; MPPT moved onto the plate; plate 0.25 K/W |
| M3 | Turn-on not simulated | Pre-biased converter, 100 ms ramp, UV blanking; simulated from START |
| M4/M5 | OV band above 4.20 V per cell; drift ignored; no latch | 25.05 V nominal, 24.89–25.21 V derived from tolerances and drift; latched comparators |
| M6 | Pre-charge threshold ambiguous by the rectifier offset | 10 s C/10 probe; decision on pad voltage under current |
| M7 | Environment and timer counted toward lockout | Only electrical faults count; avionics draw limited to 0.3 A |
| M8 | Reserve abort skipped whenever a grid kit was present | Conditioned on grid current actually flowing |
| minor | TVS, storage BMS rating, camera aspect, pad emissivity, anchor load case, storage life, costs, stale code, circular tests | Specified, corrected, or replaced by non-circular tests (86 checks) |