16c62a97d5
- 新增时标、ΔT 模型、质心时间与 UT1 支持 - 改进日月食、月掩、行星事件及路径边界计算 - 完善恒星三维自行与动态距离传播 - 扩展 SVG、GeoJSON、KML 输出与底层距离换算工具 - 整理中英文手册、示例资源及回归测试
352 lines
18 KiB
Markdown
352 lines
18 KiB
Markdown
# Sundials and Apparent Solar Time
|
|
|
|
[中文](../sundial.md) | [Back to README](../../../README.en.md)
|
|
|
|
> Full examples in this manual run from the repository root.
|
|
|
|
`sundial` gathers the apparent solar time, solar hour angle and dial geometry of the `sun` package in one place and does not introduce a second algorithm.
|
|
|
|
The dial side follows the classical planar-dial model: a polar-axis stylus whose shadow falls on an arbitrary plane, with the coordinate convention given by the constructor - a horizontal dial has **x pointing east and y pointing north**.
|
|
|
|
## Contents
|
|
|
|
- [Calculating a shadow on a horizontal sundial](#calculating-a-shadow-on-a-horizontal-sundial)
|
|
- [API Reference](#api-reference)
|
|
- [True, mean solar time and the equation of time](#true-mean-solar-time-and-the-equation-of-time)
|
|
- [Hour angle](#hour-angle)
|
|
- [Horizontal hour-line angle](#horizontal-hour-line-angle)
|
|
- [Planar dial core](#planar-dial-core)
|
|
- [Plate illumination intervals](#plate-illumination-intervals)
|
|
- [Time lines and declination curves](#time-lines-and-declination-curves)
|
|
- [Equatorial, horizontal and vertical dials](#equatorial-horizontal-and-vertical-dials)
|
|
- [Returned structures](#returned-structures)
|
|
- [Complete example](#complete-example)
|
|
- [Combined example: usable hour angles and a time line](#combined-example-usable-hour-angles-and-a-time-line)
|
|
- [Common pitfalls](#common-pitfalls)
|
|
- [Usage examples](#usage-examples)
|
|
- [Apparent solar time versus clock time](#apparent-solar-time-versus-clock-time)
|
|
- [Horizontal hour-line angles and the shadow point](#horizontal-hour-line-angles-and-the-shadow-point)
|
|
- [Plate illumination intervals and time lines](#plate-illumination-intervals-and-time-lines)
|
|
- [Degenerate geometry and per-face dial conventions](#degenerate-geometry-and-per-face-dial-conventions)
|
|
- [Parameter and result conventions](#parameter-and-result-conventions)
|
|
- [Related manuals](#related-manuals)
|
|
|
|
## Calculating a shadow on a horizontal sundial
|
|
|
|
```go
|
|
package main
|
|
|
|
import (
|
|
"fmt"
|
|
"time"
|
|
|
|
"b612.me/astro/sundial"
|
|
)
|
|
|
|
func main() {
|
|
cst := time.FixedZone("CST", 8*3600)
|
|
date := time.Date(2026, 6, 21, 9, 30, 0, 0, cst)
|
|
lon, lat := 121.4737, 31.2304
|
|
fmt.Println(sundial.TrueSolarTime(date, lon))
|
|
fmt.Println(sundial.HourAngle(date, lon))
|
|
dial := sundial.HorizontalDial(lat, 10)
|
|
shadow := dial.ShadowPointAt(date, lon)
|
|
if !shadow.Illuminated {
|
|
fmt.Println("no illuminated shadow")
|
|
return
|
|
}
|
|
fmt.Printf("x=%.6f y=%.6f\n", shadow.X, shadow.Y)
|
|
}
|
|
```
|
|
|
|
The dial length and returned coordinates use the same unit: a stylus length in centimetres gives shadow coordinates in centimetres. x points east and y north. Use the shadow only when `Illuminated` is true.
|
|
|
|
## API Reference
|
|
|
|
| Group | Entry points | Purpose | Units and convention |
|
|
| --- | --- | --- | --- |
|
|
| True/mean solar time | `TrueSolarTime` / `MeanSolarTime` | Local apparent/mean solar time at a longitude for an absolute instant | Local solar clock readings; longitude east positive (degrees) |
|
|
| Solar hour angle | `HourAngle` | Apparent solar hour angle | Degrees, negative in the morning and positive in the afternoon |
|
|
| Hour-angle helpers | `MeanSolarHourAngle` / `ZoneTimeHourAngle` | Turn a local mean-solar or zone clock reading into an apparent solar hour angle | Hours and longitude in degrees |
|
|
| Horizontal hour line | `HorizontalHourLineAngle` / `HorizontalHourLineAngleAt` | Hour-line angle of a horizontal dial relative to the noon line | Degrees |
|
|
| Planar dial core | `PlanarDial` (fields) + `Geometry` / `ShadowPointByHourAngleDeclination` / `ShadowPointAt` | Geometry and shadow point of an arbitrary plane | Coordinates share the stylus-length unit |
|
|
| Plate illumination | `PlaneIlluminatedHourAngleIntervals` / `IlluminatedHourAngleIntervals` | Plate-lit hour-angle intervals and the final usable intervals | Degrees, `[-180, 180]` |
|
|
| Time lines | `MeanSolarTimePoint` / `ZoneTimePoint` / `MeanSolarTimeLine` / `ZoneTimeLine` | Attach mean-solar or zone time lines directly to the dial geometry | Returns `PlanarShadowPoint` / `TimeLineSample` |
|
|
| Declination curves | `DeclinationCurve` / `DeclinationCurveAt` | Segmented sample chains by declination or by date | Hour-angle step in degrees |
|
|
| Special dials | `EquatorialNorthDial` / `EquatorialSouthDial` / `HorizontalDial` / `VerticalDial` | Equatorial (north/south face), horizontal and vertical dials | Latitude and normal azimuth in degrees |
|
|
|
|
The snippets below omit shared preamble variables: `date` (instant, civil time scale), `lon` (longitude, east positive, degrees), `lat` (latitude, degrees).
|
|
|
|
### True, mean solar time and the equation of time
|
|
|
|
```go
|
|
fmt.Println(sundial.TrueSolarTime(date, lon))
|
|
fmt.Println(sundial.MeanSolarTime(date, lon))
|
|
fmt.Println(sundial.TrueSolarTime(date, lon).Sub(sundial.MeanSolarTime(date, lon))) // equation of time
|
|
```
|
|
|
|
All three share the `sun` conventions: `TrueSolarTime` is apparent solar time, `MeanSolarTime` is local mean solar time, and their difference is the equation of time (apparent minus mean).
|
|
|
|
### Hour angle
|
|
|
|
```go
|
|
fmt.Println(sundial.HourAngle(date, lon)) // apparent solar hour angle, negative in the morning
|
|
fmt.Println(sundial.MeanSolarHourAngle(date, 9.5)) // hour angle for local mean solar time 9:30
|
|
fmt.Println(sundial.ZoneTimeHourAngle(date, lon, 9.5)) // hour angle for zone clock time 9:30
|
|
```
|
|
|
|
`HourAngle` solves the hour angle for an absolute instant; the other two answer "given a clock reading, where does the shadow point", one in local mean solar time and one in zone time.
|
|
|
|
### Horizontal hour-line angle
|
|
|
|
```go
|
|
fmt.Println(sundial.HorizontalHourLineAngle(31.2304, -45))
|
|
fmt.Println(sundial.HorizontalHourLineAngleAt(date, lon, 31.2304))
|
|
```
|
|
|
|
The first takes latitude and a signed hour angle, the second takes the instant and coordinates directly; both return the angle of the hour line relative to the noon line.
|
|
|
|
### Planar dial core
|
|
|
|
```go
|
|
dial := sundial.PlanarDial{
|
|
Latitude: 31.2304, PlaneNormalAzimuth: 180, PlaneNormalZenithDistance: 90, StylusLength: 10,
|
|
}
|
|
g := dial.Geometry()
|
|
fmt.Println(g.HasFiniteCenter, g.PolarStylusLength, g.PolarStylusPlaneAngle)
|
|
p := dial.ShadowPointByHourAngleDeclination(-45, 23.44)
|
|
fmt.Println(p.X, p.Y, p.Illuminated)
|
|
```
|
|
|
|
The four `PlanarDial` fields are latitude, plate-normal azimuth, normal zenith distance and stylus length. `Geometry` returns the dial centre (where the polar stylus is fixed), the polar-stylus length and its angle to the plate; `ShadowPointByHourAngleDeclination` takes a signed hour angle and the solar declination, while `ShadowPointAt` takes the instant and longitude instead.
|
|
|
|
### Plate illumination intervals
|
|
|
|
```go
|
|
for _, iv := range dial.PlaneIlluminatedHourAngleIntervals(23.44) {
|
|
fmt.Println(iv.Start, iv.End)
|
|
}
|
|
for _, iv := range dial.IlluminatedHourAngleIntervals(23.44) {
|
|
fmt.Println(iv.Start, iv.End)
|
|
}
|
|
```
|
|
|
|
The first only answers "does the plate face the Sun" (geometric illumination); the second also requires the Sun to be above the horizon and therefore gives the **final usable** hour-angle intervals.
|
|
|
|
Intervals live in `[-180, 180]` with `Start <= End`.
|
|
|
|
### Time lines and declination curves
|
|
|
|
```go
|
|
dates := []time.Time{date, date.Add(30 * time.Minute), date.Add(time.Hour)}
|
|
fmt.Println(len(dial.MeanSolarTimeLine(dates, 9.5)))
|
|
segs := dial.DeclinationCurve(23.44, 1.0)
|
|
segsAt := dial.DeclinationCurveAt(date, 1.0)
|
|
fmt.Println(len(segs), len(segsAt))
|
|
```
|
|
|
|
A time line projects the equal-instant points of a mean-solar-time line straight onto the dial as `TimeLineSample` values; a declination curve samples the plate for a fixed declination or for the declination of the day, and each segment's `Interval` is the usable hour-angle interval described above.
|
|
|
|
### Equatorial, horizontal and vertical dials
|
|
|
|
```go
|
|
h := sundial.HorizontalDial(31.2304, 10)
|
|
n := sundial.EquatorialNorthDial(31.2304, 10)
|
|
s := sundial.EquatorialSouthDial(31.2304, 10)
|
|
v := sundial.VerticalDial(31.2304, 180, 10)
|
|
fmt.Println(h.PlaneNormalZenithDistance, n.PlaneNormalAzimuth, s.PlaneNormalAzimuth)
|
|
fmt.Println(v.PlaneNormalAzimuth, v.PlaneNormalZenithDistance)
|
|
```
|
|
|
|
Each constructor sets the plate normal differently; when drawing, read it from the `PlanarDial` fields:
|
|
|
|
| Constructor | `PlaneNormalAzimuth` | `PlaneNormalZenithDistance` |
|
|
| --- | --- | --- |
|
|
| `HorizontalDial` | `180°` | `0°` (normal points at the zenith; x east, y north) |
|
|
| `EquatorialNorthDial` | `0°` | `90° - latitude` |
|
|
| `EquatorialSouthDial` | `180°` | `90° + latitude` |
|
|
| `VerticalDial` | argument normalised to `[0°, 360°)` | `90°` |
|
|
|
|
In the northern hemisphere the north-face equatorial dial serves the spring/summer half-year (positive solar declination) and the south face the autumn/winter half-year.
|
|
|
|
For `VerticalDial` the normal azimuth runs from north toward east, so a south-facing wall is `180` and an east-facing wall is `90`.
|
|
|
|
### Returned structures
|
|
|
|
| Type | Field | Meaning |
|
|
| --- | --- | --- |
|
|
| `PlanarShadowPoint` | `X` / `Y` | Shadow-point coordinates in the stylus-length unit |
|
|
| | `DenominatorQ` | Projection denominator; approaching zero means the shadow runs to infinity |
|
|
| | `SunAboveHorizon` / `PlaneIlluminated` / `Illuminated` | Sun above the horizon, plate lit, and the final combined test |
|
|
| `PlanarGeometry` | `CenterX` / `CenterY` | Dial centre (where the polar stylus is fixed) |
|
|
| | `PolarStylusLength` / `PolarStylusPlaneAngle` | Polar-stylus length and its angle to the plate |
|
|
| | `HasFiniteCenter` | False when the centre degenerates to infinity; the related quantities are `NaN` |
|
|
| `HourAngleInterval` | `Start` / `End` | Signed hour-angle interval in degrees, `Start <= End` |
|
|
| `TimeLineSample` | `Date` / `Declination` / `HourAngle` / `Point` | Instant, solar declination, apparent hour angle and the shadow point |
|
|
| `DeclinationCurveSegment` | `Declination` / `Interval` / `Samples` | Segment declination, usable hour-angle interval and sample chain |
|
|
|
|
### Complete example
|
|
|
|
```go
|
|
package main
|
|
|
|
import (
|
|
"fmt"
|
|
"time"
|
|
|
|
"b612.me/astro/sundial"
|
|
)
|
|
|
|
func main() {
|
|
date := time.Date(2026, 6, 21, 9, 30, 0, 0, time.FixedZone("CST", 8*3600))
|
|
lon, lat := 121.4737, 31.2304
|
|
|
|
trueSolar := sundial.TrueSolarTime(date, lon)
|
|
hourAngle := sundial.HourAngle(date, lon)
|
|
lineAngle := sundial.HorizontalHourLineAngle(lat, -45)
|
|
lineAngleNow := sundial.HorizontalHourLineAngleAt(date, lon, lat)
|
|
|
|
fmt.Println(trueSolar)
|
|
fmt.Printf("hour angle=%.6f line@9am=%.6f line@now=%.6f\n", hourAngle, lineAngle, lineAngleNow)
|
|
}
|
|
```
|
|
|
|
Output:
|
|
|
|
```text
|
|
2026-06-21 09:34:10.438158222 +0805 LTZ
|
|
hour angle=-36.456508 line@9am=-27.405871 line@now=-20.959182
|
|
```
|
|
|
|
The zone in the first line is a synthetic local apparent solar time zone (`+08:05`, `LTZ`, for longitude `121.4737`), so the printed value already shows how far local apparent solar time is from clock time.
|
|
|
|
### Combined example: usable hour angles and a time line
|
|
|
|
```go
|
|
dial := sundial.HorizontalDial(31.2304, 10)
|
|
for _, seg := range dial.DeclinationCurveAt(date, 1.0) {
|
|
fmt.Printf("decl=%.2f usable=%.2f..%.2f samples=%d\n", seg.Declination, seg.Interval.Start, seg.Interval.End, len(seg.Samples))
|
|
}
|
|
mean := sundial.MeanSolarTime(date, 121.4737)
|
|
samples := dial.MeanSolarTimeLine([]time.Time{mean, mean.Add(30 * time.Minute)}, 9.5)
|
|
fmt.Println(len(samples), samples[0].HourAngle)
|
|
```
|
|
|
|
Measured with `date = 2026-06-21 09:30 CST`, `121.4737 E, 31.2304 N` and a stylus of length 10:
|
|
|
|
```text
|
|
decl=23.44 usable=-105.24..105.24 samples=211
|
|
2 -37.92998436772365
|
|
```
|
|
|
|
The first line says that with the solar declination at `23.44` degrees, a horizontal dial at latitude `31.2304` is usable over hour angles `[-105.24, +105.24]` (about 14 hours) sampled at 211 points; the second gives the two samples of the "local mean solar time 9:30" time line and the first hour angle. Together the two quantities are enough to draw a horizontal dial with its usable range marked.
|
|
|
|
### Common pitfalls
|
|
|
|
- Treating the `date` of `ZoneTimePoint` as the site's local apparent solar time - it uses the date and zone only, and takes the clock reading from `zoneTimeHours`.
|
|
- Using `PlaneIlluminated` to decide whether a dial is usable during the day - use `Illuminated`, which also requires the Sun above the horizon.
|
|
- Passing the wall orientation to `VerticalDial` - `planeNormalAzimuth` is the **normal** azimuth, so a south-facing wall is `180`.
|
|
- Flipping the hour-angle sign - `HourAngle` is negative in the morning and positive in the afternoon, and `HorizontalHourLineAngle` follows the same convention.
|
|
- Assuming one interval per day - a day crossing midnight splits into several, so iterate over the returned slice.
|
|
|
|
## Usage examples
|
|
|
|
### Apparent solar time versus clock time
|
|
|
|
```go
|
|
trueSolar := sundial.TrueSolarTime(date, lon)
|
|
mean := sundial.MeanSolarTime(date, lon)
|
|
fmt.Println(trueSolar, mean)
|
|
fmt.Println(trueSolar.Sub(mean)) // equation of time
|
|
```
|
|
|
|
```text
|
|
2026-06-21 09:34:10.438158222 +0805 LTZ 2026-06-21 09:35:53.532790863 +0805 LTZ
|
|
-1m43.094632641s
|
|
```
|
|
|
|
- `TrueSolarTime` returns an instant in a synthetic zone, so subtracting your own clock time gives "how far apparent solar time runs ahead or behind".
|
|
- To turn a **clock reading** into an hour angle (for example to lay out shadow marks in zone time), use `MeanSolarHourAngle` / `ZoneTimeHourAngle` instead of adding the equation of time by hand.
|
|
|
|
### Horizontal hour-line angles and the shadow point
|
|
|
|
```go
|
|
dial := sundial.HorizontalDial(lat, 10)
|
|
fmt.Println(sundial.HorizontalHourLineAngle(lat, -45)) // hour-line angle at hour angle -45
|
|
fmt.Println(sundial.HorizontalHourLineAngleAt(date, lon, lat)) // hour-line angle right now
|
|
p := dial.ShadowPointAt(date, lon)
|
|
fmt.Println(p.X, p.Y, p.Illuminated)
|
|
```
|
|
|
|
```text
|
|
-27.40587112370779
|
|
-20.95918157094186
|
|
-6.511723246549 0.507600045956 true
|
|
```
|
|
|
|
- A horizontal dial uses `x` pointing east and `y` pointing north; `Illuminated` is the final test "Sun above the horizon and plate facing the Sun", so draw a shadow only when it is true.
|
|
- With an hour angle but no instant, use `ShadowPointByHourAngleDeclination`, which gives `-6.511402572556 0.506762879485 true` for the same geometry.
|
|
|
|
### Plate illumination intervals and time lines
|
|
|
|
```go
|
|
dial := sundial.HorizontalDial(31.2304, 10)
|
|
for _, seg := range dial.DeclinationCurveAt(date, 1.0) {
|
|
fmt.Printf("decl=%.2f usable=%.2f..%.2f samples=%d\n", seg.Declination, seg.Interval.Start, seg.Interval.End, len(seg.Samples))
|
|
}
|
|
mean := sundial.MeanSolarTime(date, 121.4737)
|
|
samples := dial.MeanSolarTimeLine([]time.Time{mean, mean.Add(30 * time.Minute)}, 9.5)
|
|
fmt.Println(len(samples), samples[0].HourAngle)
|
|
```
|
|
|
|
```text
|
|
decl=23.44 usable=-105.24..105.24 samples=211
|
|
2 -37.92998436772365
|
|
```
|
|
|
|
- `PlaneIlluminatedHourAngleIntervals` only asks whether the plate faces the Sun, while `IlluminatedHourAngleIntervals` also requires the Sun above the horizon; use the latter to answer "how long is this dial usable in a day".
|
|
- Intervals live in `[-180, 180]` with `Start <= End`, and a day crossing midnight splits into several; the full declination-curve and time-line APIs are under [Time lines and declination curves](#time-lines-and-declination-curves).
|
|
|
|
### Degenerate geometry and per-face dial conventions
|
|
|
|
```go
|
|
h := sundial.HorizontalDial(31.2304, 10)
|
|
n := sundial.EquatorialNorthDial(31.2304, 10)
|
|
s := sundial.EquatorialSouthDial(31.2304, 10)
|
|
v := sundial.VerticalDial(31.2304, 180, 10)
|
|
fmt.Println(h.PlaneNormalZenithDistance, n.PlaneNormalAzimuth, s.PlaneNormalAzimuth)
|
|
fmt.Println(v.PlaneNormalAzimuth, v.PlaneNormalZenithDistance)
|
|
```
|
|
|
|
```text
|
|
0 0 180
|
|
180 90
|
|
```
|
|
|
|
- **Degenerate case**: when the plate normal is perpendicular to the polar axis (equivalently the polar stylus is parallel to the plate), `Geometry().HasFiniteCenter` is `false`, `CenterX`/`CenterY`/`PolarStylusLength` are `NaN` and `PolarStylusPlaneAngle` is 0 - latitude `45`, normal azimuth `180` and normal zenith distance `45` is exactly such a point, and nothing drawn relative to the centre is usable.
|
|
- **Per-face conventions**: a horizontal plate's normal points at the zenith (zenith distance `0`); a vertical plate has zenith distance `90` and its `planeNormalAzimuth` is the **normal** direction (south-facing wall `180`, east-facing wall `90`); the equatorial north and south faces use `90 - latitude` and `90 + latitude`.
|
|
|
|
The full table of the four constructors is under [Equatorial, horizontal and vertical dials](#equatorial-horizontal-and-vertical-dials).
|
|
|
|
## Parameter and result conventions
|
|
|
|
- **Units**: hour angles, hour-line angles, declination, latitude, normal azimuth and normal zenith distance are all **degrees**.
|
|
|
|
`PlanarDial.StylusLength` and the returned `X`/`Y` share one length unit, which may be anything self-consistent (millimetres, metres or canvas coordinates).
|
|
- **Time scale**: observing inputs are civil instants. `TrueSolarTime` / `MeanSolarTime` return local solar clock readings; do not pass those readings back as new observing instants. See [Time scales](timescale.md).
|
|
- **Hour-angle sign**: `HourAngle` is negative in the morning and positive in the afternoon; `HourAngleInterval` uses `[-180, 180]` and guarantees `Start <= End`, so a day crossing midnight splits into several intervals.
|
|
- **The time zone of `date` (easy to get wrong)**: for `MeanSolarTimePoint` / `MeanSolarTimeLine` the `date` is the **local mean solar time of the target site** (usually the value returned by `MeanSolarTime(...)`).
|
|
|
|
`ZoneTimePoint` / `ZoneTimeLine` ignore the hour, minute and second of `date`, keep only its date and zone, and substitute the `zoneTimeHours` argument for the clock reading. Passing the wrong convention shifts the whole time line.
|
|
- **Three booleans gate a valid shadow**: `SunAboveHorizon` says the Sun is up, `PlaneIlluminated` says the plate faces the Sun, and `Illuminated` is the final test requiring both; draw a shadow only when `Illuminated` is true.
|
|
- **Degenerate case**: when the plate normal is perpendicular to the polar axis (equivalently, when the polar stylus is parallel to the plate), `PlanarGeometry.HasFiniteCenter` is `false`, `CenterX`/`CenterY`/`PolarStylusLength` are `NaN` and `PolarStylusPlaneAngle` is `0` - the centre is at infinity and nothing drawn relative to it is usable.
|
|
|
|
Latitude `45`, normal azimuth `180` and normal zenith distance `45` is exactly such a point.
|
|
- **`PlaneIlluminated` versus `Illuminated`**: the former is geometric only, the latter also requires the Sun above the horizon; use the latter when answering "how long is this dial usable in a day".
|
|
|
|
## Related manuals
|
|
|
|
- Apparent solar time, equation of time and solar position: [Sun and Moon](sun-moon.md)
|
|
- Hour angle, sidereal time and horizontal transforms: [Coordinate tools](coord.md)
|
|
- Time scale in figures: [Time Scale Declaration](map-geojson.md#time-scale-declaration)
|