feat: 扩展天文计算能力
- 新增日食、月食、本地可见性、中心线、半影区域、SVG 图示与沙罗周期信息 - 新增行星冲合、留、方照、物理星历、视直径、相位、亮肢角、轨道节点等计算 - 新增木星伽利略卫星位置、现象与接触事件计算 - 新增恒星星表、星座判定、自行修正与观测辅助能力 - 新增 coord、formula、orbit、sundial、lite/sun、lite/moon 等扩展包 - 完善农历年号、月相英文别名、视差角、大气质量、折射、日晷与双星计算 - 增加 NASA、JPL Horizons、IMCCE 等回归测试数据与基线测试 - 重构基础算法文件组织,补充大量公开 API 注释和语义回归测试 - 更新中文和英文 README,补充示例、精度说明、SVG 配图
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
// Package sundial provides apparent-solar-time helpers and planar-sundial
|
||||
// geometry utilities.
|
||||
//
|
||||
// 当前提供两层能力:
|
||||
// - 真太阳时换算
|
||||
// - 平太阳时换算
|
||||
// - 太阳时角
|
||||
// - 平太阳时 / 区时对应的时角与时间线采样
|
||||
// - 平面日晷通用几何(影尖坐标、日晷中心、极轴晷针)
|
||||
// - 平面日晷受光时角区间
|
||||
// - 赤纬曲线的分段采样
|
||||
// - 赤道 / 水平 / 垂直日晷特例
|
||||
// - 水平日晷时线角
|
||||
//
|
||||
// 对地方平太阳时时间线采样时,传入的 date 应处于目标地点的地方平太阳时区;
|
||||
// 对区时时间线采样时,date 负责提供民用日期与时区,原有钟面时间会被目标钟面读数替换。
|
||||
//
|
||||
// The package covers apparent solar time, mean solar time, hour-angle
|
||||
// conversions for mean or zone time, general planar sundial geometry,
|
||||
// illuminated hour-angle intervals, declination-curve sampling, and a few
|
||||
// common special cases such as equatorial, horizontal, and vertical dials.
|
||||
package sundial
|
||||
@@ -0,0 +1,514 @@
|
||||
package sundial
|
||||
|
||||
import (
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"b612.me/astro/sun"
|
||||
)
|
||||
|
||||
// PlanarDial 平面日晷参数 / planar-sundial parameters.
|
||||
//
|
||||
// Latitude 为地理纬度(北正南负);PlaneNormalAzimuth 为日晷盘面法线的方位角,
|
||||
// 按正北为 0°、向东增加;PlaneNormalZenithDistance 为盘面法线的天顶距,0° 表示水平日晷,
|
||||
// 90° 表示垂直日晷;StylusLength 为垂直于盘面的直晷针长度。
|
||||
//
|
||||
// 坐标系沿本章通用约定:x 轴位于盘面内且保持水平,y 轴沿盘面最大坡度向上,
|
||||
// x 正向为右侧,y 正向为上坡方向。
|
||||
type PlanarDial struct {
|
||||
Latitude float64
|
||||
PlaneNormalAzimuth float64
|
||||
PlaneNormalZenithDistance float64
|
||||
StylusLength float64
|
||||
}
|
||||
|
||||
// PlanarShadowPoint 平面日晷影尖位置 / shadow-tip position on a planar sundial.
|
||||
//
|
||||
// X/Y 为影尖在盘面坐标系中的坐标;DenominatorQ 对应书中公式里的 Q;
|
||||
// SunAboveHorizon 表示太阳在地平线上方;PlaneIlluminated 表示盘面被太阳照亮;
|
||||
// Illuminated 为前两者同时满足。
|
||||
type PlanarShadowPoint struct {
|
||||
X float64
|
||||
Y float64
|
||||
DenominatorQ float64
|
||||
SunAboveHorizon bool
|
||||
PlaneIlluminated bool
|
||||
Illuminated bool
|
||||
}
|
||||
|
||||
// PlanarGeometry 平面日晷几何量 / derived planar-sundial geometry.
|
||||
//
|
||||
// CenterX/CenterY 为日晷中心(极轴晷针固定点)坐标;PolarStylusLength 为极轴晷针长度;
|
||||
// PolarStylusPlaneAngle 为极轴晷针与盘面的夹角。HasFiniteCenter 为 false 时,
|
||||
// 表示极轴晷针与盘面平行,中心退化到无穷远处。
|
||||
type PlanarGeometry struct {
|
||||
CenterX float64
|
||||
CenterY float64
|
||||
PolarStylusLength float64
|
||||
PolarStylusPlaneAngle float64
|
||||
HasFiniteCenter bool
|
||||
}
|
||||
|
||||
// HourAngleInterval 时角区间 / hour-angle interval.
|
||||
//
|
||||
// Start/End 均为有符号太阳时角,单位度,满足 Start <= End。
|
||||
// 约定取值范围为 [-180, 180],用于表达一天中的一段连续时角。
|
||||
type HourAngleInterval struct {
|
||||
Start float64
|
||||
End float64
|
||||
}
|
||||
|
||||
// DeclinationCurveSample 赤纬曲线采样点 / sampled point on a declination curve.
|
||||
//
|
||||
// HourAngle 为采样点的太阳时角;Point 为该时角下的影尖位置与照明状态。
|
||||
type DeclinationCurveSample struct {
|
||||
HourAngle float64
|
||||
Point PlanarShadowPoint
|
||||
}
|
||||
|
||||
// DeclinationCurveSegment 赤纬曲线分段 / one illuminated segment of a declination curve.
|
||||
//
|
||||
// Interval 给出该段对应的连续受光时角范围;Samples 为该段内部的采样点。
|
||||
type DeclinationCurveSegment struct {
|
||||
Declination float64
|
||||
Interval HourAngleInterval
|
||||
Samples []DeclinationCurveSample
|
||||
}
|
||||
|
||||
// TimeLineSample 时间线采样点 / sampled point on a mean-time or zone-time line.
|
||||
//
|
||||
// Date 为该采样点对应的绝对时刻;其日期来自输入 date,钟面时间由调用参数指定。
|
||||
// Declination 为该采样瞬时的太阳赤纬;HourAngle 为换算后的视太阳时角;
|
||||
// Point 为该时角下的影尖位置与照明状态。
|
||||
type TimeLineSample struct {
|
||||
Date time.Time
|
||||
Declination float64
|
||||
HourAngle float64
|
||||
Point PlanarShadowPoint
|
||||
}
|
||||
|
||||
// EquatorialNorthDial 北面赤道日晷 / north-face equatorial dial.
|
||||
//
|
||||
// 北半球时用于春夏半年(太阳赤纬为正);南半球也可直接按公式使用。
|
||||
func EquatorialNorthDial(latitude, stylusLength float64) PlanarDial {
|
||||
return PlanarDial{
|
||||
Latitude: latitude,
|
||||
PlaneNormalAzimuth: 0,
|
||||
PlaneNormalZenithDistance: 90 - latitude,
|
||||
StylusLength: stylusLength,
|
||||
}
|
||||
}
|
||||
|
||||
// EquatorialSouthDial 南面赤道日晷 / south-face equatorial dial.
|
||||
//
|
||||
// 北半球时用于秋冬半年(太阳赤纬为负);南半球也可直接按公式使用。
|
||||
func EquatorialSouthDial(latitude, stylusLength float64) PlanarDial {
|
||||
return PlanarDial{
|
||||
Latitude: latitude,
|
||||
PlaneNormalAzimuth: 180,
|
||||
PlaneNormalZenithDistance: 90 + latitude,
|
||||
StylusLength: stylusLength,
|
||||
}
|
||||
}
|
||||
|
||||
// HorizontalDial 水平日晷 / horizontal dial.
|
||||
//
|
||||
// 该构造器采用经典水平日晷的坐标约定:x 轴向东,y 轴向北。
|
||||
func HorizontalDial(latitude, stylusLength float64) PlanarDial {
|
||||
return PlanarDial{
|
||||
Latitude: latitude,
|
||||
PlaneNormalAzimuth: 180,
|
||||
PlaneNormalZenithDistance: 0,
|
||||
StylusLength: stylusLength,
|
||||
}
|
||||
}
|
||||
|
||||
// VerticalDial 垂直日晷 / vertical dial.
|
||||
//
|
||||
// planeNormalAzimuth 为盘面法线方位角,按正北为 0°、向东增加。
|
||||
// 例如:朝南墙面取 180°,朝东墙面取 90°。
|
||||
func VerticalDial(latitude, planeNormalAzimuth, stylusLength float64) PlanarDial {
|
||||
return PlanarDial{
|
||||
Latitude: latitude,
|
||||
PlaneNormalAzimuth: normalize360(planeNormalAzimuth),
|
||||
PlaneNormalZenithDistance: 90,
|
||||
StylusLength: stylusLength,
|
||||
}
|
||||
}
|
||||
|
||||
// Geometry 返回平面日晷的中心与极轴晷针几何量 / returns the derived planar geometry.
|
||||
func (dial PlanarDial) Geometry() PlanarGeometry {
|
||||
geometry := PlanarGeometry{
|
||||
CenterX: math.NaN(),
|
||||
CenterY: math.NaN(),
|
||||
PolarStylusLength: math.NaN(),
|
||||
PolarStylusPlaneAngle: math.NaN(),
|
||||
}
|
||||
if !dial.isFinite() {
|
||||
return geometry
|
||||
}
|
||||
|
||||
P, _, _, _ := dial.baseCoefficients(0, 0)
|
||||
geometry.PolarStylusPlaneAngle = math.Asin(clampUnit(math.Abs(P))) * 180 / math.Pi
|
||||
if nearZero(P) {
|
||||
return geometry
|
||||
}
|
||||
|
||||
latSin, latCos := sinCosDeg(dial.Latitude)
|
||||
zedSin, zedCos := sinCosDeg(dial.PlaneNormalZenithDistance)
|
||||
declinationSin, declinationCos := sinCosDeg(dial.bookPlaneNormalAzimuth())
|
||||
geometry.CenterX = dial.StylusLength / P * latCos * declinationSin
|
||||
geometry.CenterY = -dial.StylusLength / P * (latSin*zedSin + latCos*zedCos*declinationCos)
|
||||
geometry.PolarStylusLength = dial.StylusLength / math.Abs(P)
|
||||
geometry.HasFiniteCenter = true
|
||||
return geometry
|
||||
}
|
||||
|
||||
// ShadowPointByHourAngleDeclination 影尖坐标(按时角与赤纬) / shadow point from hour angle and declination.
|
||||
//
|
||||
// hourAngle 为有符号太阳时角,上午为负,下午为正;declination 为太阳赤纬,单位度。
|
||||
func (dial PlanarDial) ShadowPointByHourAngleDeclination(hourAngle, declination float64) PlanarShadowPoint {
|
||||
point := PlanarShadowPoint{
|
||||
X: math.NaN(),
|
||||
Y: math.NaN(),
|
||||
DenominatorQ: math.NaN(),
|
||||
}
|
||||
if !dial.isFinite() || !isFinite(hourAngle) || !isFinite(declination) {
|
||||
return point
|
||||
}
|
||||
|
||||
_, Q, Nx, Ny := dial.baseCoefficients(hourAngle, declination)
|
||||
point.DenominatorQ = Q
|
||||
point.SunAboveHorizon = sunAboveHorizon(hourAngle, declination, dial.Latitude)
|
||||
point.PlaneIlluminated = Q > 0
|
||||
point.Illuminated = point.SunAboveHorizon && point.PlaneIlluminated
|
||||
if nearZero(Q) {
|
||||
return point
|
||||
}
|
||||
|
||||
point.X = dial.StylusLength * Nx / Q
|
||||
point.Y = dial.StylusLength * Ny / Q
|
||||
return point
|
||||
}
|
||||
|
||||
// ShadowPointAt 影尖坐标(按绝对时刻) / shadow point at an instant.
|
||||
//
|
||||
// 直接读取该时刻对应的视太阳时角和瞬时太阳赤纬,并返回平面日晷上的影尖位置。
|
||||
func (dial PlanarDial) ShadowPointAt(date time.Time, lon float64) PlanarShadowPoint {
|
||||
return dial.ShadowPointByHourAngleDeclination(HourAngle(date, lon), sun.ApparentDec(date))
|
||||
}
|
||||
|
||||
// MeanSolarTimePoint 平太阳时影尖位置 / shadow point for local mean solar time.
|
||||
//
|
||||
// date 应处于目标地点的地方平太阳时区,例如 `MeanSolarTime` 的返回值;其原有钟面时间会被忽略。
|
||||
// meanSolarHours 为地方平太阳时钟面读数,单位小时,例如 9.5 表示 09:30。
|
||||
func (dial PlanarDial) MeanSolarTimePoint(date time.Time, meanSolarHours float64) PlanarShadowPoint {
|
||||
sampleTime := dateWithClockHours(date, meanSolarHours)
|
||||
declination := sun.ApparentDec(sampleTime)
|
||||
return dial.ShadowPointByHourAngleDeclination(MeanSolarHourAngle(sampleTime, meanSolarHours), declination)
|
||||
}
|
||||
|
||||
// ZoneTimePoint 区时影尖位置 / shadow point for zone time.
|
||||
//
|
||||
// date 提供民用日期和时区,原有钟面时间会被忽略;zoneTimeHours 为该时区下的区时钟面读数。
|
||||
func (dial PlanarDial) ZoneTimePoint(date time.Time, lon, zoneTimeHours float64) PlanarShadowPoint {
|
||||
sampleTime := dateWithClockHours(date, zoneTimeHours)
|
||||
declination := sun.ApparentDec(sampleTime)
|
||||
return dial.ShadowPointByHourAngleDeclination(HourAngle(sampleTime, lon), declination)
|
||||
}
|
||||
|
||||
// MeanSolarTimeLine 平太阳时时间线 / local mean solar time line.
|
||||
//
|
||||
// dates 由调用者自行决定取样日期密度,例如每月或每日;每个 date 都应处于目标地点的地方平太阳时区,
|
||||
// 例如先通过 `MeanSolarTime` 得到对应地点的地方平太阳时再取其年月日。meanSolarHours 为地方平太阳时钟面读数。
|
||||
func (dial PlanarDial) MeanSolarTimeLine(dates []time.Time, meanSolarHours float64) []TimeLineSample {
|
||||
if !isFinite(meanSolarHours) {
|
||||
return nil
|
||||
}
|
||||
samples := make([]TimeLineSample, 0, len(dates))
|
||||
for _, date := range dates {
|
||||
sampleTime := dateWithClockHours(date, meanSolarHours)
|
||||
declination := sun.ApparentDec(sampleTime)
|
||||
hourAngle := MeanSolarHourAngle(sampleTime, meanSolarHours)
|
||||
samples = append(samples, TimeLineSample{
|
||||
Date: sampleTime,
|
||||
Declination: declination,
|
||||
HourAngle: hourAngle,
|
||||
Point: dial.ShadowPointByHourAngleDeclination(hourAngle, declination),
|
||||
})
|
||||
}
|
||||
return samples
|
||||
}
|
||||
|
||||
// ZoneTimeLine 区时时间线 / zone-time line.
|
||||
//
|
||||
// dates 由调用者自行决定取样日期密度;zoneTimeHours 为 date 所在时区的区时钟面读数。
|
||||
// 每个 date 的原有钟面时间都会被 zoneTimeHours 替换。
|
||||
func (dial PlanarDial) ZoneTimeLine(dates []time.Time, lon, zoneTimeHours float64) []TimeLineSample {
|
||||
if !isFinite(zoneTimeHours) || !isFinite(lon) {
|
||||
return nil
|
||||
}
|
||||
samples := make([]TimeLineSample, 0, len(dates))
|
||||
for _, date := range dates {
|
||||
sampleTime := dateWithClockHours(date, zoneTimeHours)
|
||||
declination := sun.ApparentDec(sampleTime)
|
||||
hourAngle := HourAngle(sampleTime, lon)
|
||||
samples = append(samples, TimeLineSample{
|
||||
Date: sampleTime,
|
||||
Declination: declination,
|
||||
HourAngle: hourAngle,
|
||||
Point: dial.ShadowPointByHourAngleDeclination(hourAngle, declination),
|
||||
})
|
||||
}
|
||||
return samples
|
||||
}
|
||||
|
||||
// PlaneIlluminatedHourAngleIntervals 盘面受光时角区间 / plane-illuminated hour-angle intervals.
|
||||
//
|
||||
// declination 为太阳赤纬,单位度。返回的区间只考虑盘面受光,不判断太阳是否在地平线上方。
|
||||
func (dial PlanarDial) PlaneIlluminatedHourAngleIntervals(declination float64) []HourAngleInterval {
|
||||
if !dial.isFinite() || !isFinite(declination) {
|
||||
return nil
|
||||
}
|
||||
sinCoeff, cosCoeff, constant := dial.qCoefficients(declination)
|
||||
return positiveHourAngleIntervals(sinCoeff, cosCoeff, constant)
|
||||
}
|
||||
|
||||
// IlluminatedHourAngleIntervals 可见且受光时角区间 / illuminated hour-angle intervals.
|
||||
//
|
||||
// declination 为太阳赤纬,单位度。结果可直接用于日晷绘图时筛掉无效时线。
|
||||
func (dial PlanarDial) IlluminatedHourAngleIntervals(declination float64) []HourAngleInterval {
|
||||
aboveHorizon := SunAboveHorizonHourAngleIntervals(dial.Latitude, declination)
|
||||
planeLit := dial.PlaneIlluminatedHourAngleIntervals(declination)
|
||||
return intersectHourAngleIntervals(aboveHorizon, planeLit)
|
||||
}
|
||||
|
||||
// DeclinationCurve 赤纬曲线采样 / declination-curve samples.
|
||||
//
|
||||
// declination 为太阳赤纬,单位度;hourAngleStep 为采样步长,单位度,常用值是 15°(每小时一格)。
|
||||
// 返回值按受光区间分段,每段都带有精确的时角范围;Samples 只包含区间内部的有效采样点。
|
||||
func (dial PlanarDial) DeclinationCurve(declination, hourAngleStep float64) []DeclinationCurveSegment {
|
||||
if !dial.isFinite() || !isFinite(declination) || !isFinite(hourAngleStep) || hourAngleStep <= 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
intervals := dial.IlluminatedHourAngleIntervals(declination)
|
||||
segments := make([]DeclinationCurveSegment, 0, len(intervals))
|
||||
for _, interval := range intervals {
|
||||
sampleAngles := intervalInteriorSampleAngles(interval, hourAngleStep)
|
||||
samples := make([]DeclinationCurveSample, 0, len(sampleAngles))
|
||||
for _, hourAngle := range sampleAngles {
|
||||
samples = append(samples, DeclinationCurveSample{
|
||||
HourAngle: hourAngle,
|
||||
Point: dial.ShadowPointByHourAngleDeclination(hourAngle, declination),
|
||||
})
|
||||
}
|
||||
segments = append(segments, DeclinationCurveSegment{
|
||||
Declination: declination,
|
||||
Interval: interval,
|
||||
Samples: samples,
|
||||
})
|
||||
}
|
||||
return segments
|
||||
}
|
||||
|
||||
// DeclinationCurveAt 瞬时赤纬曲线采样 / declination-curve samples at an instant.
|
||||
//
|
||||
// 用 date 对应瞬时太阳赤纬生成日晷分段曲线采样。
|
||||
func (dial PlanarDial) DeclinationCurveAt(date time.Time, hourAngleStep float64) []DeclinationCurveSegment {
|
||||
return dial.DeclinationCurve(sun.ApparentDec(date), hourAngleStep)
|
||||
}
|
||||
|
||||
// SunAboveHorizonHourAngleIntervals 地平线上方时角区间 / above-horizon hour-angle intervals.
|
||||
//
|
||||
// latitude 为地理纬度,declination 为太阳赤纬,单位度。结果只反映太阳是否升到地平线上方,
|
||||
// 不包含盘面朝向的影响。
|
||||
func SunAboveHorizonHourAngleIntervals(latitude, declination float64) []HourAngleInterval {
|
||||
if !isFinite(latitude) || !isFinite(declination) {
|
||||
return nil
|
||||
}
|
||||
|
||||
latRad := latitude * math.Pi / 180
|
||||
declinationRad := declination * math.Pi / 180
|
||||
threshold := -math.Tan(latRad) * math.Tan(declinationRad)
|
||||
if threshold <= -1 {
|
||||
return fullDayHourAngleIntervals()
|
||||
}
|
||||
if threshold >= 1 {
|
||||
return nil
|
||||
}
|
||||
halfWidth := math.Acos(clampUnit(threshold)) * 180 / math.Pi
|
||||
if nearZero(halfWidth) {
|
||||
return nil
|
||||
}
|
||||
return []HourAngleInterval{{Start: -halfWidth, End: halfWidth}}
|
||||
}
|
||||
|
||||
func (dial PlanarDial) baseCoefficients(hourAngle, declination float64) (P, Q, Nx, Ny float64) {
|
||||
latSin, latCos := sinCosDeg(dial.Latitude)
|
||||
zedSin, zedCos := sinCosDeg(dial.PlaneNormalZenithDistance)
|
||||
declinationSin, declinationCos := sinCosDeg(dial.bookPlaneNormalAzimuth())
|
||||
hourAngleSin, hourAngleCos := sinCosDeg(hourAngle)
|
||||
declinationTan := math.Tan(declination * math.Pi / 180)
|
||||
|
||||
P = latSin*zedCos - latCos*zedSin*declinationCos
|
||||
Q = declinationSin*zedSin*hourAngleSin +
|
||||
(latCos*zedCos+latSin*zedSin*declinationCos)*hourAngleCos +
|
||||
P*declinationTan
|
||||
Nx = declinationCos*hourAngleSin -
|
||||
declinationSin*(latSin*hourAngleCos-latCos*declinationTan)
|
||||
Ny = zedCos*declinationSin*hourAngleSin -
|
||||
(latCos*zedSin-latSin*zedCos*declinationCos)*hourAngleCos -
|
||||
(latSin*zedSin+latCos*zedCos*declinationCos)*declinationTan
|
||||
return P, Q, Nx, Ny
|
||||
}
|
||||
|
||||
func (dial PlanarDial) bookPlaneNormalAzimuth() float64 {
|
||||
return normalize360(dial.PlaneNormalAzimuth - 180)
|
||||
}
|
||||
|
||||
func (dial PlanarDial) qCoefficients(declination float64) (sinCoeff, cosCoeff, constant float64) {
|
||||
latSin, latCos := sinCosDeg(dial.Latitude)
|
||||
zedSin, zedCos := sinCosDeg(dial.PlaneNormalZenithDistance)
|
||||
declinationSin, declinationCos := sinCosDeg(dial.bookPlaneNormalAzimuth())
|
||||
P := latSin*zedCos - latCos*zedSin*declinationCos
|
||||
return declinationSin * zedSin,
|
||||
latCos*zedCos + latSin*zedSin*declinationCos,
|
||||
P * math.Tan(declination*math.Pi/180)
|
||||
}
|
||||
|
||||
func (dial PlanarDial) isFinite() bool {
|
||||
return isFinite(dial.Latitude) &&
|
||||
isFinite(dial.PlaneNormalAzimuth) &&
|
||||
isFinite(dial.PlaneNormalZenithDistance) &&
|
||||
isFinite(dial.StylusLength)
|
||||
}
|
||||
|
||||
func sunAboveHorizon(hourAngle, declination, latitude float64) bool {
|
||||
latSin, latCos := sinCosDeg(latitude)
|
||||
decSin, decCos := sinCosDeg(declination)
|
||||
_, hourAngleCos := sinCosDeg(hourAngle)
|
||||
return latSin*decSin+latCos*decCos*hourAngleCos > 0
|
||||
}
|
||||
|
||||
func sinCosDeg(value float64) (sinValue, cosValue float64) {
|
||||
rad := value * math.Pi / 180
|
||||
return math.Sin(rad), math.Cos(rad)
|
||||
}
|
||||
|
||||
func normalize360(value float64) float64 {
|
||||
value = math.Mod(value, 360)
|
||||
if value < 0 {
|
||||
value += 360
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func clampUnit(value float64) float64 {
|
||||
if value > 1 {
|
||||
return 1
|
||||
}
|
||||
if value < -1 {
|
||||
return -1
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func nearZero(value float64) bool {
|
||||
return math.Abs(value) <= 1e-15
|
||||
}
|
||||
|
||||
func positiveHourAngleIntervals(sinCoeff, cosCoeff, constant float64) []HourAngleInterval {
|
||||
radius := math.Hypot(sinCoeff, cosCoeff)
|
||||
if nearZero(radius) {
|
||||
if constant > 0 {
|
||||
return fullDayHourAngleIntervals()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
threshold := -constant / radius
|
||||
if threshold <= -1 {
|
||||
return fullDayHourAngleIntervals()
|
||||
}
|
||||
if threshold >= 1 {
|
||||
return nil
|
||||
}
|
||||
|
||||
center := math.Atan2(sinCoeff, cosCoeff) * 180 / math.Pi
|
||||
halfWidth := math.Acos(clampUnit(threshold)) * 180 / math.Pi
|
||||
if nearZero(halfWidth) {
|
||||
return nil
|
||||
}
|
||||
return splitWrappedSignedInterval(center-halfWidth, center+halfWidth)
|
||||
}
|
||||
|
||||
func splitWrappedSignedInterval(start, end float64) []HourAngleInterval {
|
||||
if end-start >= 360-negligibleHourAngle {
|
||||
return fullDayHourAngleIntervals()
|
||||
}
|
||||
for start < -180 {
|
||||
start += 360
|
||||
end += 360
|
||||
}
|
||||
for start >= 180 {
|
||||
start -= 360
|
||||
end -= 360
|
||||
}
|
||||
if end <= 180 {
|
||||
return []HourAngleInterval{{Start: start, End: end}}
|
||||
}
|
||||
return []HourAngleInterval{
|
||||
{Start: -180, End: end - 360},
|
||||
{Start: start, End: 180},
|
||||
}
|
||||
}
|
||||
|
||||
func intersectHourAngleIntervals(a, b []HourAngleInterval) []HourAngleInterval {
|
||||
if len(a) == 0 || len(b) == 0 {
|
||||
return nil
|
||||
}
|
||||
intersections := make([]HourAngleInterval, 0, len(a)+len(b))
|
||||
for i, j := 0, 0; i < len(a) && j < len(b); {
|
||||
start := math.Max(a[i].Start, b[j].Start)
|
||||
end := math.Min(a[i].End, b[j].End)
|
||||
if end-start > negligibleHourAngle {
|
||||
intersections = append(intersections, HourAngleInterval{Start: start, End: end})
|
||||
}
|
||||
switch {
|
||||
case a[i].End < b[j].End-negligibleHourAngle:
|
||||
i++
|
||||
case b[j].End < a[i].End-negligibleHourAngle:
|
||||
j++
|
||||
default:
|
||||
i++
|
||||
j++
|
||||
}
|
||||
}
|
||||
return intersections
|
||||
}
|
||||
|
||||
func intervalInteriorSampleAngles(interval HourAngleInterval, step float64) []float64 {
|
||||
if !isFinite(interval.Start) || !isFinite(interval.End) || !isFinite(step) || step <= 0 {
|
||||
return nil
|
||||
}
|
||||
if interval.End-interval.Start <= negligibleHourAngle {
|
||||
return nil
|
||||
}
|
||||
|
||||
samples := make([]float64, 0, int(math.Ceil((interval.End-interval.Start)/step)))
|
||||
first := math.Ceil((interval.Start+negligibleHourAngle)/step) * step
|
||||
for hourAngle := first; hourAngle < interval.End-negligibleHourAngle; hourAngle += step {
|
||||
samples = append(samples, hourAngle)
|
||||
}
|
||||
if len(samples) == 0 {
|
||||
samples = append(samples, (interval.Start+interval.End)/2)
|
||||
}
|
||||
return samples
|
||||
}
|
||||
|
||||
func fullDayHourAngleIntervals() []HourAngleInterval {
|
||||
return []HourAngleInterval{{Start: -180, End: 180}}
|
||||
}
|
||||
|
||||
const negligibleHourAngle = 1e-12
|
||||
@@ -0,0 +1,340 @@
|
||||
package sundial
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"b612.me/astro/sun"
|
||||
)
|
||||
|
||||
func TestPlanarShadowPointMatchesBookExamples(t *testing.T) {
|
||||
dial := PlanarDial{
|
||||
Latitude: 40,
|
||||
PlaneNormalAzimuth: 250,
|
||||
PlaneNormalZenithDistance: 50,
|
||||
StylusLength: 1,
|
||||
}
|
||||
|
||||
point := dial.ShadowPointByHourAngleDeclination(30, 23.44)
|
||||
assertClose(t, "58a x", point.X, -0.0390, 1e-4)
|
||||
assertClose(t, "58a y", point.Y, -0.3615, 1e-4)
|
||||
if !point.Illuminated {
|
||||
t.Fatalf("58a point should be illuminated")
|
||||
}
|
||||
|
||||
point = dial.ShadowPointByHourAngleDeclination(-15, -11.47)
|
||||
assertClose(t, "58a x second", point.X, -2.0007, 1e-4)
|
||||
assertClose(t, "58a y second", point.Y, -1.1069, 1e-4)
|
||||
if !point.Illuminated {
|
||||
t.Fatalf("58a second point should be illuminated")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanarGeometryMatchesBookExamples(t *testing.T) {
|
||||
dial := PlanarDial{
|
||||
Latitude: 40,
|
||||
PlaneNormalAzimuth: 250,
|
||||
PlaneNormalZenithDistance: 50,
|
||||
StylusLength: 1,
|
||||
}
|
||||
geometry := dial.Geometry()
|
||||
if !geometry.HasFiniteCenter {
|
||||
t.Fatalf("58a geometry should have finite center")
|
||||
}
|
||||
assertClose(t, "58a center x", geometry.CenterX, 3.3880, 1e-4)
|
||||
assertClose(t, "58a center y", geometry.CenterY, -3.1102, 1e-4)
|
||||
assertClose(t, "58a polar angle", geometry.PolarStylusPlaneAngle, 12.2672, 5e-4)
|
||||
|
||||
vertical := VerticalDial(-35, 340, 1)
|
||||
point := vertical.ShadowPointByHourAngleDeclination(45, 0)
|
||||
assertClose(t, "58b x first", point.X, -0.8439, 1e-4)
|
||||
assertClose(t, "58b y first", point.Y, -0.9298, 1e-4)
|
||||
|
||||
point = vertical.ShadowPointByHourAngleDeclination(0, 20.15)
|
||||
assertClose(t, "58b x second", point.X, 0.3640, 1e-4)
|
||||
assertClose(t, "58b y second", point.Y, -0.7410, 1e-4)
|
||||
|
||||
geometry = vertical.Geometry()
|
||||
if !geometry.HasFiniteCenter {
|
||||
t.Fatalf("58b geometry should have finite center")
|
||||
}
|
||||
assertClose(t, "58b center x", geometry.CenterX, 0.3640, 1e-4)
|
||||
assertClose(t, "58b center y", geometry.CenterY, 0.7451, 1e-4)
|
||||
assertClose(t, "58b polar angle", geometry.PolarStylusPlaneAngle, 50.3315, 5e-4)
|
||||
}
|
||||
|
||||
func TestPlanarIlluminationMatchesBookExample58c(t *testing.T) {
|
||||
dial := PlanarDial{
|
||||
Latitude: 40,
|
||||
PlaneNormalAzimuth: 340,
|
||||
PlaneNormalZenithDistance: 75,
|
||||
StylusLength: 1,
|
||||
}
|
||||
declination := 23.44
|
||||
|
||||
if !dial.ShadowPointByHourAngleDeclination(-105, declination).Illuminated {
|
||||
t.Fatalf("58c -105° should be illuminated")
|
||||
}
|
||||
if !dial.ShadowPointByHourAngleDeclination(-90, declination).Illuminated {
|
||||
t.Fatalf("58c -90° should be illuminated")
|
||||
}
|
||||
if dial.ShadowPointByHourAngleDeclination(-75, declination).PlaneIlluminated {
|
||||
t.Fatalf("58c -75° should leave the plane unilluminated")
|
||||
}
|
||||
if dial.ShadowPointByHourAngleDeclination(0, declination).PlaneIlluminated {
|
||||
t.Fatalf("58c 0° should remain unilluminated on the plane")
|
||||
}
|
||||
if !dial.ShadowPointByHourAngleDeclination(15, declination).Illuminated {
|
||||
t.Fatalf("58c +15° should be illuminated again")
|
||||
}
|
||||
if !dial.ShadowPointByHourAngleDeclination(105, declination).Illuminated {
|
||||
t.Fatalf("58c +105° should be illuminated")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSunAboveHorizonHourAngleIntervals(t *testing.T) {
|
||||
intervals := SunAboveHorizonHourAngleIntervals(40, 0)
|
||||
if len(intervals) != 1 {
|
||||
t.Fatalf("expected one horizon interval, got %d", len(intervals))
|
||||
}
|
||||
assertClose(t, "equinox rise", intervals[0].Start, -90, 1e-12)
|
||||
assertClose(t, "equinox set", intervals[0].End, 90, 1e-12)
|
||||
|
||||
if len(SunAboveHorizonHourAngleIntervals(80, -23.44)) != 0 {
|
||||
t.Fatalf("polar night should have no above-horizon interval")
|
||||
}
|
||||
|
||||
intervals = SunAboveHorizonHourAngleIntervals(80, 23.44)
|
||||
if len(intervals) != 1 || intervals[0].Start != -180 || intervals[0].End != 180 {
|
||||
t.Fatalf("polar day should cover the full day, got %+v", intervals)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIlluminatedHourAngleIntervalsMatchBookExample58c(t *testing.T) {
|
||||
dial := PlanarDial{
|
||||
Latitude: 40,
|
||||
PlaneNormalAzimuth: 340,
|
||||
PlaneNormalZenithDistance: 75,
|
||||
StylusLength: 1,
|
||||
}
|
||||
|
||||
intervals := dial.IlluminatedHourAngleIntervals(23.44)
|
||||
if len(intervals) != 2 {
|
||||
t.Fatalf("expected two illuminated intervals, got %d", len(intervals))
|
||||
}
|
||||
assertClose(t, "58c first start", intervals[0].Start, -111.33, 0.05)
|
||||
assertClose(t, "58c first end", intervals[0].End, -84, 1.0)
|
||||
assertClose(t, "58c second start", intervals[1].Start, 2, 1.0)
|
||||
assertClose(t, "58c second end", intervals[1].End, 111, 1.0)
|
||||
}
|
||||
|
||||
func TestDeclinationCurveSplitsByIlluminationIntervals(t *testing.T) {
|
||||
dial := PlanarDial{
|
||||
Latitude: 40,
|
||||
PlaneNormalAzimuth: 340,
|
||||
PlaneNormalZenithDistance: 75,
|
||||
StylusLength: 1,
|
||||
}
|
||||
|
||||
segments := dial.DeclinationCurve(23.44, 15)
|
||||
if len(segments) != 2 {
|
||||
t.Fatalf("expected two curve segments, got %d", len(segments))
|
||||
}
|
||||
assertClose(t, "segment first start", segments[0].Interval.Start, -111.33, 0.05)
|
||||
assertClose(t, "segment first end", segments[0].Interval.End, -84, 1.0)
|
||||
assertClose(t, "segment second start", segments[1].Interval.Start, 2, 1.0)
|
||||
assertClose(t, "segment second end", segments[1].Interval.End, 111, 1.0)
|
||||
|
||||
firstHours := []float64{-105, -90}
|
||||
secondHours := []float64{15, 30, 45, 60, 75, 90, 105}
|
||||
if len(segments[0].Samples) != len(firstHours) {
|
||||
t.Fatalf("unexpected first segment sample count: got %d want %d", len(segments[0].Samples), len(firstHours))
|
||||
}
|
||||
if len(segments[1].Samples) != len(secondHours) {
|
||||
t.Fatalf("unexpected second segment sample count: got %d want %d", len(segments[1].Samples), len(secondHours))
|
||||
}
|
||||
for index, hourAngle := range firstHours {
|
||||
assertClose(t, "first segment hour angle", segments[0].Samples[index].HourAngle, hourAngle, 1e-12)
|
||||
if !segments[0].Samples[index].Point.Illuminated {
|
||||
t.Fatalf("first segment sample %d should be illuminated", index)
|
||||
}
|
||||
}
|
||||
for index, hourAngle := range secondHours {
|
||||
assertClose(t, "second segment hour angle", segments[1].Samples[index].HourAngle, hourAngle, 1e-12)
|
||||
if !segments[1].Samples[index].Point.Illuminated {
|
||||
t.Fatalf("second segment sample %d should be illuminated", index)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeclinationCurveAtMatchesDeclinationChain(t *testing.T) {
|
||||
dial := HorizontalDial(31.2304, 1)
|
||||
date := time.Date(2026, 6, 21, 9, 30, 0, 0, time.FixedZone("CST", 8*3600))
|
||||
|
||||
got := dial.DeclinationCurveAt(date, 15)
|
||||
want := dial.DeclinationCurve(sunDeclination(date), 15)
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("segment count mismatch: got %d want %d", len(got), len(want))
|
||||
}
|
||||
for segmentIndex := range got {
|
||||
assertClose(t, "curve start", got[segmentIndex].Interval.Start, want[segmentIndex].Interval.Start, 1e-12)
|
||||
assertClose(t, "curve end", got[segmentIndex].Interval.End, want[segmentIndex].Interval.End, 1e-12)
|
||||
if len(got[segmentIndex].Samples) != len(want[segmentIndex].Samples) {
|
||||
t.Fatalf("sample count mismatch in segment %d: got %d want %d", segmentIndex, len(got[segmentIndex].Samples), len(want[segmentIndex].Samples))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeanSolarTimePointMatchesHourAngleDeclinationChain(t *testing.T) {
|
||||
dial := HorizontalDial(31.2304, 1)
|
||||
lon := 121.4737
|
||||
date := MeanSolarTime(time.Date(2026, 6, 21, 12, 0, 0, 0, time.FixedZone("CST", 8*3600)), lon)
|
||||
meanSolarHours := 9.5
|
||||
sampleTime := dateWithClockHours(date, meanSolarHours)
|
||||
|
||||
got := dial.MeanSolarTimePoint(date, meanSolarHours)
|
||||
want := dial.ShadowPointByHourAngleDeclination(HourAngle(sampleTime, longitudeFromTimeZone(sampleTime)), sunDeclination(sampleTime))
|
||||
assertClose(t, "mean solar point x", got.X, want.X, 1e-12)
|
||||
assertClose(t, "mean solar point y", got.Y, want.Y, 1e-12)
|
||||
if got.Illuminated != want.Illuminated {
|
||||
t.Fatalf("mean solar point illumination mismatch: got %v want %v", got.Illuminated, want.Illuminated)
|
||||
}
|
||||
}
|
||||
|
||||
func TestZoneTimePointMatchesHourAngleDeclinationChain(t *testing.T) {
|
||||
dial := HorizontalDial(31.2304, 1)
|
||||
date := time.Date(2026, 6, 21, 12, 0, 0, 0, time.FixedZone("CST", 8*3600))
|
||||
lon := 121.4737
|
||||
zoneTimeHours := 9.5
|
||||
sampleTime := dateWithClockHours(date, zoneTimeHours)
|
||||
|
||||
got := dial.ZoneTimePoint(date, lon, zoneTimeHours)
|
||||
want := dial.ShadowPointByHourAngleDeclination(HourAngle(sampleTime, lon), sunDeclination(sampleTime))
|
||||
assertClose(t, "zone time point x", got.X, want.X, 1e-12)
|
||||
assertClose(t, "zone time point y", got.Y, want.Y, 1e-12)
|
||||
if got.Illuminated != want.Illuminated {
|
||||
t.Fatalf("zone time point illumination mismatch: got %v want %v", got.Illuminated, want.Illuminated)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeanSolarAndZoneTimeLinesMatchPointHelpers(t *testing.T) {
|
||||
dial := HorizontalDial(31.2304, 1)
|
||||
lon := 121.4737
|
||||
dates := []time.Time{
|
||||
MeanSolarTime(time.Date(2026, 3, 21, 12, 0, 0, 0, time.FixedZone("CST", 8*3600)), lon),
|
||||
MeanSolarTime(time.Date(2026, 6, 21, 12, 0, 0, 0, time.FixedZone("CST", 8*3600)), lon),
|
||||
MeanSolarTime(time.Date(2026, 9, 23, 12, 0, 0, 0, time.FixedZone("CST", 8*3600)), lon),
|
||||
}
|
||||
meanSolarHours := 10.0
|
||||
zoneTimeHours := 10.0
|
||||
|
||||
meanSamples := dial.MeanSolarTimeLine(dates, meanSolarHours)
|
||||
if len(meanSamples) != len(dates) {
|
||||
t.Fatalf("mean-solar line sample count mismatch: got %d want %d", len(meanSamples), len(dates))
|
||||
}
|
||||
for index, sample := range meanSamples {
|
||||
expectedTime := dateWithClockHours(dates[index], meanSolarHours)
|
||||
if !sample.Date.Equal(expectedTime) {
|
||||
t.Fatalf("mean-solar line sample instant mismatch at %d: got %s want %s", index, sample.Date, expectedTime)
|
||||
}
|
||||
assertClose(t, "mean-solar line hour angle", sample.HourAngle, HourAngle(expectedTime, longitudeFromTimeZone(expectedTime)), 1e-12)
|
||||
assertClose(t, "mean-solar line declination", sample.Declination, sunDeclination(expectedTime), 1e-12)
|
||||
assertClose(t, "mean-solar line x", sample.Point.X, dial.MeanSolarTimePoint(dates[index], meanSolarHours).X, 1e-12)
|
||||
assertClose(t, "mean-solar line y", sample.Point.Y, dial.MeanSolarTimePoint(dates[index], meanSolarHours).Y, 1e-12)
|
||||
}
|
||||
|
||||
zoneDates := []time.Time{
|
||||
time.Date(2026, 3, 21, 12, 0, 0, 0, time.FixedZone("CST", 8*3600)),
|
||||
time.Date(2026, 6, 21, 12, 0, 0, 0, time.FixedZone("CST", 8*3600)),
|
||||
time.Date(2026, 9, 23, 12, 0, 0, 0, time.FixedZone("CST", 8*3600)),
|
||||
}
|
||||
zoneSamples := dial.ZoneTimeLine(zoneDates, lon, zoneTimeHours)
|
||||
if len(zoneSamples) != len(dates) {
|
||||
t.Fatalf("zone-time line sample count mismatch: got %d want %d", len(zoneSamples), len(dates))
|
||||
}
|
||||
for index, sample := range zoneSamples {
|
||||
expectedTime := dateWithClockHours(zoneDates[index], zoneTimeHours)
|
||||
if !sample.Date.Equal(expectedTime) {
|
||||
t.Fatalf("zone-time line sample instant mismatch at %d: got %s want %s", index, sample.Date, expectedTime)
|
||||
}
|
||||
assertClose(t, "zone-time line hour angle", sample.HourAngle, HourAngle(expectedTime, lon), 1e-12)
|
||||
assertClose(t, "zone-time line declination", sample.Declination, sunDeclination(expectedTime), 1e-12)
|
||||
assertClose(t, "zone-time line x", sample.Point.X, dial.ZoneTimePoint(zoneDates[index], lon, zoneTimeHours).X, 1e-12)
|
||||
assertClose(t, "zone-time line y", sample.Point.Y, dial.ZoneTimePoint(zoneDates[index], lon, zoneTimeHours).Y, 1e-12)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpecialDialConstructorsMatchKnownGeometry(t *testing.T) {
|
||||
horizontal := HorizontalDial(45, 1)
|
||||
geometry := horizontal.Geometry()
|
||||
if !geometry.HasFiniteCenter {
|
||||
t.Fatalf("horizontal dial should have finite center")
|
||||
}
|
||||
assertClose(t, "horizontal center x", geometry.CenterX, 0, 1e-12)
|
||||
assertClose(t, "horizontal center y", geometry.CenterY, -1, 1e-12)
|
||||
assertClose(t, "horizontal polar length", geometry.PolarStylusLength, math.Sqrt2, 1e-12)
|
||||
assertClose(t, "horizontal polar angle", geometry.PolarStylusPlaneAngle, 45, 1e-12)
|
||||
|
||||
equatorialNorth := EquatorialNorthDial(40, 1)
|
||||
geometry = equatorialNorth.Geometry()
|
||||
if !geometry.HasFiniteCenter {
|
||||
t.Fatalf("equatorial north dial should have finite center")
|
||||
}
|
||||
assertClose(t, "equatorial north center x", geometry.CenterX, 0, 1e-12)
|
||||
assertClose(t, "equatorial north center y", geometry.CenterY, 0, 1e-12)
|
||||
assertClose(t, "equatorial north polar length", geometry.PolarStylusLength, 1, 1e-12)
|
||||
assertClose(t, "equatorial north polar angle", geometry.PolarStylusPlaneAngle, 90, 1e-12)
|
||||
|
||||
equatorialSouth := EquatorialSouthDial(40, 1)
|
||||
geometry = equatorialSouth.Geometry()
|
||||
if !geometry.HasFiniteCenter {
|
||||
t.Fatalf("equatorial south dial should have finite center")
|
||||
}
|
||||
assertClose(t, "equatorial south center x", geometry.CenterX, 0, 1e-12)
|
||||
assertClose(t, "equatorial south center y", geometry.CenterY, 0, 1e-12)
|
||||
assertClose(t, "equatorial south polar length", geometry.PolarStylusLength, 1, 1e-12)
|
||||
assertClose(t, "equatorial south polar angle", geometry.PolarStylusPlaneAngle, 90, 1e-12)
|
||||
}
|
||||
|
||||
func TestShadowPointAtMatchesHourAngleDeclinationChain(t *testing.T) {
|
||||
dial := HorizontalDial(31.2304, 1)
|
||||
date := time.Date(2026, 6, 21, 9, 30, 0, 0, time.FixedZone("CST", 8*3600))
|
||||
lon := 121.4737
|
||||
|
||||
got := dial.ShadowPointAt(date, lon)
|
||||
want := dial.ShadowPointByHourAngleDeclination(HourAngle(date, lon), sunDeclination(date))
|
||||
assertClose(t, "point at x", got.X, want.X, 1e-12)
|
||||
assertClose(t, "point at y", got.Y, want.Y, 1e-12)
|
||||
if got.Illuminated != want.Illuminated {
|
||||
t.Fatalf("illumination chain mismatch: got %v want %v", got.Illuminated, want.Illuminated)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanarGeometryDegeneratesWhenPolarStylusParallelToPlane(t *testing.T) {
|
||||
dial := PlanarDial{
|
||||
Latitude: 45,
|
||||
PlaneNormalAzimuth: 180,
|
||||
PlaneNormalZenithDistance: 45,
|
||||
StylusLength: 1,
|
||||
}
|
||||
geometry := dial.Geometry()
|
||||
if geometry.HasFiniteCenter {
|
||||
t.Fatalf("degenerate geometry should not have a finite center")
|
||||
}
|
||||
if !math.IsNaN(geometry.CenterX) || !math.IsNaN(geometry.CenterY) || !math.IsNaN(geometry.PolarStylusLength) {
|
||||
t.Fatalf("degenerate geometry should return NaN finite quantities")
|
||||
}
|
||||
assertClose(t, "degenerate polar angle", geometry.PolarStylusPlaneAngle, 0, 1e-12)
|
||||
}
|
||||
|
||||
func sunDeclination(date time.Time) float64 {
|
||||
return sun.ApparentDec(date)
|
||||
}
|
||||
|
||||
func assertClose(t *testing.T, name string, got, want, tol float64) {
|
||||
t.Helper()
|
||||
if math.IsNaN(got) || math.Abs(got-want) > tol {
|
||||
t.Fatalf("%s mismatch: got %.12f want %.12f tol %.12f", name, got, want, tol)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package sundial
|
||||
|
||||
import (
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"b612.me/astro/sun"
|
||||
)
|
||||
|
||||
// TrueSolarTime 真太阳时 / apparent solar time.
|
||||
//
|
||||
// 返回 date 在经度 lon 处对应的真太阳时,口径沿用 `sun.ApparentSolarTime`。
|
||||
// Returns the apparent solar time for date at longitude lon.
|
||||
func TrueSolarTime(date time.Time, lon float64) time.Time {
|
||||
return sun.ApparentSolarTime(date, lon)
|
||||
}
|
||||
|
||||
// MeanSolarTime 地方平太阳时 / local mean solar time.
|
||||
//
|
||||
// 返回 date 在经度 lon 处对应的地方平太阳时,结果时区为按经度换算的地方平太阳时区。
|
||||
// 该实现直接按“真太阳时 - 均时差”构造,以与本仓库的真太阳时和均时差口径保持一致。
|
||||
// Returns the local mean solar time for date at longitude lon. The result uses
|
||||
// a synthetic local-mean-solar time zone derived from longitude and is built as
|
||||
// apparent solar time minus equation of time to keep the package's conventions aligned.
|
||||
func MeanSolarTime(date time.Time, lon float64) time.Time {
|
||||
return TrueSolarTime(date, lon).Add(time.Duration(-sun.EquationTime(date) * float64(time.Hour)))
|
||||
}
|
||||
|
||||
// HourAngle 太阳时角 / solar hour angle.
|
||||
//
|
||||
// 返回经度 lon 处的有符号太阳时角,单位度。上午为负,下午为正,中午为 0°。
|
||||
// Returns the signed apparent-solar hour angle at longitude lon, in degrees.
|
||||
func HourAngle(date time.Time, lon float64) float64 {
|
||||
return normalizeSigned180(sun.HourAngle(date, lon, 0))
|
||||
}
|
||||
|
||||
// MeanSolarHourAngle 平太阳时对应的太阳时角 / hour angle for local mean solar time.
|
||||
//
|
||||
// date 负责提供地方平太阳时日期与时区,原有钟面时间会被 meanSolarHours 替换;
|
||||
// meanSolarHours 为地方平太阳时钟面读数,单位小时,例如 9.5 表示地方平太阳时 09:30。
|
||||
// 返回对应的视太阳时角,单位度,上午为负,下午为正。
|
||||
func MeanSolarHourAngle(date time.Time, meanSolarHours float64) float64 {
|
||||
if !isFinite(meanSolarHours) {
|
||||
return math.NaN()
|
||||
}
|
||||
sampleTime := dateWithClockHours(date, meanSolarHours)
|
||||
return normalizeSigned180(HourAngle(sampleTime, longitudeFromTimeZone(sampleTime)))
|
||||
}
|
||||
|
||||
// ZoneTimeHourAngle 区时对应的太阳时角 / hour angle for zone time.
|
||||
//
|
||||
// zoneTimeHours 为 date 所在时区下的钟面读数,单位小时;lon 为当地经度,东正西负。
|
||||
// 返回该区时在给定经度上对应的视太阳时角,单位度,上午为负,下午为正。
|
||||
// date 提供民用日期和时区;其原有钟面时间会被 zoneTimeHours 替换。
|
||||
func ZoneTimeHourAngle(date time.Time, lon, zoneTimeHours float64) float64 {
|
||||
if !isFinite(zoneTimeHours) || !isFinite(lon) {
|
||||
return math.NaN()
|
||||
}
|
||||
return normalizeSigned180(HourAngle(dateWithClockHours(date, zoneTimeHours), lon))
|
||||
}
|
||||
|
||||
// HorizontalHourLineAngle 水平日晷时线角 / horizontal sundial hour-line angle.
|
||||
//
|
||||
// lat 为纬度(北正南负),hourAngle 为有符号太阳时角,单位度。返回值是相对午线的时线角,
|
||||
// 上午在东侧为负,下午在西侧为正。
|
||||
// lat is the observer latitude in degrees and hourAngle is the signed solar
|
||||
// hour angle in degrees. The result is the hour-line angle from the noon line:
|
||||
// east/morning is negative and west/afternoon is positive.
|
||||
func HorizontalHourLineAngle(lat, hourAngle float64) float64 {
|
||||
if !isFinite(hourAngle) || !isFinite(lat) {
|
||||
return math.NaN()
|
||||
}
|
||||
hourAngle = normalizeSigned180(hourAngle)
|
||||
latRad := lat * math.Pi / 180
|
||||
hourAngleRad := hourAngle * math.Pi / 180
|
||||
return math.Atan2(math.Sin(latRad)*math.Sin(hourAngleRad), math.Cos(hourAngleRad)) * 180 / math.Pi
|
||||
}
|
||||
|
||||
// HorizontalHourLineAngleAt 水平日晷时线角 / horizontal sundial hour-line angle.
|
||||
//
|
||||
// 先按给定时刻和经度求瞬时视太阳时角,再结合纬度返回水平日晷的时线角。
|
||||
func HorizontalHourLineAngleAt(date time.Time, lon, lat float64) float64 {
|
||||
return HorizontalHourLineAngle(lat, HourAngle(date, lon))
|
||||
}
|
||||
|
||||
func dateWithClockHours(date time.Time, hours float64) time.Time {
|
||||
startOfDay := time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location())
|
||||
return startOfDay.Add(time.Duration(hours * float64(time.Hour)))
|
||||
}
|
||||
|
||||
func longitudeFromTimeZone(date time.Time) float64 {
|
||||
_, offsetSeconds := date.Zone()
|
||||
return float64(offsetSeconds) / 240.0
|
||||
}
|
||||
|
||||
func clockHours(date time.Time) float64 {
|
||||
return float64(date.Hour()) +
|
||||
float64(date.Minute())/60 +
|
||||
float64(date.Second())/3600 +
|
||||
float64(date.Nanosecond())/3.6e12
|
||||
}
|
||||
|
||||
func normalizeSigned180(value float64) float64 {
|
||||
value = math.Mod(value, 360)
|
||||
if value < -180 {
|
||||
value += 360
|
||||
}
|
||||
if value >= 180 {
|
||||
value -= 360
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func isFinite(value float64) bool {
|
||||
return !math.IsNaN(value) && !math.IsInf(value, 0)
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package sundial
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"b612.me/astro/sun"
|
||||
)
|
||||
|
||||
func TestTrueSolarTimeMatchesSunPackage(t *testing.T) {
|
||||
date := time.Date(2026, 6, 21, 15, 30, 45, 123000000, time.FixedZone("CST", 8*3600))
|
||||
lon := 116.3913
|
||||
|
||||
got := TrueSolarTime(date, lon)
|
||||
want := sun.ApparentSolarTime(date, lon)
|
||||
if !got.Equal(want) {
|
||||
t.Fatalf("true solar time mismatch: got %s want %s", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeanSolarTimeMatchesSyntheticLongitudeZone(t *testing.T) {
|
||||
date := time.Date(2026, 6, 21, 15, 30, 45, 123000000, time.FixedZone("CST", 8*3600))
|
||||
lon := 116.3913
|
||||
|
||||
got := MeanSolarTime(date, lon)
|
||||
want := date.In(time.FixedZone("LTZ", int(lon*3600.0/15.0)))
|
||||
if math.Abs(got.Sub(want).Seconds()) > 1 {
|
||||
t.Fatalf("mean solar time mismatch: got %s want %s", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEquationTimeMatchesTrueMinusMeanSolarTime(t *testing.T) {
|
||||
date := time.Date(2026, 6, 21, 15, 30, 45, 123000000, time.FixedZone("CST", 8*3600))
|
||||
lon := 116.3913
|
||||
|
||||
apparent := clockHours(TrueSolarTime(date, lon))
|
||||
mean := clockHours(MeanSolarTime(date, lon))
|
||||
diff := apparent - mean
|
||||
want := sun.EquationTime(date)
|
||||
if math.Abs(diff-want) > 1e-9 {
|
||||
t.Fatalf("equation-time mismatch: got %.12f want %.12f", diff, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHourAngleMatchesTrueSolarClockFace(t *testing.T) {
|
||||
date := time.Date(2026, 6, 21, 15, 30, 45, 123000000, time.FixedZone("CST", 8*3600))
|
||||
lon := 116.3913
|
||||
|
||||
hourAngle := HourAngle(date, lon)
|
||||
trueSolar := TrueSolarTime(date, lon)
|
||||
trueHours := float64(trueSolar.Hour()) +
|
||||
float64(trueSolar.Minute())/60 +
|
||||
float64(trueSolar.Second())/3600 +
|
||||
float64(trueSolar.Nanosecond())/3.6e12
|
||||
want := normalizeSigned180((trueHours - 12) * 15)
|
||||
|
||||
if math.Abs(hourAngle-want) > 0.02 {
|
||||
t.Fatalf("hour angle mismatch: got %.6f want %.6f", hourAngle, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeanSolarHourAngleMatchesInstantChain(t *testing.T) {
|
||||
date := time.Date(2026, 6, 21, 15, 30, 45, 123000000, time.FixedZone("CST", 8*3600))
|
||||
lon := 116.3913
|
||||
|
||||
meanDate := MeanSolarTime(date, lon)
|
||||
meanSolarHours := clockHours(meanDate)
|
||||
got := MeanSolarHourAngle(meanDate, meanSolarHours)
|
||||
want := HourAngle(meanDate, longitudeFromTimeZone(meanDate))
|
||||
if math.Abs(got-want) > 1e-9 {
|
||||
t.Fatalf("mean-solar hour angle mismatch: got %.12f want %.12f", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestZoneTimeHourAngleRebuildsTargetClockInstant(t *testing.T) {
|
||||
date := time.Date(2026, 6, 21, 15, 30, 45, 123000000, time.FixedZone("CST", 8*3600))
|
||||
lon := 116.3913
|
||||
zoneTimeHours := 9.5
|
||||
|
||||
got := ZoneTimeHourAngle(date, lon, zoneTimeHours)
|
||||
want := HourAngle(dateWithClockHours(date, zoneTimeHours), lon)
|
||||
if math.Abs(got-want) > 1e-7 {
|
||||
t.Fatalf("zone-time hour angle mismatch: got %.12f want %.12f", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHorizontalHourLineAngleKnownValues(t *testing.T) {
|
||||
if math.Abs(HorizontalHourLineAngle(0, -75)) > 1e-12 {
|
||||
t.Fatalf("equatorial horizontal sundial should keep all hour lines on the noon line")
|
||||
}
|
||||
|
||||
got := HorizontalHourLineAngle(45, 45)
|
||||
want := math.Atan(math.Sin(45*math.Pi/180)*math.Tan(45*math.Pi/180)) * 180 / math.Pi
|
||||
if math.Abs(got-want) > 1e-12 {
|
||||
t.Fatalf("known-value mismatch: got %.12f want %.12f", got, want)
|
||||
}
|
||||
|
||||
if math.Abs(HorizontalHourLineAngle(45, -45)+got) > 1e-12 {
|
||||
t.Fatalf("morning/afternoon symmetry mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHorizontalHourLineAngleAtMatchesHourAngleChain(t *testing.T) {
|
||||
date := time.Date(2026, 6, 21, 15, 30, 45, 123000000, time.FixedZone("CST", 8*3600))
|
||||
lon := 116.3913
|
||||
lat := 39.9042
|
||||
|
||||
got := HorizontalHourLineAngleAt(date, lon, lat)
|
||||
want := HorizontalHourLineAngle(lat, HourAngle(date, lon))
|
||||
if math.Abs(got-want) > 1e-12 {
|
||||
t.Fatalf("hour-line chain mismatch: got %.12f want %.12f", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidHourLineInputReturnsNaN(t *testing.T) {
|
||||
if !math.IsNaN(HorizontalHourLineAngle(math.NaN(), 30)) {
|
||||
t.Fatalf("NaN latitude should produce NaN result")
|
||||
}
|
||||
if !math.IsNaN(HorizontalHourLineAngle(30, math.Inf(1))) {
|
||||
t.Fatalf("Inf hour angle should produce NaN result")
|
||||
}
|
||||
if !math.IsNaN(MeanSolarHourAngle(time.Now(), math.NaN())) {
|
||||
t.Fatalf("NaN mean-solar time should produce NaN result")
|
||||
}
|
||||
if !math.IsNaN(ZoneTimeHourAngle(time.Now(), math.Inf(1), 12)) {
|
||||
t.Fatalf("Inf longitude should produce NaN result")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user