Skip to content

fix: n_modes-independent dense solves, weight restoring on deck-built floaters, WindIO discovery path scope - #142

Merged
SMI-Lab-Inha merged 5 commits into
masterfrom
fix/solver-conditioning-floating-weight
Sep 30, 2026
Merged

SMI-Lab-Inha merged 5 commits into
masterfrom
fix/solver-conditioning-floating-weight

Conversation

@SMI-Lab-Inha

Copy link
Copy Markdown
Owner

Three defects found while integrating pyBModes 1.18.0 into a downstream engine. There is one commit for each.

1. Dense symmetric solves depended on n_modes

Root cause. Below the 500-DOF sparse threshold, the dense path solved eigh(K, M, subset_by_index=...). That call reduces the pencil through a Cholesky factor of M. A backward-stable symmetric eigensolver gets each eigenvalue of the reduced matrix only to an absolute accuracy of about eps · λ_max, so the relative error on the lowest mode is about eps · λ_max / λ_min. The ElastoDyn adapters floor rotary inertia at 1e-6, which puts that ratio near 1e15 (cond(M) ≈ 2e11 on the blade). The low modes therefore carried errors of several percent, and those errors moved with the requested window. The problem is the spectral range, not a bad DOF or missing scaling: diagonal equilibration leaves the eigenvalues unchanged, and lowering the threshold would only have hidden the defect behind eigsh.

Fix. Solve the inverted pencil M x = μ (K + s M) x, with μ = 1/(λ + s). This is the shift-invert transformation the sparse path already uses (Ericsson & Ruhe 1980). It puts the wanted modes at the top of the spectrum, where absolute accuracy becomes relative accuracy.

  • The shift s is 0 when K is positive definite.
  • Otherwise s starts from an estimate of the lowest eigenvalues and grows until K + s M factorises. Rigid-body modes and the negative eigenvalues of a column loaded past buckling are still returned.
  • A request that reaches into the upper half of the spectrum takes those modes from the mass-reduced form, which is the accurate one there.
  • The mass-reduced form stays as the fallback, and the 1.18.0 residual retry still stands behind it. test_ill_conditioned_mass.py now runs against that route explicitly.

NREL 5MW (bundled deck), first modes in Hz:

n_modes = 4 6 10 20 sparse
blade flap1 @12.1 rpm, before 0.71539 0.70959 0.76606 0.72816 0.72865
blade flap1 @12.1 rpm, after 0.72865 0.72865 0.72865 0.72865 0.72865
blade edge1, before 1.12210 1.11841 1.08844 1.13028 1.09754
blade edge1, after 1.09754 1.09754 1.09754 1.09754 1.09754
tower 1st pair, before 0.33455 / 0.33675 0.33548 / 0.33548 0.33400 / 0.33621 0.33545 / 0.33545 0.33483 / 0.33561
tower 1st pair, after 0.33483 / 0.33561 same same same same

Backward error drops from ~5e-2 to ~1e-10. test_campbell::test_first_flap_lifts_endpoint_to_endpoint sat at 2.95 % against its 3 % gate on one BLAS build; it now measures 7.6 %.

2. Floating models built from decks lost roll and pitch

Root cause. Tower.from_elastodyn_with_mooring set hydro_K to the WAMIT .hst. That matrix holds only ρ g I_wp + ρ g V z_B and leaves out the body weight, which OpenFAST applies through ElastoDyn gravity. For the ballast-stabilised OC3 spar this gives C44 = C55 = −5.0e9 N·m/rad. The general eigensolver then filtered out the two negative eigenvalues and backfilled the window, so 4 of the 6 rigid-body modes came back with no warning.

Fix. The weight restoring −g Σ mᵢ (zᵢ − z_ref) of the platform, tower (including sec_mass scaling and point masses) and RNA is now added to hydro_K.

  • Heights follow the MSL-datum conventions (draft, cm_pform, ref_msl).
  • The term is added only when a HydroDyn deck is given.
  • On OC3 it is +6.17e9 N·m/rad: +6.58e9 from the ballast and −0.41e9 from the tower and RNA.
  • from_windio_floating adds the same term in both tiers, so the deck-backed tier stays equivalent to from_elastodyn_with_mooring.
  • Tower.run now warns when hydro_K + mooring_K has a negative direction, and names the DOFs. The sign test is on the inertia-scaled matrix (Sylvester's law of inertia).

OC3 Hywind from the r-test decks (HydroDyn + MoorDyn + ElastoDyn), in Hz:

surge sway heave roll pitch yaw tower pair
before 0.00806 0.00806 0.03241 missing missing 0.04121 0.48869 / 0.49028
after 0.00806 0.00806 0.03241 0.03383 0.03383 0.04121 0.49217 / 0.49377
Jonkman (2010) NREL/TP-500-47535, FAST 0.0080 0.0080 0.0324 0.0342 0.0343 0.1210

Yaw is the catenary-only value. The published 0.1210 Hz includes the report's additional 9.834e7 N·m/rad crowfoot spring, which the r-test MoorDyn deck does not carry. With that spring added, pyBModes gives 0.127 Hz; the remaining ~5 % comes from the adapter's RNA yaw-inertia proxy.

Known and now visible. The bundled 08_nrel5mw_oc4semi sample and the OC4 r-test deck still have negative roll/pitch restoring. OC4 keeps its ballast in HydroDyn filled members rather than in PtfmMass, and the platform model does not include those members. Both now raise the new warning instead of silently labelling tower-bending modes as roll/pitch. Handling filled-member ballast is left for a follow-up.

3. WindIO discovery with "OpenFAST" in the parent path

discover_windio_inputs excluded any yaml whose absolute path contained "OpenFAST". As a result, a checkout living under e.g. ~/OpenFAST-GUI/ found no ontology, and test_review_hardening::test_discover_skips_bad_sidecar... failed there. The check now looks only at the path below the search root.

Validation

  • New tests/fem/test_dense_inverted_solver.py:
    • n_modes-independence (4 vs 20, rtol 1e-6) and dense-vs-sparse agreement on the bundled NREL 5MW blade and tower.
    • The Blevins tip-lump case, solved directly with no retry.
    • Rigid-body and indefinite pencils.
    • Full-spectrum splice.
  • New tests/test_floating_weight_restoring.py:
    • Closed-form weight matrix and the negative-restoring warning (self-contained).
    • OC3 Hywind rigid-body frequencies against Jonkman (2010) (integration).
  • VALIDATION.md rows and citations updated; scripts/audit_validation_claims.py is clean.
  • Local run on Python 3.13:
    • Default suite: 1277 passed, coverage 88.0 %.
    • Integration against the OC3 / OC4 r-test decks: 17 passed.
    • ruff and mypy clean.

The dense path reduced K x = lambda M x through a Cholesky factor of M,
and a backward-stable symmetric eigensolver returns each eigenvalue of
the reduced matrix only to an absolute accuracy of eps * lambda_max. The
ElastoDyn adapters floor rotary inertia at 1e-6, which puts
lambda_max / lambda_min near 1e15, so the lowest modes carried errors of
several percent that moved with the subset_by_index window: the NREL
5MW blade's first flap at 12.1 rpm read 0.7154 / 0.7096 / 0.7661 /
0.7282 Hz for n_modes 4 / 6 / 10 / 20.

Solve M x = mu (K + s M) x instead, the shift-invert transformation the
sparse path already applies. The wanted modes sit at the top of that
spectrum, where the same absolute accuracy is relative. The shift is
zero for a definite K and otherwise grown from an estimate of the lowest
eigenvalues until K + s M factorises, so rigid-body modes and negative
eigenvalues are still returned. Requests reaching into the upper half of
the spectrum take those modes from the mass-reduced form, which is the
accurate one there.

The blade now gives 0.72865 Hz at every window, matching the sparse
path; backward error drops from ~5e-2 to ~1e-10. The mass-reduced form
stays as the fallback, with the residual retry behind it; the retry
tests run against that route explicitly.
Tower.from_elastodyn_with_mooring took roll / pitch restoring from the
WAMIT .hst alone, which holds only rho g I_wp + rho g V z_B. OpenFAST
adds the body weight through ElastoDyn gravity; the model did not, so
the ballast-stabilised OC3 Hywind spar read C44 = C55 = -5.0e9 N m/rad
and the general eigensolver silently dropped both negative-eigenvalue
modes, returning four of the six rigid-body modes.

Add -g sum m_i (z_i - z_ref) for the platform, tower and RNA to hydro_K
(+6.17e9 N m/rad on OC3), measured from the hydro/mooring reference in
the MSL-datum conventions, and only when HydroDyn is given, since weight
without buoyancy is not a floating body. from_windio_floating adds the
same term in both tiers so the deck-backed tier stays equivalent.

OC3 Hywind from the r-test decks now returns all six rigid-body modes:
surge / sway / heave / roll / pitch / yaw = 0.00806 / 0.00806 / 0.03241
/ 0.03383 / 0.03383 / 0.04121 Hz against Jonkman (2010) NREL/TP-500-47535
0.0080 / 0.0080 / 0.0324 / 0.0342 / 0.0343 Hz; yaw is catenary-only, the
published 0.1210 Hz including an extra crowfoot spring the deck lacks.

Tower.run now warns when hydro_K + mooring_K has a negative direction
instead of letting the solver drop those modes unannounced.
discover_windio_inputs skipped any yaml whose absolute path contained
"OpenFAST", so a checkout living under a directory such as OpenFAST-GUI
found no ontology at all. Examine only the part of the path below the
directory being searched; an OpenFAST deck directory inside the tree is
still skipped.
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, add credits to your account and enable them for code reviews in your settings.

@SMI-Lab-Inha

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 30, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-09-30T10:32:58.694866Z 882d4fb Manual request
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 5266d4fdd8

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/pybmodes/models/_platform.py Outdated
z = base_z + span * (top_z - base_z)
# First moment of each part about the reference point, in kg·m.
moment = float(ps.mass_pform) * (-float(ps.cm_pform) - ref_z)
moment += float(np.trapezoid(mass_den * (z - ref_z), z))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Use an integration API available on NumPy 1.26

The project declares numpy>=1.26, but np.trapezoid was introduced in NumPy 2.0. On a supported NumPy 1.26 installation, every deck-backed or WindIO floating constructor that calls _gravitational_restoring now raises AttributeError before returning a model. Use the 1.26-compatible np.trapz or raise the minimum dependency to NumPy 2.

Useful? React with 👍 / 👎.

@SMI-Lab-Inha SMI-Lab-Inha Sep 30, 2026 •

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 0a2f234. This was wider than the new weight term, since checks.py and the ElastoDyn blade reader on master also called np.trapezoid. Every call site now goes through one version-independent helper in pybmodes._numeric, and tests/test_numeric.py scans the package so the NumPy 2 spelling can't creep back in.

Comment on lines +215 to +216
for pm in bmi.point_masses:
moment += float(pm.mass) * (base_z + float(pm.height) - ref_z)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Refresh weight restoring after adding point masses

For a floating model constructed from HydroDyn or WindIO and then modified through the public Tower.add_point_mass() API, this loop never sees the new mass because _gravitational_restoring is evaluated only during construction. The FEM mass matrix includes the added lump, but hydro_K retains the old roll/pitch weight term, producing inconsistent and potentially incorrect rigid-body frequencies; recompute the weight contribution when masses change or immediately before solving.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 0a2f234. add_point_mass now adds the lump's weight to the roll and pitch restoring on deck-built floaters, measured the same way as the constructor's term. The OC3 integration test checks it against a full recompute.

…asses

np.trapezoid only exists from NumPy 2.0, but the package declares
numpy>=1.26, so the pre-solve checks, the ElastoDyn blade reader and the
floating weight term all raised AttributeError there. They now share one
version-independent helper, and a test keeps the NumPy 2 spelling out of
the package.

Tower.add_point_mass on a deck-built floater now adds the lump's weight
to the roll and pitch restoring, so it matches a model rebuilt with the
lump in place.

Two integration tests pinned an exactly degenerate first tower pair on
the IEA-3.4 and UPSCALE 25MW decks. Their RNA roll and pitch inertias
differ, which splits the pair by 0.09 % and 1.0 %; the dense, sparse and
inverted-pencil solves agree on it. The old degeneracy was solver error,
so the tests now pin the split.
@SMI-Lab-Inha

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 0a2f234d52

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +978 to +980
mu, vecs = eigh(
gm_sym, gk_sym + shift * gm_sym,
subset_by_index=(ngd - k, ngd - 1),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve soft modes when shifting indefinite pencils

When an indefinite pencil has a negative eigenvalue much larger in magnitude than later soft modes, the required shift makes their transformed mu values indistinguishable at floating-point precision, so this subset can silently select the wrong eigenpairs. For example, K = diag(-1e16, 1, 2, 3, 4), M = I, and n_modes=2 returns [-1e16, 4], while requesting three modes returns the correct [-1e16, 1, 2]. The residual retry cannot detect this because the selected fourth mode is itself an exact eigenpair with zero residual, leaving the public result dependent on n_modes and omitting lower modes; the shifted path therefore needs ordering verification or a fallback when the shift overwhelms the requested soft spectrum.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 882d4fb. When a shifted pencil leaves any mode in the window below the μ resolution, the window now grows until its edge sits in a resolvable gap, and each swamped cluster is re-solved by Rayleigh-Ritz on its own subspace. Your example returns [-1e16, 1, 2, 3, 4][:n] for every n. The shift estimate also no longer depends on the requested window, so a rotated 40-DOF version with two eigenvalues near -1e14 gives bit-identical soft modes for 3 and 9 requested modes. Both cases are in tests/fem/test_dense_inverted_solver.py.

…ution

With one strongly negative eigenvalue the definite shift is so large
that every soft mode maps to the same μ, so their order was arbitrary
and the result depended on n_modes: K = diag(-1e16, 1, 2, 3, 4) gave
[-1e16, 4] for two modes. The window now grows until its edge falls in a
resolvable gap, and each swamped cluster is re-solved by Rayleigh-Ritz
on its own subspace. The shift estimate no longer depends on the
requested window either.
@SMI-Lab-Inha

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. 🎉

Reviewed commit: 882d4fb2b7

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@SMI-Lab-Inha
SMI-Lab-Inha merged commit 53fc2b0 into master Sep 30, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant