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.
- flow5ctl/__init__.py +35 -0
- flow5ctl/__main__.py +4 -0
- flow5ctl/advisor/__init__.py +0 -0
- flow5ctl/advisor/dragbudget.py +155 -0
- flow5ctl/advisor/guardrails.py +451 -0
- flow5ctl/advisor/stability.py +81 -0
- flow5ctl/advisor/structure.py +159 -0
- flow5ctl/cli.py +771 -0
- flow5ctl/errors.py +55 -0
- flow5ctl/flow5/__init__.py +0 -0
- flow5ctl/flow5/airfoils.py +175 -0
- flow5ctl/flow5/foilpolar.py +121 -0
- flow5ctl/flow5/markers.py +293 -0
- flow5ctl/flow5/probe.py +141 -0
- flow5ctl/flow5/results.py +309 -0
- flow5ctl/flow5/runner.py +149 -0
- flow5ctl/flow5/summary.py +427 -0
- flow5ctl/flow5/xmlgen.py +422 -0
- flow5ctl/geometry/__init__.py +0 -0
- flow5ctl/geometry/derived.py +279 -0
- flow5ctl/geometry/massprops.py +87 -0
- flow5ctl/geometry/planform.py +254 -0
- flow5ctl/mcp_server.py +615 -0
- flow5ctl/model/__init__.py +0 -0
- flow5ctl/model/design.py +280 -0
- flow5ctl/model/presets.py +74 -0
- flow5ctl/presets/custom.yaml +34 -0
- flow5ctl/presets/hpa.yaml +53 -0
- flow5ctl/presets/rc-glider.yaml +42 -0
- flow5ctl/presets/uav.yaml +40 -0
- flow5ctl/project/__init__.py +0 -0
- flow5ctl/project/store.py +262 -0
- flow5ctl/units.py +53 -0
- flow5ctl/usecases/__init__.py +0 -0
- flow5ctl/usecases/analyze.py +634 -0
- flow5ctl/usecases/define.py +124 -0
- flow5ctl/usecases/edit.py +320 -0
- flow5ctl/usecases/ground.py +129 -0
- flow5ctl/usecases/gui.py +50 -0
- flow5ctl/usecases/plot.py +95 -0
- flow5ctl/usecases/sweep.py +385 -0
- flow5ctl/usecases/trim.py +371 -0
- flow5ctl/viz/__init__.py +0 -0
- flow5ctl/viz/charts.py +374 -0
- flow5ctl/viz/palette.py +71 -0
- flow5ctl-0.1.0.dist-info/METADATA +348 -0
- flow5ctl-0.1.0.dist-info/RECORD +50 -0
- flow5ctl-0.1.0.dist-info/WHEEL +4 -0
- flow5ctl-0.1.0.dist-info/entry_points.txt +2 -0
- 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
|
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
|
+
)
|