Skip to content

Take RotD50 off the hull too, as a level set of the arc decomposition - #222

Closed
lispandfound wants to merge 1 commit into
rotd-exact-extremesfrom
rotd-exact-median
Closed

lispandfound wants to merge 1 commit into
rotd-exact-extremesfrom
rotd-exact-median

Conversation

@lispandfound

Copy link
Copy Markdown
Contributor

Stacked on #221. Fifth in the stack.

The normal cones that gave RotD00 its minimum in #221 also cut the rotated peak into arcs — one per vertex of the origin-symmetric hull S, over each of which the peak is a single cosine radius · cos(x), x measured from that vertex's own direction. Those arcs tile the circle.

That makes the measure of directions whose peak is at or below a level a closed form, arc by arc: peak ≤ level holds exactly where |x| ≥ acos(level/radius), leaving at most two sub-arcs to clip against the arc's own bounds. RotD50 is the level where that measure is half the circle.

The sum of acos terms has no closed-form inverse — Σ acos(m/rₖ) = const with distinct rₖ — so the level comes from a safeguarded Newton root find rather than algebra. Exact in the sense any root is: to the last f64 the bracket resolves. The first guess is RotD100/√2, which the √2 bound puts at or below the answer, and a bisection takes over whenever a Newton step leaves the bracket or fails to halve it, capping the work at twice a plain bisection.

The √2 bound comes back exactly

This is the part I'd flagged as a wart in #221, and it's the main reason to do this.

f(θ) ≥ RotD100·|cos(θ−φ)| pointwise, and a pointwise inequality holds for quantiles too. The median of |cos| over the circle is cos(45°) exactly, because the directions where |cos| ≥ c measure 4·acos(c), which is half the circle at c = cos(45°). So RotD50 ≥ RotD100/√2.

RotD100 RotD50 Bound
before #221 sampled sampled √2 exactly, but neither value exact
#221 exact sampled √2/cos(0.5°) ≈ 1.414267
here exact exact √2 exactly

And linearly polarised records attain it at every angle now, not only at whole degrees — the β-dependent assertion I had to write in #221 goes away in both the Rust and the Python tests.

There's a second property worth naming: the statistics are now rotation invariant. Rotate a record by 0.3° and the grid RotD50 changes; the continuous one doesn't.

RotD50 has no orientation any more

As you asked. A continuous function reaches its median at an even number of directions, two at the fewest, so naming one would be arbitrary. The statistics row goes 6 → 5 columns, rotd50_orientation leaves ROTD_COMPONENTS, and psa_rotd180 returns 187 columns rather than 188.

What moves in the numbers

RotD50 shifts by 0.002% to 0.08% of itself across the seven benchmark records — measured, not estimated:

2024p950420_MWFS_HN_20   0.046%      3366146_DFHS_HN_20   0.014%
2024p220113_RLNS_HN_20   0.023%      3497857_HVSC_HN_20   0.002%
3497857_PARS_HN_20       0.077%      3528839_LPCC_HN_20   0.032%
nquist_freq              0.038%

Against a 1% benchmark tolerance, so the comparison needed no further slack beyond the RotD00/RotD100 allowance already in #221. Worth noting I can't give a clean a priori bound here the way I could for the extremes — a level set can have arbitrarily many components, so the sampled median's error has no half-degree argument behind it. The numbers above are measurement.

Verification

  • arcs_tile_the_circle — the arc widths add to a full turn, on a square, a triangle, a noisy record and a 360-point circle. A mislabelled cone or an edge normal the wrong way round breaks the total.
  • arc_integral_matches_the_perimeter — Cauchy's formula: integrating a convex body's support function over the circle gives its perimeter. The arcs put that integral in closed form, radius·(sin(end) − sin(start)) each, and a perimeter measured straight off the vertices has to match to 1e-12. This is the sharpest structural check of the decomposition.
  • level_measure_matches_a_fine_sweep — closed-form measure against a count over 360,000 directions, at seven levels between the extremes.
  • median_matches_a_fine_sweep — both directions: half of 360,000 sampled directions fall below RotD50, and the sweep's own median agrees.
  • median_of_a_polarised_record_is_the_peak_over_root_two — the closed form the degenerate branch returns, at six polarisations on and off the grid.
  • test_rotd_statistics now asserts the analytic answer. Two timesteps at (1,0) and (0,1) give a square symmetric hull; every normal cone spans 90°, so the median level solves acos(level) = 22.5° and RotD50 is cos(π/8) exactly. The 1 degree sweep could only reach 0.9238443540096138.
  • The proptest suite checks on every generated record that RotD50 splits a 7200-direction grid in half.

Performance

No measurable change on either path. 200 stations × 20000 samples through RotD: 41–42 ms either side of this commit. pSA over 20 stations × 4000 samples × 4 periods: 25 ms either side.

rotd no longer sweeps at all — every statistic comes off the hull. That isn't visible in the timing because the cull over the raw timesteps dominates; the 180-angle sweep was about 3% of the work. rotd180_rows and psa_rotd180 still sweep, because the curve itself is what they return.

Verified

cargo test 49 passed, pytest 65 passed (97% coverage), plus ruff, ruff format --check, deptry, ty, numpydoc, cargo fmt --check, cargo clippy -D warnings, and vale clean across Python, Markdown and Rust.

🤖 Generated with Claude Code

The normal cones that gave RotD00 its minimum also cut the rotated peak into
arcs, one per vertex of the origin-symmetric hull `S`, over each of which the
peak is a single cosine `radius * cos(x)` measured from the vertex's own
direction. Those arcs tile the circle.

That makes the measure of directions whose peak is at or below a level a
closed form, arc by arc: `peak <= level` holds exactly where
`|x| >= acos(level / radius)`, which leaves at most two sub-arcs to clip
against the arc's own bounds. RotD50 is the level where that measure is half
the circle.

The sum of `acos` terms has no closed-form inverse, so the level comes from a
safeguarded Newton root find rather than an algebraic formula. Exact in the
sense any root is: to the last f64 the bracket resolves. The first guess is
`RotD100 / sqrt(2)`, which the sqrt(2) bound puts at or below the answer, and
a bisection takes over whenever a Newton step leaves the bracket or fails to
halve it, which caps the work at twice a plain bisection.

## The sqrt(2) bound comes back exactly

`f(theta) >= RotD100 |cos(theta - phi)|` pointwise, and a pointwise
inequality holds for quantiles too. The median of `|cos|` over the circle is
`cos(45 deg)` exactly, because the directions where `|cos| >= c` measure
`4 acos(c)`, which is half the circle at `c = cos(45 deg)`. So
`RotD50 >= RotD100 / sqrt(2)`.

With both statistics on the 1 degree grid the bound was exact but neither
value was. The previous commit made RotD100 exact and left RotD50 sampled,
which opened the bound to `sqrt(2) / cos(0.5 deg)`. Exact throughout, it
closes again, and linearly polarised records attain it at every angle rather
than only at whole degrees. Both the Rust and the Python polarised tests go
back to asserting sqrt(2) flat.

## RotD50 has no orientation any more

As asked. A continuous function reaches its median at an even number of
directions, two at the fewest, so naming one would be arbitrary. The
statistics row goes from six columns to five, `rotd50_orientation` leaves
`ROTD_COMPONENTS`, and `psa_rotd180` returns 187 columns rather than 188.

## What moves in the numbers

RotD50 shifts by 0.002% to 0.08% of itself across the seven benchmark
records, against a benchmark tolerance of 1%, so the comparison needed no
further slack beyond the RotD00 and RotD100 allowance already there.

## Verification

- `arcs_tile_the_circle`: the arc widths add to a full turn, on a square, a
  triangle, a noisy record and a 360-point circle. A mislabelled cone or an
  edge normal the wrong way round breaks the total.
- `arc_integral_matches_the_perimeter`: Cauchy's formula, that integrating a
  convex body's support function over the circle gives its perimeter. The
  arcs put the integral in closed form, `radius (sin(end) - sin(start))`
  each, and a perimeter measured off the vertices has to match it to 1e-12.
- `level_measure_matches_a_fine_sweep`: the closed-form measure against a
  count over 360000 directions, at seven levels between the extremes.
- `median_matches_a_fine_sweep`: both directions, that half of 360000 sampled
  directions fall below RotD50, and that the sweep's own median agrees.
- `median_of_a_polarised_record_is_the_peak_over_root_two`: the closed form
  the degenerate branch returns, at six polarisations on and off the grid.
- `test_rotd_statistics` asserts the analytic answer for two timesteps at
  (1, 0) and (0, 1): every normal cone of that square spans 90 degrees, so
  the median level solves `acos(level) = 22.5 deg` and RotD50 is
  `cos(pi / 8)`. The 1 degree sweep could only reach 0.9238443540096138.
- The proptest suite checks on every generated record that RotD50 splits a
  7200-direction grid in half.

## Performance

No measurable change on either path. 200 stations of 20000 samples through
RotD: 41-42 ms either side of this commit. pSA over 20 stations, 4000
samples and 4 periods: 25 ms either side. The cull over the raw timesteps
dominates both, which is also why dropping the 180-angle sweep from the
plain RotD path is not visible: it was about 3% of the work.

`rotd` no longer sweeps at all. `rotd180_rows` and `psa_rotd180` still do,
because the curve itself is what they return.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant