Files
astro/doc/manual/en/sundial.md
T
b612 16c62a97d5 feat: 完善时标与天象几何计算并扩展输出接口
- 新增时标、ΔT 模型、质心时间与 UT1 支持
- 改进日月食、月掩、行星事件及路径边界计算
- 完善恒星三维自行与动态距离传播
- 扩展 SVG、GeoJSON、KML 输出与底层距离换算工具
- 整理中英文手册、示例资源及回归测试
2026-09-23 18:55:12 +08:00

18 KiB

Sundials and Apparent Solar Time

中文 | Back to README

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

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

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

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

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

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

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

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

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

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:

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

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:

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

trueSolar := sundial.TrueSolarTime(date, lon)
mean := sundial.MeanSolarTime(date, lon)
fmt.Println(trueSolar, mean)
fmt.Println(trueSolar.Sub(mean)) // equation of time
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

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)
-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

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)
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.

Degenerate geometry and per-face dial conventions

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)
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.

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.

  • 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".