Skip to content

fix: isDaytime wrong during polar day - #81

Open
ceeK wants to merge 1 commit into
mainfrom
chowell/fix-polar-day-isdaytime
Open

fix: isDaytime wrong during polar day#81
ceeK wants to merge 1 commit into
mainfrom
chowell/fix-polar-day-isdaytime

Conversation

@ceeK

@ceeK ceeK commented Aug 31, 2026

Copy link
Copy Markdown
Owner

Fixes #59.

Problem

isDaytime answered "is date between today's sunrise and sunset?". Above the polar circles that question has no answer: during polar day and polar night there is no sunrise or sunset, both are nil, and the check fell through to false. Under the midnight sun isDaytime was false and isNighttime was true — the opposite of reality.

Fix

Two connected changes, one idea: a single elevation predicate is the source of truth for everything.

1. isDaytime asks the question directly. Is the sun's elevation at date above the official zenith? The USNO Almanac for Computers position model moves into a shared sunPosition(forT:), and sunIsUp(atT:above:) inverts the almanac's clock relation to evaluate elevation at any instant. Polar day, polar night, transition days, and daylight spanning UTC midnight need no special cases.

2. The published events are defined by that same predicate. Instead of the almanac's one-shot closed-form inversion (which freezes the sun's position at a 06:00/18:00 guess and therefore lands seconds-to-minutes away from the crossing the predicate sees), each event is the first whole second on the far side of the horizon crossing, found by bisecting sunIsUp between the model's solar transit and solar midnight. Elevation rises from one solar midnight to noon and falls to the next, so a crossing exists exactly when the endpoints disagree — when they agree the sun stays on one side of the zenith all day and no event is published.

isDaytime and the published times derive from one predicate and cannot disagree, at any latitude — a sunrise is the first second isDaytime turns true.

Deleting the closed-form inversion also deletes its failure modes:

  • the single-wrap normalise that pushed events near longitude ±180° onto the wrong UTC day, ~11–15 min off;
  • the shouldBeYesterday/shouldBeTomorrow day-shift heuristics;
  • the year-boundary discontinuity — instants map to their own year's fractional day-of-year via epoch arithmetic, so a fresh Solar constructed at a published cross-year event (a Tokyo sunrise on 31 Dec UTC) agrees exactly.

Public API unchanged: sunrise/sunset still return nil on polar days; isDaytime is now a computed property.

Tests

Written first (RED → GREEN), 32/32 passing:

  • Exact consistency contract: for every city fixture, isDaytime is true at the published sunrise and false one second before it; false at the published sunset and true one second before it (~950 assertions).
  • Hard cases, same exact contract: grazing midwinter at the Arctic circle (67.5°N), near-pole equinox (89.5°N — the one-shot solve was 58 min off here), the antimeridian (180°W, also asserting the event lands on the correct UTC day), and a sunrise in the previous calendar year (62.5°S).
  • Polar fixtures carried over: Lofoten polar day (the issue's exact repro), Longyearbyen polar night, West Greenland and Lofoten transition days, Norilsk and Andøya ~34-minute daylight slivers, Antarctic daylight crossing UTC midnight.
  • Fuzz: 0 contract violations in 16,944 checks (5,000 seeded random locations/dates, 2020–2028).

Accuracy and performance

Against the NOAA-derived CorrectResults.json fixtures (474 events):

mean median p95 max
main 52.2s 37.0s 146s 268s
this PR 51.8s 37.0s 143s 251s

The remaining error is the almanac position model itself (truncated series, fixed refraction), which this PR doesn't change; sunPosition(forT:) is the seam if a higher-order model is ever wanted — the bisection needs no changes to benefit from one.

Cost: ~14µs per init (arm64 release) vs ~6µs on main — the bisection evaluates the predicate ~16 times per event, all pure arithmetic (no Calendar calls in the loop). A year-of-daylight plot is ~5ms.

🤖 Generated with Claude Code

https://claude.ai/code/session_01VPzRUnPKS8j3WimEjyb2Jk

@ceeK
ceeK marked this pull request as ready for review August 31, 2026 12:58
@ceeK

ceeK commented Aug 31, 2026

Copy link
Copy Markdown
Owner Author

@claude Please review this PR and post your findings as a comment — cover correctness of the refinement/snap logic in Solar.swift, the test coverage, and anything the PR description overclaims. Reply with your review even if you find no issues.

@claude

claude Bot commented Aug 31, 2026

Copy link
Copy Markdown

Claude finished @ceeK's task in 5m 46s —— View job


Review: isDaytime wrong during polar day

  • Gather context (diff, PR description, repo layout)
  • Review correctness of refinement/snap logic in Solar.swift
  • Review test coverage in SolarTests.swift
  • Check PR description claims against the code
  • Post final findings

Note on verification method: this sandbox doesn't allow running swift build/swift test (or even python3) — those invocations required approval that wasn't available non-interactively. I verified the algorithm by hand-deriving the algebra and by cross-checking every new test's Unix timestamp against its comment via date -u -d @<epoch> (all 8 checked matched exactly, including the Antarctica UTC-midnight fixture at 1771552800 → 2026-02-20 02:00:00 UTC). I could not execute the suite myself, so please treat the correctness read below as a careful static review, not a confirmed green run.

Correctness of sunIsUp / refinement / snap

The core idea is sound and I verified the algebra: sunIsUp (Solar.swift:290-311) inverts solveCrossing's clock relation T = H + RA - 0.06571t - 6.622, UT = T - lngHour exactly —

H = (ut + lngHour) - RA + 0.06571t + 6.622

— and at the crossing itself this recovers cos(H·15°) == cosH exactly (via cos(360° - x) = cos(x)), so sinElevation >= cos(zenith) is a faithful, general reformulation of the same almanac model at an arbitrary instant, not just at the two daily crossings. The zenith boundary also checks out: cos(90.83°) ≈ -0.0145 ≈ sin(-0.83°), matching the official −0.83° elevation convention. Nice piece of math — this is the right way to eliminate the day-window special-casing.

One real concern — Solar.swift:273-283, the snap loop:

var attempts = 0
while sunIsUp(at: published, above: zenith) != sunIsUpAtEvent && attempts < 120 {
    published = published.addingTimeInterval(1)
    attempts += 1
}

This searches forward only, capped at 120 seconds, and on exhausting the cap it silently returns .time(published) anyway — with no assertion, no fallback, no signal to the caller that the predicate wasn't actually satisfied. That's exactly the contract this PR is designed to guarantee (isDaytime at sunrise/sunset must agree), so a silent cap-out reintroduces the original bug class in miniature, just for a narrower set of inputs.

The PR description itself flags the risk case: "grazing crossings at polar latitudes (≥74°)... the crossing is ill-conditioned." Near such a crossing d(sinElevation)/dt → 0, so a few seconds of residual error in the 4-iteration refined estimate can correspond to the actual predicate flip being minutes away — plausibly more than 120s in the worst case. The 4-iteration refinement loop (Solar.swift:229-244) also has no convergence check; it just runs a fixed 4 times and keeps the last estimate if a later pass lands on a polar cusp (.alwaysUp/.alwaysDown).

The write-up's claim that "the snap guarantees the contract independently of estimate quality" (used to justify a possible future cut to 1-2 refinement iterations) is the part I'd push back on — the snap only guarantees the contract if the refined estimate lands within 120s of the true crossing. It's a soft guarantee backed by refinement quality, not an independent one. Given 0 violations were found in 13,945 fuzzed samples across 2020-2028, this is evidently rare in practice, but the fuzz-and-hope approach doesn't rule out an adversarial/extreme-latitude input hitting it — and if it happens, the failure is silent rather than loud.

Suggestions (not blocking, but worth considering):

  • Widen the cap and/or search bidirectionally, since an overshoot on the wrong side is also possible in principle.
  • Add a assert/fatalError (debug-only) if the cap is exhausted, so any future regression surfaces as a crash in tests/fuzzing rather than a silently-wrong published time.
  • A fixture right at the 66.5°-ish transition (rather than the ~69°N Norilsk/Andøya slivers already covered) would directly exercise the latitude band the PR itself calls out as worst-case.

Fix this →

Test coverage

Strong. The added tests (SolarTests.swift:70-96, 208-387) cover the right taxonomy: fixture-wide sunrise/sunset↔isDaytime consistency (245 cities, reusing the existing CorrectResults.json/cities fixture), polar day at both the repro coordinates and local midnight, both transition-day directions (first/last day of polar day and polar night) including the short daylight/dark slivers, a night guard inside the 43-minute first night, and the UTC-midnight-crossing Antarctica case. I independently confirmed all the new fixture timestamps decode to exactly what their comments claim. I didn't find gaps in what's covered; the one addition I'd suggest is a fixture nearer 66.5°N/S to directly stress the snap-loop concern above, since the existing slivers are ~69°N and the PR's own accuracy section calls out ≥74° as the risk zone.

PR description vs. what's actually in the diff

  • The consistency contract, polar-day/night fixes, and the described refinement+snap mechanism match the code.
  • The accuracy table (mean/median/p95 vs. CorrectResults.json, 52.2s→51.8s etc.) and the "13,945 seeded random samples, 0 violations" fuzz claim aren't backed by anything checked into this diff or the repo — no fuzz harness or accuracy-summary test exists in the tree (only the pre-existing 5-minute-tolerance fixture tests in SolarTests.swift:36-63 use CorrectResults.json, and they don't compute/report the summary stats quoted). Not wrong, just not independently reproducible from what's in the PR — worth knowing if this table is meant to be a durable claim rather than a one-off dev-time measurement.
  • Same for the performance table (20,000 inits, µs/init) — no benchmark target is included, so it can't be re-verified from CI.
  • The "Public API unchanged" claim holds: sunrise/sunset are still Date?, still nil on polar days (SunEvent.date maps .alwaysUp/.alwaysDownnil), and isDaytime/isNighttime keep their existing public signatures.

Branch: chowell/fix-polar-day-isdaytime

@ceeK
ceeK force-pushed the chowell/fix-polar-day-isdaytime branch 2 times, most recently from 6a0a7c3 to b2a4a16 Compare August 31, 2026 23:35
During polar day (24h daylight above the polar circles) isDaytime
returned false and isNighttime true - the opposite of reality. The
window-based check collapsed "sun never rises" and "sun never sets"
into the same nil sunrise/sunset, so both fell through to false.

isDaytime now asks the question directly: is the sun's elevation at
date above the official zenith? The USNO Almanac for Computers position
model moves into sunPosition(forT:), and sunIsUp(atT:above:) inverts
the almanac's clock relation to run the altitude identity forward at
any instant. Polar day, polar night, transition days, and daylight
spanning UTC midnight need no special cases.

The published events are then defined by that same predicate rather
than by the almanac's one-shot anchored inversion: each is the first
whole second on the far side of the horizon crossing, found by
bisecting the predicate between the model's solar transit and solar
midnight. Elevation is unimodal over a solar day, so a crossing exists
exactly when the endpoints disagree - which also replaces the cosH
polar guards. isDaytime and the published times derive from one
predicate and cannot disagree, at any latitude, including grazing
crossings near the polar circles where the old one-shot solve drifted
by up to an hour.

Deleting the inversion also deletes its failure modes: the single-wrap
normalise that pushed events near longitude 180 onto the wrong UTC day,
the shouldBeYesterday/Tomorrow day-shift heuristics, and the year
boundary discontinuity (instants map to their own year's day-of-year
via epoch arithmetic, so a fresh Solar at a published cross-year event
agrees exactly).

Verified: 32/32 tests, including exact isDaytime flips at both sides of
every published event for all city fixtures and for grazing, near-pole,
antimeridian and cross-year cases; 0 contract violations in a 16,944-
check seeded fuzz (2020-2028). Accuracy vs the NOAA-derived fixtures:
mean 51.8s (main: 52.2s), max 251s (main: 268s). Cost: ~14us per init
vs ~6us on main.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VPzRUnPKS8j3WimEjyb2Jk
@ceeK
ceeK force-pushed the chowell/fix-polar-day-isdaytime branch from b2a4a16 to ee2924a Compare August 31, 2026 23:46
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.

isDaytime and isNightTime return incorrect values for polar day

1 participant