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.

In [1]:
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.

In [2]:
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
No description has been provided for this image

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.

In [3]:
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
No description has been provided for this image

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.

In [4]:
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
In [5]:
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()}")
No description has been provided for this image
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
In [6]:
# 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).

In [7]:
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
No description has been provided for this image

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.

In [8]:
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
No description has been provided for this image

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.

In [9]:
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]
No description has been provided for this image

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.
In [10]:
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.

In [11]:
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
No description has been provided for this image

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¶

In [12]:
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¶

In [13]:
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:

  1. irradiance and ambient;
  2. solar controller (25 A cap) and grid rules;
  3. storage energy balance;
  4. case thermal, from the live internal losses;
  5. drone arrival;
  6. landing on the finite pad, with σ = 100 mm scatter and random heading;
  7. the component-level sense circuit, using one randomly drawn build of the pod;
  8. the same reference controller used in the fault simulations;
  9. CC/CV pack physics, with 0.25 A of avionics draw;
  10. 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.

In [14]:
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
No description has been provided for this image
In [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.

In [16]:
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
In [17]:
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.

In [18]:
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)
In [19]:
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")
No description has been provided for this image
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.

In [20]:
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.

In [21]:
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.

In [22]:
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
No description has been provided for this image

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.

In [23]:
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.

In [24]:
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)
In [25]:
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
In [26]:
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)