4. Trades: sizing the fleet¶
The question: three missions share one airframe catalog. Which bird wins each mission, and does any one bird do everything?
The model answers through longeron.analysis. The trades module scores
every discrete mix through the interpreter. The mdao module sizes the
continuous variables with OpenMDAO. The interpreter stays the single source
of semantics, so the notebook checks every solver result against the model
itself.
You will learn how to:
read the catalog and its requirements straight from the model;
score every discrete mix exactly and read one Pareto front per mission;
brush the mission space in linked widgets, up to the compromise dashboard;
size the ISR winner with OpenMDAO, then read the N2 map and the margins;
swap a declared external aerodynamics analysis in with one keyword;
write the analysis results back into the model.
Prerequisites: tutorial 2 for execution and tutorial 3 for the review widgets. Install
the analysis extras with pip install "longeron[trades,mdao,viz]". The
widgets need JupyterLab. On a static page each widget shows a placeholder.
import longeron
from longeron.analysis import mdao, trades, viz
model = longeron.load("../examples/deepscout")
missions = {
"ISR": ("ScoutMissions::IsrUav", "stationMinutes"),
"logistics": ("ScoutMissions::LogisticsUav", "payloadRangeKgKm"),
"intercept": ("ScoutMissions::InterceptUav", "maxTargetSpeed"),
}
studies = {name: trades.TradeStudy(model, qname) for name, (qname, _) in missions.items()}
for name, study in studies.items():
points = ", ".join(f"{p.name}[{len(p.variants)}]" for p in study.points.values())
print(f"{name:9s} -> {points}")
ISR -> sensor[3], airframe[12], motors[4], props[4], battery[5], material[2]
logistics -> cargo[3], airframe[12], motors[4], props[4], battery[5], material[2]
intercept -> airframe[12], motors[4], props[4], battery[5], material[2]
The destination first: the compromise dashboard¶
The finished artifact opens the notebook.
analysis.dashboard.mission_dashboard bakes 13440 interpreter-exact
evaluations over 1920 crossed candidates into one candidate table. Four linked panels fill the notebook width and
share one 1080p screen:
a header strip with the
Pareto onlytoggle and the lineup-size slider;the parallel-coordinates table beside the MOE-versus-cost scatter;
one tab set: a summary tab for all three missions, then one tab per mission with its requirement sliders and margin card;
the lineup cards and the to-scale 3D lineup beside the tab set, so slider moves and their shapes share one glance.
The panels are resizable: drag the dividers between sections to re-balance them (double-click a divider resets), and in a taller host – right-click the output and pick Create New View for Output – the dashboard grows to fill the panel’s height instead of leaving whitespace below.
Requirement sliders read their defaults from the model’s own requirement
attributes. Priority sliders sit in the summary tab and feed the MOE, the
documented compromise score. The Pareto only toggle hides dominated
candidates from every panel. One candidate dominates another when it costs
no more and scores at least as well on every mission metric. Priority
weights never change the front.
The scatter draws two axes only: cost and the MOE. Dominance is computed over all four objectives, so the scatter is a projection. A front pick can sit below and to the right of another dot and still be efficient on an axis the scatter hides. Its lineup card and its scatter tooltip name that axis. Every front member wears the front blue – filled when it also leads the drawn plane, an open ring when its win lives on a hidden axis – so gray always means dominated, the staircase is only this plane’s frontier, and the in-plot legend and toggle hint say so. Hover a lineup card to trace its candidate across all four axes in the parallel coordinates. Click the card – or its 3D model in the lineup – to select the candidate everywhere at once: the card border, the scatter halo, the traced parallel-coordinates line, and the 3D model all take the same violet accent, deliberately distinct from the blue brush, so a selection stays visible while brushing. Clicking the 3D background clears it. If a brush or the requirement floors ever exclude every candidate, the panels empty out and say why instead of silently showing unfiltered picks.
Drag the intercept priority to 100 and the dart takes the star. Hand the
weight back to ISR and the tail-sitter returns. The rest of this notebook
rebuilds this surface one piece at a time.
(Widget cell: captured at landing.)
from longeron.analysis import dashboard
dash = dashboard.mission_dashboard(model)
dash
The catalog: architecture crossed with part class¶
examples/deepscout/missions.sysml models one trade space over the whole
program. ScoutMissions::Catalog holds seven variation points: airframe,
motors, props, battery, sensor, cargo bay, and structural material. A
mix is one choice at every variation point that a mission uses. Three
mission contexts evaluate the shared catalog:
IsrUavloiters on station with a stabilized sensor. Its metric isstationMinutes.LogisticsUavflies a parcel out and returns with an empty bay. Its metric ispayloadRangeKgKm.InterceptUavdashes one way to catch a crossing target. Its metric ismaxTargetSpeed.
The airframe point spans all three program branches, ten machines in all:
boxQuadis a cheap rotor-only quad on an open frame.teardropQuadcarries the same four rotors inside a lathed low-drag shell.openTriis the tricopter conversion: two arms, a tail boom, and a yaw-authority speed cap.hexLifteris an S1000-class heavy hexa, the camera-platform workhorse.coaxOctostacks eight stations on four booms in the quad’s footprint.ringOctospreads eight isolated discs on the widest ring.vtolWinghovers on four props and cruises on a 2.6 m wing.dartInterceptoris a rail-launched pusher with almost no payload.flyingWingSingleis a tailless single-pusher wing: the wing IS the fuselage, and the catapult launch spends no mass on hover.flyingWingTwindoubles the pushers, counter-rotating, and widens the bay to a parcel cradle.
The airframes are fictional; everything bolted to them is a real
commercial part with nominal catalog figures. The parts span two
CLASSES. The small class is the F450-scale bench kit: the EMAX MT2213,
its matched 10-inch prop, and the 3S Tattu pack. The heavy class holds
the 6S tiers: the T-Motor Antigravity MN4006, the SunnySky X4112S, and
the 2 kW T-Motor AT4120, with APC electrics, a T-Motor 15-inch carbon
lifter, three Tattu 6S packs, and one 18650 li-ion pack. Architecture
crosses part class, and the constraints keep dishonest mixes in the
space but infeasible: propFit keeps big props off small motors,
packPower keeps 2 kW motors off starved packs, cellMatch keeps
3S motors off 6S packs, and bayFit keeps the volume ledger honest –
every airframe declares a payload bay with a usable volume, and a mix
whose stowage cannot find the room busts instead of flying on paper.
All the physics lives in calc def bodies that the interpreter evaluates
directly. The calc definitions sit in four discipline packages:
Aerodynamics, Propulsion, Structures, and Performance. That
organization returns later, when the MDAO bridge groups the generated
problem by these same packages. The diagram below draws the variation tree.
The same variation points return as brushable columns in every
parallel-coordinates view.
(Widget cell: captured at landing.)
try:
from longeron import diagrams
display(diagrams.structure_diagram(model.find("ScoutMissions::Catalog"), show_attributes=False))
except ImportError:
print("optional: pip install -e vendor/ipyelk enables the SysML diagrams -- skipping here")
The requirements, as the model states them¶
ScoutMissions::MissionRequirements states each tasking as a requirement
definition. Each requirement carries its subject, its assume clause, and
its require clause. The numeric floors are model attributes:
minStationMinutes, minPayloadKg, minDeliveryRadiusKm, and
targetSpeed. The dashboard sliders read their defaults from these same
attributes, so the review surface and the requirements cannot drift apart.
(Widget cell: captured at landing.)
try:
from longeron import diagrams
display(diagrams.structure_diagram(model.find("ScoutMissions::MissionRequirements")))
except ImportError:
print("optional: pip install -e vendor/ipyelk enables the SysML diagrams -- skipping here")
The honest solver choice¶
CP-SAT works on fixed-point integer arithmetic. The mapper inlines calc
invocations, encodes max() and min() natively, and unrolls constant
integer exponents. That covers the shared MissionUAV platform: structural
sizing, the mass and cost build-ups, and the three compatibility
constraints. The cell below enumerates all 1920 crossed platform mixes
with CP-SAT and checks the result against the interpreter, mix for mix.
The mission layers stay out of reach. HoverPower raises mass to the power
1.5, DashSpeed takes a cube root, and the cruise attributes branch on
wing span. No fixed-point encoding is exact for these forms. The mapper
refuses each mission with a one-line verdict that names the innermost
operation. The refusal is a design decision. A wrong encoding would return
wrong fronts silently.
Exhaustion is the honest alternative at this scale. all_architectures()
walks each mission’s space (up to 5760 mixes) through the interpreter,
exactly, in about a second. Each infeasible mix carries violations, the names of
the constraints it breaks. The crossing grew the shared space from 288
mixes to 1280, the flying wings grew it to 1600, the twin’s tip-prop
variant to 1760, the tilt-rotor tri to 1920, and both solvers shrugged.
platform = trades.TradeStudy(model, "ScoutMissions::MissionUAV")
solved = {tuple(sorted(a.selection.items())) for a in platform.enumerate()}
exact = {tuple(sorted(a.selection.items())) for a in platform.all_architectures() if a.verified}
assert solved == exact # the interpreter is the oracle; CP-SAT must agree
print(f"platform {len(solved):3d} of 1920 crossed mixes feasible (CP-SAT == interpreter)\n")
for name, study in studies.items():
try:
study.enumerate()
except longeron.analysis.AnalysisError as err:
print(f"{name:9s} {err}\n")
spaces = {name: study.all_architectures() for name, study in studies.items()}
for name, archs in spaces.items():
feasible = sum(a.verified for a in archs)
print(f"{name:9s} {feasible:3d} of {len(archs)} mixes feasible (interpreter)")
platform 736 of 1920 crossed mixes feasible (CP-SAT == interpreter)
ISR CP-SAT cannot encode derived attribute 'hoverPowerW' -- 'pow(massKg * 9.81, 1.5)': pow of a selection-dependent value has no fixed-point encoding; the interpreter path (all_architectures()/evaluate()) stays exact
logistics CP-SAT cannot encode derived attribute 'outboundPowerW' -- 'if airframe.wingSpan > 0.0 ? CruisePower(mass...': a conditional has no fixed-point encoding; the interpreter path (all_architectures()/evaluate()) stays exact
intercept CP-SAT cannot encode derived attribute 'dashAspectRatio' -- 'if airframe.wingSpan > 0.0 ? airframe.wingSpa...': a conditional has no fixed-point encoding; the interpreter path (all_architectures()/evaluate()) stays exact
ISR 362 of 5760 mixes feasible (interpreter)
logistics 449 of 5760 mixes feasible (interpreter)
intercept 344 of 1920 mixes feasible (interpreter)
Where the drag numbers come from¶
No airframe quotes its drag area by fiat. Every dragArea value is a
wetted-area buildup inside the model. The buildup sums skin friction times
wetted area times a form factor over every surface. It then adds 15% for
interference and a bluff-body term for the open frame and exposed rotor
gear. Big wings pay for their extra skin. Slender bodies earn their
advantage from the same arithmetic.
airframes = studies["ISR"].points["airframe"]
stories = {
"boxQuad": "all bluff: open frame, battery, motor cans",
"teardropQuad": "skinned lathe l/d 4.8 + bluff arms/motors",
"openTri": "bluff plates, two arms, and the tail boom",
"hexLifter": "S1000-class spread: plates, six arms, gear",
"coaxOcto": "quad footprint + the hung lower cans",
"ringOcto": "the widest ring: eight arms in the wind",
"vtolWing": "fuselage + BOTH wing pairs + 4 wingtip pods",
"dartInterceptor": "slender body l/d 11 + thin wing + fins",
"flyingWingSingle": "one thick wing + winglets + a pusher pod",
"flyingWingTwin": "the same skin, stretched, + twin pods",
"flyingWingTwinTip": "the twin's skin; the pods ride the tips",
"tiltTriWing": "one wing + hull + tail + two tilt pods",
}
print(f"{'airframe':18s}{'CdA m^2':>9s} drag story (the model's own buildup)")
for name, variant in airframes.variants.items():
print(f"{name:18s}{variant['dragArea']:9.4f} {stories[name]}")
print("\nTakeaway: the skin you fly is the drag you pay -- the dart's 11:1 body")
print("undercuts the teardrop 2:1, and both embarrass the open frame 4:1 and up.")
airframe CdA m^2 drag story (the model's own buildup)
boxQuad 0.0550 all bluff: open frame, battery, motor cans
teardropQuad 0.0125 skinned lathe l/d 4.8 + bluff arms/motors
openTri 0.0462 bluff plates, two arms, and the tail boom
hexLifter 0.1430 S1000-class spread: plates, six arms, gear
coaxOcto 0.0605 quad footprint + the hung lower cans
ringOcto 0.0935 the widest ring: eight arms in the wind
vtolWing 0.0196 fuselage + BOTH wing pairs + 4 wingtip pods
dartInterceptor 0.0059 slender body l/d 11 + thin wing + fins
flyingWingSingle 0.0154 one thick wing + winglets + a pusher pod
flyingWingTwin 0.0185 the same skin, stretched, + twin pods
flyingWingTwinTip 0.0185 the twin's skin; the pods ride the tips
tiltTriWing 0.0177 one wing + hull + tail + two tilt pods
Takeaway: the skin you fly is the drag you pay -- the dart's 11:1 body
undercuts the teardrop 2:1, and both embarrass the open frame 4:1 and up.
Mission 1, ISR: the wing buys the loiter¶
On station, a quad must hover. Momentum theory prices hover power from disk loading. A winged family flies slow on wing lift instead, at less than one tenth of hover power.
Read the figure as a staircase. The marked points are the front, the mixes that no other mix beats on both cost and endurance. The single flying wing owns every step of it: with no hover requirement to feed, its one thick wing loiters 34 minutes on the bench-kit MT2213 and 3S pack at the cheap end – undercutting every multirotor’s 6S invoice – and runs to 275 minutes when the li-ion pack’s watt-hours ride the same skin (the blended bay pod that stows the gimbal and the pack pays its skin drag in the same buildup). The winged VTOL’s 209-minute step and the crossed multirotors (the S1000-class hexa loiters 26 minutes on the li-ion pack) all price the hover the mission never asked for, and the wing dominates every one of them off the front. The interceptor is absent, because its 0.6 kg bay cannot carry the gimbal the mission requires. The pale crosses are infeasible mixes, and the figure plots each cross at the score its broken constraints deny it.
isr_front = trades.pareto(
[a for a in spaces["ISR"] if a.verified],
minimize=("missionCost",),
maximize=("stationMinutes",),
)
isr_best = max(isr_front, key=lambda a: a.metrics["stationMinutes"])
isr_cheap = min(isr_front, key=lambda a: a.metrics["missionCost"])
fig = viz.pareto_figure(
spaces["ISR"],
x="missionCost",
y="stationMinutes",
sense=("min", "max"),
panel_y="missionMass",
xlabel="mission cost (USD)",
ylabel="time on station (min)",
panel_ylabel="mission mass (kg)",
annotate={
"flying wing, li-ion pack: 275 min": isr_best,
"bench-kit flying wing corner": isr_cheap,
},
title="The tailless wing owns the whole ISR front",
)
Mission 2, logistics: out heavy, back empty¶
The delivery flight is asymmetric. The outbound leg carries the parcel. The return leg flies with an empty bay. A fixed hover budget covers takeoff, drop-off, and landing. The metric multiplies the parcel mass by the sustained radius.
Read the figure for the same family split. Rotor-borne cruise never escapes hover power, so the quad’s radius stays under 16 km with the smallest parcel. The winged VTOL turns the same battery packs into 10 to 157 kg km of payload-range – its parcel lift wants LiPo watts, so its best courier flies the 16 Ah Tattu. The twin flying wing goes one step further: with no hover to feed, two gentle 420 W Antigravity pushers stay inside the li-ion pack’s discharge ceiling, and the chemistry’s extra watt-hours carry the mid-size parcel 74 km out – 185 kg km, the top of the front (the root bay that swallows the cradle keeps the drag ledger honest: the blended pod’s skin rides in the buildup). Every kilogram flies out and back, so the carbon spar’s saved grams show up here too.
log_front = trades.pareto(
[a for a in spaces["logistics"] if a.verified],
minimize=("missionCost",),
maximize=("payloadRangeKgKm",),
)
log_best = max(log_front, key=lambda a: a.metrics["payloadRangeKgKm"])
log_cheap = min(log_front, key=lambda a: a.metrics["missionCost"])
fig = viz.pareto_figure(
spaces["logistics"],
x="missionCost",
y="payloadRangeKgKm",
sense=("min", "max"),
panel_y="deliveryRadiusKm",
xlabel="mission cost (USD)",
ylabel="payload x radius (kg km)",
panel_ylabel="radius (km)",
annotate={"twin flying wing: 2.5 kg to 74 km": log_best, "$1236 quad: 1 kg to 8 km": log_cheap},
title="Wings turn batteries into payload-range; quads just clear 8 km",
)
Mission 3, intercept: low drag wins the dash, inside the envelope¶
Parasite drag says what the watts buy. The dash envelope says what the airframe survives using. A wing-borne dash is placarded by the alleviated gust load at the spar’s 2.5 g design point. A rotor-borne dash never outruns the 25-degree commanded-tilt cap. The catchable target speed inverts the intercept triangle at the battery-limited dash duration, using the capped speed.
The dartInterceptor pairs the lowest drag area with a 202 N/m^2 wing
loading, so its placard sits near 67 m/s and the winner rides it. The
courier wings placard near 30 m/s: a 2 m/s gust already loads their
55 N/m^2 spars to the design point. Five of the six rotor families never
reach the crossing floor at all.
Read the figure top down. The dart owns the top of the front. The teardrop holds the mid-price region – and the part-class crossing hands it the cheap corner outright: an MT2213 build on the slick shell, with the 3S pack, catches the 25 m/s floor for 1130 dollars. Every mix on this front flies LiPo and aluminum: no li-ion pack can feed the AT4120’s 2 kW draw, and under a wing-loading placard the carbon spar’s lighter grams placard LOWER, not faster.
int_front = trades.pareto(
[a for a in spaces["intercept"] if a.verified],
minimize=("missionCost",),
maximize=("maxTargetSpeed",),
)
int_best = max(int_front, key=lambda a: a.metrics["maxTargetSpeed"])
int_cheap = min(int_front, key=lambda a: a.metrics["missionCost"])
fig = viz.pareto_figure(
spaces["intercept"],
x="missionCost",
y="maxTargetSpeed",
sense=("min", "max"),
panel_y="dashSpeed",
xlabel="mission cost (USD)",
ylabel="max catchable target speed (m/s)",
panel_ylabel="dash speed (m/s)",
annotate={
"2 kW dart, placarded: 67 m/s targets": int_best,
"$1130 small-class teardrop corner": int_cheap,
},
title="The dart leads the dash; the wingless teardrop takes the mid-price front",
)
Across missions: does any one bird do everything?¶
Project each front onto the choices the missions share: airframe, motors, props, battery, and material. Call that tuple the base mix. Before the flying wings joined, one winged-VTOL base mix sat on both the ISR and logistics fronts – the buy-once bird. The specialists ended that: the single wing owns ISR, the twin owns the logistics top, and no base mix sits on two fronts anymore. No base mix reaches all three either, because the interceptor’s dash physics is a different aircraft.
def base_mix(arch):
keep = ("airframe", "motors", "props", "battery", "material")
return tuple(arch.selection[k] for k in keep)
fronts = {"ISR": isr_front, "logistics": log_front, "intercept": int_front}
membership = {}
for name, front in fronts.items():
for arch in front:
membership.setdefault(base_mix(arch), set()).add(name)
print(f"{'airframe':16s}{'motors':13s}{'props':12s}{'battery':10s}{'material':13s} fronts")
for mix, names in sorted(membership.items(), key=lambda kv: (-len(kv[1]), kv[0])):
print(
"".join(f"{part:13s}" if i else f"{part:16s}" for i, part in enumerate(mix))
+ " "
+ ", ".join(sorted(names))
)
assert not any(len(names) == 3 for names in membership.values())
airframe motors props battery material fronts
boxQuad mn4006 apc1045 liion6s6p aluminum logistics
boxQuad mn4006 apc1045 liion6s6p carbonFiber logistics
boxQuad mn4006 apc11x55 liion6s6p aluminum logistics
boxQuad mn4006 apc11x55 liion6s6p carbonFiber logistics
boxQuad mn4006 apc11x55 tattu16000 aluminum logistics
boxQuad mn4006 apc11x55 tattu16000 carbonFiber logistics
boxQuad mn4006 apc13x65 liion6s6p aluminum logistics
boxQuad mn4006 apc13x65 liion6s6p carbonFiber logistics
boxQuad mn4006 apc13x65 tattu10000 aluminum logistics
boxQuad mn4006 apc13x65 tattu10000 carbonFiber logistics
boxQuad mn4006 apc13x65 tattu16000 aluminum logistics
boxQuad mn4006 apc13x65 tattu16000 carbonFiber logistics
boxQuad mn4006 tm15x5 liion6s6p aluminum logistics
boxQuad mn4006 tm15x5 liion6s6p carbonFiber logistics
boxQuad x4112s apc11x55 tattu16000 aluminum logistics
boxQuad x4112s apc11x55 tattu16000 carbonFiber logistics
boxQuad x4112s apc13x65 tattu16000 aluminum logistics
boxQuad x4112s apc13x65 tattu16000 carbonFiber logistics
dartInterceptor at4120 apc1045 tattu10000 aluminum intercept
dartInterceptor at4120 apc1045 tattu16000 aluminum intercept
dartInterceptor at4120 apc11x55 tattu10000 aluminum intercept
dartInterceptor at4120 apc11x55 tattu16000 aluminum intercept
dartInterceptor at4120 apc13x65 tattu10000 aluminum intercept
dartInterceptor at4120 apc13x65 tattu16000 aluminum intercept
dartInterceptor at4120 tm15x5 tattu10000 aluminum intercept
dartInterceptor at4120 tm15x5 tattu16000 aluminum intercept
dartInterceptor x4112s apc1045 tattu10000 aluminum intercept
dartInterceptor x4112s apc1045 tattu5200 aluminum intercept
dartInterceptor x4112s apc11x55 tattu10000 aluminum intercept
dartInterceptor x4112s apc11x55 tattu5200 aluminum intercept
dartInterceptor x4112s apc13x65 tattu5200 aluminum intercept
flyingWingSinglemn4006 apc11x55 liion6s6p aluminum ISR
flyingWingSinglemn4006 apc11x55 liion6s6p carbonFiber ISR
flyingWingSinglemn4006 apc11x55 tattu10000 aluminum ISR
flyingWingSinglemn4006 apc11x55 tattu10000 carbonFiber ISR
flyingWingSinglemn4006 apc11x55 tattu16000 aluminum ISR
flyingWingSinglemn4006 apc11x55 tattu16000 carbonFiber ISR
flyingWingSinglemn4006 apc11x55 tattu5200 aluminum ISR
flyingWingSinglemn4006 apc11x55 tattu5200 carbonFiber ISR
flyingWingSinglemt2213 apc1045 tattu3s aluminum ISR
flyingWingSinglemt2213 apc1045 tattu3s carbonFiber ISR
flyingWingSinglex4112s apc1045 liion6s6p aluminum ISR
flyingWingSinglex4112s apc1045 tattu10000 aluminum ISR
flyingWingSinglex4112s apc1045 tattu16000 aluminum ISR
flyingWingSinglex4112s apc1045 tattu5200 aluminum ISR
flyingWingSinglex4112s apc11x55 liion6s6p aluminum ISR
flyingWingSinglex4112s apc11x55 tattu10000 aluminum ISR
flyingWingSinglex4112s apc11x55 tattu16000 aluminum ISR
flyingWingSinglex4112s apc11x55 tattu5200 aluminum ISR
flyingWingTwinTipmn4006 apc1045 liion6s6p aluminum logistics
flyingWingTwinTipmn4006 apc11x55 liion6s6p aluminum logistics
flyingWingTwinTipmn4006 apc11x55 liion6s6p carbonFiber logistics
flyingWingTwinTipmn4006 apc11x55 tattu10000 aluminum logistics
flyingWingTwinTipmn4006 apc11x55 tattu10000 carbonFiber logistics
flyingWingTwinTipmn4006 apc11x55 tattu16000 aluminum logistics
flyingWingTwinTipmn4006 apc11x55 tattu16000 carbonFiber logistics
flyingWingTwinTipmn4006 apc11x55 tattu5200 aluminum logistics
flyingWingTwinTipmn4006 apc11x55 tattu5200 carbonFiber logistics
flyingWingTwinTipx4112s apc1045 tattu10000 aluminum logistics
flyingWingTwinTipx4112s apc1045 tattu16000 aluminum logistics
flyingWingTwinTipx4112s apc1045 tattu5200 aluminum logistics
flyingWingTwinTipx4112s apc11x55 tattu10000 aluminum logistics
flyingWingTwinTipx4112s apc11x55 tattu10000 carbonFiber logistics
flyingWingTwinTipx4112s apc11x55 tattu16000 aluminum logistics
flyingWingTwinTipx4112s apc11x55 tattu16000 carbonFiber logistics
flyingWingTwinTipx4112s apc11x55 tattu5200 aluminum logistics
flyingWingTwinTipx4112s apc11x55 tattu5200 carbonFiber logistics
teardropQuad mn4006 apc1045 tattu5200 aluminum intercept
teardropQuad mn4006 apc11x55 tattu5200 aluminum intercept
teardropQuad mn4006 apc13x65 tattu5200 aluminum intercept
teardropQuad mt2213 apc1045 tattu3s aluminum intercept
teardropQuad x4112s apc1045 tattu16000 aluminum intercept
Brushing the mission space¶
viz.parcoords draws one line per base mix, material included. Each base
mix is scored on every mission at once. Each metric column holds the best
value that the base mix achieves over its equipment options. The column
holds 0 where no equipment option is feasible. Dashed gray lines fail every
mission.
Brush stationMinutes high and maxTargetSpeed high. No line survives both
brushes, which restates the answer above.
(Widget cell: captured at landing.)
cross_rows = []
cross_mixes = []
for arch in spaces["intercept"]: # the 1920 crossed base mixes
mix = dict(
zip(("airframe", "motors", "props", "battery", "material"), base_mix(arch), strict=True)
)
row = dict(mix)
for name, metric in (
("ISR", "stationMinutes"),
("logistics", "payloadRangeKgKm"),
("intercept", "maxTargetSpeed"),
):
best = [a for a in spaces[name] if a.verified and base_mix(a) == base_mix(arch)]
row[metric] = max((a.metrics[metric] for a in best), default=0.0)
row["cost"] = arch.metrics["missionCost"]
row["feasible"] = any(
row[m] > 0 for m in ("stationMinutes", "payloadRangeKgKm", "maxTargetSpeed")
)
cross_rows.append(row)
cross_mixes.append(arch)
pc = viz.parcoords(
cross_rows,
axes=[
"airframe",
"motors",
"props",
"battery",
"material",
"cost",
"stationMinutes",
"payloadRangeKgKm",
"maxTargetSpeed",
],
)
pc
Shapes to scale: the 3D viewer¶
analysis.geometry bakes each family parametrically from the selected
catalog values. The bake uses stdlib math only, with no CAD kernel.
mesh_viewer renders the baked mesh at full cell width. Drag to
orbit, right-drag to pan, scroll to zoom, and double-click to re-fit.
The second cell links the widgets. A traitlet observer watches the
parallel-coordinates selected list and re-bakes the first surviving base
mix into the viewer. Brush the airframe axis through its categories and
watch the shape switch family. Tutorial 3 teaches the selection seam behind this
pattern.
(Widget cells: captured at landing.)
from longeron.analysis import geometry
from longeron.widgets import mesh_viewer
mesh_viewer(
geometry.mission_geometry(studies["ISR"], isr_best),
label=(f"ISR winner -- {isr_best.metrics['stationMinutes']:.0f} min on station"),
)
import json
linked = mesh_viewer(
geometry.mission_geometry(studies["intercept"], cross_mixes[0]),
label=" / ".join(base_mix(cross_mixes[0])),
)
def show_first_selected(change):
indices = json.loads(change["new"] or "[]")
if indices:
mix = cross_mixes[indices[0]]
linked.mesh_json = json.dumps(geometry.mission_geometry(studies["intercept"], mix))
linked.label = " / ".join(base_mix(mix))
pc.observe(show_first_selected, names="selected")
linked
Continuous sizing: how fast should the ISR winner loiter?¶
The trade study picked the components. analysis.mdao sizes what stays
continuous. ScoutSizing::IsrPrime freezes the ISR winner’s mix as a
concrete part definition and leaves loiterSpeed free.
mdao.build_problem mirrors the part onto an OpenMDAO Problem. Attributes
become components that evaluate through the interpreter. Constraints and the
IsrStation requirement become margin outputs. The builder groups the
components by the calc definitions’ owning packages. The model’s structure
is the problem’s structure.
build = mdao.build_problem(
model, "ScoutSizing::IsrPrime", requirements=("ScoutSizing::IsrStation",)
)
p = build.problem
p.run_model()
for discipline, attrs in build.disciplines.items():
print(f"{discipline:14s} {', '.join(attrs)}")
print("loiterPowerW: ", round(float(p.get_val("loiterPowerW")[0]), 1))
print("stationMinutes:", round(float(p.get_val("stationMinutes")[0]), 1))
p.set_val("loiterSpeed", 21.0) # what-if: loiter at transit speed
p.run_model()
print(
"at 21 m/s: ",
round(float(p.get_val("stationMinutes")[0]), 1),
"min -- stationFloor margin",
round(float(p.get_val("stationFloor_margin")[0]), 1),
)
p.set_val("loiterSpeed", 15.0)
p.run_model()
Aerodynamics dragArea
Structures sparWall, sparMassKg
Propulsion hoverPowerW, usableEnergyJ, loiterPowerW
Performance stationMinutes
loiterPowerW: 79.1
stationMinutes: 200.4
at 21 m/s: 101.1 min -- stationFloor margin 11.1
The problem’s shape: an N2 map¶
Look at the structure before you trust the numbers.
analysis.structure.n2_view draws the built problem as an N2 matrix in the
OpenMDAO convention. Components sit on the diagonal in execution order. Each
coupling sits in its source’s row and its target’s column, so feed-forward
fills the upper triangle. The dashed outlines are the discipline groups that
build_problem derived from the model’s calc packages.
This sizing chain is pure feed-forward, so every dot sits above the diagonal. A feedback coupling would land below the diagonal. Hover a dot to list the coupled variables.
(Widget cell: captured at landing.)
from longeron.analysis import structure
structure.n2_view(build)
The margin picture¶
Sweep loiterSpeed past the legal window and every wall shows at once. Only
the walls are shaded. Wherever any margin goes negative, the band is hatched
and labeled with every constraint that binds there. The unshaded middle is
the feasible corridor.
Read the corridor edge by edge. Below about 11 m/s the aboveStall floor
binds. Past about 22 m/s the 90 minute stationFloor breaks. Beyond
24 m/s the belowCruise ceiling adds on top of the broken stationFloor.
The first-order drag polar rewards slower flight, so the best legal loiter
sits at the aboveStall floor.
fig = viz.margin_sweep_figure(
p,
"loiterSpeed",
[9.0 + 0.35 * i for i in range(50)],
build.constraints,
xlabel="loiter speed (m/s)",
title="The station floor caps loiter at ~22 m/s; stall and transit limits frame the corridor",
)
Declared external analyses: swapping the aerodynamics fidelity¶
First-order physics belongs in the model as calc def bodies.
Higher-fidelity tools live outside SysML. The convention shipped with the
example makes the model declare the binding:
metadata def ExternalAnalysis { attribute component : String; }
calc def CruisePower {
@ExternalAnalysis { component = "uav_aero:CruisePowerPolar"; }
in massKg : Real; in speed : Real; ...
return : Real = ...first-order drag polar...;
}
The calc’s in and return parameters are the I/O contract.
build_problem validates the contract against the wrapped component’s
actual inputs and outputs. The keyword fidelity={"CruisePower": "external"}
swaps the interpreter-backed body for the external component. Here the
component is examples/uav_aero.py, a synthetic polar that models Reynolds
effects and stall. Everything else in the problem stays untouched, so the
comparison below is one keyword.
import sys
import matplotlib.pyplot as plt
if "../examples" not in sys.path:
sys.path.insert(0, "../examples") # the uav_aero entry point
lo = mdao.build_problem(model, "ScoutSizing::IsrPrime")
hi = mdao.build_problem(model, "ScoutSizing::IsrPrime", fidelity={"CruisePower": "external"})
print("bound externals:", hi.externals)
speeds = [11.0 + 0.2 * i for i in range(51)]
station = {}
for name, b in (("first-order calc body", lo), ("uav_aero polar (external)", hi)):
values = []
for v in speeds:
b.problem.set_val("loiterSpeed", v)
b.problem.run_model()
values.append(float(b.problem.get_val("stationMinutes")[0]))
station[name] = values
fig, ax = plt.subplots(figsize=(7.0, 3.6), layout="constrained")
for (name, values), color in zip(station.items(), ("#2f6b8f", "#c2603e"), strict=True):
ax.plot(speeds, values, color=color, linewidth=1.6)
best = max(range(len(speeds)), key=lambda i: values[i])
ax.plot(speeds[best], values[best], "o", color=color, markersize=5)
ax.annotate(
f"{name}\nbest {values[best]:.0f} min @ {speeds[best]:.1f} m/s",
(speeds[best], values[best]),
xytext=(10, -6),
textcoords="offset points",
fontsize=8,
color=color,
)
ax.set_xlabel("loiter speed (m/s)")
ax.set_ylabel("time on station (min)")
ax.set_title(
"The Reynolds/stall-aware polar backs loiter off the stall and costs 40 min",
fontsize=10,
loc="left",
color="#2b2d31",
)
for side in ("top", "right"):
ax.spines[side].set_visible(False)
ax.grid(axis="y", color="#d9dbdf", linewidth=0.5)
bound externals: {'loiterPowerW': 'uav_aero:CruisePowerPolar'}
The first-order body rewards slower flight all the way to the stall floor. The external polar prices the drag rise near stall, so its optimum backs off to about 12 m/s. The promised endurance drops from 300 to 246 minutes. One keyword changed the physics. Nothing else moved.
Write the answer back into the model¶
Analysis that stays in a notebook evaporates. The model is the source of
truth, so the model absorbs what the analysis learned. Interpreter.snapshot
turns a computed instance back into model elements with bound values. The
cell below runs the full loop: instantiate the design at the analyzed
loiter speed, snapshot it into the package, save, and reload.
The saved numbers deserve one clarification. The snapshot evaluates through
the model’s own calc bodies, so the saved stationMinutes is the
first-order 280 minutes at 12.0 m/s. The polar’s 246 minutes enters the
model only when a higher-fidelity calc body replaces the first-order one.
Tutorials 6 and 9 reuse this seam. They read results that the model
already carries.
import math
import tempfile
from pathlib import Path
polar = station["uav_aero polar (external)"]
best_loiter = speeds[max(range(len(speeds)), key=lambda i: polar[i])]
interp = longeron.Interpreter(model)
sized = interp.instantiate("ScoutSizing::IsrPrime", loiterSpeed=best_loiter)
snapshot = interp.snapshot(sized, name="isrPrimeAsAnalyzed")
model.find("ScoutSizing").add(snapshot)
out = Path(tempfile.mkdtemp()) / "deepscout_analyzed.sysml"
longeron.save(model, out)
back = longeron.Interpreter(longeron.load(out)).instantiate("ScoutSizing::isrPrimeAsAnalyzed")
assert math.isclose(back.slots["stationMinutes"], sized.slots["stationMinutes"], rel_tol=1e-12)
print(f"analyzed design point: loiterSpeed = {best_loiter:.1f} m/s")
print("saved text carries the computed values:\n")
print(" part isrPrimeAsAnalyzed" + out.read_text().split("part isrPrimeAsAnalyzed")[1][:170])
minutes = back.slots["stationMinutes"]
print(f"\nreloaded: {minutes:.1f} min (first-order body at {best_loiter:.1f} m/s)")
analyzed design point: loiterSpeed = 12.4 m/s
saved text carries the computed values:
part isrPrimeAsAnalyzed : ScoutSizing::IsrPrime {
attribute loiterSpeed = 12.4;
attribute emptyMassKg = 5.28;
attribute fuselageLength = 0.95;
attribute fuselage
reloaded: 255.2 min (first-order body at 12.4 m/s)
The bird you build is a family too¶
The fleet above trades airframe families. The program’s MultiRotor
branch (examples/deepscout/multirotor.sysml) trades within one
family: an abstract MultiRotor and five configurations that redefine
its rotor populations. The QuadCopter is the stock F450 build. The
TriCopter drops one arm and vectors its tail rotor. The HexaCopter
carries six of the same motors on the bigger F550 frame. The OctoCopter
puts eight of them flat on a custom ring, so architecture is the only
variable against its siblings. The CoaxX8 stacks counter-rotating motor
pairs on the F450-size frame.
Two model attributes make the family a real trade. coaxEfficiency
prices the X8’s wake penalty: a lower rotor delivers a nominal 85
percent of its isolated thrust. Eight motors therefore buy only 7.4
rotors of lift. FailSafeHover states the redundancy axis: the craft must keep
hovering on the largest torque-balanced rotor set that survives the
worst single motor failure.
The cell below prints the family matrix through the interpreter. Read
the verdict columns first. The tricopter busts the 6-minute sortie
budget. Only the hexa, the flat octo, and the X8 tolerate a motor
failure – the octo most richly, on the balanced six of its eight
rotors. Its bill arrives on the other axes: the worst hover endurance
and the biggest invoice of the family. Then read
the carrying columns. max kg is the payload ceiling. limit names
the constraint that closes it: the book takeoff weight, or the thrust
that keeps FlightEnvelope’s hover margin. range km is the
still-air range at the stock 0.2 kg payload, with 20 percent of the
pack held back as landing reserve. Every configuration wins one axis
and loses another.
from longeron.analysis import mission3d
drone = longeron.load("../examples/deepscout")
dinterp = longeron.Interpreter(drone)
ATLANTA = [ # tutorial 2's demo sortie: a loop over Piedmont Park (lat, lon, alt m)
(33.7813, -84.3833, 350.0),
(33.7885, -84.3785, 390.0),
(33.7900, -84.3695, 380.0),
(33.7838, -84.3690, 360.0),
(33.7770, -84.3825, 350.0),
]
FAMILY = ("QuadCopter", "TriCopter", "HexaCopter", "OctoCopter", "CoaxX8")
matrix = {}
for config in FAMILY:
inst = dinterp.instantiate(f"Rotorcraft::{config}")
failsafe = dinterp.check_requirement("DeepScout::FailSafeHover", subject=inst)
minutes = mission3d.mission_values(
dinterp, ATLANTA, ground_alt=300.0, assembly=f"Rotorcraft::{config}"
)["missionMinutes"]
matrix[config] = {
"mass": inst.slots["totalMass"],
"thrust": inst.slots["usableThrust"],
"hover": inst.slots["hoverCurrent"],
"endurance": inst.slots["hoverMinutes"],
"cruise": inst.slots["maxCruiseSpeed"],
"mission": minutes,
"motorOut": failsafe.satisfied,
"maxPayload": inst.slots["maxPayload"],
"limit": "mtow"
if inst.slots["mtowPayload"] <= inst.slots["thrustLimitPayload"]
else "thrust",
"range": inst.slots["cruiseRange"],
"cost": inst.slots["totalCost"],
}
print(
f"{'config':12s}{'mass kg':>8s}{'usable N':>9s}{'hover A':>8s}"
f"{'endur min':>10s}{'cruise':>7s}{'sortie':>7s}{'motor-out':>10s}"
f"{'max kg':>7s}{'limit':>7s}{'range km':>9s}{'cost $':>7s}"
)
for config, row in matrix.items():
verdict = "PASS" if row["motorOut"] else "FAIL"
print(
f"{config:12s}{row['mass']:8.2f}{row['thrust']:9.1f}{row['hover']:8.1f}"
f"{row['endurance']:10.1f}{row['cruise']:7.1f}{row['mission']:7.2f}{verdict:>10s}"
f"{row['maxPayload']:7.2f}{row['limit']:>7s}{row['range']:9.1f}{row['cost']:7.0f}"
)
assert [matrix[c]["motorOut"] for c in FAMILY] == [False, False, True, True, True]
assert matrix["TriCopter"]["mission"] > 6.0 # the tri busts the sortie budget
# only the tri runs out of thrust before it runs out of book MTOW
assert [matrix[c]["limit"] for c in FAMILY] == ["mtow", "thrust", "mtow", "mtow", "mtow"]
best = {
"endurance": max(FAMILY, key=lambda c: matrix[c]["endurance"]),
"price": min(FAMILY, key=lambda c: matrix[c]["cost"]),
"cruise": max(FAMILY, key=lambda c: matrix[c]["cruise"]),
"payload": max(FAMILY, key=lambda c: matrix[c]["maxPayload"]),
"range": max(FAMILY, key=lambda c: matrix[c]["range"]),
}
assert best == {
"endurance": "QuadCopter",
"price": "TriCopter",
"cruise": "CoaxX8",
"payload": "HexaCopter",
"range": "CoaxX8",
}
assert all(
matrix["OctoCopter"][k] < matrix[c][k]
for k in ("endurance",)
for c in FAMILY
if c != "OctoCopter"
)
assert matrix["OctoCopter"]["cost"] == max(matrix[c]["cost"] for c in FAMILY)
print(
"\nendurance: QuadCopter | price: TriCopter | cruise + range: CoaxX8"
" | payload: HexaCopter | motor-out: hexa + octo + X8"
)
print("the flat octo pays the most, drinks the fastest, and survives a failure richest")
print("no configuration wins everything")
config mass kg usable N hover A endur min cruise sortie motor-out max kg limit range km cost $
QuadCopter 1.41 19.6 13.4 23.4 20.0 4.24 FAIL 0.29 mtow 19.4 568
TriCopter 1.31 14.7 13.8 22.6 12.4 6.22 FAIL 0.30 thrust 13.1 546
HexaCopter 1.76 29.5 15.1 20.6 19.9 4.24 PASS 0.84 mtow 17.1 670
OctoCopter 2.04 39.3 16.3 19.1 20.2 4.20 PASS 0.76 mtow 16.1 732
CoaxX8 1.70 36.3 14.0 22.2 21.1 4.07 PASS 0.50 mtow 19.5 692
endurance: QuadCopter | price: TriCopter | cruise + range: CoaxX8 | payload: HexaCopter | motor-out: hexa + octo + X8
the flat octo pays the most, drinks the fastest, and survives a failure richest
no configuration wins everything
Payload-range: what each airframe carries, and how far¶
Two model attributes close the carrying-capacity gap. maxPayload is
the lesser of two ceilings: the book takeoff-weight limit, and the
payload that still keeps FlightEnvelope’s 1.8 hover margin. The
hover margin is linear in mass, so the model inverts it as exact
algebra. failsafePayload inverts FailSafeHover the same way: the
heaviest payload that still hovers after the worst motor failure.
The chart sweeps payload from empty to each ceiling and reads
cruiseRange through the interpreter. Range is still-air distance at
max cruise on 80 percent of the pack. The 20 percent landing reserve
is a nominal planning figure, not a measured one.
Read the endpoints first. The quad flies farthest empty, but its envelope ends first: 0.29 kg, the smallest of the family. The X8 crosses above the quad before the reference payload and keeps its whole envelope after a motor failure. The hexa reaches 0.84 kg, the heavy-lift pick by far. But its redundancy has a price in kg: with a motor out, the hexa carries only 0.44 of its 0.84 kg ceiling. The flat octo keeps its whole 0.76 kg envelope through a failure – the family’s richest survivable payload – and pays for it on every efficiency axis. The tri’s curve is short and low. It is the cheap trainer, not a courier.
(Chart cell: captured at landing.)
from itertools import pairwise
import matplotlib.pyplot as plt
sweep = {}
for config in FAMILY:
loads = [matrix[config]["maxPayload"] * i / 24.0 for i in range(25)]
sweep[config] = (
loads,
[
dinterp.instantiate(f"Rotorcraft::{config}", payloadMass=p).slots["cruiseRange"]
for p in loads
],
)
# deterministic pins: every curve falls monotonically to its ceiling...
for config in FAMILY:
ranges = sweep[config][1]
assert all(a > b for a, b in pairwise(ranges)), config
# ...the quad leads empty, the X8 leads loaded, the hexa reaches farthest
assert sweep["QuadCopter"][1][0] > sweep["CoaxX8"][1][0]
assert matrix["CoaxX8"]["range"] > matrix["QuadCopter"]["range"]
assert max(sweep[c][0][-1] for c in FAMILY) == sweep["HexaCopter"][0][-1]
COLORS = { # the house accent ramp: one hue, lightness varies
"QuadCopter": "#2f6b8f",
"HexaCopter": "#4f7fa0",
"OctoCopter": "#6f9ab5",
"CoaxX8": "#93b6ca",
"TriCopter": "#b7cedd",
}
fig, ax = plt.subplots(figsize=(7.0, 3.8), layout="constrained")
for config in FAMILY:
loads, ranges = sweep[config]
ax.plot(loads, ranges, color=COLORS[config], linewidth=1.6)
ax.plot(loads[-1], ranges[-1], "o", color=COLORS[config], markersize=5)
ax.annotate(
f"{config}\n{loads[-1]:.2f} kg, {matrix[config]['limit']}",
(loads[-1], ranges[-1]),
xytext=(6, 6 if config == "QuadCopter" else -2), # clear the X8 curve
textcoords="offset points",
fontsize=8,
color=COLORS[config],
)
# the price of the hexa's redundancy, in kg: motor-out flying ends here
fs_edge = dinterp.instantiate("Rotorcraft::HexaCopter").slots["failsafePayload"]
fs_range = dinterp.instantiate("Rotorcraft::HexaCopter", payloadMass=fs_edge).slots["cruiseRange"]
ax.plot(fs_edge, fs_range, "d", color="#c2603e", markersize=6)
ax.annotate(
f"hexa motor-out ceiling: {fs_edge:.2f} kg\nthe price of flying on four",
(fs_edge, fs_range),
xytext=(-8, -20),
textcoords="offset points",
fontsize=8,
ha="right",
color="#c2603e",
)
ax.axvline(0.2, color="#d9dbdf", linewidth=0.8, linestyle=(0, (4, 3)))
ax.text(0.205, sweep["QuadCopter"][1][0], "0.2 kg reference", fontsize=8, color="#9aa0a8")
ax.set_xlabel("payload (kg)")
ax.set_ylabel("still-air range (km), 20% pack reserve")
ax.set_title(
"Payload-range through the interpreter: every curve ends at its payload ceiling",
fontsize=10,
loc="left",
color="#2b2d31",
)
for side in ("top", "right"):
ax.spines[side].set_visible(False)
ax.grid(axis="y", color="#d9dbdf", linewidth=0.5)
The family to scale¶
grand.drone_scene bakes each configuration’s geometry from its own
M0 population. The population picks the frame. Three motor
individuals make three arms at 120 degrees, with the tail rotor on a
longer boom. Six make the hexa’s 60-degree fan, eight make the octo’s
45-degree ring. The coax pairs stack two prop discs per arm, the lower
one dropped on standoffs. Tutorial 5 explains the populations behind
these builds. Tutorial 7 will own the full diagram-to-3D workflow.
geometry.lineup folds the five builds into one to-scale scene. The
X8 packs eight rotors in the quad’s footprint. The octo’s ring
out-spans even the hexa.
(Widget cell: captured at landing.)
from longeron.analysis.grand import drone_scene
family_meshes = [drone_scene(drone, f"Rotorcraft::{config}")[0] for config in FAMILY]
assert [len(mesh["discs"]) for mesh in family_meshes] == [4, 3, 6, 8, 8]
mesh_viewer(
geometry.lineup(family_meshes, labels=list(FAMILY)),
label="one abstract MultiRotor, five configurations -- to scale",
)
The redundancy axis, hunted and proved¶
analysis.verify treats FailSafeHover like any other requirement.
hunt searches the quad’s payload domain and shrinks its catch to the
simplest violating configuration: 0.0 kg. The quad fails motor-out
hover even empty. The same hunt on the hexa passes at stock and
bisects the payload edge where the verdict flips: about 0.44 kg. The
model states the same edge in closed form as failsafePayload, and
the cell asserts that the bisection agrees with the algebra.
prove goes where sampling cannot. Z3 negates the requirement under
the model’s own constraints and reports UNSAT for the X8. No payload
the takeoff-mass limit admits can break its motor-out hover. The
solver also attributes the exact envelope bound, 249/500 kg, to the
binding takeoff-mass constraint. Tutorial 6 teaches these tiers in
depth.
from fractions import Fraction
from longeron.analysis import verify
catch = verify.hunt(
drone,
"Rotorcraft::QuadCopter",
requirements=("DeepScout::FailSafeHover",),
free=("payloadMass",),
seed=0,
max_examples=30,
)
assert catch.status == "violated"
worst = catch.counterexamples[0]
print("quad shrunk catch:", worst.bindings, "->", worst.violated)
edges = verify.hunt(
drone,
"Rotorcraft::HexaCopter",
requirements=("DeepScout::FailSafeHover",),
free=("payloadMass",),
seed=0,
max_examples=60,
)
edge = next(b for b in edges.boundaries if "motorOutHover" in b.violated)
print(f"hexa motor-out payload edge (oracle-bisected): {edge.value:.4f} kg")
hexa_fs = dinterp.instantiate("Rotorcraft::HexaCopter").slots["failsafePayload"]
assert abs(edge.value - hexa_fs) < 1e-3 # the bisection agrees with the algebra
print(f"model failsafePayload (closed form): {hexa_fs:.4f} kg")
proofs = verify.prove(
drone, "Rotorcraft::CoaxX8", requirements=("DeepScout::FailSafeHover",), free=("payloadMass",)
)
proof = next(p for p in proofs.proofs if "motorOutHover" in p.requirement)
assert proof.status == "proven-safe"
print("X8 motor-out: PROVEN SAFE for every payload the takeoff limit admits")
print(f"exact envelope bound: {proof.bound} kg = {float(Fraction(proof.bound)):.3f}")
quad shrunk catch: {'payloadMass': 0.0} -> ('FailSafeHover::motorOutHover',)
hexa motor-out payload edge (oracle-bisected): 0.4445 kg
model failsafePayload (closed form): 0.4445 kg
X8 motor-out: PROVEN SAFE for every payload the takeoff limit admits
exact envelope bound: 249/500 kg = 0.498
The answer, and where the threads continue¶
No single mix reaches all three fronts – or, since the flying wings joined, even two: each mission buys its own specialist (the single wing, the twin, the dart). The model now carries the analyzed design point.
Tutorial 5 rebuilds the ISR winner as a population of named individuals and weighs them.
Tutorial 6 scores the fleet against stakeholder value and hunts for requirement violations.
Tutorial 7 measures the model’s geometry claims with a CAD engine.







