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 only toggle 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
VBox (static snapshot of the interactive widget)

Static snapshot -- run the notebook in JupyterLab (pixi run lab) to interact.

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:

  • IsrUav loiters on station with a stabilized sensor. Its metric is stationMinutes.

  • LogisticsUav flies a parcel out and returns with an empty bay. Its metric is payloadRangeKgKm.

  • InterceptUav dashes one way to catch a crossing target. Its metric is maxTargetSpeed.

The airframe point spans all three program branches, ten machines in all:

  • boxQuad is a cheap rotor-only quad on an open frame.

  • teardropQuad carries the same four rotors inside a lathed low-drag shell.

  • openTri is the tricopter conversion: two arms, a tail boom, and a yaw-authority speed cap.

  • hexLifter is an S1000-class heavy hexa, the camera-platform workhorse.

  • coaxOcto stacks eight stations on four booms in the quad’s footprint.

  • ringOcto spreads eight isolated discs on the widest ring.

  • vtolWing hovers on four props and cruises on a 2.6 m wing.

  • dartInterceptor is a rail-launched pusher with almost no payload.

  • flyingWingSingle is a tailless single-pusher wing: the wing IS the fuselage, and the catapult launch spends no mass on hover.

  • flyingWingTwin doubles 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")
Diagram (static snapshot of the interactive widget)

Static snapshot -- run the notebook in JupyterLab (pixi run lab) to interact.

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")
Diagram (static snapshot of the interactive widget)

Static snapshot -- run the notebook in JupyterLab (pixi run lab) to interact.

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",
)
../_images/6422117b2b08bbd4c9c16b218e80205e2adccfa36045972c52fd75fe73190ecd.png

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",
)
../_images/c5ac13c59a61c2e8020c09605de29ddc466f1104be4f075dd8567b23289ba97e.png

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",
)
../_images/e940cc0310f965401d21cc603fc21b80b60dd0fa4a2d4c256ce59efb549de2d6.png

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
longeron.analysis.viz._parcoords_class (static snapshot of the interactive widget)

Static snapshot -- run the notebook in JupyterLab (pixi run lab) to interact.

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"),
)
longeron.widgets.viewer3d._viewer_class (static snapshot of the interactive widget)

Static snapshot -- run the notebook in JupyterLab (pixi run lab) to interact.

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
longeron.widgets.viewer3d._viewer_class (static snapshot of the interactive widget)

Static snapshot -- run the notebook in JupyterLab (pixi run lab) to interact.

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)
traitlets.traitlets.N2Widget (static snapshot of the interactive widget)

Static snapshot -- run the notebook in JupyterLab (pixi run lab) to interact.

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",
)
../_images/65ccf527790575f4d3f1a141fed7ebfcfe144ad42d85a1aa8d5f1cda0f4598a3.png

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'}
../_images/0816c8b6bec78f7558e3b587ca75a1bbf036190dc642225755d541cf2d9fec09.png

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)
../_images/460005181d5e867b26af56b8d95c232c184ac51d1b87868fd2a3e2bc24c7a20c.png

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",
)
longeron.widgets.viewer3d._viewer_class (static snapshot of the interactive widget)

Static snapshot -- run the notebook in JupyterLab (pixi run lab) to interact.

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.