Skip to content

Commit 12cf1ec

Browse files
langestefanclaude
andcommitted
Return sunrise and sunset at the observer's element type
transit_sunrise_sunset rounds the event times to a whole second in order to build a DateTime. That discards the sub-second part, and with it anything the element type carries beyond a value. The solve already runs at the observer's element type, so the information exists right up to that final conversion and is then thrown away. transit_sunrise_sunset_seconds returns the same events as seconds since midnight UTC, keeping the element type. A Measurement observer therefore yields event times with an uncertainty, and a dual-valued observer yields differentiable ones. Neither was possible before, and the DateTime variant could not even be finite-differenced, since a step small enough to be a derivative is swamped by the rounding. The conversion now dispatches on the requested type, so the DateTime and ZonedDateTime paths are untouched, including the polar day and night branch. TransitSunriseSunset needed no change, having been made generic when TimeZones moved to an extension. Covered from both directions: the propagated uncertainty is checked against a finite-difference gradient, the ForwardDiff derivative against the same, and the two variants are asserted to describe the same instant for a Float64 observer. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NQ9zmXUHQtA5suiMB8UdA7
1 parent 4a2390a commit 12cf1ec

7 files changed

Lines changed: 143 additions & 20 deletions

File tree

‎docs/src/index.md‎

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -172,10 +172,21 @@ The geometric angles come back exact, since they do not depend on the atmosphere
172172
the apparent ones carry the pressure and temperature uncertainty. Making the observer
173173
uncertain as well widens both, and the two contributions combine in the apparent angles.
174174

175-
!!! note
176-
[`transit_sunrise_sunset`](@ref) and the `next_`/`previous_` helpers return a
177-
`DateTime`, which cannot carry an uncertainty. They compute from the nominal
178-
coordinates and drop it.
175+
### Sunrise and sunset
176+
177+
Event times are a special case, because a `DateTime` cannot carry an uncertainty.
178+
[`transit_sunrise_sunset`](@ref) rounds to a whole second and so returns the nominal
179+
times. [`transit_sunrise_sunset_seconds`](@ref) keeps the observer's element type
180+
instead, giving seconds since midnight UTC:
181+
182+
```@example srt
183+
transit_sunrise_sunset_seconds(Observer(52.35888 ± 0.01, 4.88185 ± 0.01), Date(2023, 6, 21))
184+
```
185+
186+
Rounding to a whole second also costs the sub-second part, so this variant is the more
187+
precise one even with ordinary numbers, and it is the one to use with ForwardDiff. The
188+
`next_`/`previous_` helpers return a `DateTime` and drop the uncertainty in the same way
189+
[`transit_sunrise_sunset`](@ref) does.
179190

180191
## Numeric precision
181192

‎docs/src/utilities.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -289,5 +289,6 @@ SolarPosition.Utilities.previous_solar_noon
289289

290290
```@docs
291291
SolarPosition.Utilities.transit_sunrise_sunset
292+
SolarPosition.Utilities.transit_sunrise_sunset_seconds
292293
SolarPosition.Utilities.TransitSunriseSunset
293294
```

‎src/Utilities/Utilities.jl‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ include("srt.jl")
99

1010
export TransitSunriseSunset,
1111
transit_sunrise_sunset,
12+
transit_sunrise_sunset_seconds,
1213
next_sunrise,
1314
next_sunset,
1415
next_solar_noon,

‎src/Utilities/spa.jl‎

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,12 @@ using ..Positioning: _compute_spa_srt_parameters
77
_frac_to_dt(dt_midnight, frac) =
88
dt_midnight + Dates.Second(round(Int, frac * 86_400))
99

10+
# Render a fraction of the day as the requested event type. Rounding to a whole second
11+
# discards the sub-second part and anything the element type carries beyond a value, such
12+
# as an uncertainty or a derivative, so a real-valued request keeps the number instead.
13+
_frac_to_event(::Type{R}, dt_midnight, frac) where {R} = _frac_to_dt(dt_midnight, frac)
14+
_frac_to_event(::Type{R}, _, frac) where {R <: Real} = frac * 86_400
15+
1016
# Returns (ν, α, δ): apparent sidereal time, geocentric right ascension, declination (degrees).
1117
function _compute_srt_parameters(::Type{T}, dt::DateTime, δt) where {T <: Real}
1218
srt = _compute_spa_srt_parameters(T, dt, δt)
@@ -22,6 +28,15 @@ function _transit_sunrise_sunset(
2228
return _transit_sunrise_sunset_impl(R, obs, dt, alg, nothing)
2329
end
2430

31+
function _transit_sunrise_sunset(
32+
::Type{R},
33+
obs::Observer{T},
34+
dt::DateTime,
35+
alg::SPA,
36+
) where {T <: Real, R <: Real}
37+
return _transit_sunrise_sunset_impl(R, obs, dt, alg, nothing)
38+
end
39+
2540
function _transit_sunrise_sunset_impl(
2641
::Type{R},
2742
obs::Observer{T},
@@ -86,7 +101,8 @@ function _transit_sunrise_sunset_impl(
86101
"polar day (sun above horizon)"
87102
@warn "Sun does not rise or set on this date at the given location: $polar_condition. Returning midnight UTC for all events." _group =
88103
:polar_day_night maxlog = 1
89-
return TransitSunriseSunset{R}(dt_midnight, dt_midnight, dt_midnight, tz)
104+
midnight = _frac_to_event(R, dt_midnight, zero(obs.latitude))
105+
return TransitSunriseSunset{R}(midnight, midnight, midnight, tz)
90106
end
91107

92108
H0 = acosd(cos_H0_arg)
@@ -168,12 +184,11 @@ function _transit_sunrise_sunset_impl(
168184
S_frac += 1
169185
end
170186

171-
# Convert fractions of day to DateTime
172-
# Each fraction represents seconds into the day from midnight UTC
187+
# Each fraction represents the offset into the day from midnight UTC
173188
return TransitSunriseSunset{R}(
174-
_frac_to_dt(dt_midnight, T_frac),
175-
_frac_to_dt(dt_midnight, R_frac),
176-
_frac_to_dt(dt_midnight, S_frac),
189+
_frac_to_event(R, dt_midnight, T_frac),
190+
_frac_to_event(R, dt_midnight, R_frac),
191+
_frac_to_event(R, dt_midnight, S_frac),
177192
tz,
178193
)
179194
end

‎src/Utilities/srt.jl‎

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,16 @@ function TransitSunriseSunset{DateTime}(
4343
return TransitSunriseSunset{DateTime}(transit, sunrise, sunset)
4444
end
4545

46+
# Constructor for a real element type, where the events are seconds since midnight UTC
47+
function TransitSunriseSunset{T}(
48+
transit::T,
49+
sunrise::T,
50+
sunset::T,
51+
::Nothing,
52+
) where {T <: Real}
53+
return TransitSunriseSunset{T}(transit, sunrise, sunset)
54+
end
55+
4656
"""Calculate the sun transit, sunrise, and sunset
4757
for a given date at an Observer location.
4858
"""
@@ -62,6 +72,41 @@ function transit_sunrise_sunset(
6272
return transit_sunrise_sunset(obs, DateTime(dt), alg)
6373
end
6474

75+
"""
76+
$(TYPEDSIGNATURES)
77+
78+
Calculate the sun transit, sunrise, and sunset as seconds since midnight UTC.
79+
80+
[`transit_sunrise_sunset`](@ref) rounds to a whole second in order to return a `DateTime`.
81+
This variant keeps the event times at the observer's element type instead, so sub-second
82+
precision survives, and so does anything the element type carries beyond a value. A
83+
`Measurement` observer therefore yields event times with an uncertainty, and a dual-valued
84+
observer yields differentiable ones.
85+
86+
# Arguments
87+
- `obs`: Observer location
88+
- `dt`: date of interest, any time of day is ignored
89+
- `alg`: Solar algorithm to use (default: SPA())
90+
91+
# Returns
92+
- `TransitSunriseSunset{T}` whose fields are seconds since midnight UTC
93+
"""
94+
function transit_sunrise_sunset_seconds(
95+
obs::Observer{T},
96+
dt::DateTime,
97+
alg::SolarAlgorithm = SPA(),
98+
)::TransitSunriseSunset{T} where {T <: Real}
99+
return _transit_sunrise_sunset(T, obs, dt, alg)
100+
end
101+
102+
function transit_sunrise_sunset_seconds(
103+
obs::Observer{T},
104+
dt::Date,
105+
alg::SolarAlgorithm = SPA(),
106+
)::TransitSunriseSunset{T} where {T <: Real}
107+
return transit_sunrise_sunset_seconds(obs, DateTime(dt), alg)
108+
end
109+
65110
# Helper function for next_* functions
66111
function _next_event(obs::Observer, dt::DateTime, alg::SolarAlgorithm, event_field::Symbol)
67112
date_only = Date(dt)

‎test/positioning/test-autodiff.jl‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,23 @@ using StructArrays: StructArrays
5050
@test ad ≈ fd atol = 1.0e-8
5151
end
5252

53+
# only the seconds variant is differentiable. The DateTime one rounds to a whole
54+
# second, which flattens the derivative to zero almost everywhere and makes it a step
55+
# function elsewhere, so the finite-difference reference uses the seconds variant too
56+
@testset "Sunrise and sunset derivatives" begin
57+
day = DateTime(2024, 6, 21)
58+
h = 0.05
59+
@testset "$field" for field in (:transit, :sunrise, :sunset)
60+
f = lat -> getproperty(
61+
transit_sunrise_sunset_seconds(Observer(lat, 10.0), day), field,
62+
)
63+
ad = ForwardDiff.derivative(f, 45.0)
64+
fd = (f(45.0 + h) - f(45.0 - h)) / 2h
65+
@test isfinite(ad)
66+
@test ad ≈ fd rtol = 1.0e-3
67+
end
68+
end
69+
5370
@testset "Second derivatives via nested duals" begin
5471
f = x -> solar_position(Observer(x[1], x[2]), dt, PSA(), NoRefraction()).elevation
5572
hess = ForwardDiff.hessian(f, [45.0, 10.0])

‎test/positioning/test-measurements.jl‎

Lines changed: 43 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -87,17 +87,50 @@ using StructArrays: StructArrays
8787
@test df.elevation == pos.elevation
8888
end
8989

90-
# a DateTime cannot carry an uncertainty, so the sunrise and sunset utilities compute
91-
# from the nominal coordinates and drop it. This test pins that down as deliberate
92-
# behaviour rather than leaving it to be rediscovered.
93-
@testset "Sunrise and sunset drop the uncertainty" begin
90+
@testset "Sunrise and sunset" begin
91+
day = DateTime(2024, 6, 21)
9492
nominal = Observer(45.0, 10.0)
95-
loose = Observer(45.0 ± 5.0, 10.0 ± 5.0)
96-
for f in (next_sunrise, next_sunset, next_solar_noon)
97-
@test f(loose, dt) == f(nominal, dt)
93+
loose = Observer(45.0 ± σ, 10.0 ± σ)
94+
95+
# a DateTime cannot carry an uncertainty, so this API computes from the nominal
96+
# coordinates and drops it
97+
@testset "the DateTime variant drops it" begin
98+
wide = Observer(45.0 ± 5.0, 10.0 ± 5.0)
99+
for f in (next_sunrise, next_sunset, next_solar_noon)
100+
@test f(wide, dt) == f(nominal, dt)
101+
end
102+
a = transit_sunrise_sunset(wide, Date(2024, 6, 21))
103+
b = transit_sunrise_sunset(nominal, Date(2024, 6, 21))
104+
@test (a.transit, a.sunrise, a.sunset) == (b.transit, b.sunrise, b.sunset)
105+
end
106+
107+
@testset "the seconds variant keeps it" begin
108+
ev = transit_sunrise_sunset_seconds(loose, day)
109+
@test ev isa TransitSunriseSunset{Measurement{Float64}}
110+
for field in (:transit, :sunrise, :sunset)
111+
@test uncertainty(getfield(ev, field)) > 0
112+
end
113+
114+
# the reference gradient is built from the seconds variant as well, since a
115+
# step small enough for the DateTime variant is swamped by its rounding
116+
h = 0.05
117+
sr(lat, lon) = transit_sunrise_sunset_seconds(Observer(lat, lon), day).sunrise
118+
∂lat = (sr(45.0 + h, 10.0) - sr(45.0 - h, 10.0)) / 2h
119+
∂lon = (sr(45.0, 10.0 + h) - sr(45.0, 10.0 - h)) / 2h
120+
@test uncertainty(ev.sunrise) ≈ hypot(∂lat * σ, ∂lon * σ) rtol = 1.0e-3
121+
end
122+
123+
# the two variants must describe the same instant, the seconds one simply keeps
124+
# the sub-second part that rounding to a DateTime throws away
125+
@testset "the variants agree for a Float64 observer" begin
126+
ev = transit_sunrise_sunset_seconds(nominal, day)
127+
ref = transit_sunrise_sunset(nominal, day)
128+
midnight = DateTime(Date(day))
129+
for field in (:transit, :sunrise, :sunset)
130+
secs = getfield(ev, field)
131+
@test midnight + Second(round(Int, secs)) == getfield(ref, field)
132+
@test secs != round(secs)
133+
end
98134
end
99-
a = transit_sunrise_sunset(loose, Date(2024, 6, 21))
100-
b = transit_sunrise_sunset(nominal, Date(2024, 6, 21))
101-
@test (a.transit, a.sunrise, a.sunset) == (b.transit, b.sunrise, b.sunset)
102135
end
103136
end

0 commit comments

Comments
 (0)