flapjax-model-gen 0.1.0__tar.gz
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.
- flapjax_model_gen-0.1.0/.github/workflows/publish.yml +42 -0
- flapjax_model_gen-0.1.0/.gitignore +10 -0
- flapjax_model_gen-0.1.0/LICENSE +22 -0
- flapjax_model_gen-0.1.0/PKG-INFO +179 -0
- flapjax_model_gen-0.1.0/README.md +157 -0
- flapjax_model_gen-0.1.0/pyproject.toml +44 -0
- flapjax_model_gen-0.1.0/src/flapjax_model_gen/__init__.py +20 -0
- flapjax_model_gen-0.1.0/src/flapjax_model_gen/linear_polar.py +47 -0
- flapjax_model_gen-0.1.0/src/flapjax_model_gen/nastran/__init__.py +23 -0
- flapjax_model_gen-0.1.0/src/flapjax_model_gen/nastran/aero_mesh.py +124 -0
- flapjax_model_gen-0.1.0/src/flapjax_model_gen/nastran/mass.py +96 -0
- flapjax_model_gen-0.1.0/src/flapjax_model_gen/nastran/stiffness.py +165 -0
- flapjax_model_gen-0.1.0/src/flapjax_model_gen/rigid.py +44 -0
- flapjax_model_gen-0.1.0/src/flapjax_model_gen/strip_forces.py +44 -0
- flapjax_model_gen-0.1.0/src/flapjax_model_gen/twist_grid.py +48 -0
- flapjax_model_gen-0.1.0/src/flapjax_model_gen/twist_match.py +143 -0
- flapjax_model_gen-0.1.0/tests/conftest.py +3 -0
- flapjax_model_gen-0.1.0/tests/nastran/test_aero_mesh.py +86 -0
- flapjax_model_gen-0.1.0/tests/nastran/test_mass.py +82 -0
- flapjax_model_gen-0.1.0/tests/nastran/test_stiffness.py +156 -0
- flapjax_model_gen-0.1.0/tests/test_linear_polar.py +30 -0
- flapjax_model_gen-0.1.0/tests/test_rigid.py +42 -0
- flapjax_model_gen-0.1.0/tests/test_twist_grid.py +37 -0
- flapjax_model_gen-0.1.0/tests/test_twist_match.py +38 -0
- flapjax_model_gen-0.1.0/uv.lock +1424 -0
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
name: Publish to PyPI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
workflow_dispatch:
|
|
5
|
+
|
|
6
|
+
jobs:
|
|
7
|
+
build:
|
|
8
|
+
name: Build distribution
|
|
9
|
+
runs-on: ubuntu-latest
|
|
10
|
+
steps:
|
|
11
|
+
- uses: actions/checkout@v7
|
|
12
|
+
|
|
13
|
+
- name: Install uv
|
|
14
|
+
uses: astral-sh/setup-uv@v10.2.0
|
|
15
|
+
|
|
16
|
+
- name: Build sdist and wheel
|
|
17
|
+
run: uv build
|
|
18
|
+
|
|
19
|
+
- name: Upload build artifacts
|
|
20
|
+
uses: actions/upload-artifact@v7
|
|
21
|
+
with:
|
|
22
|
+
name: dist
|
|
23
|
+
path: dist/
|
|
24
|
+
|
|
25
|
+
publish:
|
|
26
|
+
name: Publish to PyPI
|
|
27
|
+
needs: build
|
|
28
|
+
runs-on: ubuntu-latest
|
|
29
|
+
environment:
|
|
30
|
+
name: pypi
|
|
31
|
+
url: https://pypi.org/p/flapjax-model-gen
|
|
32
|
+
permissions:
|
|
33
|
+
id-token: write # required for PyPI trusted publishing (OIDC) -- no API token secret needed
|
|
34
|
+
steps:
|
|
35
|
+
- name: Download build artifacts
|
|
36
|
+
uses: actions/download-artifact@v8
|
|
37
|
+
with:
|
|
38
|
+
name: dist
|
|
39
|
+
path: dist/
|
|
40
|
+
|
|
41
|
+
- name: Publish to PyPI
|
|
42
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026, Loads Control and Aeroelastics, Imperial College London
|
|
4
|
+
All rights reserved.
|
|
5
|
+
|
|
6
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
7
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
8
|
+
in the Software without restriction, including without limitation the rights
|
|
9
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
10
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
11
|
+
furnished to do so, subject to the following conditions:
|
|
12
|
+
|
|
13
|
+
The above copyright notice and this permission notice shall be included in all
|
|
14
|
+
copies or substantial portions of the Software.
|
|
15
|
+
|
|
16
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
17
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
18
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
19
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
20
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
21
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
22
|
+
SOFTWARE.
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: flapjax-model-gen
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Match strip-wise aerodynamic corrections (cl0/cla/cm0/cma) to a NASTRAN-ready twist distribution using flapjax.
|
|
5
|
+
Author-email: Ben Preston <b.preston23@imperial.ac.uk>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Keywords: aeroelasticity,flapjax,nastran,vortex lattice method
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Intended Audience :: Science/Research
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Classifier: Topic :: Scientific/Engineering
|
|
16
|
+
Classifier: Topic :: Scientific/Engineering :: Physics
|
|
17
|
+
Requires-Python: >=3.12
|
|
18
|
+
Requires-Dist: flapjax>=1.3.6
|
|
19
|
+
Requires-Dist: jax>=0.8.1
|
|
20
|
+
Requires-Dist: pynastran>=1.3.4
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
|
|
23
|
+
# flapjax-model-gen
|
|
24
|
+
|
|
25
|
+
Match strip-wise aerodynamic corrections (`cl0`, `cla`, `cm0`, `cma`) — defined about an aircraft's flight
|
|
26
|
+
shape, with no knowledge of local angle of attack or elastic deformation — to a NASTRAN-ready aerodynamic twist
|
|
27
|
+
distribution, using [flapjax](https://github.com/ben-l-p/flapjax) as the differentiable solver in the loop.
|
|
28
|
+
|
|
29
|
+
This is a thin, fast-moving companion package to `flapjax`, kept separate so it can be iterated on and released
|
|
30
|
+
independently.
|
|
31
|
+
|
|
32
|
+
## What's here
|
|
33
|
+
|
|
34
|
+
Most of the workflow this package supports is already implemented in `flapjax` itself
|
|
35
|
+
(`flapjax.aero.utils.apply_polar_correction` / `strip_alpha` / `project_forcing_to_beam`, wired into `UVLM` via
|
|
36
|
+
its `polar_data` / `polar_function` arguments — see `flapjax`'s `models/cantilever_wing/polar_correction.ipynb`
|
|
37
|
+
tutorial). This package adds the pieces that were missing:
|
|
38
|
+
|
|
39
|
+
- `linear_polar.py` — a `PolarFunction` (`flapjax`'s `(alpha, data) -> (cl, cd, cm)` interface) that evaluates
|
|
40
|
+
an affine correction, `cl = cl0 + cla * alpha`, `cm = cm0 + cma * alpha`, `cd = 0`, so `cl0`/`cla`/`cm0`/`cma`
|
|
41
|
+
data can be dropped straight into `UVLM(polar_data=..., polar_function=...)` exactly like a tabulated polar.
|
|
42
|
+
- `rigid.py` — rigidly rotates a structure's reference SE(3) frames (`rotate_hg`), for representing a change in
|
|
43
|
+
angle of attack as a *rotation of the geometry*, matching the correction data's own convention, rather than
|
|
44
|
+
rotating the freestream direction.
|
|
45
|
+
- `twist_grid.py` — `make_twisted_grid`, a per-spanwise-station generalisation of `flapjax`'s
|
|
46
|
+
`make_rectangular_grid(..., twist=...)` (which applies one scalar twist to the whole grid). This is the search
|
|
47
|
+
space the optimizer below works in.
|
|
48
|
+
- `strip_forces.py` — thin helpers around `AeroCase.project_forcing_to_beam` for pulling global-frame,
|
|
49
|
+
per-node force/moment vectors out of a solved case, on either the rigid reference shape or a deformed one.
|
|
50
|
+
- `twist_match.py` — `match_twist`, a JAX-native Levenberg-Marquardt solver (autodiff Jacobian,
|
|
51
|
+
`jax.lax.while_loop`) that finds the twist distribution minimising the residual between a candidate model's
|
|
52
|
+
forces and a target force field. This is the genuinely new piece: no optimizer for this existed in `flapjax`.
|
|
53
|
+
|
|
54
|
+
## Suggested workflow
|
|
55
|
+
|
|
56
|
+
This mirrors the four-step process it was built for; wire the pieces together to fit your model rather than
|
|
57
|
+
treating this as a locked pipeline — in particular, **check the assumption flagged in step 3** before trusting
|
|
58
|
+
the result.
|
|
59
|
+
|
|
60
|
+
```python
|
|
61
|
+
import jax.numpy as jnp
|
|
62
|
+
from flapjax.aero import ConstantFlowField
|
|
63
|
+
from flapjax_model_gen import (
|
|
64
|
+
LinearPolar, linear_polar_function, rotate_hg, rigid_corrected_forces,
|
|
65
|
+
make_twisted_grid, match_twist,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
# 1. Target forces directly from the correction data, on the rigid, undeformed flight shape.
|
|
69
|
+
# `uvlm_corrected` is your UVLM/CoupledAeroelastic built with
|
|
70
|
+
# polar_data=[LinearPolar(cl0, cla, cm0, cma)], polar_function=[linear_polar_function].
|
|
71
|
+
target_0 = rigid_corrected_forces(uvlm_corrected, hg=structure.hg0)
|
|
72
|
+
target_1 = rigid_corrected_forces(uvlm_corrected, hg=rotate_hg(structure.hg0, jnp.deg2rad(1.0)))
|
|
73
|
+
|
|
74
|
+
# 2. Static deformation under the corrected aero at AoA=0 (full FSI solve).
|
|
75
|
+
sol_0 = coupled_corrected.static_solve(prescribed_dofs=range(6), horseshoe=True)
|
|
76
|
+
|
|
77
|
+
# 3. Rigidly rotate the *converged, deformed* shape and take a single (no-FSI) aero pass at AoA=1.
|
|
78
|
+
# NOTE: apply_polar_correction evaluates cl0/cla/cm0/cma at each strip's *locally computed* AoA, which in a
|
|
79
|
+
# coupled solve includes any elastic twist -- extrapolating a correction that was only derived from rigid
|
|
80
|
+
# sweeps. That's usually the intended reading of "sectional" data, but confirm it before relying on it.
|
|
81
|
+
hg_1 = rotate_hg(sol_0.structure.hg, jnp.deg2rad(1.0))
|
|
82
|
+
model_1 = rigid_corrected_forces(uvlm_corrected, hg=hg_1)
|
|
83
|
+
|
|
84
|
+
# 4. Build an *uncorrected* aero model with per-station twist as the free variable, and match it (on the same,
|
|
85
|
+
# fixed deformed geometry from step 2/3) against whichever of the above you're treating as ground truth.
|
|
86
|
+
def forces_fn(twist):
|
|
87
|
+
x0_aero = [make_twisted_grid(m, n, chord, ea, twist=twist)]
|
|
88
|
+
uvlm_plain.set_design_variables(..., x0_aero=x0_aero)
|
|
89
|
+
f0 = rigid_corrected_forces(uvlm_plain, hg=structure.hg0)
|
|
90
|
+
f1 = rigid_corrected_forces(uvlm_plain, hg=rotate_hg(sol_0.structure.hg, jnp.deg2rad(1.0)))
|
|
91
|
+
return jnp.stack([f0, f1])
|
|
92
|
+
|
|
93
|
+
target = jnp.stack([target_0, target_1])
|
|
94
|
+
result = match_twist(forces_fn, target, twist0=jnp.zeros(n + 1))
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
A single per-strip twist angle is a pure shift of local incidence: it can reproduce a `cl0`-like offset against
|
|
98
|
+
whatever lift-curve slope the plain panel aerodynamics already has, but it cannot independently fix a
|
|
99
|
+
mismatched `cla`, and it can't inject an independent camber-driven `cm0` (a flat panel has no camber). Worth
|
|
100
|
+
checking that the plain UVLM's native `cla` per strip is already close to the target before trusting the fit —
|
|
101
|
+
if it isn't, geometry/discretisation needs adjusting, not just twist.
|
|
102
|
+
|
|
103
|
+
## Building a flapjax model from NASTRAN inputs
|
|
104
|
+
|
|
105
|
+
`flapjax_model_gen.nastran` converts a NASTRAN aircraft model into flapjax `BeamStructure`/`UVLM` inputs, using
|
|
106
|
+
[pyNastran](https://github.com/SteveDoyle2/pyNastran) to parse bulk data. It expects three input files, matching
|
|
107
|
+
how these models are actually organised:
|
|
108
|
+
|
|
109
|
+
- **Aerodynamic mesh** (`.bdf`, `CAERO1` panels) — `nastran.parse_caero1_panels` extracts each panel's leading/
|
|
110
|
+
trailing-edge corner points (already resolved to the global frame, including its own `CP` coordinate system);
|
|
111
|
+
`nastran.build_local_grid` then rediscretizes them into a flapjax local grid.
|
|
112
|
+
- **Mass model** (one or more `.nsb` files, `CONM2` entries only — e.g. one structural, one fuel) —
|
|
113
|
+
`nastran.parse_mass_files` sums them into one lumped 6x6 mass matrix per node.
|
|
114
|
+
- **Stiffness model** (`.bdf`, `GRID` + `CBEAM` + `PBEAM`, referencing `MAT1`) — `nastran.parse_stiffness_bdf`
|
|
115
|
+
builds node coordinates, connectivity, per-element orientation (`y_vector`) and per-element 6x6 stiffness.
|
|
116
|
+
|
|
117
|
+
```python
|
|
118
|
+
import jax.numpy as jnp
|
|
119
|
+
from flapjax.aero import GridDiscretisation, UVLM
|
|
120
|
+
from flapjax.structure import BeamStructure
|
|
121
|
+
from flapjax.coupled import CoupledAeroelastic
|
|
122
|
+
from flapjax_model_gen.nastran import (
|
|
123
|
+
parse_stiffness_bdf, parse_mass_files, read_aero_bdf, parse_caero1_panels, build_local_grid,
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
beam_model = parse_stiffness_bdf("stiffness.bdf")
|
|
127
|
+
m_lumped, m_lumped_index = parse_mass_files(["structure.nsb", "fuel.nsb"], node_ids=beam_model.node_ids)
|
|
128
|
+
|
|
129
|
+
structure = BeamStructure(
|
|
130
|
+
num_nodes=len(beam_model.node_ids),
|
|
131
|
+
connectivity=beam_model.connectivity,
|
|
132
|
+
y_vector=beam_model.y_vector,
|
|
133
|
+
k_cs_index=beam_model.k_cs_index,
|
|
134
|
+
m_lumped_index=m_lumped_index,
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
aero_model = read_aero_bdf("aero.bdf")
|
|
138
|
+
panels = parse_caero1_panels(aero_model) # group/order multi-panel surfaces yourself, e.g. sorted by span
|
|
139
|
+
local_grid = build_local_grid(panels, beam_model.coords, m=8, span_axis=1)
|
|
140
|
+
|
|
141
|
+
uvlm = UVLM(
|
|
142
|
+
grid_shapes=[GridDiscretisation(m=8, n=len(beam_model.node_ids) - 1, m_star=20)],
|
|
143
|
+
dof_mapping=jnp.arange(len(beam_model.node_ids)),
|
|
144
|
+
)
|
|
145
|
+
wing = CoupledAeroelastic(structure, uvlm)
|
|
146
|
+
wing.set_design_variables(
|
|
147
|
+
coords=beam_model.coords, k_cs=beam_model.k_cs, m_cs=None, m_lumped=m_lumped,
|
|
148
|
+
dt=..., flowfield=..., x0_aero=[local_grid],
|
|
149
|
+
)
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
**What's deliberately out of scope / assumed**, each backed by a clear error or warning rather than a silent
|
|
153
|
+
wrong answer if violated:
|
|
154
|
+
|
|
155
|
+
- Only `CAERO1`, `CBEAM`/`PBEAM` (not `PBEAML`/`PBCOMP`), and `MAT1` are supported.
|
|
156
|
+
- `CBEAM`'s `OFFT` must be the default `'GGG'`, and end offsets (`WA`/`WB`) must be zero — no offset beam ends.
|
|
157
|
+
- `CONM2`'s `CID` must be the basic coordinate system (`0` or `-1`).
|
|
158
|
+
- A tapered `PBEAM` (multiple stations) is collapsed to one constant cross-section per element by averaging its
|
|
159
|
+
station values — flapjax elements are constant-property, so this is an approximation for a genuinely tapered
|
|
160
|
+
element, not a limitation you can configure around.
|
|
161
|
+
- `PBEAM`'s `I12` (bend-bend coupling) is not incorporated into `k_cs` — a warning fires if it's nonzero, since
|
|
162
|
+
results will be approximate for that element.
|
|
163
|
+
- The aerodynamic grid's spanwise stations are placed exactly at the given structural node positions (flapjax's
|
|
164
|
+
`dof_mapping` ties each aero column rigidly to one structural node, with no separate spline layer like
|
|
165
|
+
NASTRAN's beam splines) — `build_local_grid` doesn't take an independent spanwise panel count.
|
|
166
|
+
- The axis convention (`CBEAM`'s orientation vector = flapjax's `y_vector` directly; PBEAM `I1`→bending about
|
|
167
|
+
local z, `I2`→about local y) was verified against the NASTRAN QRG and cross-checked against flapjax's own
|
|
168
|
+
beam code, not assumed — see the docstring in `nastran/stiffness.py` for the derivation.
|
|
169
|
+
|
|
170
|
+
## Development
|
|
171
|
+
|
|
172
|
+
```bash
|
|
173
|
+
uv sync --dev
|
|
174
|
+
uv run pytest
|
|
175
|
+
uv run ruff check src
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
Tests are deliberately lightweight (geometry/algebra invariants and a synthetic least-squares problem for the
|
|
179
|
+
optimizer) rather than full aeroelastic solves, so the suite stays fast to iterate against.
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
# flapjax-model-gen
|
|
2
|
+
|
|
3
|
+
Match strip-wise aerodynamic corrections (`cl0`, `cla`, `cm0`, `cma`) — defined about an aircraft's flight
|
|
4
|
+
shape, with no knowledge of local angle of attack or elastic deformation — to a NASTRAN-ready aerodynamic twist
|
|
5
|
+
distribution, using [flapjax](https://github.com/ben-l-p/flapjax) as the differentiable solver in the loop.
|
|
6
|
+
|
|
7
|
+
This is a thin, fast-moving companion package to `flapjax`, kept separate so it can be iterated on and released
|
|
8
|
+
independently.
|
|
9
|
+
|
|
10
|
+
## What's here
|
|
11
|
+
|
|
12
|
+
Most of the workflow this package supports is already implemented in `flapjax` itself
|
|
13
|
+
(`flapjax.aero.utils.apply_polar_correction` / `strip_alpha` / `project_forcing_to_beam`, wired into `UVLM` via
|
|
14
|
+
its `polar_data` / `polar_function` arguments — see `flapjax`'s `models/cantilever_wing/polar_correction.ipynb`
|
|
15
|
+
tutorial). This package adds the pieces that were missing:
|
|
16
|
+
|
|
17
|
+
- `linear_polar.py` — a `PolarFunction` (`flapjax`'s `(alpha, data) -> (cl, cd, cm)` interface) that evaluates
|
|
18
|
+
an affine correction, `cl = cl0 + cla * alpha`, `cm = cm0 + cma * alpha`, `cd = 0`, so `cl0`/`cla`/`cm0`/`cma`
|
|
19
|
+
data can be dropped straight into `UVLM(polar_data=..., polar_function=...)` exactly like a tabulated polar.
|
|
20
|
+
- `rigid.py` — rigidly rotates a structure's reference SE(3) frames (`rotate_hg`), for representing a change in
|
|
21
|
+
angle of attack as a *rotation of the geometry*, matching the correction data's own convention, rather than
|
|
22
|
+
rotating the freestream direction.
|
|
23
|
+
- `twist_grid.py` — `make_twisted_grid`, a per-spanwise-station generalisation of `flapjax`'s
|
|
24
|
+
`make_rectangular_grid(..., twist=...)` (which applies one scalar twist to the whole grid). This is the search
|
|
25
|
+
space the optimizer below works in.
|
|
26
|
+
- `strip_forces.py` — thin helpers around `AeroCase.project_forcing_to_beam` for pulling global-frame,
|
|
27
|
+
per-node force/moment vectors out of a solved case, on either the rigid reference shape or a deformed one.
|
|
28
|
+
- `twist_match.py` — `match_twist`, a JAX-native Levenberg-Marquardt solver (autodiff Jacobian,
|
|
29
|
+
`jax.lax.while_loop`) that finds the twist distribution minimising the residual between a candidate model's
|
|
30
|
+
forces and a target force field. This is the genuinely new piece: no optimizer for this existed in `flapjax`.
|
|
31
|
+
|
|
32
|
+
## Suggested workflow
|
|
33
|
+
|
|
34
|
+
This mirrors the four-step process it was built for; wire the pieces together to fit your model rather than
|
|
35
|
+
treating this as a locked pipeline — in particular, **check the assumption flagged in step 3** before trusting
|
|
36
|
+
the result.
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
import jax.numpy as jnp
|
|
40
|
+
from flapjax.aero import ConstantFlowField
|
|
41
|
+
from flapjax_model_gen import (
|
|
42
|
+
LinearPolar, linear_polar_function, rotate_hg, rigid_corrected_forces,
|
|
43
|
+
make_twisted_grid, match_twist,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
# 1. Target forces directly from the correction data, on the rigid, undeformed flight shape.
|
|
47
|
+
# `uvlm_corrected` is your UVLM/CoupledAeroelastic built with
|
|
48
|
+
# polar_data=[LinearPolar(cl0, cla, cm0, cma)], polar_function=[linear_polar_function].
|
|
49
|
+
target_0 = rigid_corrected_forces(uvlm_corrected, hg=structure.hg0)
|
|
50
|
+
target_1 = rigid_corrected_forces(uvlm_corrected, hg=rotate_hg(structure.hg0, jnp.deg2rad(1.0)))
|
|
51
|
+
|
|
52
|
+
# 2. Static deformation under the corrected aero at AoA=0 (full FSI solve).
|
|
53
|
+
sol_0 = coupled_corrected.static_solve(prescribed_dofs=range(6), horseshoe=True)
|
|
54
|
+
|
|
55
|
+
# 3. Rigidly rotate the *converged, deformed* shape and take a single (no-FSI) aero pass at AoA=1.
|
|
56
|
+
# NOTE: apply_polar_correction evaluates cl0/cla/cm0/cma at each strip's *locally computed* AoA, which in a
|
|
57
|
+
# coupled solve includes any elastic twist -- extrapolating a correction that was only derived from rigid
|
|
58
|
+
# sweeps. That's usually the intended reading of "sectional" data, but confirm it before relying on it.
|
|
59
|
+
hg_1 = rotate_hg(sol_0.structure.hg, jnp.deg2rad(1.0))
|
|
60
|
+
model_1 = rigid_corrected_forces(uvlm_corrected, hg=hg_1)
|
|
61
|
+
|
|
62
|
+
# 4. Build an *uncorrected* aero model with per-station twist as the free variable, and match it (on the same,
|
|
63
|
+
# fixed deformed geometry from step 2/3) against whichever of the above you're treating as ground truth.
|
|
64
|
+
def forces_fn(twist):
|
|
65
|
+
x0_aero = [make_twisted_grid(m, n, chord, ea, twist=twist)]
|
|
66
|
+
uvlm_plain.set_design_variables(..., x0_aero=x0_aero)
|
|
67
|
+
f0 = rigid_corrected_forces(uvlm_plain, hg=structure.hg0)
|
|
68
|
+
f1 = rigid_corrected_forces(uvlm_plain, hg=rotate_hg(sol_0.structure.hg, jnp.deg2rad(1.0)))
|
|
69
|
+
return jnp.stack([f0, f1])
|
|
70
|
+
|
|
71
|
+
target = jnp.stack([target_0, target_1])
|
|
72
|
+
result = match_twist(forces_fn, target, twist0=jnp.zeros(n + 1))
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
A single per-strip twist angle is a pure shift of local incidence: it can reproduce a `cl0`-like offset against
|
|
76
|
+
whatever lift-curve slope the plain panel aerodynamics already has, but it cannot independently fix a
|
|
77
|
+
mismatched `cla`, and it can't inject an independent camber-driven `cm0` (a flat panel has no camber). Worth
|
|
78
|
+
checking that the plain UVLM's native `cla` per strip is already close to the target before trusting the fit —
|
|
79
|
+
if it isn't, geometry/discretisation needs adjusting, not just twist.
|
|
80
|
+
|
|
81
|
+
## Building a flapjax model from NASTRAN inputs
|
|
82
|
+
|
|
83
|
+
`flapjax_model_gen.nastran` converts a NASTRAN aircraft model into flapjax `BeamStructure`/`UVLM` inputs, using
|
|
84
|
+
[pyNastran](https://github.com/SteveDoyle2/pyNastran) to parse bulk data. It expects three input files, matching
|
|
85
|
+
how these models are actually organised:
|
|
86
|
+
|
|
87
|
+
- **Aerodynamic mesh** (`.bdf`, `CAERO1` panels) — `nastran.parse_caero1_panels` extracts each panel's leading/
|
|
88
|
+
trailing-edge corner points (already resolved to the global frame, including its own `CP` coordinate system);
|
|
89
|
+
`nastran.build_local_grid` then rediscretizes them into a flapjax local grid.
|
|
90
|
+
- **Mass model** (one or more `.nsb` files, `CONM2` entries only — e.g. one structural, one fuel) —
|
|
91
|
+
`nastran.parse_mass_files` sums them into one lumped 6x6 mass matrix per node.
|
|
92
|
+
- **Stiffness model** (`.bdf`, `GRID` + `CBEAM` + `PBEAM`, referencing `MAT1`) — `nastran.parse_stiffness_bdf`
|
|
93
|
+
builds node coordinates, connectivity, per-element orientation (`y_vector`) and per-element 6x6 stiffness.
|
|
94
|
+
|
|
95
|
+
```python
|
|
96
|
+
import jax.numpy as jnp
|
|
97
|
+
from flapjax.aero import GridDiscretisation, UVLM
|
|
98
|
+
from flapjax.structure import BeamStructure
|
|
99
|
+
from flapjax.coupled import CoupledAeroelastic
|
|
100
|
+
from flapjax_model_gen.nastran import (
|
|
101
|
+
parse_stiffness_bdf, parse_mass_files, read_aero_bdf, parse_caero1_panels, build_local_grid,
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
beam_model = parse_stiffness_bdf("stiffness.bdf")
|
|
105
|
+
m_lumped, m_lumped_index = parse_mass_files(["structure.nsb", "fuel.nsb"], node_ids=beam_model.node_ids)
|
|
106
|
+
|
|
107
|
+
structure = BeamStructure(
|
|
108
|
+
num_nodes=len(beam_model.node_ids),
|
|
109
|
+
connectivity=beam_model.connectivity,
|
|
110
|
+
y_vector=beam_model.y_vector,
|
|
111
|
+
k_cs_index=beam_model.k_cs_index,
|
|
112
|
+
m_lumped_index=m_lumped_index,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
aero_model = read_aero_bdf("aero.bdf")
|
|
116
|
+
panels = parse_caero1_panels(aero_model) # group/order multi-panel surfaces yourself, e.g. sorted by span
|
|
117
|
+
local_grid = build_local_grid(panels, beam_model.coords, m=8, span_axis=1)
|
|
118
|
+
|
|
119
|
+
uvlm = UVLM(
|
|
120
|
+
grid_shapes=[GridDiscretisation(m=8, n=len(beam_model.node_ids) - 1, m_star=20)],
|
|
121
|
+
dof_mapping=jnp.arange(len(beam_model.node_ids)),
|
|
122
|
+
)
|
|
123
|
+
wing = CoupledAeroelastic(structure, uvlm)
|
|
124
|
+
wing.set_design_variables(
|
|
125
|
+
coords=beam_model.coords, k_cs=beam_model.k_cs, m_cs=None, m_lumped=m_lumped,
|
|
126
|
+
dt=..., flowfield=..., x0_aero=[local_grid],
|
|
127
|
+
)
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
**What's deliberately out of scope / assumed**, each backed by a clear error or warning rather than a silent
|
|
131
|
+
wrong answer if violated:
|
|
132
|
+
|
|
133
|
+
- Only `CAERO1`, `CBEAM`/`PBEAM` (not `PBEAML`/`PBCOMP`), and `MAT1` are supported.
|
|
134
|
+
- `CBEAM`'s `OFFT` must be the default `'GGG'`, and end offsets (`WA`/`WB`) must be zero — no offset beam ends.
|
|
135
|
+
- `CONM2`'s `CID` must be the basic coordinate system (`0` or `-1`).
|
|
136
|
+
- A tapered `PBEAM` (multiple stations) is collapsed to one constant cross-section per element by averaging its
|
|
137
|
+
station values — flapjax elements are constant-property, so this is an approximation for a genuinely tapered
|
|
138
|
+
element, not a limitation you can configure around.
|
|
139
|
+
- `PBEAM`'s `I12` (bend-bend coupling) is not incorporated into `k_cs` — a warning fires if it's nonzero, since
|
|
140
|
+
results will be approximate for that element.
|
|
141
|
+
- The aerodynamic grid's spanwise stations are placed exactly at the given structural node positions (flapjax's
|
|
142
|
+
`dof_mapping` ties each aero column rigidly to one structural node, with no separate spline layer like
|
|
143
|
+
NASTRAN's beam splines) — `build_local_grid` doesn't take an independent spanwise panel count.
|
|
144
|
+
- The axis convention (`CBEAM`'s orientation vector = flapjax's `y_vector` directly; PBEAM `I1`→bending about
|
|
145
|
+
local z, `I2`→about local y) was verified against the NASTRAN QRG and cross-checked against flapjax's own
|
|
146
|
+
beam code, not assumed — see the docstring in `nastran/stiffness.py` for the derivation.
|
|
147
|
+
|
|
148
|
+
## Development
|
|
149
|
+
|
|
150
|
+
```bash
|
|
151
|
+
uv sync --dev
|
|
152
|
+
uv run pytest
|
|
153
|
+
uv run ruff check src
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
Tests are deliberately lightweight (geometry/algebra invariants and a synthetic least-squares problem for the
|
|
157
|
+
optimizer) rather than full aeroelastic solves, so the suite stays fast to iterate against.
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "flapjax-model-gen"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Match strip-wise aerodynamic corrections (cl0/cla/cm0/cma) to a NASTRAN-ready twist distribution using flapjax."
|
|
5
|
+
authors = [{ name = "Ben Preston", email = "b.preston23@imperial.ac.uk" }]
|
|
6
|
+
keywords = ["aeroelasticity", "nastran", "vortex lattice method", "flapjax"]
|
|
7
|
+
classifiers = [
|
|
8
|
+
"Development Status :: 3 - Alpha",
|
|
9
|
+
"Intended Audience :: Science/Research",
|
|
10
|
+
"Operating System :: OS Independent",
|
|
11
|
+
"Programming Language :: Python :: 3",
|
|
12
|
+
"Programming Language :: Python :: 3.12",
|
|
13
|
+
"Programming Language :: Python :: 3.13",
|
|
14
|
+
"Topic :: Scientific/Engineering",
|
|
15
|
+
"Topic :: Scientific/Engineering :: Physics",
|
|
16
|
+
]
|
|
17
|
+
requires-python = ">=3.12"
|
|
18
|
+
readme = "README.md"
|
|
19
|
+
license = "MIT"
|
|
20
|
+
license-files = ["LICENSE"]
|
|
21
|
+
dependencies = [
|
|
22
|
+
"flapjax>=1.3.6",
|
|
23
|
+
"jax>=0.8.1",
|
|
24
|
+
"pynastran>=1.3.4",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
[dependency-groups]
|
|
28
|
+
dev = [
|
|
29
|
+
"pytest>=9.0",
|
|
30
|
+
"ruff>=0.15",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
[tool.pytest.ini_options]
|
|
34
|
+
minversion = "7.0"
|
|
35
|
+
testpaths = ["tests"]
|
|
36
|
+
python_files = ["test*.py"]
|
|
37
|
+
addopts = "-q --tb=short"
|
|
38
|
+
|
|
39
|
+
[build-system]
|
|
40
|
+
requires = ["hatchling"]
|
|
41
|
+
build-backend = "hatchling.build"
|
|
42
|
+
|
|
43
|
+
[tool.hatch.build.targets.wheel]
|
|
44
|
+
packages = ["src/flapjax_model_gen"]
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from flapjax_model_gen.linear_polar import LinearPolar, linear_polar_function
|
|
2
|
+
from flapjax_model_gen.rigid import PITCH_AXIS_DEFAULT, rotate_hg
|
|
3
|
+
from flapjax_model_gen.strip_forces import (
|
|
4
|
+
rigid_corrected_forces,
|
|
5
|
+
strip_forces_from_aero,
|
|
6
|
+
)
|
|
7
|
+
from flapjax_model_gen.twist_grid import make_twisted_grid
|
|
8
|
+
from flapjax_model_gen.twist_match import TwistMatchResult, match_twist
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"PITCH_AXIS_DEFAULT",
|
|
12
|
+
"LinearPolar",
|
|
13
|
+
"TwistMatchResult",
|
|
14
|
+
"linear_polar_function",
|
|
15
|
+
"make_twisted_grid",
|
|
16
|
+
"match_twist",
|
|
17
|
+
"rigid_corrected_forces",
|
|
18
|
+
"rotate_hg",
|
|
19
|
+
"strip_forces_from_aero",
|
|
20
|
+
]
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
r"""
|
|
2
|
+
Represent a strip-wise, rigid-shape aerodynamic correction (``cl0``, ``cla``, ``cm0``, ``cma``) as a flapjax
|
|
3
|
+
``PolarFunction``, so it can be passed straight into ``UVLM(polar_data=..., polar_function=...)`` /
|
|
4
|
+
``apply_polar_correction`` alongside (or instead of) a tabulated airfoil polar.
|
|
5
|
+
|
|
6
|
+
These coefficients define an affine ``cl(alpha) = cl0 + cla * alpha`` and ``cm(alpha) = cm0 + cma * alpha`` about
|
|
7
|
+
the quarter-chord, with no drag term (``cd`` is left at zero) since the source data has none. Because
|
|
8
|
+
``apply_polar_correction`` samples this function at each strip's *locally computed* angle of attack -- which, in
|
|
9
|
+
a coupled solve, includes any elastic contribution -- using it inside ``CoupledAeroelastic.static_solve`` is an
|
|
10
|
+
explicit extrapolation of a correction that was itself derived only from rigid-body rotations of the flight
|
|
11
|
+
shape. That is usually the intended behaviour (treating the coefficients as sectional/local data), but it is an
|
|
12
|
+
assumption worth confirming rather than a given.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from typing import NamedTuple
|
|
18
|
+
|
|
19
|
+
from jax import Array
|
|
20
|
+
from jax import numpy as jnp
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class LinearPolar(NamedTuple):
|
|
24
|
+
r"""Per-strip affine lift/moment correction, about the quarter-chord.
|
|
25
|
+
:param cl0: Lift coefficient at zero angle of attack, ``(n_strip,)``.
|
|
26
|
+
:param cla: Lift-curve slope, per radian, ``(n_strip,)``.
|
|
27
|
+
:param cm0: Pitching-moment coefficient at zero angle of attack, ``(n_strip,)``.
|
|
28
|
+
:param cma: Pitching-moment slope, per radian, ``(n_strip,)``.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
cl0: Array
|
|
32
|
+
cla: Array
|
|
33
|
+
cm0: Array
|
|
34
|
+
cma: Array
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def linear_polar_function(alpha: Array, data: LinearPolar) -> tuple[Array, Array, Array]:
|
|
38
|
+
r"""
|
|
39
|
+
``PolarFunction`` sampling an affine ``cl(alpha)``/``cm(alpha)`` model instead of a tabulated curve.
|
|
40
|
+
:param alpha: Per-strip angle of attack, radians, ``(n_strip,)``.
|
|
41
|
+
:param data: Affine correction coefficients.
|
|
42
|
+
:return: ``(cl, cd, cm)``, each ``(n_strip,)``; ``cd`` is identically zero.
|
|
43
|
+
"""
|
|
44
|
+
cl = data.cl0 + data.cla * alpha
|
|
45
|
+
cd = jnp.zeros_like(alpha)
|
|
46
|
+
cm = data.cm0 + data.cma * alpha
|
|
47
|
+
return cl, cd, cm
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from flapjax_model_gen.nastran.aero_mesh import (
|
|
2
|
+
CaeroPanel,
|
|
3
|
+
build_local_grid,
|
|
4
|
+
parse_caero1_panels,
|
|
5
|
+
read_aero_bdf,
|
|
6
|
+
)
|
|
7
|
+
from flapjax_model_gen.nastran.mass import parse_mass_files
|
|
8
|
+
from flapjax_model_gen.nastran.stiffness import (
|
|
9
|
+
NastranBeamModel,
|
|
10
|
+
parse_stiffness_bdf,
|
|
11
|
+
read_stiffness_bdf,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"CaeroPanel",
|
|
16
|
+
"NastranBeamModel",
|
|
17
|
+
"build_local_grid",
|
|
18
|
+
"parse_caero1_panels",
|
|
19
|
+
"parse_mass_files",
|
|
20
|
+
"parse_stiffness_bdf",
|
|
21
|
+
"read_aero_bdf",
|
|
22
|
+
"read_stiffness_bdf",
|
|
23
|
+
]
|