Take RotD50 off the hull too, as a level set of the arc decomposition - #222
Closed
lispandfound wants to merge 1 commit into
Closed
lispandfound wants to merge 1 commit into
lispandfound wants to merge 1 commit into
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 cosineradius · cos(x),xmeasured 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 ≤ levelholds 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
acosterms has no closed-form inverse —Σ acos(m/rₖ) = constwith distinctrₖ— 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 isRotD100/√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 iscos(45°)exactly, because the directions where|cos| ≥ cmeasure4·acos(c), which is half the circle atc = cos(45°). SoRotD50 ≥ RotD100/√2.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_orientationleavesROTD_COMPONENTS, andpsa_rotd180returns 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:
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_statisticsnow 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 solvesacos(level) = 22.5°and RotD50 iscos(π/8)exactly. The 1 degree sweep could only reach0.9238443540096138.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.
rotdno 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_rowsandpsa_rotd180still sweep, because the curve itself is what they return.Verified
cargo test49 passed,pytest65 passed (97% coverage), plusruff,ruff format --check,deptry,ty,numpydoc,cargo fmt --check,cargo clippy -D warnings, andvaleclean across Python, Markdown and Rust.🤖 Generated with Claude Code