From 69f1444fcd4ae0132b11cbc87fcb29c3142b508e Mon Sep 17 00:00:00 2001 From: Jae Hoon Seo Date: Wed, 21 Oct 2026 16:41:43 +0900 Subject: [PATCH 1/3] chore: point branch references at main after the rename from master --- .github/ISSUE_TEMPLATE/code_review.yml | 2 +- .github/workflows/ci.yml | 2 +- .github/workflows/release.yml | 4 +- CONTRIBUTING.md | 8 +- README.md | 6 +- VALIDATION.md | 342 +++++++++--------- cases/ECOSYSTEM_FINDING.md | 2 +- docs/changelog.rst | 2 +- docs/deployment.rst | 10 +- docs/index.rst | 2 +- docs/installation.rst | 2 +- docs/release_checklist.rst | 8 +- pyproject.toml | 2 +- .../_examples/reference_decks/README.md | 4 +- .../sample_inputs/01_uniform_blade/README.md | 2 +- .../_examples/sample_inputs/README.md | 4 +- 16 files changed, 201 insertions(+), 201 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/code_review.yml b/.github/ISSUE_TEMPLATE/code_review.yml index 3b18e4b..3b7124b 100644 --- a/.github/ISSUE_TEMPLATE/code_review.yml +++ b/.github/ISSUE_TEMPLATE/code_review.yml @@ -42,7 +42,7 @@ body: attributes: label: Review scope description: Branch, tag, or commit SHA the review was done against. - placeholder: "e.g. master @ b41a05c, dev @ 8af258e" + placeholder: "e.g. main @ b41a05c, dev @ 8af258e" validations: required: true diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index addd6cf..be1d391 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,7 +2,7 @@ name: CI on: push: - branches: [master, main] + branches: [main, dev] pull_request: jobs: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0f6e70c..72a51e7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -27,9 +27,9 @@ on: workflow_dispatch: inputs: ref: - description: "Branch / tag / SHA to test (default: master)" + description: "Branch / tag / SHA to test (default: main)" required: false - default: "master" + default: "main" permissions: contents: read diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 42cbe25..4485edd 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -12,7 +12,7 @@ pip install -e ".[dev,plots]" pytest ``` -The default `pytest` run uses only self-contained tests (synthetic decks + closed-form references). The `integration` marker gates tests that need upstream OpenFAST / BModes data; place those clones under `external/` (gitignored). See [installation](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/docs/installation.rst) for the full Windows + conda quickstart. +The default `pytest` run uses only self-contained tests (synthetic decks + closed-form references). The `integration` marker gates tests that need upstream OpenFAST / BModes data; place those clones under `external/` (gitignored). See [installation](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/docs/installation.rst) for the full Windows + conda quickstart. ## Ground rules @@ -51,7 +51,7 @@ pip install pre-commit pre-commit install ``` -This runs ruff (auto-fix), the standard hygiene hooks (trailing-whitespace, end-of-file-fixer, YAML / TOML validation, large-file guard, merge-conflict marker check), `codespell`, and `insert-license` — which inserts the Apache 2.0 boilerplate header into any new `.py` file under `src/pybmodes/`, `scripts/`, `cases/`, or `noxfile.py` that doesn't already have it (template at [`.license_header.txt`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/.license_header.txt)). To run manually: +This runs ruff (auto-fix), the standard hygiene hooks (trailing-whitespace, end-of-file-fixer, YAML / TOML validation, large-file guard, merge-conflict marker check), `codespell`, and `insert-license` — which inserts the Apache 2.0 boilerplate header into any new `.py` file under `src/pybmodes/`, `scripts/`, `cases/`, or `noxfile.py` that doesn't already have it (template at [`.license_header.txt`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/.license_header.txt)). To run manually: ```bash pre-commit run --all-files @@ -114,8 +114,8 @@ For maintainers, the full pre-tag sequence is in [`https://pybmodes.readthedocs. ## Reporting issues -Open a [GitHub issue](https://github.com/SMI-Lab-Inha/pyBModes/issues). For security-sensitive reports, see [`SECURITY.md`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/SECURITY.md). +Open a [GitHub issue](https://github.com/SMI-Lab-Inha/pyBModes/issues). For security-sensitive reports, see [`SECURITY.md`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/SECURITY.md). ## Code of conduct -This project follows the [Contributor Covenant](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/CODE_OF_CONDUCT.md). +This project follows the [Contributor Covenant](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/CODE_OF_CONDUCT.md). diff --git a/README.md b/README.md index 12c604f..e82d720 100644 --- a/README.md +++ b/README.md @@ -9,11 +9,11 @@ `pybmodes` is a pure-Python finite-element library for wind-turbine blade and tower modal analysis. It reads OpenFAST (ElastoDyn / SubDyn / HydroDyn / MoorDyn), BModes `.bmi`, and WISDEM / WindIO ontology YAML inputs; solves the coupled flap–lag–torsion–axial vibration modes with a 15-DOF Bernoulli-Euler beam element; and emits ElastoDyn-compatible mode-shape polynomials, MAC-tracked Campbell diagrams, and bundled Markdown / HTML / CSV reports. -Validated against the BModes Fortran reference solver on six benchmark cases (NREL 5MW land + OC3 monopile + OC3 Hywind floating spar, IEA-3.4-130-RWT, BModes CertTest 03 / 04) to **better than 0.01 %** on every comparison. As of 1.8.0 the dedicated [Validation workflow](https://github.com/SMI-Lab-Inha/pyBModes/actions/workflows/validation.yml) (weekly cron + `workflow_dispatch`) enforces the strict tolerance in CI for the cases whose external data is clonable from a public GitHub repository **at a manifest-pinned SHA**. Cloning is manifest-driven (`verify_external_data.py --clone`), so CI fetches and `--strict`-checks every **required** entry: the NREL 5MW family in [`OpenFAST/r-test`](https://github.com/OpenFAST/r-test), all four IEA Task-37 reference turbines ([`IEA-3.4-130-RWT`](https://github.com/IEAWindTask37/IEA-3.4-130-RWT), [`IEA-10.0-198-RWT`](https://github.com/IEAWindTask37/IEA-10.0-198-RWT), [`IEA-15-240-RWT`](https://github.com/IEAWindTask37/IEA-15-240-RWT), [`IEA-22-280-RWT`](https://github.com/IEAWindTask37/IEA-22-280-RWT)), and the [`WISDEM`](https://github.com/WISDEM/WISDEM) WindIO examples — a missing or off-pin required clone hard-fails the run. Only the `optional = true` entries (MoorPy / RAFT cross-reference clones) are left out of CI as maintainer-local checks. Publishing to PyPI is itself gated on a green run of this workflow for the tagged commit. The two BModes CertTest cases (Test03, Test04) depend on `external/BModes`, a NREL download not on GitHub; those tests skip cleanly when the data is absent and stay maintainer-local enforcement. The per-PR `ci.yml` continues to run the self-contained suite (synthetic + closed-form-referenced) and tolerates "no tests collected" on the integration step when the runner has no upstream data. See [`VALIDATION.md`](VALIDATION.md) for the full per-case matrix and [`external/MANIFEST.toml`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/external/MANIFEST.toml) for the manifest-pinned commit SHAs plus line-ending-normalized SHA-256 hashes for the text validation decks. +Validated against the BModes Fortran reference solver on six benchmark cases (NREL 5MW land + OC3 monopile + OC3 Hywind floating spar, IEA-3.4-130-RWT, BModes CertTest 03 / 04) to **better than 0.01 %** on every comparison. As of 1.8.0 the dedicated [Validation workflow](https://github.com/SMI-Lab-Inha/pyBModes/actions/workflows/validation.yml) (weekly cron + `workflow_dispatch`) enforces the strict tolerance in CI for the cases whose external data is clonable from a public GitHub repository **at a manifest-pinned SHA**. Cloning is manifest-driven (`verify_external_data.py --clone`), so CI fetches and `--strict`-checks every **required** entry: the NREL 5MW family in [`OpenFAST/r-test`](https://github.com/OpenFAST/r-test), all four IEA Task-37 reference turbines ([`IEA-3.4-130-RWT`](https://github.com/IEAWindTask37/IEA-3.4-130-RWT), [`IEA-10.0-198-RWT`](https://github.com/IEAWindTask37/IEA-10.0-198-RWT), [`IEA-15-240-RWT`](https://github.com/IEAWindTask37/IEA-15-240-RWT), [`IEA-22-280-RWT`](https://github.com/IEAWindTask37/IEA-22-280-RWT)), and the [`WISDEM`](https://github.com/WISDEM/WISDEM) WindIO examples — a missing or off-pin required clone hard-fails the run. Only the `optional = true` entries (MoorPy / RAFT cross-reference clones) are left out of CI as maintainer-local checks. Publishing to PyPI is itself gated on a green run of this workflow for the tagged commit. The two BModes CertTest cases (Test03, Test04) depend on `external/BModes`, a NREL download not on GitHub; those tests skip cleanly when the data is absent and stay maintainer-local enforcement. The per-PR `ci.yml` continues to run the self-contained suite (synthetic + closed-form-referenced) and tolerates "no tests collected" on the integration step when the runner has no upstream data. See [`VALIDATION.md`](VALIDATION.md) for the full per-case matrix and [`external/MANIFEST.toml`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/external/MANIFEST.toml) for the manifest-pinned commit SHAs plus line-ending-normalized SHA-256 hashes for the text validation decks. ## Documentation -Full documentation is published on [Read the Docs](https://pybmodes.readthedocs.io/en/latest/) (the status badge above shows whether that build is live); the table below links straight to the rendered pages. The source lives under [`docs/`](https://github.com/SMI-Lab-Inha/pyBModes/tree/master/docs) if you'd rather read it on GitHub, and you can build it offline (below). +Full documentation is published on [Read the Docs](https://pybmodes.readthedocs.io/en/latest/) (the status badge above shows whether that build is live); the table below links straight to the rendered pages. The source lives under [`docs/`](https://github.com/SMI-Lab-Inha/pyBModes/tree/main/docs) if you'd rather read it on GitHub, and you can build it offline (below). | Page | What's there | | --- | --- | @@ -56,7 +56,7 @@ Or from conda-forge: conda install -c conda-forge pybmodes ``` -That installs the runtime core (`numpy` + `scipy`). Add an extra for optional features — `[plots]`, `[windio]`, `[notebook]`, `[docs]` (matrix below). For a source / editable checkout (contributors, or tracking `master`): +That installs the runtime core (`numpy` + `scipy`). Add an extra for optional features — `[plots]`, `[windio]`, `[notebook]`, `[docs]` (matrix below). For a source / editable checkout (contributors, or tracking `main`): ```bash git clone https://github.com/SMI-Lab-Inha/pyBModes.git diff --git a/VALIDATION.md b/VALIDATION.md index 4a0589a..41930ee 100644 --- a/VALIDATION.md +++ b/VALIDATION.md @@ -78,35 +78,35 @@ metrics: | Case | Source / reference | Quantity | Tolerance | Worst observed | Test file | Needs external data | | --- | --- | --- | ---: | ---: | --- | :---: | -| Uniform Euler-Bernoulli cantilever, modes 1-3 | $\beta_n L = \{1.875,\,4.694,\,7.855,\ldots\}$ (textbook) | flap frequency | < 0.5 % | < 0.005 % | [`tests/fem/test_cantilever.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/fem/test_cantilever.py) | no | -| Same, mode 4 | textbook | flap frequency | < 1 % | (within tol) | [`tests/fem/test_cantilever.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/fem/test_cantilever.py) | no | -| Same, mode 5 | textbook | flap frequency | < 2 % | (within tol) | [`tests/fem/test_cantilever.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/fem/test_cantilever.py) | no | -| Hermite cubic mesh-convergence | analytical $h^4$ rate | error ratio at h × 2 | > 5 (i.e. ≥ 5×) | confirmed | [`tests/fem/test_cantilever.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/fem/test_cantilever.py) | no | -| Cantilever + tip mass, $\mu \in [0, 5]$ | Blevins (1979) / Karnovsky & Lebed (2001) frequency equation | 1st bending frequency | < 0.5 % | (within tol) | [`tests/fem/test_uniform_tower_analytical.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/fem/test_uniform_tower_analytical.py) | no | -| Rotating uniform blade, flap modes 1-3, Ω ∈ [0, 12] rad/s | Wright et al. (1982) — *Vibration Modes of Centrifugally Stiffened Beams*, J. Appl. Mech. (transcribed from Bir 2009 Table 3a) | flap frequency | < 0.5 % | (within tol) | [`tests/fem/test_rotating_uniform_blade.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/fem/test_rotating_uniform_blade.py) | no | -| Rotating uniform blade + tip mass, flap modes 1-2, Ω ∈ [0, 12] rad/s | Wright et al. (1982) (Bir 2010 Table 5) | flap frequency | < 0.1 % | (within tol) | [`tests/fem/test_rotating_blade_with_tip_mass.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/fem/test_rotating_blade_with_tip_mass.py) | no | -| Inextensible spinning cable, flap modes 1-3, Ω ∈ [2, 30] rad/s | Bir 2009 §III.B / Eq. 8: $\omega_k = \Omega\sqrt{k(2k-1)}$ | flap frequency | < 0.5 % | (within tol) | [`tests/fem/test_rotating_cable.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/fem/test_rotating_cable.py) | no | -| Uniform steel tube cantilever via `Tower.from_geometry` (D, t, L → derived EI / mass) | Euler-Bernoulli closed form, $\beta_1 L = 1.875104$ | 1st bending frequency | < 0.1 % | machine-exact (forward-derived from geometry) | [`tests/test_geometry_windio.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/test_geometry_windio.py) | no | -| `n_nodes` mesh refinement is bias-free + self-convergent (issue #35) | uniform tube: identical to native grid + Euler-Bernoulli; taper: self-convergence $n{=}200$ vs $400$ | 1st & 4th bending frequency; mode-shape sample count | uniform = EB < 0.1 %; taper 1st < 0.2 %, 4th < 2 % ($200$ vs $400$) | (within tol) | [`tests/test_geometry_windio.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/test_geometry_windio.py) | no | -| BModes v3.00 CertTest **Test01** — non-uniform rotating blade, 60 rpm | BModes Fortran solver `.out` | per-mode frequency, modes 1-6 | < 1 % | < 0.005 % across 20 modes | [`tests/test_certtest.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/test_certtest.py) | yes | -| BModes v3.00 CertTest **Test01**, modes 7+ | BModes Fortran solver `.out` | per-mode frequency | < 3 % | (within tol) | [`tests/test_certtest.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/test_certtest.py) | yes | -| BModes v3.00 CertTest **Test02** — rotating blade + tip mass + offsets | BModes Fortran solver `.out` | per-mode frequency, modes 1-6 / 7+ | < 1 % / < 3 % | < 0.005 % across 20 modes | [`tests/test_certtest.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/test_certtest.py) | yes | -| BModes v3.00 CertTest **Test03** — 82.4 m tower with top mass + c.m. offsets | BModes Fortran solver `.out` | per-mode frequency, modes 1-6 / 7+ | < 1 % / < 3 % | < 0.005 % across 20 modes | [`tests/test_certtest.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/test_certtest.py) | yes | -| BModes v3.00 CertTest **Test04** — Test03 + tension-wire support | BModes Fortran solver `.out` | per-mode frequency, modes 1-6 / 7+ | < 1 % / < 3 % | < 0.005 % across 20 modes | [`tests/test_certtest.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/test_certtest.py) | yes | -| **CS_Monopile** — *NREL 5MW Reference Turbine* on the *OC3 Monopile* (`hub_conn = 3`, soft monopile, mooring stiffness) | BModes JJ (v1.03.01) `.out` | per-mode frequency, first 10 modes | < 0.01 % | < 0.005 % | [`tests/test_certtest.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/test_certtest.py) | yes | -| **OC3Hywind** — *NREL 5MW* on the *OC3 Hywind* floating spar (`hub_conn = 2`, full hydro + mooring + 6×6 platform inertia) | BModes JJ (v1.03.01) `.out` | per-mode frequency, first 9 modes | < 0.01 % | ≤ 0.0003 % across surge / sway / yaw / roll / pitch / heave + 1st-2nd tower bending | [`tests/test_certtest.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/test_certtest.py) | yes | -| Degenerate-pair resolver, symmetric tower | construction (`EI_FA == EI_SS`) | post-rotation FA / SS purity | $p_{\text{FA}}, p_{\text{SS}} > 0.99$ | (within tol) | [`tests/test_classifier.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/test_classifier.py) | no | -| IEA-3.4 modes 1-2 — degenerate-pair resolver fires no warning | IEA Wind Task 37 RWT deck | classifier verdict | no `RuntimeWarning` | clean | [`tests/test_classifier.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/test_classifier.py) | yes | -| Self-weight geometric stiffness — cantilever under a tip weight (issue #134) | Euler buckling of a cantilever, $P_{cr} = \pi^2 EI / 4L^2$ (Timoshenko & Gere 1961, Ch. 2) | critical tip weight at which the tangent stiffness loses positive definiteness | < 0.5 % | < 0.2 % | [`tests/fem/test_gravity_and_point_mass.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/fem/test_gravity_and_point_mass.py) | no | -| Self-weight geometric stiffness — cantilever under its own weight (issue #134) | Greenhill (1881) self-weight buckling, $q_{cr} L^3 / EI = 7.8373$ (tabulated in Timoshenko & Gere 1961, Art. 2.16) | critical distributed weight | < 0.5 % | < 0.2 % | [`tests/fem/test_gravity_and_point_mass.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/fem/test_gravity_and_point_mass.py) | no | -| Discrete mid-span point mass (issue #35) | cantilever carrying one lump at station $a$: $f = \sqrt{3EI/(m a^3)}/2\pi$ (Blevins 1979, Table 8-1) | 1st frequency; mesh-position independence | < 0.5 %; coarse-vs-fine < 0.2 % | (within tol) | [`tests/fem/test_gravity_and_point_mass.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/fem/test_gravity_and_point_mass.py) | no | -| Distributed Winkler soil bed vs the lumped mudline condensation (issue #118) | Psaroudakis et al. (2021) / Yu & Amdahl (2023) Eq. 25 is the exact static condensation of a constant-EI pile on a bed of rate $k = D_P E_{SO}$ | coupled 1st frequency, distributed bed vs lumped springs | < 1 % | 0.4 % (the embedded pile inertia the condensed form drops) | [`tests/test_foundation.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/test_foundation.py) | no | -| Distributed Winkler bed converges on the rigid mudline clamp (issue #118) | limit $E_{SO} \to \infty$; residual compliance scales as the elastic length $(4EI/k)^{1/4}$ | 1st frequency at $E_{SO} \times 10^8$ vs the clamped model | < 1 % | (within tol) | [`tests/test_foundation.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/test_foundation.py) | no | -| Dense symmetric solve is independent of the requested mode count | NREL 5MW ElastoDyn blade (12.1 rpm) and tower, bundled deck; shift-invert spectral transformation (Ericsson & Ruhe 1980) | lowest modes for 4 vs 20 requested; dense vs sparse shift-invert | `rtol = 1e-6` | identical to roundoff (blade flap1 0.72865 Hz at every window; was 0.715 / 0.710 / 0.766 / 0.728 Hz for 4 / 6 / 10 / 20) | [`tests/fem/test_dense_inverted_solver.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/fem/test_dense_inverted_solver.py) | no | -| Near-singular mass solved directly on the dense path | cantilever carrying one lump: $f = \sqrt{3EI/(m L^3)}/2\pi$ (Blevins 1979, Table 8-1), 4e5:1 lump-to-beam mass ratio | 1st frequency, no residual retry, backward error | < 0.5 %; residual < 1e-6 | (within tol) | [`tests/fem/test_dense_inverted_solver.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/fem/test_dense_inverted_solver.py) | no | -| Near-singular mass matrix does not silently corrupt the low modes | cantilever carrying one lump: $f = \sqrt{3EI/(m a^3)}/2\pi$ (Blevins 1979, Table 8-1), at a 4e5:1 lump-to-beam mass ratio | 1st frequency, dense and sparse dispatch sizes, through the residual retry behind the mass-reduced fallback | < 0.5 % | (within tol; the unguarded mass-reduced solve is 137 % out) | [`tests/fem/test_ill_conditioned_mass.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/fem/test_ill_conditioned_mass.py) | no | -| Rigid-body modes are not lost to the solver guard | construction (rank-deficient $K$, well-conditioned $M$; requested subsets of 1, 3, 6 and 10 modes) | zero modes retained, requested mode count returned | exact | (within tol) | [`tests/fem/test_ill_conditioned_mass.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/fem/test_ill_conditioned_mass.py) | no | -| Deck-reader `n_nodes` refinement (issue #58) | Euler-Bernoulli closed form, modes 1-3, plus self-convergence $n{=}100$ vs $200$ | bending frequencies | < 1 %; self-convergence < 0.2 % | (within tol) | [`tests/test_refine_mesh.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/test_refine_mesh.py) | no | +| Uniform Euler-Bernoulli cantilever, modes 1-3 | $\beta_n L = \{1.875,\,4.694,\,7.855,\ldots\}$ (textbook) | flap frequency | < 0.5 % | < 0.005 % | [`tests/fem/test_cantilever.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/fem/test_cantilever.py) | no | +| Same, mode 4 | textbook | flap frequency | < 1 % | (within tol) | [`tests/fem/test_cantilever.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/fem/test_cantilever.py) | no | +| Same, mode 5 | textbook | flap frequency | < 2 % | (within tol) | [`tests/fem/test_cantilever.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/fem/test_cantilever.py) | no | +| Hermite cubic mesh-convergence | analytical $h^4$ rate | error ratio at h × 2 | > 5 (i.e. ≥ 5×) | confirmed | [`tests/fem/test_cantilever.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/fem/test_cantilever.py) | no | +| Cantilever + tip mass, $\mu \in [0, 5]$ | Blevins (1979) / Karnovsky & Lebed (2001) frequency equation | 1st bending frequency | < 0.5 % | (within tol) | [`tests/fem/test_uniform_tower_analytical.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/fem/test_uniform_tower_analytical.py) | no | +| Rotating uniform blade, flap modes 1-3, Ω ∈ [0, 12] rad/s | Wright et al. (1982) — *Vibration Modes of Centrifugally Stiffened Beams*, J. Appl. Mech. (transcribed from Bir 2009 Table 3a) | flap frequency | < 0.5 % | (within tol) | [`tests/fem/test_rotating_uniform_blade.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/fem/test_rotating_uniform_blade.py) | no | +| Rotating uniform blade + tip mass, flap modes 1-2, Ω ∈ [0, 12] rad/s | Wright et al. (1982) (Bir 2010 Table 5) | flap frequency | < 0.1 % | (within tol) | [`tests/fem/test_rotating_blade_with_tip_mass.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/fem/test_rotating_blade_with_tip_mass.py) | no | +| Inextensible spinning cable, flap modes 1-3, Ω ∈ [2, 30] rad/s | Bir 2009 §III.B / Eq. 8: $\omega_k = \Omega\sqrt{k(2k-1)}$ | flap frequency | < 0.5 % | (within tol) | [`tests/fem/test_rotating_cable.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/fem/test_rotating_cable.py) | no | +| Uniform steel tube cantilever via `Tower.from_geometry` (D, t, L → derived EI / mass) | Euler-Bernoulli closed form, $\beta_1 L = 1.875104$ | 1st bending frequency | < 0.1 % | machine-exact (forward-derived from geometry) | [`tests/test_geometry_windio.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/test_geometry_windio.py) | no | +| `n_nodes` mesh refinement is bias-free + self-convergent (issue #35) | uniform tube: identical to native grid + Euler-Bernoulli; taper: self-convergence $n{=}200$ vs $400$ | 1st & 4th bending frequency; mode-shape sample count | uniform = EB < 0.1 %; taper 1st < 0.2 %, 4th < 2 % ($200$ vs $400$) | (within tol) | [`tests/test_geometry_windio.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/test_geometry_windio.py) | no | +| BModes v3.00 CertTest **Test01** — non-uniform rotating blade, 60 rpm | BModes Fortran solver `.out` | per-mode frequency, modes 1-6 | < 1 % | < 0.005 % across 20 modes | [`tests/test_certtest.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/test_certtest.py) | yes | +| BModes v3.00 CertTest **Test01**, modes 7+ | BModes Fortran solver `.out` | per-mode frequency | < 3 % | (within tol) | [`tests/test_certtest.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/test_certtest.py) | yes | +| BModes v3.00 CertTest **Test02** — rotating blade + tip mass + offsets | BModes Fortran solver `.out` | per-mode frequency, modes 1-6 / 7+ | < 1 % / < 3 % | < 0.005 % across 20 modes | [`tests/test_certtest.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/test_certtest.py) | yes | +| BModes v3.00 CertTest **Test03** — 82.4 m tower with top mass + c.m. offsets | BModes Fortran solver `.out` | per-mode frequency, modes 1-6 / 7+ | < 1 % / < 3 % | < 0.005 % across 20 modes | [`tests/test_certtest.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/test_certtest.py) | yes | +| BModes v3.00 CertTest **Test04** — Test03 + tension-wire support | BModes Fortran solver `.out` | per-mode frequency, modes 1-6 / 7+ | < 1 % / < 3 % | < 0.005 % across 20 modes | [`tests/test_certtest.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/test_certtest.py) | yes | +| **CS_Monopile** — *NREL 5MW Reference Turbine* on the *OC3 Monopile* (`hub_conn = 3`, soft monopile, mooring stiffness) | BModes JJ (v1.03.01) `.out` | per-mode frequency, first 10 modes | < 0.01 % | < 0.005 % | [`tests/test_certtest.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/test_certtest.py) | yes | +| **OC3Hywind** — *NREL 5MW* on the *OC3 Hywind* floating spar (`hub_conn = 2`, full hydro + mooring + 6×6 platform inertia) | BModes JJ (v1.03.01) `.out` | per-mode frequency, first 9 modes | < 0.01 % | ≤ 0.0003 % across surge / sway / yaw / roll / pitch / heave + 1st-2nd tower bending | [`tests/test_certtest.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/test_certtest.py) | yes | +| Degenerate-pair resolver, symmetric tower | construction (`EI_FA == EI_SS`) | post-rotation FA / SS purity | $p_{\text{FA}}, p_{\text{SS}} > 0.99$ | (within tol) | [`tests/test_classifier.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/test_classifier.py) | no | +| IEA-3.4 modes 1-2 — degenerate-pair resolver fires no warning | IEA Wind Task 37 RWT deck | classifier verdict | no `RuntimeWarning` | clean | [`tests/test_classifier.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/test_classifier.py) | yes | +| Self-weight geometric stiffness — cantilever under a tip weight (issue #134) | Euler buckling of a cantilever, $P_{cr} = \pi^2 EI / 4L^2$ (Timoshenko & Gere 1961, Ch. 2) | critical tip weight at which the tangent stiffness loses positive definiteness | < 0.5 % | < 0.2 % | [`tests/fem/test_gravity_and_point_mass.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/fem/test_gravity_and_point_mass.py) | no | +| Self-weight geometric stiffness — cantilever under its own weight (issue #134) | Greenhill (1881) self-weight buckling, $q_{cr} L^3 / EI = 7.8373$ (tabulated in Timoshenko & Gere 1961, Art. 2.16) | critical distributed weight | < 0.5 % | < 0.2 % | [`tests/fem/test_gravity_and_point_mass.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/fem/test_gravity_and_point_mass.py) | no | +| Discrete mid-span point mass (issue #35) | cantilever carrying one lump at station $a$: $f = \sqrt{3EI/(m a^3)}/2\pi$ (Blevins 1979, Table 8-1) | 1st frequency; mesh-position independence | < 0.5 %; coarse-vs-fine < 0.2 % | (within tol) | [`tests/fem/test_gravity_and_point_mass.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/fem/test_gravity_and_point_mass.py) | no | +| Distributed Winkler soil bed vs the lumped mudline condensation (issue #118) | Psaroudakis et al. (2021) / Yu & Amdahl (2023) Eq. 25 is the exact static condensation of a constant-EI pile on a bed of rate $k = D_P E_{SO}$ | coupled 1st frequency, distributed bed vs lumped springs | < 1 % | 0.4 % (the embedded pile inertia the condensed form drops) | [`tests/test_foundation.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/test_foundation.py) | no | +| Distributed Winkler bed converges on the rigid mudline clamp (issue #118) | limit $E_{SO} \to \infty$; residual compliance scales as the elastic length $(4EI/k)^{1/4}$ | 1st frequency at $E_{SO} \times 10^8$ vs the clamped model | < 1 % | (within tol) | [`tests/test_foundation.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/test_foundation.py) | no | +| Dense symmetric solve is independent of the requested mode count | NREL 5MW ElastoDyn blade (12.1 rpm) and tower, bundled deck; shift-invert spectral transformation (Ericsson & Ruhe 1980) | lowest modes for 4 vs 20 requested; dense vs sparse shift-invert | `rtol = 1e-6` | identical to roundoff (blade flap1 0.72865 Hz at every window; was 0.715 / 0.710 / 0.766 / 0.728 Hz for 4 / 6 / 10 / 20) | [`tests/fem/test_dense_inverted_solver.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/fem/test_dense_inverted_solver.py) | no | +| Near-singular mass solved directly on the dense path | cantilever carrying one lump: $f = \sqrt{3EI/(m L^3)}/2\pi$ (Blevins 1979, Table 8-1), 4e5:1 lump-to-beam mass ratio | 1st frequency, no residual retry, backward error | < 0.5 %; residual < 1e-6 | (within tol) | [`tests/fem/test_dense_inverted_solver.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/fem/test_dense_inverted_solver.py) | no | +| Near-singular mass matrix does not silently corrupt the low modes | cantilever carrying one lump: $f = \sqrt{3EI/(m a^3)}/2\pi$ (Blevins 1979, Table 8-1), at a 4e5:1 lump-to-beam mass ratio | 1st frequency, dense and sparse dispatch sizes, through the residual retry behind the mass-reduced fallback | < 0.5 % | (within tol; the unguarded mass-reduced solve is 137 % out) | [`tests/fem/test_ill_conditioned_mass.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/fem/test_ill_conditioned_mass.py) | no | +| Rigid-body modes are not lost to the solver guard | construction (rank-deficient $K$, well-conditioned $M$; requested subsets of 1, 3, 6 and 10 modes) | zero modes retained, requested mode count returned | exact | (within tol) | [`tests/fem/test_ill_conditioned_mass.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/fem/test_ill_conditioned_mass.py) | no | +| Deck-reader `n_nodes` refinement (issue #58) | Euler-Bernoulli closed form, modes 1-3, plus self-convergence $n{=}100$ vs $200$ | bending frequencies | < 1 %; self-convergence < 0.2 % | (within tol) | [`tests/test_refine_mesh.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/test_refine_mesh.py) | no | **Citations** (full author / year forms used in the table above). NREL reports link to the canonical ``docs.nrel.gov`` PDF; journal / @@ -143,16 +143,16 @@ implementation. Per-block verdicts: PASS < 0.01, WARN < 0.10, FAIL ≥ 0.10. | Case | Source / reference | Quantity | Tolerance | Worst observed (file_rms) | Test file | Needs external data | | --- | --- | --- | ---: | ---: | --- | :---: | -| Validator on stock NREL 5MW r-test deck — TwFAM2Sh / TwSSM2Sh | OpenFAST r-test (commit `dd5feaaa`) | file polynomial RMS vs pyBmodes mode shape (detection target) | verdict = FAIL | 5.08 (TwFAM2Sh) / 5.90 (TwSSM2Sh) | [`tests/test_validate.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/test_validate.py) | yes | -| Validator on same deck — 1st tower modes + blade modes | OpenFAST r-test | file polynomial RMS | verdict = PASS | 0.0081 / 0.0075 / 0.0020-0.0090 | [`tests/test_validate.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/test_validate.py) | yes | -| Patch round-trip on staged NREL 5MW copy | self (post-`patch_dat`) | file polynomial RMS after patching | verdict = PASS, ratio ≈ 1.0 | ratio drift < 1 % (text-precision artefact) | [`tests/test_validate.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/test_validate.py) | yes | -| `reference_decks/nrel5mw_land/` patched deck | committed deliverable | per-block verdict | PASS or WARN, no FAIL | all PASS | [`tests/test_reference_decks.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/test_reference_decks.py) | no (artefact tracked) | -| `reference_decks/nrel5mw_oc3monopile/` patched deck | committed deliverable | per-block verdict | PASS or WARN, no FAIL | all PASS | [`tests/test_reference_decks.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/test_reference_decks.py) | no | -| `reference_decks/iea34_land/` patched deck | committed deliverable | per-block verdict | PASS or WARN, no FAIL | all PASS | [`tests/test_reference_decks.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/test_reference_decks.py) | no | -| `reference_decks/nrel5mw_oc3spar/` patched deck (OC3 Hywind floating spar; cantilever basis) | committed deliverable | per-block verdict | PASS or WARN, no FAIL | all PASS | [`tests/test_reference_decks.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/test_reference_decks.py) | no | -| `reference_decks/nrel5mw_oc4semi/` patched deck (OC4 DeepCwind semi; cantilever basis) | committed deliverable | per-block verdict | PASS or WARN, no FAIL | all PASS | [`tests/test_reference_decks.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/test_reference_decks.py) | no | -| `reference_decks/iea15mw_umainesemi/` patched deck (UMaine VolturnUS-S; cantilever basis) | committed deliverable | per-block verdict | PASS or WARN, no FAIL | WARN on TwSSM2Sh (1.6 % RMS — ElastoDyn-basis representation limit; see footer in the deck's `validation_report.txt`) | [`tests/test_reference_decks.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/test_reference_decks.py) | no | -| Pre-patch sanity — at least one `before_patch.txt` shows FAIL or WARN | committed before-patch reports | per-deck overall verdict | ≥ 1 FAIL/WARN | 6/6 FAIL | [`tests/test_reference_decks.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/test_reference_decks.py) | no | +| Validator on stock NREL 5MW r-test deck — TwFAM2Sh / TwSSM2Sh | OpenFAST r-test (commit `dd5feaaa`) | file polynomial RMS vs pyBmodes mode shape (detection target) | verdict = FAIL | 5.08 (TwFAM2Sh) / 5.90 (TwSSM2Sh) | [`tests/test_validate.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/test_validate.py) | yes | +| Validator on same deck — 1st tower modes + blade modes | OpenFAST r-test | file polynomial RMS | verdict = PASS | 0.0081 / 0.0075 / 0.0020-0.0090 | [`tests/test_validate.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/test_validate.py) | yes | +| Patch round-trip on staged NREL 5MW copy | self (post-`patch_dat`) | file polynomial RMS after patching | verdict = PASS, ratio ≈ 1.0 | ratio drift < 1 % (text-precision artefact) | [`tests/test_validate.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/test_validate.py) | yes | +| `reference_decks/nrel5mw_land/` patched deck | committed deliverable | per-block verdict | PASS or WARN, no FAIL | all PASS | [`tests/test_reference_decks.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/test_reference_decks.py) | no (artefact tracked) | +| `reference_decks/nrel5mw_oc3monopile/` patched deck | committed deliverable | per-block verdict | PASS or WARN, no FAIL | all PASS | [`tests/test_reference_decks.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/test_reference_decks.py) | no | +| `reference_decks/iea34_land/` patched deck | committed deliverable | per-block verdict | PASS or WARN, no FAIL | all PASS | [`tests/test_reference_decks.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/test_reference_decks.py) | no | +| `reference_decks/nrel5mw_oc3spar/` patched deck (OC3 Hywind floating spar; cantilever basis) | committed deliverable | per-block verdict | PASS or WARN, no FAIL | all PASS | [`tests/test_reference_decks.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/test_reference_decks.py) | no | +| `reference_decks/nrel5mw_oc4semi/` patched deck (OC4 DeepCwind semi; cantilever basis) | committed deliverable | per-block verdict | PASS or WARN, no FAIL | all PASS | [`tests/test_reference_decks.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/test_reference_decks.py) | no | +| `reference_decks/iea15mw_umainesemi/` patched deck (UMaine VolturnUS-S; cantilever basis) | committed deliverable | per-block verdict | PASS or WARN, no FAIL | WARN on TwSSM2Sh (1.6 % RMS — ElastoDyn-basis representation limit; see footer in the deck's `validation_report.txt`) | [`tests/test_reference_decks.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/test_reference_decks.py) | no | +| Pre-patch sanity — at least one `before_patch.txt` shows FAIL or WARN | committed before-patch reports | per-deck overall verdict | ≥ 1 FAIL/WARN | 6/6 FAIL | [`tests/test_reference_decks.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/test_reference_decks.py) | no | ## Track C — supporting-pipeline behavioural cases @@ -167,138 +167,138 @@ is behavioural / contract-style. | Case | Source / reference | Quantity | Tolerance | Worst observed | Test file | Needs external data | | --- | --- | --- | ---: | ---: | --- | :---: | -| `check_model` — non-monotonic span detected | construction | `ModelWarning(severity='WARN')` raised | always | (within tol) | [`tests/test_checks.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/test_checks.py) | no | -| `check_model` — zero / negative mass density detected | construction | `ModelWarning(severity='ERROR')` raised | always | (within tol) | [`tests/test_checks.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/test_checks.py) | no | -| `check_model` — stiffness jump > 5× detected | construction | `ModelWarning(severity='WARN')` raised | always | (within tol) | [`tests/test_checks.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/test_checks.py) | no | -| `check_model` — EI_FA / EI_SS extreme ratio detected | construction | `ModelWarning(severity='INFO')` raised | always | (within tol) | [`tests/test_checks.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/test_checks.py) | no | -| `check_model` — RNA mass > tower mass detected | construction | `ModelWarning(severity='INFO')` raised | always | (within tol) | [`tests/test_checks.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/test_checks.py) | no | -| `check_model` — singular `PlatformSupport` matrix detected | construction | `ModelWarning(severity='ERROR')` raised | always | (within tol) | [`tests/test_checks.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/test_checks.py) | no | -| `check_model` — n_modes > 6 × n_nodes detected | construction | `ModelWarning(severity='ERROR')` raised | always | (within tol) | [`tests/test_checks.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/test_checks.py) | no | -| `check_model` — polynomial-fit cond > 1e4 detected | construction | `ModelWarning(severity='WARN' or 'ERROR')` raised | always | (within tol) | [`tests/test_checks.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/test_checks.py) | no | -| `Tower.run(check_model=True/False)` auto-run integration | construction | `UserWarning` emitted on True / suppressed on False | always | (within tol) | [`tests/test_checks.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/test_checks.py) | no | -| `mac_matrix` — identity test (shapes vs themselves) | construction | diagonal entries equal 1, off-diag < 1 for distinct shapes | exact / `np.allclose` | (within tol) | [`tests/test_mac.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/test_mac.py) | no | -| `mac_matrix` — orthogonal shapes (FA-only vs SS-only) | construction | every entry = 0 | exact / `np.allclose` | (within tol) | [`tests/test_mac.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/test_mac.py) | no | -| `compare_modes` — frequency-shift sign matches input direction | construction | sign of `frequency_shift` matches sign of (f_B − f_A) | exact / `np.allclose` | (within tol) | [`tests/test_mac.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/test_mac.py) | no | -| `compare_modes` — Hungarian-optimal mode pairing | construction | output pair = identity when shapes match | exact | (within tol) | [`tests/test_mac.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/test_mac.py) | no | -| `plot_mac` — matplotlib smoke test | construction | Figure has ≥ 1 Axes; title carries labels | structural | (within tol) | [`tests/test_mac.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/test_mac.py) | no | -| `ModalResult.save / load` — NPZ round-trip | self | per-field `np.allclose` vs original | `rtol = 1e-12` | (within tol) | [`tests/test_serialize.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/test_serialize.py) | no | -| `ModalResult.to_json / from_json` — JSON round-trip | self | per-field equality vs original | exact / `np.allclose` | (within tol) | [`tests/test_serialize.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/test_serialize.py) | no | -| `ModalResult` — metadata capture (version + timestamp + git hash) | self | metadata dict populated with non-empty pybmodes_version | always | (within tol) | [`tests/test_serialize.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/test_serialize.py) | no | -| `CampbellResult.save / load` — NPZ round-trip | self | per-field `np.allclose` vs original | `rtol = 1e-12` | (within tol) | [`tests/test_serialize.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/test_serialize.py) | no | -| `CampbellResult.to_csv` — spec column order | self | header = `[rpm, , _mac]` | exact match | (within tol) | [`tests/test_serialize.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/test_serialize.py) | no | -| NPZ load is pickle-free on modern archives; legacy `dtype=object` `__meta__` is **refused by default** (object-array unpickling can execute code) and loads only via the opt-in `allow_legacy_pickle=True` (F2) | construction (forged legacy archive) | modern load emits no warning; default legacy load raises `ValueError` ("refusing to load a legacy …"); opt-in load raises `UserWarning` + round-trips | exact / structural | (within tol) | [`tests/test_serialize.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/test_serialize.py) | no | -| `_validate_lengths` / `CampbellResult._validate` reject malformed schemas before any export (F5) | construction (bad participation / label shapes) | `ValueError` before save / to_json / to_csv | always raises | (within tol) | [`tests/test_serialize.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/test_serialize.py) | no | -| `check_model` n_modes guard uses exact `n_free_dof`, not `6·n_nodes` — no false ERROR in (6·n_nodes, n_free_dof] (F3) | construction (nselt=10 ⇒ n_free_dof=90) | n_modes=80 clean; n_modes=200 ERROR | always | (within tol) | [`tests/test_checks.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/test_checks.py) | no | -| `pybmodes patch` rejects conflicting `--output` / `--output-dir` (F4); silent-agree preserved for equal / single | construction | exit 2 + clear message only when paths differ | exact match | (within tol) | [`tests/test_cli_patch_args.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/test_cli_patch_args.py) | no | -| `kaimal_spectrum` — IEC 61400-1 longitudinal closed form | textbook: `S(0)=4σ²L/U`, strictly decreasing in `f` | low-freq plateau + monotonicity | exact / `pytest.approx` | (within tol) | [`tests/test_environmental.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/test_environmental.py) | no | -| `jonswap_spectrum` — peak at `1/Tp`; `m0 = Hs²/16` significant-wave-height identity | textbook JONSWAP; the Hs identity is exact by construction | `f_peak`, `∫S df` | peak `abs=2e-3`; `m0` `rel=2 %` | (within tol) | [`tests/test_environmental.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/test_environmental.py) | no | -| `plot_environmental_spectra` — structure (bands + tower lines + spectra + legend) and input guards | construction | axvspans / vlines / legend entries; `ValueError` on bad `freq_max` / `harmonics` | structural / always raises | (within tol) | [`tests/test_environmental.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/test_environmental.py) | no | -| `read_out(strict=True)` — fail-loud on short / non-numeric / non-finite / duplicate-mode / empty `.out`; default stays tolerant | construction (malformed `.out` fixtures) | `BModeOutParseError` with file/line/mode context vs tolerant skip | exact match / always raises | (within tol) | [`tests/test_out_parser.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/test_out_parser.py) | no | -| `generate_report` — markdown contains frequencies | construction | every 4-dp frequency appears in body | exact match | (within tol) | [`tests/test_report.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/test_report.py) | no | -| `generate_report` — HTML is well-formed | construction | DOCTYPE + balanced `` / `` / `
` tags | structural | (within tol) | [`tests/test_report.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/test_report.py) | no | -| `generate_report` — CSV has coefficient columns | construction | second header row has `C2..C6, rms_residual, cond_number` | exact column match | (within tol) | [`tests/test_report.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/test_report.py) | no | -| `pybmodes batch` — discovery filter excludes Tower / Blade / SubDyn `.dat` | self (committed `reference_decks/`) | 6 main decks found; 0 aux files | exact | (within tol) | [`tests/test_batch.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/test_batch.py) | no | -| `pybmodes batch` — summary CSV column set | construction | header = `[filename, overall_verdict, TwFAM2Sh_ratio, TwSSM2Sh_ratio, n_fail, n_warn]` | exact column match | (within tol) | [`tests/test_batch.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/test_batch.py) | no | -| `pybmodes batch --patch` — every block reaches PASS / WARN | OpenFAST r-test 5MW deck | per-deck `overall_verdict` post-patch | no FAIL | (within tol) | [`tests/test_batch.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/test_batch.py) | yes | -| `pybmodes examples --copy --kind samples` — vendors `cases/sample_inputs/` | self (committed bundle) | four analytical-reference subdirs + `reference_turbines/` present at dest | exact match | (within tol) | [`tests/test_examples_cli.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/test_examples_cli.py) | no | -| `pybmodes examples --copy --kind decks` — vendors `reference_decks/` | self (committed bundle) | `nrel5mw_land/` present at dest; `sample_inputs/` not vendored | exact match | (within tol) | [`tests/test_examples_cli.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/test_examples_cli.py) | no | -| `pybmodes examples --copy` (default `--kind all`) — vendors both bundles | self (committed bundle) | both `sample_inputs/` and `reference_decks/` present at dest | exact match | (within tol) | [`tests/test_examples_cli.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/test_examples_cli.py) | no | -| `pybmodes examples` — destination conflict guard | construction (pre-existing target subdir) | exit code 2 + preexisting file preserved | exact match | (within tol) | [`tests/test_examples_cli.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/test_examples_cli.py) | no | -| `pybmodes examples --force` — overwrites stale destination | construction (pre-existing target subdir + stale file) | stale file removed; real bundle present | exact match | (within tol) | [`tests/test_examples_cli.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/test_examples_cli.py) | no | -| `WamitReader.read` — surge A_inf vs manual redimensionalisation | IEA-15-240-RWT-UMaineSemi WAMIT `.1` | `A_inf[0,0]` (dim, kg) | `rel = 1 %` | (within tol) | [`tests/test_wamit_reader.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/test_wamit_reader.py) | yes | -| `WamitReader.read` — heave A_inf vs manual redim | IEA-15-240-RWT-UMaineSemi WAMIT `.1` | `A_inf[2,2]` (dim, kg) | `rel = 1 %` | (within tol) | [`tests/test_wamit_reader.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/test_wamit_reader.py) | yes | -| `WamitReader.read` — pitch A_inf vs manual redim | IEA-15-240-RWT-UMaineSemi WAMIT `.1` | `A_inf[4,4]` (dim, kg·m²) | `rel = 1 %` | (within tol) | [`tests/test_wamit_reader.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/test_wamit_reader.py) | yes | -| `WamitReader.read` — heave C_hst vs manual redim | IEA-15-240-RWT-UMaineSemi WAMIT `.hst` | `C_hst[2,2]` (dim, N/m) | `rel = 1 %` | (within tol) | [`tests/test_wamit_reader.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/test_wamit_reader.py) | yes | -| `WamitReader.read` — pitch C_hst positive (stable semi) | IEA-15-240-RWT-UMaineSemi WAMIT `.hst` | `C_hst[3,3] > 0` and ≈ 2.193e9 N·m/rad | `rel = 1 %` | (within tol) | [`tests/test_wamit_reader.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/test_wamit_reader.py) | yes | -| `WamitReader.read` — A_inf and C_hst symmetric (no transposed-index bug) | IEA-15-240-RWT-UMaineSemi WAMIT pair | `max(\|M − M.T\|) < 1e-3 · max(\|M\|)` | always | (within tol) | [`tests/test_wamit_reader.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/test_wamit_reader.py) | yes | -| `WamitReader._resolve_pot_path` — quoting / separator robustness | construction (4 spellings of one path) | all forms resolve to the same Path | exact match | (within tol) | [`tests/test_wamit_reader.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/test_wamit_reader.py) | yes | -| `WamitReader.read` — clear error on missing PotFile | construction (non-existent root) | `FileNotFoundError` naming `.1` | exact match | (within tol) | [`tests/test_wamit_reader.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/test_wamit_reader.py) | yes | -| `HydroDynReader` — ULEN / PotMod / PotFile / PtfmRefzt / ρ / g | IEA-15-240-RWT-UMaineSemi `_HydroDyn.dat` | 6 scalar fields | exact / default | (within tol) | [`tests/test_wamit_reader.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/test_wamit_reader.py) | yes | -| `WamitReader` — upper-triangle-only `.hst` / `.1` mirrored | synthetic 2-entry input | `C[2,4] == C[4,2]` and `A_inf[0,2] == A_inf[2,0]` after parse | exact | (within tol) | [`tests/test_wamit_reader_synth.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/test_wamit_reader_synth.py) | no | -| `WamitReader` — Fortran `D` / `d` exponents accepted | synthetic `4.430486D+02` / `1.233416d+04` | dimensional values match the C-form equivalents | `rel = 1e-12` | (within tol) | [`tests/test_wamit_reader_synth.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/test_wamit_reader_synth.py) | no | -| `_scan_platform_fields` — raises on malformed critical scalars | synthetic deck with `not_a_number PtfmMass` | `ValueError` naming the offending field | exact match | (within tol) | [`tests/test_mooring.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/test_mooring.py) | no | -| `Line.solve_static` — inextensible limit matches Irvine 1981 §2.3 | construction (EA → ∞) | residuals on Irvine eq. (2.27)–(2.29) | `rel = 1e-4` | (within tol) | [`tests/test_mooring.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/test_mooring.py) | no | -| `Line.solve_static` — horizontal-line symmetry | construction (ΔZ = 0) | `V_F = W·L/2` at fairlead | `rel = 1e-6` | (within tol) | [`tests/test_mooring.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/test_mooring.py) | no | -| `Line.solve_static` — Newton residuals satisfied to ≤ solver tol | construction (typical slack-line geometry) | both catenary residuals after solve | `< 1e-6` m | (within tol) | [`tests/test_mooring.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/test_mooring.py) | no | -| `MooringSystem.restoring_force` — 3-fold-symmetric zero offset | construction (synthetic 3-line layout) | F\_x, F\_y, M\_x, M\_y, M\_z all `< 1e-3·\|F\_z\|` | `rel = 1e-3` | (within tol) | [`tests/test_mooring.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/test_mooring.py) | no | -| `MooringSystem.stiffness_matrix` — diagonal positive | construction (synthetic 3-line) | every diag entry > 0 | always | (within tol) | [`tests/test_mooring.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/test_mooring.py) | no | -| `MooringSystem.stiffness_matrix` — symmetric after symmetrisation | construction (synthetic 3-line) | `max(\|K − K.T\|) < 1e-10 · max(\|K\|)` | always | (within tol) | [`tests/test_mooring.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/test_mooring.py) | no | -| `MooringSystem.from_moordyn` — OC3 Hywind layout round-trip | OpenFAST r-test 5MW_OC3Spar MoorDyn `.dat` | 3 lines / 6 points / fairlead radius 5.2 / anchor radius 853.87 / L 902.2 | `rel = 1e-3` | (within tol) | [`tests/test_mooring.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/test_mooring.py) | yes | -| `MooringSystem.stiffness_matrix` — OC3 Hywind surge stiffness | OC3 r-test MoorDyn `.dat` | `K[0,0] ≈ 41,180 N/m` (Jonkman 2010 Table 5-1) | `rel = 5%` | within 0.01 % | [`tests/test_mooring.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/test_mooring.py) | yes | -| `MooringSystem.stiffness_matrix` — OC3 yaw stiffness (catenary-only) | OC3 r-test MoorDyn `.dat` | `K[5,5] ≈ 1.156e7 N·m/rad` (catenary lines only; OC3's delta-line crowfoot adds the bulk of the published 9.83e7) | `rel = 5%` | (within tol) | [`tests/test_mooring.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/test_mooring.py) | yes | -| `MooringSystem.stiffness_matrix` — 3-fold-symmetry signatures | OC3 r-test MoorDyn `.dat` | K[0,0] = K[1,1], K[3,3] = K[4,4], K[0,4]·K[1,3] < 0 | `rel = 1e-4` | (within tol) | [`tests/test_mooring.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/test_mooring.py) | yes | -| `Tower.from_elastodyn_with_mooring` — end-to-end OC3 coupled solve | OC3 ElastoDyn + r-test MoorDyn `.dat` | hub\_conn=2, PlatformSupport populated, 1st tower-bending in 0.4–0.6 Hz | `rel = 5%` | within 1.2 % of Jonkman 2010's 0.482 Hz | [`tests/test_mooring.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/test_mooring.py) | yes | -| Floating weight restoring $-g \sum m_i (z_i - z_{ref})$ in roll / pitch (platform + tower + RNA) | closed form; classical $C_{44} = \rho g V (z_B - z_G) + \rho g I_{wp}$ (Faltinsen 1990, ch. 2) | 6×6 weight matrix, reference-point shift, mass scaling, point masses | `rel = 1e-9` | exact | [`tests/test_floating_weight_restoring.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/test_floating_weight_restoring.py) | no | -| Negative platform restoring is reported, not silently dropped | construction (OC3 Hywind sample with the weight term removed) | `UserWarning` naming roll / pitch | always | (within tol) | [`tests/test_floating_weight_restoring.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/test_floating_weight_restoring.py) | no | -| **`Tower.from_elastodyn_with_mooring` — OC3 Hywind rigid-body frequencies** (WAMIT `.hst` + weight of platform, tower and RNA) | Jonkman (2010) NREL/TP-500-47535, FAST full-system natural frequencies: surge/sway 0.0080, heave 0.0324, roll 0.0342, pitch 0.0343 Hz | six labelled rigid-body modes | surge/sway/roll/pitch 3 %, heave 2 % | surge/sway 0.8 %, heave 0.0 %, roll 1.1 %, pitch 1.4 % (roll/pitch were missing before the weight term) | [`tests/test_floating_weight_restoring.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/test_floating_weight_restoring.py) | yes | -| HydroDyn filled-member ballast mass properties (body-fixed, as HydroDyn precomputes them) | closed form: solid cylinder, frustum volume / centroid / axial moment, parallel axis | mass, centre of mass, 3×3 inertia; partial / full / empty, horizontal, inclined, tapered members; both member-table layouts | `rel = 1e-12` | exact | [`tests/test_hydrodyn_ballast.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/test_hydrodyn_ballast.py) | no | -| **`Tower.from_elastodyn_with_mooring` — OC4 DeepCwind ballasted platform** (steel `PtfmMass` + two HydroDyn fill groups) | Robertson et al. (2014) NREL/TP-5000-60601: platform incl. ballast 1.3473e7 kg, CM 13.46 m below SWL | platform mass, CM depth; positive roll / pitch restoring; six labelled rigid-body modes; heave vs single-DOF estimate | mass `rel = 1e-3`, CM `abs = 0.01 m`, heave `rel = 2%` | mass +0.02 %, CM 0.000 m | [`tests/test_hydrodyn_ballast.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/test_hydrodyn_ballast.py) | yes | -| Bundled floating samples are statically stable | construction (samples 07-11) | six labelled rigid-body modes, no negative-restoring warning | always | (within tol) | [`tests/test_floating_samples_spectra.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/test_floating_samples_spectra.py) | no | -| `MudlineFoundation.from_soil_properties` — Yu & Amdahl (2023) Table 9 DTU 10 MW flexible pile, E\_SO = 30 MPa | Yu and Amdahl (2023) Marine Structures 92, 103482, Table 9 row 2 | K\_hh, K\_hr, K\_rr via Shadlou and Bhattacharya (2016) formulas | `rel = 10%` | within 3.0 % on all three springs | [`tests/test_foundation.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/test_foundation.py) | no | -| `MudlineFoundation.from_soil_properties` — Yu & Amdahl (2023) Table 9 DTU 10 MW rigid pile, E\_SO = 30 MPa | Yu and Amdahl (2023) Table 9 row 1 | K\_hh, K\_hr, K\_rr via Shadlou and Bhattacharya (2016) rigid-pile formulas | `rel = 5%` | within 2.9 % on all three springs | [`tests/test_foundation.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/test_foundation.py) | no | -| `MudlineFoundation.as_mooring_K` — cross-coupling sign matches Jonkman 2010 OC3 convention | construction | K\[0,4\] < 0, K\[1,3\] > 0, K\[0,4\]·K\[1,3\] < 0 | always | (within tol) | [`tests/test_foundation.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/test_foundation.py) | no | -| Randolph (1981) pile classifier — auto dispatch to flexible / rigid / intermediate | construction (synthetic geometries) | high L/D → flexible; low L/D → rigid; intermediate emits `UserWarning` and falls back to flexible | always | (within tol) | [`tests/test_foundation.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/test_foundation.py) | no | -| Sparse `eigsh` shift-invert — matches dense `eigh` | construction (SPD problem at threshold + 100 DOFs) | lowest-k eigenvalues | `rtol = 1e-8` | (within tol) | [`tests/fem/test_sparse_solver.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/fem/test_sparse_solver.py) | no | -| Sparse path triggered above threshold | construction | log message announces "sparse shift-invert" | always | (within tol) | [`tests/fem/test_sparse_solver.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/fem/test_sparse_solver.py) | no | -| Sparse path skipped below threshold | construction | log message does not mention "sparse" | always | (within tol) | [`tests/fem/test_sparse_solver.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/fem/test_sparse_solver.py) | no | -| Asymmetric problems fall back to dense general `eig` | construction (asymmetric K) | log message announces "dense general"; sparse not invoked | always | (within tol) | [`tests/fem/test_sparse_solver.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/fem/test_sparse_solver.py) | no | -| Indefinite `K` keeps the algebraically lowest window on every path | construction (diagonal, coupled with scaled mass, zero modes, SPD control) + NREL 5MW land tower under gravity at 1000× RNA mass, default vs 120-node mesh | lowest-k eigenvalues; negative and zero modes kept; SPD still sparse; buckled tower identical on both meshes | `rtol = 1e-9` | exact | [`tests/fem/test_sparse_solver.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/fem/test_sparse_solver.py) | no | -| Positive-definiteness guard (banded Cholesky) agrees with a dense Cholesky | construction (random symmetric, tridiagonal, singular, full-width coupling, tiny asymmetry) | definite / not definite | always | (within tol) | [`tests/fem/test_sparse_solver.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/fem/test_sparse_solver.py) | no | -| `pybmodes patch --dry-run` writes nothing (mtime check) | construction | mtime of every staged file unchanged | exact match | (within tol) | [`tests/test_validate.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/test_validate.py) | yes | -| `pybmodes patch --diff` PR format contains `×` improvement ratio | construction | stdout contains `×`, `RMS improvement:`, `better` literals | exact match | (within tol) | [`tests/test_validate.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/test_validate.py) | yes | -| `pybmodes patch --output` leaves source byte-identical | construction | source-file sha256 unchanged | exact match | (within tol) | [`tests/test_validate.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/test_validate.py) | yes | -| Default in-place `pybmodes patch` emits first-time-run hint | construction | stdout contains `--dry-run --diff` | exact match | (within tol) | [`tests/test_validate.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/test_validate.py) | yes | -| Tower torsion-contamination filter — rejects T_tor ≥ 10 % | construction (synthetic torsion-contaminated mode) | `rejected_modes` carries the contaminated mode | always | (within tol) | [`tests/test_classifier.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/test_classifier.py) | no | -| Tower torsion-contamination filter — accepts pure bending | construction (synthetic clean modes) | `rejected_modes` is empty | always | (within tol) | [`tests/test_classifier.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/test_classifier.py) | no | -| IEA-3.4 deck — torsion participations populated, summing to 1 | OpenFAST IEA-3.4-130-RWT deck | per-mode `(T_FA, T_SS, T_tor)` triple | `Σ = 1` to `abs_tol = 1e-9` | (within tol) | [`tests/test_classifier.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/test_classifier.py) | yes | -| Campbell sweep — Hungarian MAC tracking on bundled NREL 5MW reference deck | committed `reference_decks/nrel5mw_land/` | `mac_to_previous` ≥ 0.90 between consecutive steps on a smooth sweep | always | (within tol) | [`tests/test_campbell.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/test_campbell.py) | no | -| Campbell sweep — input validation (NaN / inf / negative / unsorted RPM) | construction | `ValueError` raised | always raises | (within tol) | [`tests/test_campbell.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/test_campbell.py) | no | -| Campbell sweep — restores `bbmi.rot_rpm` after sweep (clean + on-exception) | construction | post-sweep / post-exception BMI state | unchanged | (within tol) | [`tests/test_campbell.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/test_campbell.py) | no | -| Campbell sweep — tower modes constant across all rotor speeds | construction | tower frequency vs rotor speed | exactly constant (no `rpm` dependence in tower modal eigenproblem) | (within tol) | [`tests/test_campbell.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/test_campbell.py) | no | -| `plot_campbell` — 6-DOF platform overlay: degenerate-pair merge + Hz/period labels + log axis (issue #39); default-off byte-identical; non-finite/≤0 skipped | construction (synthetic CampbellResult + platform_modes) | horizontal refs + right-margin labels + `yscale`; regression invariant w/o platform_modes | structural / exact | (within tol) | [`tests/test_campbell.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/test_campbell.py) | no | -| ElastoDyn-compat blade adapter — strips `str_tw`, `tw_iner`, offsets | Jonkman 2015 NREL forum guidance | resulting BMI fields | zeroed for compat-on, preserved for compat-off | (within tol) | [`tests/test_elastodyn_compatible.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/test_elastodyn_compatible.py) | no | -| ElastoDyn-compat — frequency drift on flap modes is small | construction | rel freq diff vs compat-off | ≤ ~ few % on flap modes | (within tol) | [`tests/test_elastodyn_compatible.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/test_elastodyn_compatible.py) | no | -| ElastoDyn `.dat` parse → write → parse round-trip | self | per-field `np.allclose` vs original | `rtol = 1e-12` | (within tol) | [`tests/test_elastodyn_reader.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/test_elastodyn_reader.py) | yes | -| Polynomial-fit design-matrix cond-number reporting | construction | `RuntimeWarning` above thresholds | WARN > 1e4, FAIL > 1e6 | (within tol) | [`tests/test_fitting.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/test_fitting.py) | no | -| BMI / section-properties parser primitives | construction (synthetic fixtures) | round-trip equality | exact / `np.allclose` | (within tol) | [`tests/test_io.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/test_io.py) | no | -| FEM building blocks — boundary conditions, eigensolver, normalisation | construction | per-DOF / per-mode invariants | exact / `np.allclose` | (within tol) | [`tests/fem/test_*.py`](https://github.com/SMI-Lab-Inha/pyBModes/tree/master/tests/fem/) | no | -| `tubular_section_props` — closed-form tube identities | $A=\pi(R_o^2-R_i^2)$, $I=\tfrac{\pi}{4}(R_o^4-R_i^4)$, $J=2I$, $G=E/2(1+\nu)$ (textbook) | mass\_den / flp\_stff / tor\_stff / axial\_stff / flp\_iner | exact | `pytest.approx` | [`tests/test_geometry_windio.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/test_geometry_windio.py) | no | -| `outfitting_factor` — scales mass + rotary inertia only, never stiffness | construction (factor 1.07) | mass × 1.07; EI / GJ / EA / structural ρI unchanged | exact | `pytest.approx` | [`tests/test_geometry_windio.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/test_geometry_windio.py) | no | -| Geometry guard — rejects D ≤ 0 / t ≤ 0 / 2t ≥ D | construction (degenerate sections) | `ValueError` raised | always raises | (within tol) | [`tests/test_geometry_windio.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/test_geometry_windio.py) | no | -| WindIO dialect equivalence — modern `outer_shape`/`structure` vs older `outer_shape_bem`/`internal_structure_2d_fem` | construction (numerically identical fixtures) | same `WindIOTubular` from both key dialects | exact / `np.allclose` | (within tol) | [`tests/test_geometry_windio.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/test_geometry_windio.py) | no | -| WindIO duplicate-anchor tolerance — IEA-10's redefined-anchor habit | construction (`&g` redefined; stock `SafeLoader` raises `ComposerError`) | parses (last-wins) where strict PyYAML fails | exact | (within tol) | [`tests/test_geometry_windio.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/test_geometry_windio.py) | no | -| WindIO orthotropic wall material — clear out-of-scope error | construction (list-valued composite E) | `ValueError` naming "orthotropic", not bare `float(list)` `TypeError` | exact match | (within tol) | [`tests/test_geometry_windio.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/test_geometry_windio.py) | no | -| WindIO thickness interp — linear vs piecewise-constant differs on a taper | construction (smoothly tapered wall) | mid-station wall thickness diverges by > 0.01 m | exact | 0.035 vs 0.05 m | [`tests/test_geometry_windio.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/test_geometry_windio.py) | no | -| WindIO PyYAML-absent guard — names the `[windio]` extra | construction (monkeypatched import) | `ModuleNotFoundError` matching `pybmodes\[windio\]` | exact match | (within tol) | [`tests/test_geometry_windio.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/test_geometry_windio.py) | no | -| **IEA-15 base WindIO yaml → derived mass / EI vs IEA-15 *Monopile* ElastoDyn tower table** (RNA-independent like-for-like; that deck was generated from this geometry) | IEA Wind Task 37 IEA-15-240-RWT ontology + OpenFAST Monopile deck | distributed mass\_den & FA EI, station-by-station | < 0.5 % | **7.5 × 10⁻¹² (machine-exact)** | [`tests/test_geometry_windio.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/test_geometry_windio.py) | yes | -| Upstream WindIO corpus — IEA-3.4 / 10 / 15 / 22 + WISDEM examples (both dialects + IEA-10 dup-anchor) parse to a physically sane tube | IEA Wind Task 37 RWT ontologies + WISDEM example yamls | grid ∈ [0,1] monotone, D / t > 0, 2t < D, steel E / ρ / ν range, span > 0 | structural | 12 yamls clean | [`tests/test_geometry_windio.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/test_geometry_windio.py) | yes | -| `Tower.from_windio` — older-dialect modal smoke (IEA-3.4 / 10 / 22) | IEA Wind Task 37 RWT ontologies | full FEM solve → bare-member spectrum | finite, positive, ascending | (within tol) | [`tests/test_geometry_windio.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/test_geometry_windio.py) | yes | -| Older-dialect yaml-derived mass / EI vs same-turbine ElastoDyn tower table (ballpark — those decks were not 1:1 geometry round-trips, unlike IEA-15) | IEA-3.4 / 10 / 22 ontology + own OpenFAST tower deck | distributed mass\_den & FA EI envelope | mass < 25 %, EI < 30 % | (within tol) | [`tests/test_geometry_windio.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/test_geometry_windio.py) | yes | -| **WindIO auto-RNA (`lumped_rna_cal`) → tower-top lumped mass vs the IEA-22 ElastoDyn deck** (issue #82; hub + nacelle `elastic_properties_mb` + integrated blade span mass assembled to the tower top). Rotary inertia is WindIO-native and not deck-matched (`NacYIner` ecosystem drift — same pattern as `cases/ECOSYSTEM_FINDING.md`) | IEA Wind Task 37 IEA-22-280-RWT ontology + OpenFAST Monopile deck | RNA total mass & vertical CM lever | < 0.5 % | mass ≈ 0.05 %, CM ≈ 0.005 % | [`tests/test_windio_rna.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/test_windio_rna.py) | yes | -| CLT laminate primitives — reduced stiffness / ply transform / ABD / membrane condensation | Jones, *Mechanics of Composite Materials* (2nd ed.) §2.5–4.3 closed forms | $Q$, $\bar Q(\theta)$, $A/B/D$, $\tilde A$ | exact | `pytest.approx` (12 cases) | [`tests/test_windio_blade.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/test_windio_blade.py) | no | -| Airfoil `nd_arc` profile — arc spine / LE-TE landmarks / chord↔arc / blend | construction (analytic circle & ellipse) | `s_le`, `tc`, landmark `(x,y)`, blend weights | exact (≤ 2e-3) | (within tol) | [`tests/test_windio_blade.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/test_windio_blade.py) | no | -| WindIO web/layer `nd_arc` resolver — older-explicit ≡ modern-anchor-registry dialect equivalence | construction (numerically identical fixtures) | identical resolved bands from both dialects | exact / `np.allclose` | (within tol) | [`tests/test_windio_blade.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/test_windio_blade.py) | no | -| Thin-wall reduction — isotropic tube & box vs exact closed form | textbook thin-ring / thin-wall-box EA / EI / GJ / mass | section properties | exact | < 2 % (discretisation) | [`tests/test_windio_blade.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/test_windio_blade.py) | no | -| Multi-cell Bredt–Batho — symmetric interior web carries zero shear flow | analytic (a symmetric diametral web ⇒ GJ unchanged vs webless) | `GJ`, `n_cells` | exact | < 2 % | [`tests/test_windio_blade.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/test_windio_blade.py) | no | -| `windio_blade` dual-dialect end-to-end | construction (tube blade, both WindIO dialects) | identical `SectionProperties`; physically sane | exact / `np.allclose` | (within tol) | [`tests/test_windio_blade.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/test_windio_blade.py) | no | -| **6×6 cross-sectional decoupling** (issue #50) — rigid-offset congruence → tension centre → principal-axis eigen-decomposition → shear centre / `GJ`; analytic round-trips | construction (build a 6×6 *from* known decoupled props at a known offset / rotation) | recovered `EA`/`EI_flap`/`EI_edge`/`GJ`/`mass`/centres + defining decoupling property | exact (`rtol≤1e-6`) round-trip | (within tol) | [`tests/test_decouple.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/test_decouple.py) | no | -| **WindIO published distributed elastic properties → decoupled beam** (issue #48/#50) — `elastic="auto"` prefers them over PreComp; both dialects parsed onto the span grid as the **full** 6×6 then decoupled (elastic/shear centre + principal elastic axes — *not* raw diagonal); `"file"`/`"precomp"`/bad-value guards | construction (tube blade with embedded published properties, both dialects) | `SectionProperties` from the decoupled 6×6; mode-string dispatch | exact / `np.allclose`; `ValueError` on bad/missing | (within tol) | [`tests/test_windio_blade.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/test_windio_blade.py) | no | -| **WindIO published elastic properties vs the turbine's own BeamDyn 6×6**, IEA-15, span 0.15–0.90 (issue #48/#50) — *decoupled-vs-decoupled* (the BeamDyn 6×6 is also referenced at the blade axis, so the apples-to-apples oracle is the decoupled BeamDyn, not its raw diagonal); also pins that raw≠decoupled so the case genuinely exercises #50, and that the published path stays far tighter than PreComp | IEA Wind Task 37 IEA-15 ontology + companion `*_BeamDyn_blade.dat` | decoupled `mass` / `EA` / `EI_flap` / `EI_edge` / `GJ` | mass & EA med < 3 %; EI med < 5 %; GJ med < 8 %; raw-vs-decoupled EI > 5 %; strictly tighter than `elastic="precomp"` | (within tol) | [`tests/test_windio_blade.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/test_windio_blade.py) | yes | -| **WindIO composite blade → distributed beam props vs the turbine's own BeamDyn 6×6** (WISDEM-PreComp-generated; `elastic="precomp"` — the PreComp-fallback path's true scope), IEA-3.4 / 10 / 15 / 22, span 0.15–0.90 | IEA Wind Task 37 RWT ontology + companion `*_BeamDyn_blade.dat` | `mass` / `EA` (PreComp-class) ; `GJ` & `EI` (diagonal-reduction approximate — documented limitation) | mass med < 6 % / max < 15 %; EA med < 10 % / max < 18 %; GJ med < 22 % / max < 47 %; EI med < 35 % / max < 55 % | mass ≈ 1.5–4 % med ; EA ≈ 1–8 % med ; GJ ≈ 3–18 % med (IEA-10 worst, composite multi-cell torsion) ; EI ≈ 2–27 % med (weak axis worst — omits spar-cap-offset / bend-twist coupling) | [`tests/test_windio_blade.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/test_windio_blade.py) | yes | -| WindIO `elastic_properties_mb` ecosystem-drift caveat — a turbine's published `_mb` 6×6 is not always mutually consistent with its own companion BeamDyn deck (IEA-10 `_mb` ≈ 50 % from its BeamDyn 6×6); `auto` treats the WindIO ontology as canonical per #48 intent | IEA-10 ontology `_mb` vs `*_BeamDyn_blade.dat` | documented divergence (not a parser bug — same pattern as `cases/ECOSYSTEM_FINDING.md`) | n/a (documented, not gated) | ≈ 50 % (intrinsic to upstream data) | [`cases/ECOSYSTEM_FINDING.md`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/cases/ECOSYSTEM_FINDING.md) | yes | -| `RotatingBlade.from_windio` — modal smoke, all 4 RWTs (both dialects + parametric-layer blades) | IEA Wind Task 37 RWT ontologies | full FEM → parked-blade spectrum | finite, positive, ascending | (within tol) | [`tests/test_windio_blade.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/test_windio_blade.py) | yes | -| WindIO floating reader — joints (cartesian + cylindrical `[r,θ°,z]`), member geometry, axial-joint resolution, transition | construction | parsed `WindIOFloating` | exact / `np.allclose` | (within tol) | [`tests/test_windio_floating.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/test_windio_floating.py) | no | -| Hydrostatic restoring — single surface-piercing cylinder & off-axis column vs closed form | textbook waterplane + buoyancy (WAMIT/`.hst` convention) | `C_hst` 6×6 | exact | < 1 % (discretisation) | [`tests/test_windio_floating.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/test_windio_floating.py) | no | -| Morison added mass + RAFT end-cap & rigid-body inertia vs closed form | thin-ring transverse + RAFT `Ca_End` end disc; thin-wall steel | `A_inf` / mass 6×6 | exact | `pytest.approx` | [`tests/test_windio_floating.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/test_windio_floating.py) | no | -| `from_windio_mooring` — topology / props / 120°-symmetric stiffness / regression-warn / bad-ref | construction | `MooringSystem` + 6×6 `K` | exact / symmetry | (within tol) | [`tests/test_windio_floating.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/test_windio_floating.py) | no | -| `from_windio_floating` — yaml-only coupled modal smoke (stable single-column FOWT) | construction | full coupled FEM spectrum | finite, ascending, soft RB ≪ tower | (within tol) | [`tests/test_windio_floating.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/test_windio_floating.py) | no | -| **WindIO floating C_hst vs the turbine's own potential-flow WAMIT `.hst`** (geometry-exact anchor) | IEA-15 UMaine VolturnUS-S ontology + companion HydroDyn/WAMIT | heave / roll / pitch restoring | < 3 % / < 4 % | **heave 0.8 %, roll/pitch 1.6 %** | [`tests/test_windio_floating.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/test_windio_floating.py) | yes | -| WindIO *screening-preview* Morison `A_inf` & structural mass vs WAMIT / ElastoDyn (the yaml-only tier — NOT industry-grade, emits a `UserWarning`) | IEA-15 UMaine VolturnUS-S + companion HydroDyn / ElastoDyn | added-mass diagonal / `PtfmMass` | surge/sway/yaw < 45 %; heave/roll/pitch factor ~2; mass a lower bound | surge 22 %, heave 53 % (Morison ≠ BEM, as RAFT/WISDEM also find — supply a HydroDyn deck for industry grade); struct+fixed mass ≈ 0.36·PtfmMass (trim ballast excluded by design) | [`tests/test_windio_floating.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/test_windio_floating.py) | yes | -| `from_windio_mooring` vs `from_moordyn` (the yaml-only *screening* mooring tier) | IEA-15 UMaine VolturnUS-S ontology + companion MoorDyn | deck-fallback line props; 6×6 `K` | props exact (rel<1e-9); roll/pitch<15 %, heave<15 %, yaw<20 %, surge/sway<40 % | props exact; roll/pitch ~3 %, heave ~9 %, surge/sway ~32 % (WindIO centreline vs MoorDyn surface-attachment radius — the screening path; the coupled industry path below uses the full MoorDyn system) | [`tests/test_windio_floating.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/test_windio_floating.py) | yes | -| **`Tower.from_windio_floating` (companion decks present) vs the BModes-JJ-validated `from_elastodyn_with_mooring`** — the industry-grade tier (full deck model: MoorDyn system + WAMIT A_inf/C_hst + ElastoDyn PtfmMass/RIner+RNA; only the tower is the Phase-1 machine-exact WindIO one) | IEA Wind Task 37 ontology + companion HydroDyn/MoorDyn/ElastoDyn | coupled rigid-body + tower-bending frequencies | every platform RB mode + 1st tower bending < 1 %; 2nd+ tower harmonics < 8 % | **all 6 platform rigid-body modes + 1st tower bending 0.0–0.3 %** (reference grade); 2nd+ tower harmonics ≤ 6 % (the Phase-1 WindIO-vs-ElastoDyn tower-discretisation residual, orthogonal to the platform) | [`tests/test_windio_floating.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/test_windio_floating.py) | yes | -| `Tower.from_windio_floating(..., platform_support=...)` — injected-platform tier (WindIO tower + caller-supplied PlatformSupport; floater designed separately, issue #35) | construction (synthetic FOWT yaml + stable diagonal PlatformSupport) | byte-equivalence to the manual WindIO-tower-BMI + attach-PlatformSupport recipe; no screening warning; decks mutually-exclusive `ValueError`; soft RB ≪ tower | `rtol=1e-9` / exact / structural | (within tol) — adds no new numerics; the free-free PlatformSupport FEM is the OC3-Hywind-certified one (`test_certtest`) | [`tests/test_windio_floating.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/test_windio_floating.py) | no | -| `pybmodes windio` one-click — companion-deck auto-discovery scoping (bare yaml ⇒ no decks/screening; RWT layout ⇒ scoped, config-matched, aux-excluded) + fixed-tower / floating-screening / bad-input | construction | discovery dict + report file + exit code + SCREENING warning | exact / structural | (within tol) | [`tests/test_windio_cli.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/test_windio_cli.py) | no | -| `pybmodes windio ` end-to-end — discovery picks the UMaineSemi *floating* decks ⇒ industry-grade deck-backed coupled model | IEA Wind Task 37 IEA-15 RWT tree (ontology + companion HydroDyn/MoorDyn/ElastoDyn) | discovered config + exit code + report + no screening warning | exact / structural | (within tol) | [`tests/test_windio_cli.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/test_windio_cli.py) | yes | +| `check_model` — non-monotonic span detected | construction | `ModelWarning(severity='WARN')` raised | always | (within tol) | [`tests/test_checks.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/test_checks.py) | no | +| `check_model` — zero / negative mass density detected | construction | `ModelWarning(severity='ERROR')` raised | always | (within tol) | [`tests/test_checks.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/test_checks.py) | no | +| `check_model` — stiffness jump > 5× detected | construction | `ModelWarning(severity='WARN')` raised | always | (within tol) | [`tests/test_checks.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/test_checks.py) | no | +| `check_model` — EI_FA / EI_SS extreme ratio detected | construction | `ModelWarning(severity='INFO')` raised | always | (within tol) | [`tests/test_checks.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/test_checks.py) | no | +| `check_model` — RNA mass > tower mass detected | construction | `ModelWarning(severity='INFO')` raised | always | (within tol) | [`tests/test_checks.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/test_checks.py) | no | +| `check_model` — singular `PlatformSupport` matrix detected | construction | `ModelWarning(severity='ERROR')` raised | always | (within tol) | [`tests/test_checks.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/test_checks.py) | no | +| `check_model` — n_modes > 6 × n_nodes detected | construction | `ModelWarning(severity='ERROR')` raised | always | (within tol) | [`tests/test_checks.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/test_checks.py) | no | +| `check_model` — polynomial-fit cond > 1e4 detected | construction | `ModelWarning(severity='WARN' or 'ERROR')` raised | always | (within tol) | [`tests/test_checks.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/test_checks.py) | no | +| `Tower.run(check_model=True/False)` auto-run integration | construction | `UserWarning` emitted on True / suppressed on False | always | (within tol) | [`tests/test_checks.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/test_checks.py) | no | +| `mac_matrix` — identity test (shapes vs themselves) | construction | diagonal entries equal 1, off-diag < 1 for distinct shapes | exact / `np.allclose` | (within tol) | [`tests/test_mac.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/test_mac.py) | no | +| `mac_matrix` — orthogonal shapes (FA-only vs SS-only) | construction | every entry = 0 | exact / `np.allclose` | (within tol) | [`tests/test_mac.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/test_mac.py) | no | +| `compare_modes` — frequency-shift sign matches input direction | construction | sign of `frequency_shift` matches sign of (f_B − f_A) | exact / `np.allclose` | (within tol) | [`tests/test_mac.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/test_mac.py) | no | +| `compare_modes` — Hungarian-optimal mode pairing | construction | output pair = identity when shapes match | exact | (within tol) | [`tests/test_mac.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/test_mac.py) | no | +| `plot_mac` — matplotlib smoke test | construction | Figure has ≥ 1 Axes; title carries labels | structural | (within tol) | [`tests/test_mac.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/test_mac.py) | no | +| `ModalResult.save / load` — NPZ round-trip | self | per-field `np.allclose` vs original | `rtol = 1e-12` | (within tol) | [`tests/test_serialize.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/test_serialize.py) | no | +| `ModalResult.to_json / from_json` — JSON round-trip | self | per-field equality vs original | exact / `np.allclose` | (within tol) | [`tests/test_serialize.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/test_serialize.py) | no | +| `ModalResult` — metadata capture (version + timestamp + git hash) | self | metadata dict populated with non-empty pybmodes_version | always | (within tol) | [`tests/test_serialize.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/test_serialize.py) | no | +| `CampbellResult.save / load` — NPZ round-trip | self | per-field `np.allclose` vs original | `rtol = 1e-12` | (within tol) | [`tests/test_serialize.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/test_serialize.py) | no | +| `CampbellResult.to_csv` — spec column order | self | header = `[rpm, , _mac]` | exact match | (within tol) | [`tests/test_serialize.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/test_serialize.py) | no | +| NPZ load is pickle-free on modern archives; legacy `dtype=object` `__meta__` is **refused by default** (object-array unpickling can execute code) and loads only via the opt-in `allow_legacy_pickle=True` (F2) | construction (forged legacy archive) | modern load emits no warning; default legacy load raises `ValueError` ("refusing to load a legacy …"); opt-in load raises `UserWarning` + round-trips | exact / structural | (within tol) | [`tests/test_serialize.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/test_serialize.py) | no | +| `_validate_lengths` / `CampbellResult._validate` reject malformed schemas before any export (F5) | construction (bad participation / label shapes) | `ValueError` before save / to_json / to_csv | always raises | (within tol) | [`tests/test_serialize.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/test_serialize.py) | no | +| `check_model` n_modes guard uses exact `n_free_dof`, not `6·n_nodes` — no false ERROR in (6·n_nodes, n_free_dof] (F3) | construction (nselt=10 ⇒ n_free_dof=90) | n_modes=80 clean; n_modes=200 ERROR | always | (within tol) | [`tests/test_checks.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/test_checks.py) | no | +| `pybmodes patch` rejects conflicting `--output` / `--output-dir` (F4); silent-agree preserved for equal / single | construction | exit 2 + clear message only when paths differ | exact match | (within tol) | [`tests/test_cli_patch_args.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/test_cli_patch_args.py) | no | +| `kaimal_spectrum` — IEC 61400-1 longitudinal closed form | textbook: `S(0)=4σ²L/U`, strictly decreasing in `f` | low-freq plateau + monotonicity | exact / `pytest.approx` | (within tol) | [`tests/test_environmental.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/test_environmental.py) | no | +| `jonswap_spectrum` — peak at `1/Tp`; `m0 = Hs²/16` significant-wave-height identity | textbook JONSWAP; the Hs identity is exact by construction | `f_peak`, `∫S df` | peak `abs=2e-3`; `m0` `rel=2 %` | (within tol) | [`tests/test_environmental.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/test_environmental.py) | no | +| `plot_environmental_spectra` — structure (bands + tower lines + spectra + legend) and input guards | construction | axvspans / vlines / legend entries; `ValueError` on bad `freq_max` / `harmonics` | structural / always raises | (within tol) | [`tests/test_environmental.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/test_environmental.py) | no | +| `read_out(strict=True)` — fail-loud on short / non-numeric / non-finite / duplicate-mode / empty `.out`; default stays tolerant | construction (malformed `.out` fixtures) | `BModeOutParseError` with file/line/mode context vs tolerant skip | exact match / always raises | (within tol) | [`tests/test_out_parser.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/test_out_parser.py) | no | +| `generate_report` — markdown contains frequencies | construction | every 4-dp frequency appears in body | exact match | (within tol) | [`tests/test_report.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/test_report.py) | no | +| `generate_report` — HTML is well-formed | construction | DOCTYPE + balanced `` / `` / `
` tags | structural | (within tol) | [`tests/test_report.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/test_report.py) | no | +| `generate_report` — CSV has coefficient columns | construction | second header row has `C2..C6, rms_residual, cond_number` | exact column match | (within tol) | [`tests/test_report.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/test_report.py) | no | +| `pybmodes batch` — discovery filter excludes Tower / Blade / SubDyn `.dat` | self (committed `reference_decks/`) | 6 main decks found; 0 aux files | exact | (within tol) | [`tests/test_batch.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/test_batch.py) | no | +| `pybmodes batch` — summary CSV column set | construction | header = `[filename, overall_verdict, TwFAM2Sh_ratio, TwSSM2Sh_ratio, n_fail, n_warn]` | exact column match | (within tol) | [`tests/test_batch.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/test_batch.py) | no | +| `pybmodes batch --patch` — every block reaches PASS / WARN | OpenFAST r-test 5MW deck | per-deck `overall_verdict` post-patch | no FAIL | (within tol) | [`tests/test_batch.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/test_batch.py) | yes | +| `pybmodes examples --copy --kind samples` — vendors `cases/sample_inputs/` | self (committed bundle) | four analytical-reference subdirs + `reference_turbines/` present at dest | exact match | (within tol) | [`tests/test_examples_cli.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/test_examples_cli.py) | no | +| `pybmodes examples --copy --kind decks` — vendors `reference_decks/` | self (committed bundle) | `nrel5mw_land/` present at dest; `sample_inputs/` not vendored | exact match | (within tol) | [`tests/test_examples_cli.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/test_examples_cli.py) | no | +| `pybmodes examples --copy` (default `--kind all`) — vendors both bundles | self (committed bundle) | both `sample_inputs/` and `reference_decks/` present at dest | exact match | (within tol) | [`tests/test_examples_cli.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/test_examples_cli.py) | no | +| `pybmodes examples` — destination conflict guard | construction (pre-existing target subdir) | exit code 2 + preexisting file preserved | exact match | (within tol) | [`tests/test_examples_cli.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/test_examples_cli.py) | no | +| `pybmodes examples --force` — overwrites stale destination | construction (pre-existing target subdir + stale file) | stale file removed; real bundle present | exact match | (within tol) | [`tests/test_examples_cli.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/test_examples_cli.py) | no | +| `WamitReader.read` — surge A_inf vs manual redimensionalisation | IEA-15-240-RWT-UMaineSemi WAMIT `.1` | `A_inf[0,0]` (dim, kg) | `rel = 1 %` | (within tol) | [`tests/test_wamit_reader.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/test_wamit_reader.py) | yes | +| `WamitReader.read` — heave A_inf vs manual redim | IEA-15-240-RWT-UMaineSemi WAMIT `.1` | `A_inf[2,2]` (dim, kg) | `rel = 1 %` | (within tol) | [`tests/test_wamit_reader.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/test_wamit_reader.py) | yes | +| `WamitReader.read` — pitch A_inf vs manual redim | IEA-15-240-RWT-UMaineSemi WAMIT `.1` | `A_inf[4,4]` (dim, kg·m²) | `rel = 1 %` | (within tol) | [`tests/test_wamit_reader.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/test_wamit_reader.py) | yes | +| `WamitReader.read` — heave C_hst vs manual redim | IEA-15-240-RWT-UMaineSemi WAMIT `.hst` | `C_hst[2,2]` (dim, N/m) | `rel = 1 %` | (within tol) | [`tests/test_wamit_reader.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/test_wamit_reader.py) | yes | +| `WamitReader.read` — pitch C_hst positive (stable semi) | IEA-15-240-RWT-UMaineSemi WAMIT `.hst` | `C_hst[3,3] > 0` and ≈ 2.193e9 N·m/rad | `rel = 1 %` | (within tol) | [`tests/test_wamit_reader.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/test_wamit_reader.py) | yes | +| `WamitReader.read` — A_inf and C_hst symmetric (no transposed-index bug) | IEA-15-240-RWT-UMaineSemi WAMIT pair | `max(\|M − M.T\|) < 1e-3 · max(\|M\|)` | always | (within tol) | [`tests/test_wamit_reader.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/test_wamit_reader.py) | yes | +| `WamitReader._resolve_pot_path` — quoting / separator robustness | construction (4 spellings of one path) | all forms resolve to the same Path | exact match | (within tol) | [`tests/test_wamit_reader.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/test_wamit_reader.py) | yes | +| `WamitReader.read` — clear error on missing PotFile | construction (non-existent root) | `FileNotFoundError` naming `.1` | exact match | (within tol) | [`tests/test_wamit_reader.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/test_wamit_reader.py) | yes | +| `HydroDynReader` — ULEN / PotMod / PotFile / PtfmRefzt / ρ / g | IEA-15-240-RWT-UMaineSemi `_HydroDyn.dat` | 6 scalar fields | exact / default | (within tol) | [`tests/test_wamit_reader.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/test_wamit_reader.py) | yes | +| `WamitReader` — upper-triangle-only `.hst` / `.1` mirrored | synthetic 2-entry input | `C[2,4] == C[4,2]` and `A_inf[0,2] == A_inf[2,0]` after parse | exact | (within tol) | [`tests/test_wamit_reader_synth.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/test_wamit_reader_synth.py) | no | +| `WamitReader` — Fortran `D` / `d` exponents accepted | synthetic `4.430486D+02` / `1.233416d+04` | dimensional values match the C-form equivalents | `rel = 1e-12` | (within tol) | [`tests/test_wamit_reader_synth.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/test_wamit_reader_synth.py) | no | +| `_scan_platform_fields` — raises on malformed critical scalars | synthetic deck with `not_a_number PtfmMass` | `ValueError` naming the offending field | exact match | (within tol) | [`tests/test_mooring.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/test_mooring.py) | no | +| `Line.solve_static` — inextensible limit matches Irvine 1981 §2.3 | construction (EA → ∞) | residuals on Irvine eq. (2.27)–(2.29) | `rel = 1e-4` | (within tol) | [`tests/test_mooring.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/test_mooring.py) | no | +| `Line.solve_static` — horizontal-line symmetry | construction (ΔZ = 0) | `V_F = W·L/2` at fairlead | `rel = 1e-6` | (within tol) | [`tests/test_mooring.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/test_mooring.py) | no | +| `Line.solve_static` — Newton residuals satisfied to ≤ solver tol | construction (typical slack-line geometry) | both catenary residuals after solve | `< 1e-6` m | (within tol) | [`tests/test_mooring.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/test_mooring.py) | no | +| `MooringSystem.restoring_force` — 3-fold-symmetric zero offset | construction (synthetic 3-line layout) | F\_x, F\_y, M\_x, M\_y, M\_z all `< 1e-3·\|F\_z\|` | `rel = 1e-3` | (within tol) | [`tests/test_mooring.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/test_mooring.py) | no | +| `MooringSystem.stiffness_matrix` — diagonal positive | construction (synthetic 3-line) | every diag entry > 0 | always | (within tol) | [`tests/test_mooring.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/test_mooring.py) | no | +| `MooringSystem.stiffness_matrix` — symmetric after symmetrisation | construction (synthetic 3-line) | `max(\|K − K.T\|) < 1e-10 · max(\|K\|)` | always | (within tol) | [`tests/test_mooring.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/test_mooring.py) | no | +| `MooringSystem.from_moordyn` — OC3 Hywind layout round-trip | OpenFAST r-test 5MW_OC3Spar MoorDyn `.dat` | 3 lines / 6 points / fairlead radius 5.2 / anchor radius 853.87 / L 902.2 | `rel = 1e-3` | (within tol) | [`tests/test_mooring.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/test_mooring.py) | yes | +| `MooringSystem.stiffness_matrix` — OC3 Hywind surge stiffness | OC3 r-test MoorDyn `.dat` | `K[0,0] ≈ 41,180 N/m` (Jonkman 2010 Table 5-1) | `rel = 5%` | within 0.01 % | [`tests/test_mooring.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/test_mooring.py) | yes | +| `MooringSystem.stiffness_matrix` — OC3 yaw stiffness (catenary-only) | OC3 r-test MoorDyn `.dat` | `K[5,5] ≈ 1.156e7 N·m/rad` (catenary lines only; OC3's delta-line crowfoot adds the bulk of the published 9.83e7) | `rel = 5%` | (within tol) | [`tests/test_mooring.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/test_mooring.py) | yes | +| `MooringSystem.stiffness_matrix` — 3-fold-symmetry signatures | OC3 r-test MoorDyn `.dat` | K[0,0] = K[1,1], K[3,3] = K[4,4], K[0,4]·K[1,3] < 0 | `rel = 1e-4` | (within tol) | [`tests/test_mooring.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/test_mooring.py) | yes | +| `Tower.from_elastodyn_with_mooring` — end-to-end OC3 coupled solve | OC3 ElastoDyn + r-test MoorDyn `.dat` | hub\_conn=2, PlatformSupport populated, 1st tower-bending in 0.4–0.6 Hz | `rel = 5%` | within 1.2 % of Jonkman 2010's 0.482 Hz | [`tests/test_mooring.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/test_mooring.py) | yes | +| Floating weight restoring $-g \sum m_i (z_i - z_{ref})$ in roll / pitch (platform + tower + RNA) | closed form; classical $C_{44} = \rho g V (z_B - z_G) + \rho g I_{wp}$ (Faltinsen 1990, ch. 2) | 6×6 weight matrix, reference-point shift, mass scaling, point masses | `rel = 1e-9` | exact | [`tests/test_floating_weight_restoring.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/test_floating_weight_restoring.py) | no | +| Negative platform restoring is reported, not silently dropped | construction (OC3 Hywind sample with the weight term removed) | `UserWarning` naming roll / pitch | always | (within tol) | [`tests/test_floating_weight_restoring.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/test_floating_weight_restoring.py) | no | +| **`Tower.from_elastodyn_with_mooring` — OC3 Hywind rigid-body frequencies** (WAMIT `.hst` + weight of platform, tower and RNA) | Jonkman (2010) NREL/TP-500-47535, FAST full-system natural frequencies: surge/sway 0.0080, heave 0.0324, roll 0.0342, pitch 0.0343 Hz | six labelled rigid-body modes | surge/sway/roll/pitch 3 %, heave 2 % | surge/sway 0.8 %, heave 0.0 %, roll 1.1 %, pitch 1.4 % (roll/pitch were missing before the weight term) | [`tests/test_floating_weight_restoring.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/test_floating_weight_restoring.py) | yes | +| HydroDyn filled-member ballast mass properties (body-fixed, as HydroDyn precomputes them) | closed form: solid cylinder, frustum volume / centroid / axial moment, parallel axis | mass, centre of mass, 3×3 inertia; partial / full / empty, horizontal, inclined, tapered members; both member-table layouts | `rel = 1e-12` | exact | [`tests/test_hydrodyn_ballast.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/test_hydrodyn_ballast.py) | no | +| **`Tower.from_elastodyn_with_mooring` — OC4 DeepCwind ballasted platform** (steel `PtfmMass` + two HydroDyn fill groups) | Robertson et al. (2014) NREL/TP-5000-60601: platform incl. ballast 1.3473e7 kg, CM 13.46 m below SWL | platform mass, CM depth; positive roll / pitch restoring; six labelled rigid-body modes; heave vs single-DOF estimate | mass `rel = 1e-3`, CM `abs = 0.01 m`, heave `rel = 2%` | mass +0.02 %, CM 0.000 m | [`tests/test_hydrodyn_ballast.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/test_hydrodyn_ballast.py) | yes | +| Bundled floating samples are statically stable | construction (samples 07-11) | six labelled rigid-body modes, no negative-restoring warning | always | (within tol) | [`tests/test_floating_samples_spectra.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/test_floating_samples_spectra.py) | no | +| `MudlineFoundation.from_soil_properties` — Yu & Amdahl (2023) Table 9 DTU 10 MW flexible pile, E\_SO = 30 MPa | Yu and Amdahl (2023) Marine Structures 92, 103482, Table 9 row 2 | K\_hh, K\_hr, K\_rr via Shadlou and Bhattacharya (2016) formulas | `rel = 10%` | within 3.0 % on all three springs | [`tests/test_foundation.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/test_foundation.py) | no | +| `MudlineFoundation.from_soil_properties` — Yu & Amdahl (2023) Table 9 DTU 10 MW rigid pile, E\_SO = 30 MPa | Yu and Amdahl (2023) Table 9 row 1 | K\_hh, K\_hr, K\_rr via Shadlou and Bhattacharya (2016) rigid-pile formulas | `rel = 5%` | within 2.9 % on all three springs | [`tests/test_foundation.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/test_foundation.py) | no | +| `MudlineFoundation.as_mooring_K` — cross-coupling sign matches Jonkman 2010 OC3 convention | construction | K\[0,4\] < 0, K\[1,3\] > 0, K\[0,4\]·K\[1,3\] < 0 | always | (within tol) | [`tests/test_foundation.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/test_foundation.py) | no | +| Randolph (1981) pile classifier — auto dispatch to flexible / rigid / intermediate | construction (synthetic geometries) | high L/D → flexible; low L/D → rigid; intermediate emits `UserWarning` and falls back to flexible | always | (within tol) | [`tests/test_foundation.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/test_foundation.py) | no | +| Sparse `eigsh` shift-invert — matches dense `eigh` | construction (SPD problem at threshold + 100 DOFs) | lowest-k eigenvalues | `rtol = 1e-8` | (within tol) | [`tests/fem/test_sparse_solver.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/fem/test_sparse_solver.py) | no | +| Sparse path triggered above threshold | construction | log message announces "sparse shift-invert" | always | (within tol) | [`tests/fem/test_sparse_solver.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/fem/test_sparse_solver.py) | no | +| Sparse path skipped below threshold | construction | log message does not mention "sparse" | always | (within tol) | [`tests/fem/test_sparse_solver.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/fem/test_sparse_solver.py) | no | +| Asymmetric problems fall back to dense general `eig` | construction (asymmetric K) | log message announces "dense general"; sparse not invoked | always | (within tol) | [`tests/fem/test_sparse_solver.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/fem/test_sparse_solver.py) | no | +| Indefinite `K` keeps the algebraically lowest window on every path | construction (diagonal, coupled with scaled mass, zero modes, SPD control) + NREL 5MW land tower under gravity at 1000× RNA mass, default vs 120-node mesh | lowest-k eigenvalues; negative and zero modes kept; SPD still sparse; buckled tower identical on both meshes | `rtol = 1e-9` | exact | [`tests/fem/test_sparse_solver.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/fem/test_sparse_solver.py) | no | +| Positive-definiteness guard (banded Cholesky) agrees with a dense Cholesky | construction (random symmetric, tridiagonal, singular, full-width coupling, tiny asymmetry) | definite / not definite | always | (within tol) | [`tests/fem/test_sparse_solver.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/fem/test_sparse_solver.py) | no | +| `pybmodes patch --dry-run` writes nothing (mtime check) | construction | mtime of every staged file unchanged | exact match | (within tol) | [`tests/test_validate.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/test_validate.py) | yes | +| `pybmodes patch --diff` PR format contains `×` improvement ratio | construction | stdout contains `×`, `RMS improvement:`, `better` literals | exact match | (within tol) | [`tests/test_validate.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/test_validate.py) | yes | +| `pybmodes patch --output` leaves source byte-identical | construction | source-file sha256 unchanged | exact match | (within tol) | [`tests/test_validate.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/test_validate.py) | yes | +| Default in-place `pybmodes patch` emits first-time-run hint | construction | stdout contains `--dry-run --diff` | exact match | (within tol) | [`tests/test_validate.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/test_validate.py) | yes | +| Tower torsion-contamination filter — rejects T_tor ≥ 10 % | construction (synthetic torsion-contaminated mode) | `rejected_modes` carries the contaminated mode | always | (within tol) | [`tests/test_classifier.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/test_classifier.py) | no | +| Tower torsion-contamination filter — accepts pure bending | construction (synthetic clean modes) | `rejected_modes` is empty | always | (within tol) | [`tests/test_classifier.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/test_classifier.py) | no | +| IEA-3.4 deck — torsion participations populated, summing to 1 | OpenFAST IEA-3.4-130-RWT deck | per-mode `(T_FA, T_SS, T_tor)` triple | `Σ = 1` to `abs_tol = 1e-9` | (within tol) | [`tests/test_classifier.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/test_classifier.py) | yes | +| Campbell sweep — Hungarian MAC tracking on bundled NREL 5MW reference deck | committed `reference_decks/nrel5mw_land/` | `mac_to_previous` ≥ 0.90 between consecutive steps on a smooth sweep | always | (within tol) | [`tests/test_campbell.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/test_campbell.py) | no | +| Campbell sweep — input validation (NaN / inf / negative / unsorted RPM) | construction | `ValueError` raised | always raises | (within tol) | [`tests/test_campbell.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/test_campbell.py) | no | +| Campbell sweep — restores `bbmi.rot_rpm` after sweep (clean + on-exception) | construction | post-sweep / post-exception BMI state | unchanged | (within tol) | [`tests/test_campbell.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/test_campbell.py) | no | +| Campbell sweep — tower modes constant across all rotor speeds | construction | tower frequency vs rotor speed | exactly constant (no `rpm` dependence in tower modal eigenproblem) | (within tol) | [`tests/test_campbell.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/test_campbell.py) | no | +| `plot_campbell` — 6-DOF platform overlay: degenerate-pair merge + Hz/period labels + log axis (issue #39); default-off byte-identical; non-finite/≤0 skipped | construction (synthetic CampbellResult + platform_modes) | horizontal refs + right-margin labels + `yscale`; regression invariant w/o platform_modes | structural / exact | (within tol) | [`tests/test_campbell.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/test_campbell.py) | no | +| ElastoDyn-compat blade adapter — strips `str_tw`, `tw_iner`, offsets | Jonkman 2015 NREL forum guidance | resulting BMI fields | zeroed for compat-on, preserved for compat-off | (within tol) | [`tests/test_elastodyn_compatible.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/test_elastodyn_compatible.py) | no | +| ElastoDyn-compat — frequency drift on flap modes is small | construction | rel freq diff vs compat-off | ≤ ~ few % on flap modes | (within tol) | [`tests/test_elastodyn_compatible.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/test_elastodyn_compatible.py) | no | +| ElastoDyn `.dat` parse → write → parse round-trip | self | per-field `np.allclose` vs original | `rtol = 1e-12` | (within tol) | [`tests/test_elastodyn_reader.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/test_elastodyn_reader.py) | yes | +| Polynomial-fit design-matrix cond-number reporting | construction | `RuntimeWarning` above thresholds | WARN > 1e4, FAIL > 1e6 | (within tol) | [`tests/test_fitting.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/test_fitting.py) | no | +| BMI / section-properties parser primitives | construction (synthetic fixtures) | round-trip equality | exact / `np.allclose` | (within tol) | [`tests/test_io.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/test_io.py) | no | +| FEM building blocks — boundary conditions, eigensolver, normalisation | construction | per-DOF / per-mode invariants | exact / `np.allclose` | (within tol) | [`tests/fem/test_*.py`](https://github.com/SMI-Lab-Inha/pyBModes/tree/main/tests/fem/) | no | +| `tubular_section_props` — closed-form tube identities | $A=\pi(R_o^2-R_i^2)$, $I=\tfrac{\pi}{4}(R_o^4-R_i^4)$, $J=2I$, $G=E/2(1+\nu)$ (textbook) | mass\_den / flp\_stff / tor\_stff / axial\_stff / flp\_iner | exact | `pytest.approx` | [`tests/test_geometry_windio.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/test_geometry_windio.py) | no | +| `outfitting_factor` — scales mass + rotary inertia only, never stiffness | construction (factor 1.07) | mass × 1.07; EI / GJ / EA / structural ρI unchanged | exact | `pytest.approx` | [`tests/test_geometry_windio.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/test_geometry_windio.py) | no | +| Geometry guard — rejects D ≤ 0 / t ≤ 0 / 2t ≥ D | construction (degenerate sections) | `ValueError` raised | always raises | (within tol) | [`tests/test_geometry_windio.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/test_geometry_windio.py) | no | +| WindIO dialect equivalence — modern `outer_shape`/`structure` vs older `outer_shape_bem`/`internal_structure_2d_fem` | construction (numerically identical fixtures) | same `WindIOTubular` from both key dialects | exact / `np.allclose` | (within tol) | [`tests/test_geometry_windio.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/test_geometry_windio.py) | no | +| WindIO duplicate-anchor tolerance — IEA-10's redefined-anchor habit | construction (`&g` redefined; stock `SafeLoader` raises `ComposerError`) | parses (last-wins) where strict PyYAML fails | exact | (within tol) | [`tests/test_geometry_windio.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/test_geometry_windio.py) | no | +| WindIO orthotropic wall material — clear out-of-scope error | construction (list-valued composite E) | `ValueError` naming "orthotropic", not bare `float(list)` `TypeError` | exact match | (within tol) | [`tests/test_geometry_windio.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/test_geometry_windio.py) | no | +| WindIO thickness interp — linear vs piecewise-constant differs on a taper | construction (smoothly tapered wall) | mid-station wall thickness diverges by > 0.01 m | exact | 0.035 vs 0.05 m | [`tests/test_geometry_windio.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/test_geometry_windio.py) | no | +| WindIO PyYAML-absent guard — names the `[windio]` extra | construction (monkeypatched import) | `ModuleNotFoundError` matching `pybmodes\[windio\]` | exact match | (within tol) | [`tests/test_geometry_windio.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/test_geometry_windio.py) | no | +| **IEA-15 base WindIO yaml → derived mass / EI vs IEA-15 *Monopile* ElastoDyn tower table** (RNA-independent like-for-like; that deck was generated from this geometry) | IEA Wind Task 37 IEA-15-240-RWT ontology + OpenFAST Monopile deck | distributed mass\_den & FA EI, station-by-station | < 0.5 % | **7.5 × 10⁻¹² (machine-exact)** | [`tests/test_geometry_windio.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/test_geometry_windio.py) | yes | +| Upstream WindIO corpus — IEA-3.4 / 10 / 15 / 22 + WISDEM examples (both dialects + IEA-10 dup-anchor) parse to a physically sane tube | IEA Wind Task 37 RWT ontologies + WISDEM example yamls | grid ∈ [0,1] monotone, D / t > 0, 2t < D, steel E / ρ / ν range, span > 0 | structural | 12 yamls clean | [`tests/test_geometry_windio.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/test_geometry_windio.py) | yes | +| `Tower.from_windio` — older-dialect modal smoke (IEA-3.4 / 10 / 22) | IEA Wind Task 37 RWT ontologies | full FEM solve → bare-member spectrum | finite, positive, ascending | (within tol) | [`tests/test_geometry_windio.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/test_geometry_windio.py) | yes | +| Older-dialect yaml-derived mass / EI vs same-turbine ElastoDyn tower table (ballpark — those decks were not 1:1 geometry round-trips, unlike IEA-15) | IEA-3.4 / 10 / 22 ontology + own OpenFAST tower deck | distributed mass\_den & FA EI envelope | mass < 25 %, EI < 30 % | (within tol) | [`tests/test_geometry_windio.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/test_geometry_windio.py) | yes | +| **WindIO auto-RNA (`lumped_rna_cal`) → tower-top lumped mass vs the IEA-22 ElastoDyn deck** (issue #82; hub + nacelle `elastic_properties_mb` + integrated blade span mass assembled to the tower top). Rotary inertia is WindIO-native and not deck-matched (`NacYIner` ecosystem drift — same pattern as `cases/ECOSYSTEM_FINDING.md`) | IEA Wind Task 37 IEA-22-280-RWT ontology + OpenFAST Monopile deck | RNA total mass & vertical CM lever | < 0.5 % | mass ≈ 0.05 %, CM ≈ 0.005 % | [`tests/test_windio_rna.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/test_windio_rna.py) | yes | +| CLT laminate primitives — reduced stiffness / ply transform / ABD / membrane condensation | Jones, *Mechanics of Composite Materials* (2nd ed.) §2.5–4.3 closed forms | $Q$, $\bar Q(\theta)$, $A/B/D$, $\tilde A$ | exact | `pytest.approx` (12 cases) | [`tests/test_windio_blade.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/test_windio_blade.py) | no | +| Airfoil `nd_arc` profile — arc spine / LE-TE landmarks / chord↔arc / blend | construction (analytic circle & ellipse) | `s_le`, `tc`, landmark `(x,y)`, blend weights | exact (≤ 2e-3) | (within tol) | [`tests/test_windio_blade.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/test_windio_blade.py) | no | +| WindIO web/layer `nd_arc` resolver — older-explicit ≡ modern-anchor-registry dialect equivalence | construction (numerically identical fixtures) | identical resolved bands from both dialects | exact / `np.allclose` | (within tol) | [`tests/test_windio_blade.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/test_windio_blade.py) | no | +| Thin-wall reduction — isotropic tube & box vs exact closed form | textbook thin-ring / thin-wall-box EA / EI / GJ / mass | section properties | exact | < 2 % (discretisation) | [`tests/test_windio_blade.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/test_windio_blade.py) | no | +| Multi-cell Bredt–Batho — symmetric interior web carries zero shear flow | analytic (a symmetric diametral web ⇒ GJ unchanged vs webless) | `GJ`, `n_cells` | exact | < 2 % | [`tests/test_windio_blade.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/test_windio_blade.py) | no | +| `windio_blade` dual-dialect end-to-end | construction (tube blade, both WindIO dialects) | identical `SectionProperties`; physically sane | exact / `np.allclose` | (within tol) | [`tests/test_windio_blade.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/test_windio_blade.py) | no | +| **6×6 cross-sectional decoupling** (issue #50) — rigid-offset congruence → tension centre → principal-axis eigen-decomposition → shear centre / `GJ`; analytic round-trips | construction (build a 6×6 *from* known decoupled props at a known offset / rotation) | recovered `EA`/`EI_flap`/`EI_edge`/`GJ`/`mass`/centres + defining decoupling property | exact (`rtol≤1e-6`) round-trip | (within tol) | [`tests/test_decouple.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/test_decouple.py) | no | +| **WindIO published distributed elastic properties → decoupled beam** (issue #48/#50) — `elastic="auto"` prefers them over PreComp; both dialects parsed onto the span grid as the **full** 6×6 then decoupled (elastic/shear centre + principal elastic axes — *not* raw diagonal); `"file"`/`"precomp"`/bad-value guards | construction (tube blade with embedded published properties, both dialects) | `SectionProperties` from the decoupled 6×6; mode-string dispatch | exact / `np.allclose`; `ValueError` on bad/missing | (within tol) | [`tests/test_windio_blade.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/test_windio_blade.py) | no | +| **WindIO published elastic properties vs the turbine's own BeamDyn 6×6**, IEA-15, span 0.15–0.90 (issue #48/#50) — *decoupled-vs-decoupled* (the BeamDyn 6×6 is also referenced at the blade axis, so the apples-to-apples oracle is the decoupled BeamDyn, not its raw diagonal); also pins that raw≠decoupled so the case genuinely exercises #50, and that the published path stays far tighter than PreComp | IEA Wind Task 37 IEA-15 ontology + companion `*_BeamDyn_blade.dat` | decoupled `mass` / `EA` / `EI_flap` / `EI_edge` / `GJ` | mass & EA med < 3 %; EI med < 5 %; GJ med < 8 %; raw-vs-decoupled EI > 5 %; strictly tighter than `elastic="precomp"` | (within tol) | [`tests/test_windio_blade.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/test_windio_blade.py) | yes | +| **WindIO composite blade → distributed beam props vs the turbine's own BeamDyn 6×6** (WISDEM-PreComp-generated; `elastic="precomp"` — the PreComp-fallback path's true scope), IEA-3.4 / 10 / 15 / 22, span 0.15–0.90 | IEA Wind Task 37 RWT ontology + companion `*_BeamDyn_blade.dat` | `mass` / `EA` (PreComp-class) ; `GJ` & `EI` (diagonal-reduction approximate — documented limitation) | mass med < 6 % / max < 15 %; EA med < 10 % / max < 18 %; GJ med < 22 % / max < 47 %; EI med < 35 % / max < 55 % | mass ≈ 1.5–4 % med ; EA ≈ 1–8 % med ; GJ ≈ 3–18 % med (IEA-10 worst, composite multi-cell torsion) ; EI ≈ 2–27 % med (weak axis worst — omits spar-cap-offset / bend-twist coupling) | [`tests/test_windio_blade.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/test_windio_blade.py) | yes | +| WindIO `elastic_properties_mb` ecosystem-drift caveat — a turbine's published `_mb` 6×6 is not always mutually consistent with its own companion BeamDyn deck (IEA-10 `_mb` ≈ 50 % from its BeamDyn 6×6); `auto` treats the WindIO ontology as canonical per #48 intent | IEA-10 ontology `_mb` vs `*_BeamDyn_blade.dat` | documented divergence (not a parser bug — same pattern as `cases/ECOSYSTEM_FINDING.md`) | n/a (documented, not gated) | ≈ 50 % (intrinsic to upstream data) | [`cases/ECOSYSTEM_FINDING.md`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/cases/ECOSYSTEM_FINDING.md) | yes | +| `RotatingBlade.from_windio` — modal smoke, all 4 RWTs (both dialects + parametric-layer blades) | IEA Wind Task 37 RWT ontologies | full FEM → parked-blade spectrum | finite, positive, ascending | (within tol) | [`tests/test_windio_blade.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/test_windio_blade.py) | yes | +| WindIO floating reader — joints (cartesian + cylindrical `[r,θ°,z]`), member geometry, axial-joint resolution, transition | construction | parsed `WindIOFloating` | exact / `np.allclose` | (within tol) | [`tests/test_windio_floating.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/test_windio_floating.py) | no | +| Hydrostatic restoring — single surface-piercing cylinder & off-axis column vs closed form | textbook waterplane + buoyancy (WAMIT/`.hst` convention) | `C_hst` 6×6 | exact | < 1 % (discretisation) | [`tests/test_windio_floating.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/test_windio_floating.py) | no | +| Morison added mass + RAFT end-cap & rigid-body inertia vs closed form | thin-ring transverse + RAFT `Ca_End` end disc; thin-wall steel | `A_inf` / mass 6×6 | exact | `pytest.approx` | [`tests/test_windio_floating.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/test_windio_floating.py) | no | +| `from_windio_mooring` — topology / props / 120°-symmetric stiffness / regression-warn / bad-ref | construction | `MooringSystem` + 6×6 `K` | exact / symmetry | (within tol) | [`tests/test_windio_floating.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/test_windio_floating.py) | no | +| `from_windio_floating` — yaml-only coupled modal smoke (stable single-column FOWT) | construction | full coupled FEM spectrum | finite, ascending, soft RB ≪ tower | (within tol) | [`tests/test_windio_floating.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/test_windio_floating.py) | no | +| **WindIO floating C_hst vs the turbine's own potential-flow WAMIT `.hst`** (geometry-exact anchor) | IEA-15 UMaine VolturnUS-S ontology + companion HydroDyn/WAMIT | heave / roll / pitch restoring | < 3 % / < 4 % | **heave 0.8 %, roll/pitch 1.6 %** | [`tests/test_windio_floating.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/test_windio_floating.py) | yes | +| WindIO *screening-preview* Morison `A_inf` & structural mass vs WAMIT / ElastoDyn (the yaml-only tier — NOT industry-grade, emits a `UserWarning`) | IEA-15 UMaine VolturnUS-S + companion HydroDyn / ElastoDyn | added-mass diagonal / `PtfmMass` | surge/sway/yaw < 45 %; heave/roll/pitch factor ~2; mass a lower bound | surge 22 %, heave 53 % (Morison ≠ BEM, as RAFT/WISDEM also find — supply a HydroDyn deck for industry grade); struct+fixed mass ≈ 0.36·PtfmMass (trim ballast excluded by design) | [`tests/test_windio_floating.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/test_windio_floating.py) | yes | +| `from_windio_mooring` vs `from_moordyn` (the yaml-only *screening* mooring tier) | IEA-15 UMaine VolturnUS-S ontology + companion MoorDyn | deck-fallback line props; 6×6 `K` | props exact (rel<1e-9); roll/pitch<15 %, heave<15 %, yaw<20 %, surge/sway<40 % | props exact; roll/pitch ~3 %, heave ~9 %, surge/sway ~32 % (WindIO centreline vs MoorDyn surface-attachment radius — the screening path; the coupled industry path below uses the full MoorDyn system) | [`tests/test_windio_floating.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/test_windio_floating.py) | yes | +| **`Tower.from_windio_floating` (companion decks present) vs the BModes-JJ-validated `from_elastodyn_with_mooring`** — the industry-grade tier (full deck model: MoorDyn system + WAMIT A_inf/C_hst + ElastoDyn PtfmMass/RIner+RNA; only the tower is the Phase-1 machine-exact WindIO one) | IEA Wind Task 37 ontology + companion HydroDyn/MoorDyn/ElastoDyn | coupled rigid-body + tower-bending frequencies | every platform RB mode + 1st tower bending < 1 %; 2nd+ tower harmonics < 8 % | **all 6 platform rigid-body modes + 1st tower bending 0.0–0.3 %** (reference grade); 2nd+ tower harmonics ≤ 6 % (the Phase-1 WindIO-vs-ElastoDyn tower-discretisation residual, orthogonal to the platform) | [`tests/test_windio_floating.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/test_windio_floating.py) | yes | +| `Tower.from_windio_floating(..., platform_support=...)` — injected-platform tier (WindIO tower + caller-supplied PlatformSupport; floater designed separately, issue #35) | construction (synthetic FOWT yaml + stable diagonal PlatformSupport) | byte-equivalence to the manual WindIO-tower-BMI + attach-PlatformSupport recipe; no screening warning; decks mutually-exclusive `ValueError`; soft RB ≪ tower | `rtol=1e-9` / exact / structural | (within tol) — adds no new numerics; the free-free PlatformSupport FEM is the OC3-Hywind-certified one (`test_certtest`) | [`tests/test_windio_floating.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/test_windio_floating.py) | no | +| `pybmodes windio` one-click — companion-deck auto-discovery scoping (bare yaml ⇒ no decks/screening; RWT layout ⇒ scoped, config-matched, aux-excluded) + fixed-tower / floating-screening / bad-input | construction | discovery dict + report file + exit code + SCREENING warning | exact / structural | (within tol) | [`tests/test_windio_cli.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/test_windio_cli.py) | no | +| `pybmodes windio ` end-to-end — discovery picks the UMaineSemi *floating* decks ⇒ industry-grade deck-backed coupled model | IEA Wind Task 37 IEA-15 RWT tree (ontology + companion HydroDyn/MoorDyn/ElastoDyn) | discovered config + exit code + report + no screening warning | exact / structural | (within tol) | [`tests/test_windio_cli.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/test_windio_cli.py) | yes | ## What "needs external data" means — and how integration coverage is gated diff --git a/cases/ECOSYSTEM_FINDING.md b/cases/ECOSYSTEM_FINDING.md index 9938c70..acac392 100644 --- a/cases/ECOSYSTEM_FINDING.md +++ b/cases/ECOSYSTEM_FINDING.md @@ -206,7 +206,7 @@ metric is what surfaces it. ### Independent corroboration: IEA-15 v1.1 release notes The IEA-15-240-RWT v1.1 release notes -([IEAWindTask37/IEA-15-240-RWT/ReleaseNotes.md](https://github.com/IEAWindTask37/IEA-15-240-RWT/blob/master/ReleaseNotes.md)) +([IEAWindTask37/IEA-15-240-RWT/ReleaseNotes.md](https://github.com/IEAWindTask37/IEA-15-240-RWT/blob/main/ReleaseNotes.md)) independently confirm the polynomial-fit error pyBmodes flags above: diff --git a/docs/changelog.rst b/docs/changelog.rst index b313aab..76477fc 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -7,7 +7,7 @@ each release block lists changes under *Added*, *Changed*, *Fixed*, *Deprecated*, *Removed*, and *Security* as appropriate. -The version on ``master`` between tagged releases is the +The version on ``main`` between tagged releases is the ``[Unreleased]`` block — work-in-progress that will land in the next minor or patch release. diff --git a/docs/deployment.rst b/docs/deployment.rst index d2ddbda..ddc2b6d 100644 --- a/docs/deployment.rst +++ b/docs/deployment.rst @@ -23,7 +23,7 @@ Two consequences worth remembering: - **The strict build is the gate.** A docs PR that introduces any Sphinx warning fails RTD's build (matching the CI ``docs`` job's ``-W --keep-going`` flag). The deployed site only updates after a - green build on master. + green build on main. One-time setup (maintainer) --------------------------- @@ -59,13 +59,13 @@ CHANGELOG / VALIDATION / CONTRIBUTING will 404. https://pybmodes.readthedocs.io/en/latest/ - The ``latest`` URL tracks ``master``; tagged releases become + The ``latest`` URL tracks ``main``; tagged releases become versioned URLs like ``/en/v1.7.0/``. 5. **Webhook is auto-installed (usually)** RTD installs a GitHub webhook on the repo as part of import, - so every push to ``master`` (and every tag matching the + so every push to ``main`` (and every tag matching the versioning policy) auto-triggers a fresh build. .. note:: @@ -93,7 +93,7 @@ CHANGELOG / VALIDATION / CONTRIBUTING will 404. and add the ``GitHub incoming webhook`` if it didn't auto-install on the return. - After that, every push to master fires a build automatically. + After that, every push to main fires a build automatically. What to do when a build fails ----------------------------- @@ -122,7 +122,7 @@ RTD supports per-tag versioning, so every release tag adds a new URL prefix that pins the docs at that point in time. Recommended policy: -- ``latest`` — tracks ``master``; what users land on by default. +- ``latest`` — tracks ``main``; what users land on by default. - ``stable`` — tracks the most recent tag matching ``v\d+\.\d+\.\d+`` (no pre-release suffix); what most external links should point at. - ``v1.7.0`` (etc.) — per-tag versioned URL for citation and diff --git a/docs/index.rst b/docs/index.rst index dc9d20a..c4d76b0 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -130,7 +130,7 @@ License ------- Apache 2.0 — see `LICENSE -`_. +`_. Copyright 2024-2026 Jae Hoon Seo, Marine Structural Mechanics and Integrity Lab (SMI Lab), Inha University. diff --git a/docs/installation.rst b/docs/installation.rst index b59ccb1..8defe31 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -56,7 +56,7 @@ conda-forge packages alongside it, ``matplotlib`` for From source (editable) ---------------------- -For contributors and anyone tracking ``master``: +For contributors and anyone tracking ``main``: .. code-block:: bash diff --git a/docs/release_checklist.rst b/docs/release_checklist.rst index 78245f4..5dd1d3d 100644 --- a/docs/release_checklist.rst +++ b/docs/release_checklist.rst @@ -208,12 +208,12 @@ commit's stat shows only those four files changed. .. code-block:: bash - git push origin master + git push origin main git tag -a vX.Y.Z -m "pyBmodes X.Y.Z — " git push origin vX.Y.Z The ``v`` prefix is the standard convention PyPI, GitHub Releases, -and conda-forge all expect. Push the master branch *before* the +and conda-forge all expect. Push the main branch *before* the tag so the tag refers to a commit that's on the remote. .. important:: @@ -222,9 +222,9 @@ tag so the tag refers to a commit that's on the remote. commit and confirm it is green before pushing the tag.** The publish workflow's ``validation-gate`` job refuses to publish unless a successful ``validation.yml`` run exists for the exact - commit the tag points at. After ``git push origin master``, + commit the tag points at. After ``git push origin main``, dispatch it from the Actions tab (Validation (external data) → - Run workflow → ``master``), wait for green, *then* push the tag. + Run workflow → ``main``), wait for green, *then* push the tag. The tag push fires the **PyPI publish workflow** (``.github/workflows/publish.yml``) — see step 10 below. Don't diff --git a/pyproject.toml b/pyproject.toml index 841f5cd..7c73798 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,7 +32,7 @@ classifiers = [ Homepage = "https://github.com/SMI-Lab-Inha/pybmodes" Repository = "https://github.com/SMI-Lab-Inha/pybmodes" Issues = "https://github.com/SMI-Lab-Inha/pybmodes/issues" -Changelog = "https://github.com/SMI-Lab-Inha/pybmodes/blob/master/CHANGELOG.md" +Changelog = "https://github.com/SMI-Lab-Inha/pybmodes/blob/main/CHANGELOG.md" [project.scripts] pybmodes = "pybmodes.cli:main" diff --git a/src/pybmodes/_examples/reference_decks/README.md b/src/pybmodes/_examples/reference_decks/README.md index a8e8923..d4bbb5d 100644 --- a/src/pybmodes/_examples/reference_decks/README.md +++ b/src/pybmodes/_examples/reference_decks/README.md @@ -41,7 +41,7 @@ polynomial coefficients that **do not represent the mode shapes produced by the same files' structural inputs**. The 2nd-mode tower coefficients (`TwFAM2Sh`, `TwSSM2Sh`) are the worst offenders, with RMS residuals 170×–2,500× higher than pyBmodes' own fits. See -[`cases/ECOSYSTEM_FINDING.md`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/cases/ECOSYSTEM_FINDING.md) +[`cases/ECOSYSTEM_FINDING.md`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/cases/ECOSYSTEM_FINDING.md) for the quantified evidence and the [`VALIDATION_SUMMARY.md`](VALIDATION_SUMMARY.md) in this directory for per-block before-and-after numbers across all three cases. @@ -95,7 +95,7 @@ polynomial-coefficient lines are rewritten. ## How to regenerate The reference decks here are produced by -[`scripts/build_reference_decks.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/scripts/build_reference_decks.py), +[`scripts/build_reference_decks.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/scripts/build_reference_decks.py), which copies the upstream sources, runs `pybmodes patch`, and re-runs the validator. To regenerate from scratch: diff --git a/src/pybmodes/_examples/sample_inputs/01_uniform_blade/README.md b/src/pybmodes/_examples/sample_inputs/01_uniform_blade/README.md index 938cb4b..13a42b1 100644 --- a/src/pybmodes/_examples/sample_inputs/01_uniform_blade/README.md +++ b/src/pybmodes/_examples/sample_inputs/01_uniform_blade/README.md @@ -65,7 +65,7 @@ print(modal.frequencies[:4]) # [0.5596, 1.7696, 3.5069, 9.8194] This case mirrors the parameters of the Bir 2009 / Wright 1982 Table 3a non-rotating row (Ω = 0). The same analytical reference is used in the project's whitebox test -[`tests/fem/test_cantilever.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/fem/test_cantilever.py). +[`tests/fem/test_cantilever.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/fem/test_cantilever.py). Citation: Wright, A. D.; Smith, C. E.; Thresher, R. W.; Wang, J. C. *Vibration Modes of Centrifugally Stiffened Beams*, ASME *Journal of diff --git a/src/pybmodes/_examples/sample_inputs/README.md b/src/pybmodes/_examples/sample_inputs/README.md index 0de7797..b94381f 100644 --- a/src/pybmodes/_examples/sample_inputs/README.md +++ b/src/pybmodes/_examples/sample_inputs/README.md @@ -97,7 +97,7 @@ Apache 2.0 licence. ## See also -- [`tests/fem/`](https://github.com/SMI-Lab-Inha/pyBModes/tree/master/tests/fem) — the project's whitebox FEM +- [`tests/fem/`](https://github.com/SMI-Lab-Inha/pyBModes/tree/main/tests/fem) — the project's whitebox FEM validation tests, which build the FEM matrices directly without going through the BMI parser. Each case here mirrors one of the whitebox tests with the same physical parameters. @@ -110,7 +110,7 @@ Apache 2.0 licence. tracked in git and the previous relative link (`../../external/BModes/docs/examples/`) didn't resolve from inside the packaged wheel anyway. -- [`tests/_synthetic_bmi.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/master/tests/_synthetic_bmi.py) — the +- [`tests/_synthetic_bmi.py`](https://github.com/SMI-Lab-Inha/pyBModes/blob/main/tests/_synthetic_bmi.py) — the programmatic `.bmi` writer used by tests for in-tmp_path round-trip fixtures. Useful as a starting point if you need to generate sample decks with parameter sweeps rather than by hand. From f2ae48f72d542e960a7ad80894a7bca0a4155a28 Mon Sep 17 00:00:00 2001 From: Jae Hoon Seo Date: Wed, 21 Oct 2026 16:46:02 +0900 Subject: [PATCH 2/3] docs: serve the docs badge through shields.io --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index e82d720..054f4ef 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [![CI](https://github.com/SMI-Lab-Inha/pyBModes/actions/workflows/ci.yml/badge.svg)](https://github.com/SMI-Lab-Inha/pyBModes/actions/workflows/ci.yml) [![Validation](https://github.com/SMI-Lab-Inha/pyBModes/actions/workflows/validation.yml/badge.svg)](https://github.com/SMI-Lab-Inha/pyBModes/actions/workflows/validation.yml) -[![Docs](https://readthedocs.org/projects/pybmodes/badge/?version=latest)](https://pybmodes.readthedocs.io/en/latest/) +[![Docs](https://img.shields.io/readthedocs/pybmodes/latest?label=docs)](https://pybmodes.readthedocs.io/en/latest/) [![conda-forge](https://img.shields.io/conda/vn/conda-forge/pybmodes.svg)](https://anaconda.org/conda-forge/pybmodes) [![Python 3.11+](https://img.shields.io/badge/python-3.11%2B-blue.svg)](https://www.python.org/downloads/) [![License: Apache 2.0](https://img.shields.io/badge/license-Apache%202.0-green.svg)](LICENSE) From 0a57d5910b7dd2e630ebea1e04f96b76081ac39b Mon Sep 17 00:00:00 2001 From: Jae Hoon Seo Date: Wed, 21 Oct 2026 16:46:02 +0900 Subject: [PATCH 3/3] chore: bump version to 1.19.1, promote CHANGELOG --- CHANGELOG.md | 15 +++++++++++++++ CITATION.cff | 4 ++-- pyproject.toml | 2 +- src/pybmodes/__init__.py | 2 +- 4 files changed, 19 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index db96b44..1498d0a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,21 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +(nothing yet) + +## [1.19.1] — 2026-10-21 + +### Changed + +- **The default branch is now `main`**, renamed from `master`, with + development happening on `dev`. Existing `master` links on GitHub + redirect, but a local clone that tracks `master` should run + `git branch -m master main && git fetch && git branch -u origin/main main`. +- **The documentation badge displays again** on GitHub and PyPI. It + pointed at Read the Docs' legacy badge endpoint, which rate-limits the + image proxies both sites fetch README images through (HTTP 429), so it + rendered as a broken image. It is now served through shields.io. + ### Fixed - **The sparse solver path dropped negative modes.** Above 500 DOF a diff --git a/CITATION.cff b/CITATION.cff index 0711008..2bb5e36 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -22,8 +22,8 @@ authors: email: jaehoon.seo@inha.ac.kr affiliation: "Marine Structural Mechanics and Integrity Lab (SMI Lab), Inha University" orcid: "https://orcid.org/0000-0003-1047-2119" -version: 1.19.0 -date-released: "2026-09-30" +version: 1.19.1 +date-released: "2026-10-21" license: Apache-2.0 repository-code: "https://github.com/SMI-Lab-Inha/pyBModes" url: "https://github.com/SMI-Lab-Inha/pyBModes" diff --git a/pyproject.toml b/pyproject.toml index 7c73798..501964f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "pybmodes" -version = "1.19.0" +version = "1.19.1" description = "Python finite-element library for wind turbine blade and tower modal analysis (OpenFAST/ElastoDyn)" readme = "README.md" license = { file = "LICENSE" } diff --git a/src/pybmodes/__init__.py b/src/pybmodes/__init__.py index 8f6ae54..b96f1d3 100644 --- a/src/pybmodes/__init__.py +++ b/src/pybmodes/__init__.py @@ -243,7 +243,7 @@ except PackageNotFoundError: # Fallback for an uninstalled source tree (no package metadata). # Keep in step with ``pyproject.toml`` ``[project] version``. - __version__ = "1.19.0" + __version__ = "1.19.1" __all__ = [ "CheckOptions",