flow5ctl 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. flow5ctl/__init__.py +35 -0
  2. flow5ctl/__main__.py +4 -0
  3. flow5ctl/advisor/__init__.py +0 -0
  4. flow5ctl/advisor/dragbudget.py +155 -0
  5. flow5ctl/advisor/guardrails.py +451 -0
  6. flow5ctl/advisor/stability.py +81 -0
  7. flow5ctl/advisor/structure.py +159 -0
  8. flow5ctl/cli.py +771 -0
  9. flow5ctl/errors.py +55 -0
  10. flow5ctl/flow5/__init__.py +0 -0
  11. flow5ctl/flow5/airfoils.py +175 -0
  12. flow5ctl/flow5/foilpolar.py +121 -0
  13. flow5ctl/flow5/markers.py +293 -0
  14. flow5ctl/flow5/probe.py +141 -0
  15. flow5ctl/flow5/results.py +309 -0
  16. flow5ctl/flow5/runner.py +149 -0
  17. flow5ctl/flow5/summary.py +427 -0
  18. flow5ctl/flow5/xmlgen.py +422 -0
  19. flow5ctl/geometry/__init__.py +0 -0
  20. flow5ctl/geometry/derived.py +279 -0
  21. flow5ctl/geometry/massprops.py +87 -0
  22. flow5ctl/geometry/planform.py +254 -0
  23. flow5ctl/mcp_server.py +615 -0
  24. flow5ctl/model/__init__.py +0 -0
  25. flow5ctl/model/design.py +280 -0
  26. flow5ctl/model/presets.py +74 -0
  27. flow5ctl/presets/custom.yaml +34 -0
  28. flow5ctl/presets/hpa.yaml +53 -0
  29. flow5ctl/presets/rc-glider.yaml +42 -0
  30. flow5ctl/presets/uav.yaml +40 -0
  31. flow5ctl/project/__init__.py +0 -0
  32. flow5ctl/project/store.py +262 -0
  33. flow5ctl/units.py +53 -0
  34. flow5ctl/usecases/__init__.py +0 -0
  35. flow5ctl/usecases/analyze.py +634 -0
  36. flow5ctl/usecases/define.py +124 -0
  37. flow5ctl/usecases/edit.py +320 -0
  38. flow5ctl/usecases/ground.py +129 -0
  39. flow5ctl/usecases/gui.py +50 -0
  40. flow5ctl/usecases/plot.py +95 -0
  41. flow5ctl/usecases/sweep.py +385 -0
  42. flow5ctl/usecases/trim.py +371 -0
  43. flow5ctl/viz/__init__.py +0 -0
  44. flow5ctl/viz/charts.py +374 -0
  45. flow5ctl/viz/palette.py +71 -0
  46. flow5ctl-0.1.0.dist-info/METADATA +348 -0
  47. flow5ctl-0.1.0.dist-info/RECORD +50 -0
  48. flow5ctl-0.1.0.dist-info/WHEEL +4 -0
  49. flow5ctl-0.1.0.dist-info/entry_points.txt +2 -0
  50. flow5ctl-0.1.0.dist-info/licenses/LICENSE +202 -0
flow5ctl/__init__.py ADDED
@@ -0,0 +1,35 @@
1
+ """flow5ctl — AI-driven aircraft design with flow5.
2
+
3
+ The public surface is the use-case layer (`flow5ctl.usecases`) and the CLI. The
4
+ `flow5ctl.flow5` package is the only code that knows flow5 exists; nothing in
5
+ `flow5ctl.geometry` or `flow5ctl.model` may import it, so the aerodynamic model is
6
+ testable with flow5 absent. See docs/ARCHITECTURE.md.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from importlib.metadata import PackageNotFoundError
11
+ from importlib.metadata import version as _installed_version
12
+
13
+
14
+ def _version() -> str:
15
+ """The one place the version comes from is the packaging metadata.
16
+
17
+ It used to be written here as well as in `pyproject.toml`, and the two drifted
18
+ the moment one of them was bumped: the 0.1.0 wheel was built correctly while
19
+ `flow5ctl --version`, `doctor` and the `flow5://status` resource all still said
20
+ `0.1.0.dev0`. A user installing a release would have been told they had a
21
+ pre-release. Reading it back from the installed distribution means there is
22
+ nothing to keep in step.
23
+
24
+ The fallback is for running out of a source tree with nothing installed at all,
25
+ where the honest answer is that we do not know rather than a number that might
26
+ be wrong.
27
+ """
28
+ try:
29
+ return _installed_version("flow5ctl")
30
+ except PackageNotFoundError:
31
+ return "0+unknown"
32
+
33
+
34
+ __version__ = _version()
35
+ __all__ = ["__version__"]
flow5ctl/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
File without changes
@@ -0,0 +1,155 @@
1
+ """What the drag figure does NOT include.
2
+
3
+ A VLM analysis of a wing and a tail returns the drag of a wing and a tail. On a
4
+ human-powered aircraft that is roughly two thirds of the aeroplane: the rigging
5
+ wires, the fairing, the pilot's body, the wheel and every joint between components
6
+ are simply not in the model, and flow5 has no way to put them there through this
7
+ interface (`<body>` exists but is a lifting-surface fuselage, not a drag bookkeeping
8
+ device).
9
+
10
+ Leaving the reader to remember that is how a number gets quoted as if it were the
11
+ aeroplane's. This module names the missing pieces and puts a size on them, so the
12
+ gap between "what was modelled" and "what will fly" is on the page next to the L/D.
13
+
14
+ **These are published estimates, not measurements by this project.** They are deliberately given as ranges: an HPA's rigging
15
+ drag depends on wire diameter, count, length and whether the wires are faired, and
16
+ a design that has not chosen those yet cannot be given a single number.
17
+
18
+ The estimates are expressed as a fraction of the *modelled* total drag at the best
19
+ lift-to-drag point, because that is the number a designer is looking at when they
20
+ need the warning. The per-item ranges are not added together to reach the total -
21
+ see `_TOTAL`.
22
+ """
23
+ from __future__ import annotations
24
+
25
+ from dataclasses import dataclass
26
+
27
+
28
+ @dataclass(frozen=True, slots=True)
29
+ class Missing:
30
+ """One thing the aerodynamic model does not contain."""
31
+
32
+ name: str
33
+ low: float
34
+ high: float
35
+ """Fraction of the modelled drag, low and high estimate."""
36
+ note: str
37
+
38
+ def as_dict(self) -> dict:
39
+ return {"item": self.name, "low": self.low, "high": self.high, "note": self.note}
40
+
41
+
42
+ #: Human-powered aircraft. Ranges from the Daedalus and Monarch project reports and
43
+ #: from Birdman Rally teams' own published drag budgets; see docs/DESIGN-GUIDE.md.
44
+ #: An HPA carries a lot of wire and very little else, which is why rigging dominates.
45
+ _HPA: tuple[Missing, ...] = (
46
+ Missing("rigging wires", 0.10, 0.30,
47
+ "bare wire runs at a section drag coefficient near 1.0. A 32 m aircraft "
48
+ "carries tens of metres of it. Faired wire cuts this by roughly half."),
49
+ Missing("pilot and fairing", 0.08, 0.20,
50
+ "the pilot is a bluff body. A good fairing is worth a lot here and a bad "
51
+ "one is worse than none, because it separates."),
52
+ Missing("interference at joints", 0.03, 0.08,
53
+ "wing-to-fuselage, tail-to-boom, and every strut end."),
54
+ Missing("surface finish and rib stitching", 0.02, 0.06,
55
+ "film over ribs is not the smooth aerofoil the 2D polar assumes."),
56
+ Missing("undercarriage", 0.01, 0.04,
57
+ "small if retracted or dropped, not small if left hanging."),
58
+ )
59
+
60
+ #: RC gliders and UAVs. No rigging, a much smaller fuselage relative to the wing,
61
+ #: and usually a moulded surface - so the budget is dominated by the fuselage itself.
62
+ _SMALL: tuple[Missing, ...] = (
63
+ Missing("fuselage", 0.06, 0.15, "not modelled as a drag source by this analysis."),
64
+ Missing("interference at joints", 0.02, 0.06, "wing root and tail junctions."),
65
+ Missing("surface finish", 0.01, 0.04, "a moulded surface is close to the 2D "
66
+ "assumption; a built-up one is not."),
67
+ Missing("control gaps and linkages", 0.01, 0.03, "hinge lines and horns."),
68
+ )
69
+
70
+ #: The total, taken from published whole-aircraft drag budgets rather than by summing
71
+ #: the per-item highs above. Summing them assumes every item is simultaneously at its
72
+ #: worst, which no flying aircraft is: for a Daedalus-class HPA the lifting surfaces
73
+ #: are roughly three quarters of the total drag, putting everything else near 25-35 %
74
+ #: of the modelled figure. The per-item ranges say where that total comes from and
75
+ #: which choices move it; they are not meant to be added up.
76
+ _TOTAL: dict[str, tuple[float, float]] = {
77
+ "hpa": (0.20, 0.40),
78
+ "rc-glider": (0.08, 0.20),
79
+ "uav": (0.08, 0.20),
80
+ }
81
+
82
+ _BY_PRESET: dict[str, tuple[Missing, ...]] = {
83
+ "hpa": _HPA,
84
+ "rc-glider": _SMALL,
85
+ "uav": _SMALL,
86
+ }
87
+
88
+
89
+ def items_for(preset_name: str) -> tuple[Missing, ...]:
90
+ """The missing-drag list for a preset, or nothing for `custom`.
91
+
92
+ `custom` gets nothing on purpose: the whole point of that preset is that no
93
+ assumptions are made about what kind of aircraft it is, and a drag budget IS an
94
+ assumption about the airframe.
95
+ """
96
+ return _BY_PRESET.get(preset_name, ())
97
+
98
+
99
+ def budget(preset_name: str, modelled_ld: float | None) -> dict | None:
100
+ """A drag budget for what is not in the model, or None when nothing is known.
101
+
102
+ `modelled_ld` is the best lift-to-drag ratio the analysis produced. The realistic
103
+ band is that figure divided by (1 + missing fraction), because adding drag at
104
+ constant lift divides the ratio.
105
+ """
106
+ items = items_for(preset_name)
107
+ if not items or not modelled_ld or modelled_ld <= 0:
108
+ return None
109
+ low, high = _TOTAL.get(preset_name, (0.0, 0.0))
110
+ if high <= 0:
111
+ return None
112
+ return {
113
+ "modelled_best_LD": round(modelled_ld, 2),
114
+ "missing_fraction": {"low": round(low, 3), "high": round(high, 3)},
115
+ "realistic_best_LD": {
116
+ "low": round(modelled_ld / (1 + high), 2),
117
+ "high": round(modelled_ld / (1 + low), 2),
118
+ },
119
+ "items": [i.as_dict() for i in items],
120
+ "basis": ("published estimates for this class of aircraft, not measurements "
121
+ "by this project"),
122
+ }
123
+
124
+
125
+ def warning(preset_name: str, modelled_ld: float | None,
126
+ induced_bias: float | None = None) -> str | None:
127
+ """One sentence for the analysis output, or None when there is nothing to say.
128
+
129
+ `induced_bias` is the fraction of the induced drag flow5 leaves out at this
130
+ aircraft's aspect ratio. It is **not** folded into the band, because the band
131
+ comes from published whole-aircraft budgets measured against a modelled drag
132
+ that was assumed sound. Two optimistic errors that stack have to be said to
133
+ stack, or the reader subtracts one of them and thinks they are done.
134
+ """
135
+ b = budget(preset_name, modelled_ld)
136
+ if b is None:
137
+ return None
138
+ lo, hi = b["realistic_best_LD"]["low"], b["realistic_best_LD"]["high"]
139
+ frac = b["missing_fraction"]
140
+ names = ", ".join(i["item"] for i in b["items"][:3])
141
+ stacked = ""
142
+ if induced_bias and induced_bias >= 0.05:
143
+ stacked = (
144
+ f" That band does not include the {induced_bias:.0%} of the induced drag "
145
+ "flow5 leaves out at this aspect ratio, which is a separate error in the "
146
+ "same direction: the two stack, so treat the band as an upper bound too."
147
+ )
148
+ return (
149
+ f"this L/D of {b['modelled_best_LD']} is for the lifting surfaces only. "
150
+ f"{names} and the rest are not in the model and flow5 cannot put them there. "
151
+ f"Published estimates for this class put them at {frac['low']:.0%}-{frac['high']:.0%} "
152
+ f"of the modelled drag, which would give a realistic {lo}-{hi}. Compare a "
153
+ "published aircraft's figure against that band, not against the number above."
154
+ + stacked
155
+ )
@@ -0,0 +1,451 @@
1
+ """Make the wrong thing hard.
2
+
3
+ flow5 will accept a stability request on a fixed-speed polar and answer with an
4
+ eigenvalue of 5.995e+51. It will accept an alpha sweep well past the airfoil's stall
5
+ and extrapolate a straight lift line through it. It will accept an inviscid run and
6
+ report an L/D three times too high. None of these raise an error.
7
+
8
+ So the guardrails live here, ahead of the solver, and they refuse rather than warn
9
+ when the result would be meaningless.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import itertools
14
+ from dataclasses import dataclass, field
15
+
16
+ from ..errors import DesignError, UnsupportedByFlow5
17
+ from ..geometry.derived import Derived
18
+ from ..model.presets import Preset
19
+
20
+ STABILITY_TYPES = {"T7"}
21
+ DERIVATIVE_TYPES = {"T7"}
22
+
23
+
24
+ @dataclass(slots=True)
25
+ class Check:
26
+ warnings: list[str] = field(default_factory=list)
27
+ notes: list[str] = field(default_factory=list)
28
+
29
+ def warn(self, message: str) -> None:
30
+ self.warnings.append(message)
31
+
32
+ def note(self, message: str) -> None:
33
+ self.notes.append(message)
34
+
35
+
36
+ def check_polar_type(polar_type: str, *, wants_stability: bool = False,
37
+ derivatives: bool = False) -> None:
38
+ """Refuse combinations flow5 answers wrongly."""
39
+ pt = polar_type.upper()
40
+
41
+ if pt == "T8":
42
+ raise UnsupportedByFlow5(
43
+ "flow5 accepts a T8 polar and returns nonsense from it rather than "
44
+ "refusing. Measured on a 3 m glider asked for alpha 2 to 8 in steps of "
45
+ "2: one point came back, at a speed of 2.0 m/s that nothing in the "
46
+ "request mentioned, reporting a lift-to-drag ratio of 68.6. Whatever "
47
+ "T8POLAR is for, it is not an alpha sweep, and flow5's own "
48
+ "documentation does not say. Use T1, T2, T3, T5 or T7."
49
+ )
50
+
51
+ if pt == "T4":
52
+ raise UnsupportedByFlow5(
53
+ "a T4 polar holds the angle of attack and sweeps the speed, and this "
54
+ "tool has no way to express a speed range — `alpha` is the only sweep "
55
+ "there is. flow5 rejects the analysis we generate for it outright "
56
+ "(\"matched no plane with any analysis\"), which used to be reported as "
57
+ "a bug in flow5ctl. To vary speed at a fixed attitude, use `sweep` on "
58
+ "the `speed` parameter with a T1 polar, which is the same question "
59
+ "asked in a way that works."
60
+ )
61
+
62
+ if pt in {"T6"}:
63
+ raise UnsupportedByFlow5(
64
+ "T6 control polars need flap or control-surface definitions, and flow5's "
65
+ "plane XML has no hinge elements — a flap belongs to flow5's Foil object, "
66
+ "which a .dat file cannot carry. Planes loaded from a GUI-made project "
67
+ "cannot be paired with new analyses either, so there is no way in. "
68
+ "See docs/FLOW5-INTERFACE.md section 3.3."
69
+ )
70
+
71
+ if wants_stability and pt not in STABILITY_TYPES:
72
+ raise DesignError(
73
+ f"A {pt} polar cannot answer a stability question. flow5 will return "
74
+ "eigenvalues of order 1e51 from a non-T7 polar rather than refusing. "
75
+ "Use polar_type='T7' (STABILITYPOLAR) instead.\n"
76
+ "Static margin and neutral point ARE available from a T1 polar; it is the "
77
+ "dynamic modes that require T7."
78
+ )
79
+
80
+ if derivatives and pt not in DERIVATIVE_TYPES:
81
+ raise DesignError(
82
+ f"Compute_derivatives is only meaningful on a T7 polar; on a {pt} polar "
83
+ "flow5 fills the derivative columns with zeros and the eigenvalues with "
84
+ "nonsense. Use polar_type='T7'."
85
+ )
86
+
87
+
88
+ def check_analysis(derived: Derived, preset: Preset, *, polar_type: str,
89
+ alpha: tuple[float, float, float] | None, viscous: bool,
90
+ on_the_fly: bool, ground_height: float | None) -> Check:
91
+ c = Check()
92
+ limits = preset.limits
93
+
94
+ panels = derived.panel_count
95
+ max_panels = int(limits.get("max_panels", 6000))
96
+ if panels > max_panels:
97
+ raise DesignError(
98
+ f"the mesh has {panels} panels, above the {preset.name} preset's ceiling of "
99
+ f"{max_panels}. Reduce wing.panels.spanwise or chordwise. Measured on a 34 m "
100
+ "high-aspect-ratio wing, results were already converged at 544 panels, so a "
101
+ "finer mesh usually buys nothing."
102
+ )
103
+
104
+ if alpha is not None:
105
+ max_alpha = float(limits.get("max_alpha", 14.0))
106
+ if max(abs(alpha[0]), abs(alpha[1])) > max_alpha:
107
+ c.warn(
108
+ f"the alpha sweep reaches {max(abs(alpha[0]), abs(alpha[1])):g}°, beyond "
109
+ f"{max_alpha:g}° where a potential-flow result is fiction — there is no "
110
+ "separation model, so CL keeps rising past the real CL_max. Treat the "
111
+ "top of this polar as unusable."
112
+ )
113
+ if alpha[2] <= 0:
114
+ raise DesignError("the alpha step must be positive")
115
+
116
+ if not viscous:
117
+ c.warn(
118
+ "this is an INVISCID run. On the shipped 3 m glider at alpha 0 the drag "
119
+ "came to 0.000332 inviscid against 0.017991 viscous — the inviscid run left out "
120
+ "98 % of the drag. Do not quote an L/D from it."
121
+ )
122
+ elif on_the_fly:
123
+ if len(derived.surfaces) > 1:
124
+ c.warn(
125
+ "on-the-fly XFoil can fail outright on a multi-surface aircraft: on one "
126
+ "3-surface glider it reported Cl = 3.23 on the elevator, discarded every "
127
+ "operating point, and had not finished after two minutes. It does not "
128
+ "always — the shipped 3 m glider runs all five points — but it is 8x "
129
+ "slower there (6.5 s against 0.8 s) and gives 19-28 % less viscous drag "
130
+ "than the interpolated method, so the two must never be mixed inside one "
131
+ "comparison. Check that every operating point you asked for came back."
132
+ )
133
+ c.note("viscous drag from on-the-fly XFoil; do not compare against interpolated runs.")
134
+ else:
135
+ c.note("viscous drag interpolated from a 2D polar mesh.")
136
+
137
+ if ground_height is not None:
138
+ span = derived.reference_span
139
+ if span and ground_height > span:
140
+ c.warn(
141
+ f"ground height {ground_height:g} m exceeds the span {span:g} m; ground "
142
+ "effect will be negligible at that height."
143
+ )
144
+ c.note(
145
+ "ground effect is on. Report the out-of-ground-effect case too — measured "
146
+ "on the shipped examples with the current defaults, +15.2 % on best L/D "
147
+ "for the 3 m glider at h = 0.30 m and +16.0 % for the 34 m HPA at "
148
+ "h = 2.0 m. `--compare-ground` gives both from one call."
149
+ )
150
+ elif preset.analysis.get("ground_effect"):
151
+ c.warn(
152
+ f"the {preset.name} preset expects ground effect to matter, but this "
153
+ "analysis has none. For an aircraft flown a few metres above water that "
154
+ "omits a large part of the performance."
155
+ )
156
+
157
+ if polar_type.upper() in {"T2", "T3"}:
158
+ c.note(
159
+ "a fixed-lift or glide polar flies slower at high CL, so the local Reynolds "
160
+ "number drops well below cruise. The 2D polar mesh must cover that."
161
+ )
162
+ if alpha is not None and alpha[0] <= 0.0:
163
+ c.warn(
164
+ f"this {polar_type.upper()} polar starts at α = {alpha[0]:g}°. A "
165
+ "fixed-lift or glide polar has no solution where the aircraft produces "
166
+ "no lift — the required speed diverges, and flow5 solves an enormous "
167
+ "speed rather than refusing. Start the sweep above the zero-lift angle "
168
+ "(α > 0 for a symmetric section)."
169
+ )
170
+ return c
171
+
172
+
173
+ def check_geometry(derived: Derived, preset: Preset) -> Check:
174
+ """Sanity thresholds, plus the checks flow5 will never do for you."""
175
+ c = Check()
176
+
177
+ def band(key: str, value: float | None, label: str, fmt: str = "{:.3g}") -> None:
178
+ b = preset.band(key)
179
+ if b is None or value is None:
180
+ return
181
+ lo, hi = b
182
+ if value < lo or value > hi:
183
+ c.warn(
184
+ f"{label} is {fmt.format(value)}, outside the {lo:g}–{hi:g} range typical "
185
+ f"for {preset.label.lower()}. Check the design, or use preset='custom' if "
186
+ "this is deliberate."
187
+ )
188
+
189
+ band("aspect_ratio", derived.aspect_ratio, "aspect ratio", "{:.1f}")
190
+ band("wing_loading", derived.wing_loading, "wing loading (kg/m²)")
191
+ band("reynolds_at_mac", derived.reynolds_at_mac, "Reynolds number at the MAC", "{:.3g}")
192
+ band("tail_volume_h", derived.tail_volume_h, "horizontal tail volume")
193
+ band("tail_volume_v", derived.tail_volume_v, "vertical tail volume", "{:.4g}")
194
+
195
+ fin = next((x for x in derived.surfaces if x.wing.role == "fin"), None)
196
+ if fin is not None and fin.wing.count == 2 and preset.band("tail_volume_v"):
197
+ lo, hi = preset.band("tail_volume_v")
198
+ if derived.tail_volume_v is not None and not (lo <= derived.tail_volume_v <= hi):
199
+ # The band was fitted to single-fin aircraft. A twin fin needs more total
200
+ # area for the same effect - each one sits in a worse part of the flow -
201
+ # and one published human-powered aircraft flies at 0.0264 with two.
202
+ # Rather than invent a second band from one data point, say so.
203
+ c.note(
204
+ "that band was set from aircraft with one fin. This design has two, "
205
+ "which normally need more total area for the same effect, so the "
206
+ "figure above may be reasonable — compare it against twin-fin "
207
+ "aircraft rather than against the band."
208
+ )
209
+
210
+ _check_wake_plane(derived, c)
211
+ _check_induced_drag_bias(derived, c)
212
+ _check_spanwise_mesh(derived, c)
213
+ _check_extra_surfaces(derived, c)
214
+
215
+ if derived.tail_volume_h is None and len(derived.surfaces) == 1:
216
+ c.note("wing only — no tail, so pitch trim and stability cannot be assessed.")
217
+
218
+ _check_coincident_surfaces(derived, c)
219
+
220
+ semi_span = derived.reference_span / 2.0
221
+ if derived.mass.from_components and derived.mass.lateral_inertia_is_degenerate(semi_span):
222
+ kx = derived.mass.roll_radius_of_gyration
223
+ c.warn(
224
+ f"the roll radius of gyration is {kx:.3g} m, only "
225
+ f"{kx / semi_span * 100:.1f} % of the semi-span (Ixx = {derived.mass.ixx:.3g} "
226
+ "kg·m²). The mass model has essentially no spanwise content, so "
227
+ "lateral-directional results will be meaningless — flow5 returns `inf` for "
228
+ "roll damping in this situation. Give the wing structure mass spanwise "
229
+ "positions; real aircraft sit around 15-35 % of semi-span."
230
+ )
231
+ if not derived.mass.from_components:
232
+ c.note(
233
+ "mass was given as a total, so inertia is unknown and lateral-directional "
234
+ "results will not be meaningful. Use mass.components to get inertia."
235
+ )
236
+
237
+ if derived.cg_percent_mac is not None:
238
+ c.note(f"CG is at {derived.cg_percent_mac * 100:.1f} % MAC.")
239
+ return c
240
+
241
+
242
+ #: Below this many spanwise panels per semi-span, induced drag is measurably
243
+ #: optimistic. See docs/log/2026-09-04-induced-drag-and-the-mesh.md.
244
+ MIN_SPANWISE = 25
245
+
246
+
247
+ #: Below this the bias is under about 5 % and saying so every time would be noise.
248
+ INDUCED_DRAG_AR_FLOOR = 15.0
249
+
250
+ #: Measured on elliptic wings, where the exact answer is e = 1.0 and a planar wing
251
+ #: cannot beat it. (aspect ratio, fraction by which flow5's induced drag is low).
252
+ #: docs/log/2026-09-04-induced-drag-against-avl.md
253
+ _INDUCED_BIAS = ((6.0, 0.009), (10.0, 0.024), (15.0, 0.046), (20.0, 0.072),
254
+ (25.0, 0.098), (30.0, 0.124), (40.0, 0.174), (50.0, 0.219))
255
+
256
+
257
+ def induced_drag_bias(aspect_ratio: float) -> float:
258
+ """How much of the induced drag flow5 leaves out, as a fraction, at this AR.
259
+
260
+ Linear between the measured points and held flat outside them, because
261
+ extrapolating a fitted curve past AR 50 would be inventing numbers.
262
+ """
263
+ pts = _INDUCED_BIAS
264
+ if aspect_ratio <= pts[0][0]:
265
+ return pts[0][1]
266
+ if aspect_ratio >= pts[-1][0]:
267
+ return pts[-1][1]
268
+ for (a0, b0), (a1, b1) in itertools.pairwise(pts):
269
+ if a0 <= aspect_ratio <= a1:
270
+ return b0 + (b1 - b0) * (aspect_ratio - a0) / (a1 - a0)
271
+ return pts[-1][1]
272
+
273
+
274
+ def _check_induced_drag_bias(derived: Derived, c: Check) -> None:
275
+ """flow5 under-predicts induced drag, and the shortfall grows with span.
276
+
277
+ Measured against AVL and against the one case with an exact answer: an elliptic
278
+ planar wing has e = 1.0 and cannot exceed it. flow5 returns 1.024 at AR 10 and
279
+ **1.210 at AR 40** — 21 % past a hard physical limit — while AVL returns 0.997
280
+ and 0.996 on the same planforms. It is not the mesh, the panel distribution or
281
+ the method: varying all of those moves it by 0.4 %.
282
+
283
+ Human-powered aircraft fly at AR 30-45, where induced drag is most of the drag
284
+ budget, so this is the largest known bias in the tool for its main users. It is
285
+ reported rather than corrected: applying a fudge factor to a solver's output
286
+ would hide the problem and would be wrong for any case not measured here.
287
+ """
288
+ ar = derived.aspect_ratio
289
+ if not ar or ar < INDUCED_DRAG_AR_FLOOR:
290
+ return
291
+ bias = induced_drag_bias(ar)
292
+ c.warn(
293
+ f"at aspect ratio {ar:.0f}, flow5's induced drag is about {bias:.0%} low. "
294
+ "Measured on elliptic wings, where the exact answer is a span efficiency of "
295
+ "1.0 and no planar wing can beat it: flow5 returns 1.21 at AR 40, while AVL "
296
+ "returns 0.996 on the same wing. Lift is unaffected — the two solvers agree "
297
+ "within 0.6 % — and changing method does not help: flow5's panel methods "
298
+ "(QUADS, TRIUNIFORM, TRILINEAR) come out 21 % on the other side of the same "
299
+ "limit. So the lift-to-drag "
300
+ "figures from this run are optimistic on top of everything the drag budget "
301
+ "already lists, and the more so the more of the drag is induced. Cross-check "
302
+ "against AVL before committing a design."
303
+ )
304
+
305
+
306
+ #: A downstream surface closer than this to the wing's own plane, as a fraction of
307
+ #: the wing's MAC, sits in its trailing vortex sheet and the induced drag is wrong.
308
+ WAKE_PLANE_MAC = 0.10
309
+
310
+
311
+ def _check_wake_plane(derived: Derived, c: Check) -> None:
312
+ """A tail level with the wing sits in its wake sheet, and the drag halves.
313
+
314
+ The wing's trailing vortices leave at its own height and run downstream. Put a
315
+ horizontal tail at exactly that height and its control points sit on the sheet,
316
+ which is singular; flow5 does not complain, it returns a number.
317
+
318
+ Measured on an AR 12 wing of 0.25 m chord with a 0.9 x 0.15 m tail 1.2 m behind,
319
+ inviscid, at alpha 6, moving only the tail's z:
320
+
321
+ | tail z | as a fraction of chord | induced drag | span efficiency |
322
+ |---|---|---|---|
323
+ | 0.000 | 0 | 0.00483 | **1.93 — impossible** |
324
+ | 0.001 | 0.4 % | 0.00734 | 1.27 — impossible |
325
+ | 0.005 | 2 % | 0.00935 | 0.996 |
326
+ | 0.010 | 4 % | 0.00964 | 0.966 |
327
+ | 0.020 | 8 % | 0.00977 | 0.953 |
328
+
329
+ Two centimetres of offset **doubles** the induced drag, to a value that then
330
+ matches AVL within 3 %. So this is not a small sensitivity to a modelling
331
+ choice: at zero offset the answer is out by a factor of two, in the optimistic
332
+ direction, with nothing in the output to say so.
333
+
334
+ Real aircraft rarely sit exactly there, but a design.yaml written as
335
+ `position: [1.2, 0, 0]` does, and that is an easy thing to type.
336
+ """
337
+ main = derived.main
338
+ if main is None:
339
+ return
340
+ mac = derived.reference_chord
341
+ if not mac:
342
+ return
343
+ z_wing = main.position_m[2]
344
+ for s in derived.surfaces:
345
+ if s is main or s.wing.role == "fin":
346
+ continue # a fin is vertical; it has no such plane
347
+ if s.position_m[0] <= main.position_m[0]:
348
+ continue # a canard is upstream of the sheet
349
+ gap = abs(s.position_m[2] - z_wing)
350
+ if gap >= WAKE_PLANE_MAC * mac:
351
+ continue
352
+ name = s.wing.name or s.wing.role
353
+ c.warn(
354
+ f"{name} sits {gap:.3g} m from the wing's own height, which is "
355
+ f"{gap / mac:.0%} of the MAC, and it is behind the wing. That puts it in "
356
+ "the wing's trailing vortex sheet, where the induced drag comes out "
357
+ "wrong and flow5 says nothing. Measured on a comparable layout: at zero "
358
+ "offset the induced drag was half its converged value and the span "
359
+ "efficiency read 1.93, which is impossible; moving the tail 2 cm — 8 % "
360
+ f"of chord — doubled it and brought it within 3 % of AVL. Offset {name} "
361
+ "vertically by at least a tenth of the MAC, or model the height it "
362
+ "actually has."
363
+ )
364
+
365
+
366
+ def _check_extra_surfaces(derived: Derived, c: Check) -> None:
367
+ """Tail volume is a two-surface idea, and a fourth surface breaks it.
368
+
369
+ Both tail volume coefficients are area x lever arm over the main wing's area and
370
+ a reference length, and every published band for them was fitted to aircraft
371
+ with one lifting wing and one tail. On a tandem or a canard the lift is shared
372
+ between two surfaces and the bands are simply not about that aircraft, so the
373
+ number is still computed - it is what flow5's geometry gives - but it is not
374
+ something to size against.
375
+ """
376
+ extra = [s for s in derived.surfaces if s.wing.role == "other"]
377
+ if not extra:
378
+ return
379
+ names = ", ".join(s.wing.name or "unnamed" for s in extra)
380
+ if derived.tail_volume_h is not None or derived.tail_volume_v is not None:
381
+ c.note(
382
+ f"this design has a lifting surface beyond the wing, elevator and fin "
383
+ f"({names}). The tail volumes above are still computed from the elevator "
384
+ "and fin alone, and the bands they are compared against were fitted to "
385
+ "aircraft with one wing and one tail. On a tandem or a canard they are "
386
+ "not the right measure of pitch or yaw authority — check the trimmed "
387
+ "condition and the static margin instead."
388
+ )
389
+
390
+
391
+ def _check_spanwise_mesh(derived: Derived, c: Check) -> None:
392
+ """Induced drag is set by the span, and a coarse span makes it optimistic.
393
+
394
+ Measured on rectangular wings at AR 10 and AR 40, inviscid, varying only the
395
+ spanwise panel count. Span efficiency comes out **above 1** on a coarse mesh —
396
+ impossible for a planar wing — and falls monotonically, linearly in 1/N:
397
+
398
+ | spanwise per semi-span | how optimistic the induced drag is |
399
+ |---|---|
400
+ | 20 | about 3 % |
401
+ | 40 | about 1.5 % |
402
+ | 80 | about 0.7 % |
403
+
404
+ Chordwise panels make no difference to this at all: 7, 13 and 21 chordwise agree
405
+ to four decimal places. So the fix is always to spend the panels on the span.
406
+ """
407
+ main = derived.main
408
+ if main is None:
409
+ return
410
+ spanwise = getattr(main.wing.panels, "spanwise", None)
411
+ if not spanwise or spanwise >= MIN_SPANWISE:
412
+ return
413
+ c.note(
414
+ f"the wing has {spanwise} spanwise panels per semi-span. Induced drag is set "
415
+ "by the span, and below about 25 it is optimistic — measured 3 % at 20 "
416
+ "panels, with the span efficiency coming out above 1, which is impossible "
417
+ "for a planar wing. Chordwise panels do not help this; 40 spanwise brings it "
418
+ "inside 1.5 %."
419
+ )
420
+
421
+
422
+ def _check_coincident_surfaces(derived: Derived, c: Check) -> None:
423
+ """Two surfaces in the same place make flow5 produce nonsense, not an error.
424
+
425
+ Observed: a fin whose root sat exactly on the elevator gave an effective angle of
426
+ attack of -104 degrees at the elevator centre and failed the whole analysis. flow5
427
+ does not check for this, so we do.
428
+ """
429
+ tol = 0.02
430
+ for i, a in enumerate(derived.surfaces):
431
+ for b in derived.surfaces[i + 1:]:
432
+ same_x = abs(a.position_m[0] - b.position_m[0]) < tol
433
+ same_z = abs(a.position_m[2] - b.position_m[2]) < tol
434
+ if not (same_x and same_z):
435
+ continue
436
+ roles = {a.wing.role, b.wing.role}
437
+ if roles == {"elevator", "fin"}:
438
+ c.warn(
439
+ f"the fin root and the elevator are both at x = "
440
+ f"{a.position_m[0]:.3g} m, z = {a.position_m[2]:.3g} m. Coincident "
441
+ "surfaces make flow5 compute nonsensical local flow angles and fail "
442
+ "the analysis. Offset the fin root a few centimetres above the "
443
+ "elevator."
444
+ )
445
+ else:
446
+ c.warn(
447
+ f"{a.wing.name or a.wing.role!r} and {b.wing.name or b.wing.role!r} "
448
+ f"are at the same position (x = {a.position_m[0]:.3g} m, "
449
+ f"z = {a.position_m[2]:.3g} m). Overlapping panels give unreliable "
450
+ "results."
451
+ )