Skip to content

Commit e3d6d81

Browse files
committed
feat: add explicit angle_units override for WindIO rotor angles
The magnitude heuristic cannot resolve a degrees file whose cone and tilt are both sub-half-degree (a zero-tilt turbine with a fractional-degree precone reads as radians). Rejecting small values is not an option either, since a legitimate radians file routinely has a small precone with zero tilt. Add an explicit escape hatch instead: read_windio_rna gains angle_units ('auto' | 'rad' | 'deg'), forwarded from Tower.from_windio and from_windio_with_monopile as rna_angle_units. 'auto' (default) keeps the pair heuristic for the common case; 'rad' / 'deg' take the file at its word for the rare ambiguous one. Unknown values are rejected.
1 parent fb42ee5 commit e3d6d81

3 files changed

Lines changed: 88 additions & 20 deletions

File tree

src/pybmodes/io/windio.py

Lines changed: 40 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -732,32 +732,45 @@ def _finite_float(value: Any, what: str) -> float:
732732
return f
733733

734734

735-
def _windio_rotor_angles(cone_value: Any, uptilt_value: Any) -> tuple[float, float]:
736-
"""Convert the WindIO precone and shaft tilt to radians, resolving the
737-
rad/deg ambiguity once for the pair.
735+
def _windio_rotor_angles(
736+
cone_value: Any, uptilt_value: Any, units: str = "auto"
737+
) -> tuple[float, float]:
738+
"""Convert the WindIO precone and shaft tilt to radians.
738739
739740
The WindIO v2 turbine schema annotates ``hub.cone_angle`` and drivetrain
740741
``uptilt`` as degrees, but the IEA reference ontologies store radians
741-
(IEA-22 ``cone_angle`` 0.0698 = 4 deg, ``uptilt`` 0.1047 = 6 deg). A file
742-
uses one convention throughout, so decide it once from the *larger* of the
743-
two magnitudes rather than per value: a degrees file reliably carries a
744-
several-degree shaft tilt (magnitude well above any radians precone /
745-
tilt, which stay under ~0.3 rad), whereas judging each value alone would
746-
misread a small schema-conforming degrees angle such as 0.25 deg as
747-
0.25 rad. If the larger magnitude exceeds 0.5 the pair is degrees;
748-
otherwise radians. Anything above 90 is non-physical either way and is
749-
rejected (issue #130 review).
742+
(IEA-22 ``cone_angle`` 0.0698 = 4 deg, ``uptilt`` 0.1047 = 6 deg).
743+
``units`` selects how to reconcile that:
744+
745+
- ``"rad"`` / ``"deg"`` — take the file at its word (no guessing).
746+
- ``"auto"`` (default) — a file uses one convention throughout, so decide
747+
it once from the *larger* of the two magnitudes: a degrees file reliably
748+
carries a several-degree shaft tilt whose magnitude dwarfs any radians
749+
precone / tilt (which stay under ~0.3 rad), so the larger angle
750+
disambiguates the pair even when the cone is a fraction of a degree.
751+
Larger magnitude over 0.5 means degrees, otherwise radians. The
752+
residual blind spot is a degrees file whose angles are *both* below
753+
~0.5 deg (e.g. a zero-tilt turbine with a sub-degree precone); it reads
754+
as radians. Pass ``units="deg"`` for such a file (issue #130 review).
755+
756+
Anything above 90 (in the resolved unit) is non-physical and rejected.
750757
"""
751758
cone = _finite_float(cone_value, "hub cone_angle")
752759
uptilt = _finite_float(uptilt_value, "nacelle.drivetrain.uptilt")
753-
biggest = max(abs(cone), abs(uptilt))
754-
if biggest > 90.0:
760+
if units not in ("auto", "rad", "deg"):
761+
raise ValueError(
762+
f"angle_units must be 'auto', 'rad' or 'deg'; got {units!r}."
763+
)
764+
if units == "auto":
765+
biggest = max(abs(cone), abs(uptilt))
766+
units = "deg" if biggest > 0.5 else "rad"
767+
if max(abs(cone), abs(uptilt)) > 90.0:
755768
raise ValueError(
756769
f"rotor precone / shaft tilt (cone_angle={cone!r}, uptilt="
757-
f"{uptilt!r}) is non-physical as either radians or degrees; "
758-
f"expected values well under 90 deg."
770+
f"{uptilt!r}) is non-physical in {units}; expected values well "
771+
f"under 90 deg."
759772
)
760-
if biggest > 0.5: # degrees: a real shaft tilt dwarfs any radians angle
773+
if units == "deg":
761774
return float(np.radians(cone)), float(np.radians(uptilt))
762775
return cone, uptilt
763776

@@ -980,6 +993,7 @@ def read_windio_rna(
980993
component_hub: str = "hub",
981994
component_nacelle: str = "nacelle",
982995
component_blade: str = "blade",
996+
angle_units: str = "auto",
983997
) -> TipMassProps:
984998
"""Lump the WindIO rotor-nacelle assembly into a tower-top ``TipMassProps``.
985999
@@ -1024,6 +1038,12 @@ def read_windio_rna(
10241038
goes beyond the ElastoDyn deck path's bare point-mass lumping, which the
10251039
WindIO ontology's per-station blade mass makes possible.
10261040
1041+
``angle_units`` selects how ``cone_angle`` / ``uptilt`` are read. The
1042+
WindIO v2 schema annotates them as degrees, but the IEA reference files
1043+
store radians; ``"auto"`` (default) disambiguates by magnitude (see
1044+
:func:`_windio_rotor_angles`), while ``"rad"`` / ``"deg"`` take the file
1045+
at its word for the rare all-sub-degree case ``"auto"`` cannot resolve.
1046+
10271047
Requires the optional ``[windio]`` extra (PyYAML).
10281048
"""
10291049
from pybmodes.io.bmi import TipMassProps
@@ -1112,7 +1132,9 @@ def _nac_geom(key: str) -> float:
11121132
)
11131133
if hub_r < 0.0:
11141134
raise ValueError(f"hub diameter must be non-negative; got {hub_diam!r}.")
1115-
cone_ang, uptilt = _windio_rotor_angles(hub.get("cone_angle", 0.0), uptilt_raw)
1135+
cone_ang, uptilt = _windio_rotor_angles(
1136+
hub.get("cone_angle", 0.0), uptilt_raw, angle_units
1137+
)
11161138
cone_cos = float(np.cos(cone_ang))
11171139
cone_sin = float(np.sin(cone_ang))
11181140

src/pybmodes/models/tower.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -325,6 +325,7 @@ def from_windio(
325325
tip_mass: TipMassProps | float | None = None,
326326
n_nodes: int | None = None,
327327
lumped_rna_cal: bool = False,
328+
rna_angle_units: str = "auto",
328329
) -> Tower:
329330
"""Build a tower (or monopile) model directly from a **WindIO**
330331
ontology ``.yaml`` (issue #35).
@@ -372,6 +373,11 @@ def from_windio(
372373
expressed at the tower top in the clamped-base convention, which
373374
a free-base / soil-flexible base interprets differently. Default
374375
``False``.
376+
rna_angle_units : how the auto-RNA reads ``cone_angle`` / ``uptilt``
377+
when ``lumped_rna_cal=True``. ``"auto"`` (default) disambiguates
378+
the WindIO rad/deg ambiguity by magnitude; pass ``"rad"`` or
379+
``"deg"`` to take the file at its word. Ignored unless
380+
``lumped_rna_cal`` is set.
375381
376382
Notes
377383
-----
@@ -411,7 +417,7 @@ def from_windio(
411417
)
412418
from pybmodes.io.windio import read_windio_rna
413419

414-
tip_mass = read_windio_rna(yaml_path)
420+
tip_mass = read_windio_rna(yaml_path, angle_units=rna_angle_units)
415421

416422
g = read_windio_tubular(
417423
yaml_path, component=component, thickness_interp=thickness_interp,
@@ -440,6 +446,7 @@ def from_windio_with_monopile(
440446
n_nodes: int | None = None,
441447
water_depth: float | None = None,
442448
lumped_rna_cal: bool = False,
449+
rna_angle_units: str = "auto",
443450
) -> Tower:
444451
"""Build a combined **monopile + tower** fixed-bottom cantilever
445452
from a WindIO ontology ``.yaml`` (issue #92).
@@ -484,6 +491,9 @@ def from_windio_with_monopile(
484491
blocks via :func:`pybmodes.io.windio.read_windio_rna` and use
485492
it as ``tip_mass``. Requires an IEA-22-class ontology; mutually
486493
exclusive with ``tip_mass``. Default ``False``.
494+
rna_angle_units : how the auto-RNA reads ``cone_angle`` / ``uptilt``
495+
when ``lumped_rna_cal=True`` (``"auto"`` / ``"rad"`` / ``"deg"``);
496+
see :meth:`from_windio`. Ignored unless ``lumped_rna_cal`` is set.
487497
488498
Notes
489499
-----
@@ -509,7 +519,7 @@ def from_windio_with_monopile(
509519
)
510520
from pybmodes.io.windio import read_windio_rna
511521

512-
tip_mass = read_windio_rna(yaml_path)
522+
tip_mass = read_windio_rna(yaml_path, angle_units=rna_angle_units)
513523

514524
mt = read_windio_monopile_tower(
515525
yaml_path,

tests/test_windio_rna.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,42 @@ def _mk(cone: float, uptilt: float):
245245
assert getattr(deg, attr) == pytest.approx(getattr(rad, attr))
246246

247247

248+
def test_rna_explicit_deg_units_override(tmp_path: pathlib.Path) -> None:
249+
"""The all-sub-degree case auto cannot resolve: a zero-tilt degrees file
250+
with a 0.3 deg cone. angle_units='deg' takes the file at its word, so it
251+
reads as 0.3 deg, not 0.3 rad (Codex review on #130)."""
252+
pytest.importorskip("yaml")
253+
import numpy as np
254+
255+
from pybmodes.io.windio import read_windio_rna
256+
257+
o = _base_ontology()
258+
o["components"]["hub"]["cone_angle"] = 0.3
259+
o["components"]["nacelle"]["drivetrain"]["uptilt"] = 0.0
260+
p = _write(o, tmp_path, "small_deg.yaml")
261+
262+
as_deg = read_windio_rna(p, angle_units="deg")
263+
# equivalent radians file resolved by auto
264+
o2 = _base_ontology()
265+
o2["components"]["hub"]["cone_angle"] = float(np.radians(0.3))
266+
o2["components"]["nacelle"]["drivetrain"]["uptilt"] = 0.0
267+
as_rad = read_windio_rna(_write(o2, tmp_path, "small_rad.yaml"))
268+
for attr in ("cm_axial", "ixx", "iyy", "izz"):
269+
assert getattr(as_deg, attr) == pytest.approx(getattr(as_rad, attr))
270+
# and auto (default) would misread the 0.3 as radians -> different result
271+
as_auto = read_windio_rna(p)
272+
assert as_auto.izz != pytest.approx(as_deg.izz)
273+
274+
275+
def test_rna_rejects_bad_angle_units(tmp_path: pathlib.Path) -> None:
276+
"""An unknown angle_units value is rejected."""
277+
pytest.importorskip("yaml")
278+
from pybmodes.io.windio import read_windio_rna
279+
280+
with pytest.raises(ValueError, match="angle_units"):
281+
read_windio_rna(_write(_base_ontology(), tmp_path, "u.yaml"), angle_units="grad")
282+
283+
248284
def test_rna_rejects_nonphysical_angle(tmp_path: pathlib.Path) -> None:
249285
"""A value non-physical as both radians and degrees (e.g. 200) is rejected
250286
rather than silently evaluated at a meaningless angle."""

0 commit comments

Comments
 (0)