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.
@@ -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,10 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv/
4
+ .pytest_cache/
5
+ .ruff_cache/
6
+ *.egg-info/
7
+ dist/
8
+ build/
9
+ .python-version
10
+ .idea/
@@ -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
+ ]