feat: 引入可切换星历模型并扩展天象观测链路

- 新增半解析、旧模型、自定义与多源堆叠星历实例,提供 Context、Instance、SPK 读取及状态与视位置计算
- 统一太阳、月球和行星实例接口,扩展黄经穿越、留、相位、最近角距、偕日可见、任意高度升落、轨道节点及凌日等天象查询
- 完善日月食、月掩与凌日的月缘剖面、有限盘面接触、站心修正、局地可见性、全球路径和地图输出
- 新增 SVG、GeoJSON、KML 天象输出及凌日、月掩和月缘地形相关缓存与几何求解
- 新增恒星星表导入与查询,支持 CSV、Hipparcos、BSC5、Stellarium 等格式及名称、交叉标识、锥面检索和星等过滤
- 补充 IAU 2006、地球定向、折射消光、大气观测、轨道传播及半解析行星/月球数据支持
- 将默认未来民用时标切换为 TimeScaleLeapHour,更新 2027–2400 农历速查表、中文日期解析和时区边界处理
- 增加事件基线、跨模型回归、几何边界、月缘地形和输出契约测试,完善中英文手册、示例资源、NOTICE 与忽略规则
This commit is contained in:
2026-10-15 23:54:15 +08:00
parent 16c62a97d5
commit 044f2f4381
674 changed files with 62983 additions and 8396 deletions
+84
View File
@@ -0,0 +1,84 @@
package geojson
import (
eclipsecore "b612.me/astro/eclipse"
"b612.me/astro/mercury"
"b612.me/astro/venus"
)
// MarshalLunarEclipse 将月食 P1/P4 站心月心可见区、几何地平线以及 P1-P4 的时间包络编码为 GeoJSON,不含折射。
// MarshalLunarEclipse encodes the P1/P4 topocentric Moon-center visibility regions, their geometric horizons and the P1-P4 time envelopes, without refraction.
// boundaryPoints 小于等于零时使用 360;其他值限制在 [12, 1440]。
// boundaryPoints values <= 0 use 360; other values are clamped to [12, 1440].
func MarshalLunarEclipse(info eclipsecore.LunarEclipseInfo, boundaryPoints int) ([]byte, error) {
return defaultModule.MarshalLunarEclipse(info, boundaryPoints)
}
// MarshalLunarEclipseWithOptions 编码月食,输出内容与采样精度由 options 选择。
// MarshalLunarEclipseWithOptions encodes a lunar eclipse with the content and sampling selected by options.
func MarshalLunarEclipseWithOptions(
info eclipsecore.LunarEclipseInfo,
boundaryPoints int,
options LunarEclipseOptions,
) ([]byte, error) {
return defaultModule.MarshalLunarEclipseWithOptions(info, boundaryPoints, options)
}
// MarshalLunarEclipseWithTimeMarkers 编码月食,并沿半影开始到结束的月下点轨迹追加 Point 要素。
// MarshalLunarEclipseWithTimeMarkers encodes a lunar eclipse and adds Point Features along the sublunar track from penumbral start through end.
// 已有要素保持不变;标记标签使用 options.Location,时间值保持 UTC。
// Existing features are unchanged; marker labels use options.Location while time values stay UTC.
func MarshalLunarEclipseWithTimeMarkers(
info eclipsecore.LunarEclipseInfo,
boundaryPoints int,
options TimeMarkerOptions,
) ([]byte, error) {
return defaultModule.MarshalLunarEclipseWithTimeMarkers(info, boundaryPoints, options)
}
// MarshalSolarEclipse 将日食半影足迹和可选中心食带编码为 GeoJSON。
// MarshalSolarEclipse encodes penumbral footprints and an optional central path as GeoJSON.
func MarshalSolarEclipse(
partial eclipsecore.SolarEclipsePartialFootprintsInfo,
central *eclipsecore.SolarEclipsePath,
) ([]byte, error) {
return defaultModule.MarshalSolarEclipse(partial, central)
}
// MarshalSolarEclipseShadowInstant 单时刻阴影足迹的最小 GeoJSON:只含该时刻的区域与物理边界 / one instant as the smallest useful GeoJSON.
func MarshalSolarEclipseShadowInstant(
instant eclipsecore.SolarEclipseShadowInstant,
) ([]byte, error) {
return defaultModule.MarshalSolarEclipseShadowInstant(instant)
}
// MarshalSolarEclipseWithOptions 编码日食,输出内容由 options 选择 / encodes a solar eclipse with the content selected by options.
func MarshalSolarEclipseWithOptions(
partial eclipsecore.SolarEclipsePartialFootprintsInfo,
central *eclipsecore.SolarEclipsePath,
options SolarEclipseOptions,
) ([]byte, error) {
return defaultModule.MarshalSolarEclipseWithOptions(partial, central, options)
}
// MarshalSolarEclipseWithTimeMarkers 编码日食,并沿中心线按固定间隔追加 Point 要素;已有要素不变,标记标签使用 options.Location,时间值保持 UTC。
// MarshalSolarEclipseWithTimeMarkers encodes a solar eclipse and adds Point Features at regular intervals along the central line. Existing features are unchanged; marker labels use options.Location while time values stay UTC.
func MarshalSolarEclipseWithTimeMarkers(
partial eclipsecore.SolarEclipsePartialFootprintsInfo,
central *eclipsecore.SolarEclipsePath,
options TimeMarkerOptions,
) ([]byte, error) {
return defaultModule.MarshalSolarEclipseWithTimeMarkers(partial, central, options)
}
// MarshalMercuryTransit 将水星凌日的全球可见分区、一触/四触视地平线与可选站心接触等时线编码为 GeoJSON。
// MarshalMercuryTransit encodes the global visibility regions, C1/C4 apparent horizons and optional topocentric contact isochrones of a Mercury transit.
func MarshalMercuryTransit(info mercury.TransitInfo, options PlanetTransitOptions) ([]byte, error) {
return defaultModule.MarshalMercuryTransit(info, options)
}
// MarshalVenusTransit 将金星凌日的全球可见分区、一触/四触视地平线与可选站心接触等时线编码为 GeoJSON。
// MarshalVenusTransit encodes the global visibility regions, C1/C4 apparent horizons and optional topocentric contact isochrones of a Venus transit.
func MarshalVenusTransit(info venus.TransitInfo, options PlanetTransitOptions) ([]byte, error) {
return defaultModule.MarshalVenusTransit(info, options)
}
+50
View File
@@ -0,0 +1,50 @@
package geojson
import (
"b612.me/astro/internal/ephem"
"b612.me/astro/moon"
)
// Generator 指定星历实例的计算模块 / computation module bound to an ephemeris instance.
type Generator struct{ inst *ephem.Instance }
// NewWithInstance 创建指定星历实例的模块,nil 使用全局模型 / create a module; nil uses the global model.
func NewWithInstance(inst *ephem.Instance) *Generator { return &Generator{inst: inst} }
var defaultModule Generator
// MarshalPlanetOccultation 将月掩行星的部分掩、全掩和中心线编码为 GeoJSON / encode a planetary occultation path as GeoJSON.
func (module *Generator) MarshalPlanetOccultation(path moon.PlanetOccultationPath) ([]byte, error) {
return MarshalPlanetOccultation(path)
}
// MarshalPlanetOccultationFootprints 将指定时刻可用的精确行星外切和内切足迹编码为 GeoJSON FeatureCollection;事件外返回空集合 / encode planetary occultation footprints at an instant; empty outside the event.
func (module *Generator) MarshalPlanetOccultationFootprints(instant moon.PlanetOccultationInstant) ([]byte, error) {
return MarshalPlanetOccultationFootprints(instant)
}
// MarshalPlanetOccultationWithTimeMarkers 编码行星月掩,并沿中心线按固定间隔追加 Point 要素 / encode a planetary occultation with time markers along the centre line.
func (module *Generator) MarshalPlanetOccultationWithTimeMarkers(
path moon.PlanetOccultationPath,
options TimeMarkerOptions,
) ([]byte, error) {
return MarshalPlanetOccultationWithTimeMarkers(path, options)
}
// MarshalStarOccultation 将月掩恒星的全球掩带和中心线编码为 GeoJSON / encode a stellar occultation path as GeoJSON.
func (module *Generator) MarshalStarOccultation(path moon.StarOccultationPath) ([]byte, error) {
return MarshalStarOccultation(path)
}
// MarshalStarOccultationFootprint 将指定时刻的精确恒星月掩足迹编码为 GeoJSON FeatureCollection;事件外返回空集合 / encode a stellar occultation footprint at an instant; empty outside the event.
func (module *Generator) MarshalStarOccultationFootprint(instant moon.StarOccultationInstant) ([]byte, error) {
return MarshalStarOccultationFootprint(instant)
}
// MarshalStarOccultationWithTimeMarkers 编码恒星月掩,并沿中心线按固定间隔追加 Point 要素 / encode a stellar occultation with time markers along the centre line.
func (module *Generator) MarshalStarOccultationWithTimeMarkers(
path moon.StarOccultationPath,
options TimeMarkerOptions,
) ([]byte, error) {
return MarshalStarOccultationWithTimeMarkers(path, options)
}
+74
View File
@@ -0,0 +1,74 @@
package geojson
import (
"b612.me/astro/basic"
eclipsecore "b612.me/astro/eclipse"
"b612.me/astro/internal/ephem"
"b612.me/astro/moon"
"testing"
"time"
)
type contextAPI interface {
MarshalLunarEclipse(info eclipsecore.LunarEclipseInfo, boundaryPoints int) ([]byte, error)
MarshalLunarEclipseWithOptions(
info eclipsecore.LunarEclipseInfo,
boundaryPoints int,
options LunarEclipseOptions,
) ([]byte, error)
MarshalLunarEclipseWithTimeMarkers(
info eclipsecore.LunarEclipseInfo,
boundaryPoints int,
options TimeMarkerOptions,
) ([]byte, error)
MarshalPlanetOccultation(path moon.PlanetOccultationPath) ([]byte, error)
MarshalPlanetOccultationFootprints(instant moon.PlanetOccultationInstant) ([]byte, error)
MarshalPlanetOccultationWithTimeMarkers(
path moon.PlanetOccultationPath,
options TimeMarkerOptions,
) ([]byte, error)
MarshalSolarEclipse(
partial eclipsecore.SolarEclipsePartialFootprintsInfo,
central *eclipsecore.SolarEclipsePath,
) ([]byte, error)
MarshalSolarEclipseShadowInstant(
instant eclipsecore.SolarEclipseShadowInstant,
) ([]byte, error)
MarshalSolarEclipseWithOptions(
partial eclipsecore.SolarEclipsePartialFootprintsInfo,
central *eclipsecore.SolarEclipsePath,
options SolarEclipseOptions,
) ([]byte, error)
MarshalSolarEclipseWithTimeMarkers(
partial eclipsecore.SolarEclipsePartialFootprintsInfo,
central *eclipsecore.SolarEclipsePath,
options TimeMarkerOptions,
) ([]byte, error)
MarshalStarOccultation(path moon.StarOccultationPath) ([]byte, error)
MarshalStarOccultationFootprint(instant moon.StarOccultationInstant) ([]byte, error)
MarshalStarOccultationWithTimeMarkers(
path moon.StarOccultationPath,
options TimeMarkerOptions,
) ([]byte, error)
}
var _ contextAPI = (*Generator)(nil)
func TestContextModuleIsolation(t *testing.T) {
previous := basic.GetEphemerisModel()
t.Cleanup(func() { basic.SetEphemerisModel(previous) })
for _, model := range []ephem.Model{ephem.Legacy, ephem.Semianalytic} {
basic.SetEphemerisModel(basic.EphemerisModel(model))
info, ok := eclipsecore.LunarEclipseOnDateWithInstance(time.Date(2024, 3, 25, 0, 0, 0, 0, time.UTC), ephem.NewModelInstance(model))
if !ok {
t.Fatal("known lunar eclipse not found")
}
want, wantErr := MarshalLunarEclipse(info, 12)
module := NewWithInstance(ephem.NewModelInstance(model))
basic.SetEphemerisModel(basic.EphemerisModel(1 - int(model)))
got, gotErr := module.MarshalLunarEclipse(info, 12)
if gotErr != nil || wantErr != nil || string(got) != string(want) {
t.Fatalf("model %d: GeoJSON escaped instance: %v/%v", model, gotErr, wantErr)
}
}
}
+349 -175
View File
@@ -6,7 +6,6 @@ import (
"sort"
"time"
"b612.me/astro"
"b612.me/astro/basic"
eclipsecore "b612.me/astro/eclipse"
"b612.me/astro/internal/geodata"
@@ -447,13 +446,12 @@ const (
maximumLunarBoundaryPoints = 1440
)
// MarshalSolarEclipse 将日食半影足迹和可选中心食带编码为 GeoJSON。
// MarshalSolarEclipse encodes penumbral footprints and an optional central path as GeoJSON.
func MarshalSolarEclipse(
// MarshalSolarEclipse 将日食半影足迹和可选中心食带编码为 GeoJSON / encode solar eclipse footprints and the central band as GeoJSON.
func (module *Generator) MarshalSolarEclipse(
partial eclipsecore.SolarEclipsePartialFootprintsInfo,
central *eclipsecore.SolarEclipsePath,
) ([]byte, error) {
return marshalSolarEclipse(partial, central, SolarEclipseOptions{})
return marshalSolarEclipse(partial, central, SolarEclipseOptions{}, module)
}
// SolarEclipseOptions 控制日食 GeoJSON 的输出内容。
@@ -473,29 +471,32 @@ type SolarEclipseOptions struct {
}
// MarshalSolarEclipseWithOptions 编码日食,输出内容由 options 选择 / encodes a solar eclipse with the content selected by options.
func MarshalSolarEclipseWithOptions(
func (module *Generator) MarshalSolarEclipseWithOptions(
partial eclipsecore.SolarEclipsePartialFootprintsInfo,
central *eclipsecore.SolarEclipsePath,
options SolarEclipseOptions,
) ([]byte, error) {
return marshalSolarEclipse(partial, central, options)
return marshalSolarEclipse(partial, central, options, module)
}
// MarshalSolarEclipseWithTimeMarkers 编码日食,并沿中心线按固定间隔追加 Point 要素;已有要素不变,标记标签使用 options.Location,时间值保持 UTC。
// MarshalSolarEclipseWithTimeMarkers encodes a solar eclipse and adds Point Features at regular intervals along the central line. Existing features are unchanged; marker labels use options.Location while time values stay UTC.
func MarshalSolarEclipseWithTimeMarkers(
// MarshalSolarEclipseWithTimeMarkers 编码日食,并沿中心线按固定间隔追加 Point 要素;已有要素不变,标记标签使用 options.Location,时间值保持 UTC / encode a solar eclipse with time markers along the centre line.
func (module *Generator) MarshalSolarEclipseWithTimeMarkers(
partial eclipsecore.SolarEclipsePartialFootprintsInfo,
central *eclipsecore.SolarEclipsePath,
options TimeMarkerOptions,
) ([]byte, error) {
return marshalSolarEclipse(partial, central, SolarEclipseOptions{TimeMarkers: &options})
return marshalSolarEclipse(partial, central, SolarEclipseOptions{TimeMarkers: &options}, module)
}
func marshalSolarEclipse(
partial eclipsecore.SolarEclipsePartialFootprintsInfo,
central *eclipsecore.SolarEclipsePath,
options SolarEclipseOptions,
) ([]byte, error) {
options SolarEclipseOptions, modules ...*Generator) ([]byte, error) {
module := &defaultModule
if len(modules) > 0 {
module = modules[0]
}
markerOptions := options.TimeMarkers
if markerOptions != nil {
if err := validateTimeMarkerOptions(*markerOptions); err != nil {
@@ -515,7 +516,7 @@ func marshalSolarEclipse(
// 几何(月下点、地平闭合弧、带宽限界)必须用民用时刻算:只有写进属性的时刻换时标。
geometryPartial := partial
civilCentral := central
if timeScale == astro.TimeScaleUT1 {
if timeScale == basic.TimeScaleUT1 {
partial = eclipsecore.SolarEclipsePartialFootprintsInUT1(partial)
if central != nil {
converted := eclipsecore.SolarEclipsePathInUT1(*central)
@@ -530,7 +531,7 @@ func marshalSolarEclipse(
features := make([]feature, 0, len(partial.Footprints)+9)
for footprintIndex, footprint := range partial.Footprints {
// 闭合弧由月下点决定,必须用未换时标的同一足迹算几何。
polygon, err := solarPartialFootprintPolygon(geometryPartial.Footprints[footprintIndex], true)
polygon, err := solarPartialFootprintPolygon(geometryPartial.Footprints[footprintIndex], true, module)
if err != nil {
return nil, err
}
@@ -552,7 +553,7 @@ func marshalSolarEclipse(
footprintProperties["geometry_role"] = "horizon-closed-region"
footprintProperties["closure"] = solarHorizonClosureProperties(
geometryPartial.Footprints[footprintIndex].Time, footprint.Time,
solarHorizonClosureExact(footprint.Boundaries, footprint.HorizonEnds),
solarHorizonClosureExact(footprint.Boundaries, footprint.HorizonEnds), module,
)
}
if len(polygon) == 1 {
@@ -574,7 +575,7 @@ func marshalSolarEclipse(
))
}
if value, source, ok, err := solarPartialBandGeometry(geometryPartial); err != nil {
if value, source, ok, err := solarPartialBandGeometry(geometryPartial, module); err != nil {
return nil, fmt.Errorf("geojson: solar partial band: %w", err)
} else if ok {
bandProperties := cloneProperties(properties)
@@ -590,7 +591,7 @@ func marshalSolarEclipse(
return nil, err
}
features, err = appendSolarFootprintFeatures(
features, "central-shadow-footprint", partial.CentralShadowFootprints, properties,
features, "central-shadow-footprint", partial.CentralShadowFootprints, properties, module,
)
if err != nil {
return nil, err
@@ -675,7 +676,7 @@ func marshalSolarEclipse(
// ends of the real annular/total region.
// 带宽与限界几何按民用时刻构造;写出的时刻仍取换过时标的 central。
geometryCentral := central
if timeScale == astro.TimeScaleUT1 && civilCentral != nil {
if timeScale == basic.TimeScaleUT1 && civilCentral != nil {
geometryCentral = civilCentral
}
bandFootprints = solarCentralBandFootprintsForPath(geometryPartial, geometryCentral)
@@ -1061,7 +1062,7 @@ type solarBandSideRun struct {
north bool
}
// solarCentralBandLimitSidesFromRings 由食带边界派生南北限:逐点投影定侧,同侧最长连通段按路径序拼接。
// solarCentralBandLimitSidesFromRings 由食带边界派生南北限:环顶点按自身投影侧别切段,同侧最长连通组按路径序拼接。
func solarCentralBandLimitSidesFromRings(
rings [][]eclipsecore.SolarEclipsePathPoint,
centerLine []eclipsecore.SolarEclipsePathPoint,
@@ -1081,9 +1082,7 @@ func solarCentralBandLimitSidesFromRings(
return northern, southern, true
}
// solarBandSideRuns cuts every ring into runs that keep one side of the center
// line. A run ends where the boundary crosses the center line, jumps across the
// seam, or stalls against the projection.
// solarBandSideRuns 把每个环按逐点投影侧别切成同侧段;投影沿环连续追踪,中心线回折处不跳支。
func solarBandSideRuns(
rings [][]eclipsecore.SolarEclipsePathPoint,
centerLine []eclipsecore.SolarEclipsePathPoint,
@@ -1091,20 +1090,23 @@ func solarBandSideRuns(
var runs []solarBandSideRun
for _, ring := range rings {
points := openSolarPathRing(ring)
if len(points) < 3 {
count := len(points)
if count < 3 {
continue
}
progress := make([]float64, len(points))
times := make([]time.Time, len(points))
north := make([]bool, len(points))
for index, point := range points {
value, stamp, isNorth, ok := solarBandProjectOnCenterLine(point, centerLine)
if !ok {
return nil, false
projections, start, ok := solarBandTrackRingProjections(points, centerLine)
if !ok {
return nil, false
}
// 从起点之后第一个侧别翻转处开环,使首段不在环的接缝处被截断。
origin := start
for offset := 1; offset < count; offset++ {
index := (start + offset) % count
if projections[index].side != projections[(index+count-1)%count].side {
origin = index
break
}
progress[index] = value
times[index] = stamp
north[index] = isNorth
}
current := solarBandSideRun{}
flush := func() {
@@ -1113,15 +1115,16 @@ func solarBandSideRuns(
}
current = solarBandSideRun{}
}
for index := range points {
next := (index + 1) % len(points)
for offset := 0; offset < count; offset++ {
index := (origin + offset) % count
next := (index + 1) % count
projection := projections[index]
current.points = append(current.points, points[index])
current.times = append(current.times, times[index])
current.progress = append(current.progress, progress[index])
current.north = north[index]
current.progress = append(current.progress, projection.progress)
current.times = append(current.times, solarBandCenterLineTime(centerLine, projection.progress))
current.north = projection.side > 0
seamJump := math.Abs(math.Remainder(points[next].Longitude-points[index].Longitude, 360)) > 180
stalled := math.Abs(progress[next]-progress[index]) > 3
if north[index] != north[next] || seamJump || stalled {
if projection.side != projections[next].side || seamJump {
flush()
}
}
@@ -1130,6 +1133,143 @@ func solarBandSideRuns(
return runs, true
}
// solarBandTrackRingProjections 为环上每个顶点在中心线的局部最近点中选一个,使"距离 + 沿线跳变超额"之和最小(Viterbi),回折与端点处不跳支。
func solarBandTrackRingProjections(
points []eclipsecore.SolarEclipsePathPoint,
centerLine []eclipsecore.SolarEclipsePathPoint,
) ([]solarBandSideProjection, int, bool) {
count := len(points)
if len(centerLine) < 2 {
return nil, 0, false
}
middle := float64(len(centerLine)-1) / 2
along := solarBandCenterLineAlongKM(centerLine)
ringCandidates := make([][]solarBandTrackCandidate, count)
start, startGap := 0, math.Inf(1)
for index, point := range points {
ringCandidates[index] = solarBandTrackCandidates(point, centerLine, along)
// 全局最近点就是距离最小的局部极小。
nearest := ringCandidates[index][0]
for _, candidate := range ringCandidates[index][1:] {
if candidate.distanceKM < nearest.distanceKM {
nearest = candidate
}
}
if gap := math.Abs(nearest.projection.progress - middle); gap < startGap {
start, startGap = index, gap
}
}
candidates := make([][]solarBandTrackCandidate, count)
for offset := range candidates {
candidates[offset] = ringCandidates[(start+offset)%count]
}
cost := make([][]float64, count)
from := make([][]int, count)
cost[0] = make([]float64, len(candidates[0]))
for state, candidate := range candidates[0] {
cost[0][state] = candidate.distanceKM
}
for offset := 1; offset < count; offset++ {
step := solarCentralBandPointDistanceKM(points[(start+offset-1)%count], points[(start+offset)%count])
cost[offset] = make([]float64, len(candidates[offset]))
from[offset] = make([]int, len(candidates[offset]))
for state, candidate := range candidates[offset] {
best, bestFrom := math.Inf(1), 0
for previousState, previous := range candidates[offset-1] {
// 沿中心线的移动超过环上步长两倍的部分才计罚。
jump := math.Max(0, math.Abs(candidate.alongKM-previous.alongKM)-2*step)
if value := cost[offset-1][previousState] + jump; value < best {
best, bestFrom = value, previousState
}
}
cost[offset][state] = best + candidate.distanceKM
from[offset][state] = bestFrom
}
}
state := 0
for candidate := range cost[count-1] {
if cost[count-1][candidate] < cost[count-1][state] {
state = candidate
}
}
projections := make([]solarBandSideProjection, count)
for offset := count - 1; offset >= 0; offset-- {
projections[(start+offset)%count] = candidates[offset][state].projection
if offset > 0 {
state = from[offset][state]
}
}
return projections, start, true
}
type solarBandTrackCandidate struct {
projection solarBandSideProjection
distanceKM float64
alongKM float64
}
// solarBandTrackCandidates 返回顶点到中心线距离沿线的全部局部极小(含平台与两端)。
func solarBandTrackCandidates(
point eclipsecore.SolarEclipsePathPoint,
centerLine []eclipsecore.SolarEclipsePathPoint,
along []float64,
) []solarBandTrackCandidate {
segments := len(centerLine) - 1
projections := make([]solarBandSideProjection, segments)
distances := make([]float64, segments)
for position := range projections {
projections[position], distances[position] = solarBandProjectOnSegment(point, centerLine, position)
}
var candidates []solarBandTrackCandidate
for position := range projections {
if position > 0 && distances[position-1] < distances[position] ||
position+1 < segments && distances[position+1] < distances[position] {
continue
}
// 平台只取首段,避免同一极小重复入选。
if position > 0 && distances[position-1] == distances[position] {
continue
}
progress := projections[position].progress
segment := solarBandProgressSegment(progress, len(centerLine))
fraction := progress - float64(segment)
candidates = append(candidates, solarBandTrackCandidate{
projection: projections[position],
distanceKM: 111.32 * distances[position],
alongKM: along[segment] + fraction*(along[segment+1]-along[segment]),
})
}
return candidates
}
// solarBandCenterLineAlongKM 是中心线各顶点的累计沿线距离。
func solarBandCenterLineAlongKM(centerLine []eclipsecore.SolarEclipsePathPoint) []float64 {
along := make([]float64, len(centerLine))
for index := 1; index < len(centerLine); index++ {
along[index] = along[index-1] + solarCentralBandPointDistanceKM(centerLine[index-1], centerLine[index])
}
return along
}
func solarBandProgressSegment(progress float64, size int) int {
segment := int(math.Floor(progress))
if segment > size-2 {
segment = size - 2
}
if segment < 0 {
segment = 0
}
return segment
}
// solarBandCenterLineTime is the center-line time at one fractional position.
func solarBandCenterLineTime(centerLine []eclipsecore.SolarEclipsePathPoint, progress float64) time.Time {
position := solarBandProgressSegment(progress, len(centerLine))
fraction := math.Max(0, math.Min(1, progress-float64(position)))
first, second := centerLine[position], centerLine[position+1]
return first.Time.Add(time.Duration(float64(second.Time.Sub(first.Time)) * fraction))
}
// solarBandSidePoints concatenates the runs of one side in path order and
// spreads their times evenly, because the export requires strictly increasing
// times. Runs that do not touch each other belong to different boundary
@@ -1230,43 +1370,41 @@ func meanProgress(values []float64) float64 {
return total / float64(len(values))
}
// solarBandProjectOnCenterLine projects one band point onto the center line and
// reports its position along the path, the matching time, and whether it falls
// north of the center line at that position.
func solarBandProjectOnCenterLine(
// solarBandSideProjection is the nearest center-line position of one band vertex.
type solarBandSideProjection struct {
// progress is the segment index plus the fraction along that segment.
progress float64
// side is +1 when the vertex lies at or north of the center line there, -1 south.
side int
}
// solarBandProjectOnSegment 返回顶点在中心线第 position 段上的投影与局部平面距离(度)。
func solarBandProjectOnSegment(
point eclipsecore.SolarEclipsePathPoint,
centerLine []eclipsecore.SolarEclipsePathPoint,
) (float64, time.Time, bool, bool) {
bestDistance := math.Inf(1)
bestProgress := 0.0
bestTime := centerLine[0].Time
bestLatitude := centerLine[0].Latitude
position int,
) (solarBandSideProjection, float64) {
scale := math.Cos(point.Latitude * math.Pi / 180)
for position := 0; position+1 < len(centerLine); position++ {
first, second := centerLine[position], centerLine[position+1]
ax := math.Remainder(first.Longitude-point.Longitude, 360) * scale
ay := first.Latitude - point.Latitude
bx := math.Remainder(second.Longitude-point.Longitude, 360) * scale
by := second.Latitude - point.Latitude
dx, dy := bx-ax, by-ay
length := dx*dx + dy*dy
fraction := 0.0
if length > 0 {
fraction = math.Max(0, math.Min(1, -(ax*dx+ay*dy)/length))
}
distance := math.Hypot(ax+fraction*dx, ay+fraction*dy)
if distance >= bestDistance {
continue
}
bestDistance = distance
bestProgress = float64(position) + fraction
bestTime = first.Time.Add(time.Duration(float64(second.Time.Sub(first.Time)) * fraction))
bestLatitude = first.Latitude + fraction*(second.Latitude-first.Latitude)
first, second := centerLine[position], centerLine[position+1]
ax := math.Remainder(first.Longitude-point.Longitude, 360) * scale
ay := first.Latitude - point.Latitude
bx := math.Remainder(second.Longitude-point.Longitude, 360) * scale
by := second.Latitude - point.Latitude
dx, dy := bx-ax, by-ay
length := dx*dx + dy*dy
fraction := 0.0
if length > 0 {
fraction = math.Max(0, math.Min(1, -(ax*dx+ay*dy)/length))
}
if math.IsInf(bestDistance, 1) {
return 0, time.Time{}, false, false
offset := point.Latitude - (first.Latitude + fraction*(second.Latitude-first.Latitude))
side := -1
if offset >= 0 {
side = 1
}
return bestProgress, bestTime, point.Latitude >= bestLatitude, true
return solarBandSideProjection{
progress: float64(position) + fraction,
side: side,
}, math.Hypot(ax+fraction*dx, ay+fraction*dy)
}
// solarCentralLimitSeparationKM returns the greatest distance from one exported
@@ -1802,34 +1940,27 @@ type LunarEclipseOptions struct {
EnvelopeLongitudePoints int
}
// MarshalLunarEclipseWithOptions 编码月食,输出内容与采样精度由 options 选择。
// MarshalLunarEclipseWithOptions encodes a lunar eclipse with the content and sampling selected by options.
func MarshalLunarEclipseWithOptions(
// MarshalLunarEclipseWithOptions 编码月食,输出内容与采样精度由 options 选择 / encode a lunar eclipse with selectable content and sampling.
func (module *Generator) MarshalLunarEclipseWithOptions(
info eclipsecore.LunarEclipseInfo,
boundaryPoints int,
options LunarEclipseOptions,
) ([]byte, error) {
return marshalLunarEclipse(info, boundaryPoints, options)
return marshalLunarEclipse(info, boundaryPoints, options, module)
}
// MarshalLunarEclipse 将月食 P1/P4 站心月心可见区、几何地平线以及 P1-P4 的时间包络编码为 GeoJSON,不含折射。
// MarshalLunarEclipse encodes the P1/P4 topocentric Moon-center visibility regions, their geometric horizons and the P1-P4 time envelopes, without refraction.
// boundaryPoints 小于等于零时使用 360;其他值限制在 [12, 1440]。
// boundaryPoints values <= 0 use 360; other values are clamped to [12, 1440].
func MarshalLunarEclipse(info eclipsecore.LunarEclipseInfo, boundaryPoints int) ([]byte, error) {
return marshalLunarEclipse(info, boundaryPoints, LunarEclipseOptions{})
// MarshalLunarEclipse 将月食 P1/P4 站心月心可见区、几何地平线以及 P1-P4 的时间包络编码为 GeoJSON,不含折射 / encode lunar eclipse visibility regions and contact envelopes as GeoJSON.
func (module *Generator) MarshalLunarEclipse(info eclipsecore.LunarEclipseInfo, boundaryPoints int) ([]byte, error) {
return marshalLunarEclipse(info, boundaryPoints, LunarEclipseOptions{}, module)
}
// MarshalLunarEclipseWithTimeMarkers 编码月食,并沿半影开始到结束的月下点轨迹追加 Point 要素。
// MarshalLunarEclipseWithTimeMarkers encodes a lunar eclipse and adds Point Features along the sublunar track from penumbral start through end.
// 已有要素保持不变;标记标签使用 options.Location,时间值保持 UTC。
// Existing features are unchanged; marker labels use options.Location while time values stay UTC.
func MarshalLunarEclipseWithTimeMarkers(
// MarshalLunarEclipseWithTimeMarkers 编码月食,并沿半影开始到结束的月下点轨迹追加 Point 要素 / encode a lunar eclipse with time markers along the sublunar track.
func (module *Generator) MarshalLunarEclipseWithTimeMarkers(
info eclipsecore.LunarEclipseInfo,
boundaryPoints int,
options TimeMarkerOptions,
) ([]byte, error) {
return marshalLunarEclipse(info, boundaryPoints, LunarEclipseOptions{TimeMarkers: &options})
return marshalLunarEclipse(info, boundaryPoints, LunarEclipseOptions{TimeMarkers: &options}, module)
}
type lunarLatitudeInterval struct {
@@ -1946,15 +2077,20 @@ type lunarHorizonColumns struct {
roots [][]float64
}
func lunarHorizonColumnsAt(at time.Time, longitudePoints int, longitudes []float64) (lunarHorizonColumns, error) {
state := basic.MoonStateAt(basic.Date2JD(at.UTC()))
func lunarHorizonColumnsAt(at time.Time, longitudePoints int, longitudes []float64, modules ...*Generator) (lunarHorizonColumns, error) {
module := &defaultModule
if len(modules) > 0 {
module = modules[0]
}
state := basic.MoonStateAtWithInstance(basic.Date2JD(at.UTC()), module.inst)
points := state.MoonHorizon(longitudePoints)
if len(points) < 3 {
return lunarHorizonColumns{}, fmt.Errorf("geojson: lunar visibility horizon is unavailable")
}
return lunarHorizonColumns{
state: state,
roots: lunarHorizonSeriesFor(lunarHorizonRefinedPoints(points, at)).rootsByLongitude(longitudes),
roots: lunarHorizonSeriesFor(lunarHorizonRefinedPoints(points, at, module)).rootsByLongitude(longitudes),
}, nil
}
@@ -1979,15 +2115,19 @@ func lunarBandColumn(primary lunarHorizonColumns, longitude float64, index int,
// lunarVisibilityUnionColumns 逐经度取 [start, end] 上"月亮在地平上"可见区的并集:
// 只比区间端点会漏掉极区掠射时两刻之间短暂露出的窗口,必须按与包络同一档位采样后求并。
func lunarVisibilityUnionColumns(
start, end time.Time, samples, longitudePoints int, longitudes []float64,
) ([][]lunarLatitudeInterval, error) {
start, end time.Time, samples, longitudePoints int, longitudes []float64, modules ...*Generator) ([][]lunarLatitudeInterval, error) {
module := &defaultModule
if len(modules) > 0 {
module = modules[0]
}
columns := make([][]lunarLatitudeInterval, len(longitudes))
if samples < 1 {
samples = 1
}
for index := 0; index <= samples; index++ {
at := start.Add(end.Sub(start) * time.Duration(index) / time.Duration(samples))
instant, err := lunarHorizonColumnsAt(at, longitudePoints, longitudes)
instant, err := lunarHorizonColumnsAt(at, longitudePoints, longitudes, module)
if err != nil {
return nil, err
}
@@ -2022,21 +2162,25 @@ func lunarPenumbraBandGeometries(
info eclipsecore.LunarEclipseInfo,
boundaryPoints int,
options LunarEclipseOptions,
wantMoonset, wantMoonrise bool,
) ([2]geometry, error) {
wantMoonset, wantMoonrise bool, modules ...*Generator) ([2]geometry, error) {
module := &defaultModule
if len(modules) > 0 {
module = modules[0]
}
var result [2]geometry
longitudes := lunarVisibilityLongitudes(boundaryPoints, options)
longitudePoints := len(longitudes) - 1
columns := make([]lunarHorizonColumns, 2)
for index, at := range []time.Time{info.PenumbralStart, info.PenumbralEnd} {
value, err := lunarHorizonColumnsAt(at, longitudePoints, longitudes)
value, err := lunarHorizonColumnsAt(at, longitudePoints, longitudes, module)
if err != nil {
return result, err
}
columns[index] = value
}
umbralColumns, err := lunarVisibilityUnionColumns(
info.PartialStart, info.PartialEnd, lunarUmbralBandSamples(info, options), longitudePoints, longitudes,
info.PartialStart, info.PartialEnd, lunarUmbralBandSamples(info, options), longitudePoints, longitudes, module,
)
if err != nil {
return result, err
@@ -2083,8 +2227,12 @@ func lunarVisibilityEnvelopeGeometries(
info eclipsecore.LunarEclipseInfo,
boundaryPoints int,
options LunarEclipseOptions,
wantUnion, wantIntersection bool,
) (geometry, geometry, error) {
wantUnion, wantIntersection bool, modules ...*Generator) (geometry, geometry, error) {
module := &defaultModule
if len(modules) > 0 {
module = modules[0]
}
start, end := info.PenumbralStart, info.PenumbralEnd
if !start.Before(end) {
return geometry{}, geometry{}, fmt.Errorf("geojson: lunar eclipse penumbral interval is invalid")
@@ -2099,7 +2247,7 @@ func lunarVisibilityEnvelopeGeometries(
states := make([]basic.MoonState, len(times))
roots := make([][][]float64, len(times))
for index, at := range times {
columns, err := lunarHorizonColumnsAt(at, longitudePoints, longitudes)
columns, err := lunarHorizonColumnsAt(at, longitudePoints, longitudes, module)
if err != nil {
return geometry{}, geometry{}, err
}
@@ -2149,12 +2297,17 @@ func lunarVisibilityEnvelopeGeometries(
return unionGeometry, intersectionGeometry, nil
}
func lunarHorizonRefinedPoints(points [][2]float64, at time.Time) [][2]float64 {
func lunarHorizonRefinedPoints(points [][2]float64, at time.Time, modules ...*Generator) [][2]float64 {
module := &defaultModule
if len(modules) > 0 {
module = modules[0]
}
horizon := make([]geodata.GeoPoint, len(points))
for index, point := range points {
horizon[index] = geodata.GeoPoint{Longitude: point[0], Latitude: point[1]}
}
horizon = lunarhorizon.RefineWithin(horizon, at, lunarVisibilityHorizonToleranceDegrees)
horizon = lunarhorizon.RefineWithinWithInstance(horizon, at, lunarVisibilityHorizonToleranceDegrees, module.inst)
refined := make([][2]float64, len(horizon))
for index, point := range horizon {
refined[index] = [2]float64{point.Longitude, point.Latitude}
@@ -2397,8 +2550,12 @@ func lunarVisibilityEnvelopeGeometry(longitudes []float64, columns [][]lunarLati
func marshalLunarEclipse(
info eclipsecore.LunarEclipseInfo,
boundaryPoints int,
options LunarEclipseOptions,
) ([]byte, error) {
options LunarEclipseOptions, modules ...*Generator) ([]byte, error) {
module := &defaultModule
if len(modules) > 0 {
module = modules[0]
}
markerOptions := options.TimeMarkers
if markerOptions != nil {
if err := validateTimeMarkerOptions(*markerOptions); err != nil {
@@ -2414,7 +2571,7 @@ func marshalLunarEclipse(
}
// 几何一律用民用时刻,只有写进属性的时刻换时标,否则把 UT1 读数当民用时刻会平移月下点。
geometry := info
if timeScale == astro.TimeScaleUT1 {
if timeScale == basic.TimeScaleUT1 {
info = eclipsecore.LunarEclipseInfoInUT1(info)
}
boundaryPoints = normalizeLunarBoundaryPoints(boundaryPoints)
@@ -2434,22 +2591,22 @@ func marshalLunarEclipse(
{role: "visible-at-p4", horizonRole: "p4-horizon", geometryTime: geometry.PenumbralEnd, labelTime: info.PenumbralEnd},
}
for _, contact := range contacts {
points := basic.MoonHorizon(basic.Date2JD(contact.geometryTime.UTC()), boundaryPoints)
points := basic.MoonHorizonWithInstance(basic.Date2JD(contact.geometryTime.UTC()), boundaryPoints, module.inst)
horizon := make([]geodata.GeoPoint, len(points))
for index, point := range points {
horizon[index] = geodata.GeoPoint{Longitude: point[0], Latitude: point[1]}
}
horizon = lunarhorizon.Refine(horizon, contact.geometryTime)
horizon = lunarhorizon.RefineWithInstance(horizon, contact.geometryTime, module.inst)
value, err := multiPolygonGeometry([][]geodata.GeoPoint{horizon})
if err != nil {
return nil, fmt.Errorf("geojson: %s: %w", contact.role, err)
}
// 日界线剪裁会在相邻世界各输出一次零宽薄片:顶点全落在同一条子午线上、平面面积只剩
// 浮点噪声(实测 1.8e-12 deg²,刚好越过共享剪裁器 1e-12 的零面积阈值)。只在本月食
// 浮点噪声(可能越过共享剪裁器 1e-12 的零面积阈值)。只在本月食
// 可见区导出里丢弃它——共享剪裁器的输出被掩星拓扑依赖,不能在那里过滤。
// The antimeridian split can emit one zero-width sliver per adjacent world: every vertex
// on one meridian and a planar area of pure floating-point noise (measured 1.8e-12 deg^2,
// just past the shared splitter's 1e-12 zero-area floor). Drop it here, in the lunar
// on one meridian and a planar area of pure floating-point noise (possibly past the
// shared splitter's 1e-12 zero-area floor). Drop it here, in the lunar
// visibility export only; the shared splitter's output feeds occultation topology and must
// not be filtered.
value = dropDegenerateMultiPolygonRings(value)
@@ -2478,7 +2635,7 @@ func marshalLunarEclipse(
wantPenumbraMoonrise := !skippedLunarRole(options.SkipRoles, "penumbra-moonrise")
if info.HasPartial && (wantPenumbraMoonset || wantPenumbraMoonrise) {
bandGeometries, bandErr := lunarPenumbraBandGeometries(
geometry, boundaryPoints, options, wantPenumbraMoonset, wantPenumbraMoonrise,
geometry, boundaryPoints, options, wantPenumbraMoonset, wantPenumbraMoonrise, module,
)
if bandErr != nil {
return nil, bandErr
@@ -2497,7 +2654,7 @@ func marshalLunarEclipse(
wantThroughout := !skippedLunarRole(options.SkipRoles, "visible-throughout-eclipse")
if wantDuring || wantThroughout {
unionGeometry, intersectionGeometry, envelopeErr := lunarVisibilityEnvelopeGeometries(
geometry, boundaryPoints, options, wantDuring, wantThroughout,
geometry, boundaryPoints, options, wantDuring, wantThroughout, module,
)
if envelopeErr != nil {
return nil, envelopeErr
@@ -2513,7 +2670,7 @@ func marshalLunarEclipse(
}
}
maximum := lunarSubpoint(geometry.Maximum)
maximum := lunarSubpoint(geometry.Maximum, module)
features, err := appendPointFeature(
features,
lunarEclipseEvent,
@@ -2525,7 +2682,7 @@ func marshalLunarEclipse(
return nil, err
}
if markerOptions != nil {
markers, markerErr := lunarEclipseTimeMarkerSamples(geometry, timeScale, *markerOptions)
markers, markerErr := lunarEclipseTimeMarkerSamples(geometry, timeScale, *markerOptions, module)
if markerErr != nil {
return nil, markerErr
}
@@ -2547,12 +2704,16 @@ func marshalLunarEclipse(
// 因为掩带的面选择依赖这些填充提示,端点外扩会改变极区边缘的面归属。
func solarPartialFootprintPolygon(
footprint eclipsecore.SolarEclipsePartialFootprint,
horizonExact bool,
) ([]geodata.GeoPoint, error) {
horizonExact bool, modules ...*Generator) ([]geodata.GeoPoint, error) {
module := &defaultModule
if len(modules) > 0 {
module = modules[0]
}
if footprint.Time.IsZero() {
return nil, fmt.Errorf("geojson: solar partial footprint time is required")
}
input, err := solarClosureFootprint(footprint)
input, err := solarClosureFootprint(footprint, module)
if err != nil {
return nil, err
}
@@ -2568,8 +2729,12 @@ func solarPartialFootprintPolygon(
// Instantaneous footprints remain available for selecting the current shadow,
// but are not part of this time-independent outline.
func solarPartialBandGeometry(
partial eclipsecore.SolarEclipsePartialFootprintsInfo,
) (geometry, string, bool, error) {
partial eclipsecore.SolarEclipsePartialFootprintsInfo, modules ...*Generator) (geometry, string, bool, error) {
module := &defaultModule
if len(modules) > 0 {
module = modules[0]
}
if len(partial.PartialBandContours) == 0 || len(partial.RiseSetCurves) == 0 {
return solarPartialBandOverlayGeometry(partial.Footprints, partial.RiseSetCurves)
}
@@ -2585,7 +2750,7 @@ func solarPartialBandGeometry(
}
footprints := make([]solarclosure.Footprint, 0, len(partial.Footprints))
for _, footprint := range partial.Footprints {
input, err := solarClosureFootprint(footprint)
input, err := solarClosureFootprint(footprint, module)
if err != nil {
return geometry{}, "", false, err
}
@@ -2613,7 +2778,7 @@ func solarPartialBandGeometry(
}
for polygonIndex, polygon := range polygons {
for pointIndex, point := range polygon {
polygons[polygonIndex][pointIndex].Longitude = normalizeLongitude(point.Longitude)
polygons[polygonIndex][pointIndex].Longitude = geodata.NormalizeLongitude(point.Longitude)
}
}
value, err := multiPolygonGeometry(polygons)
@@ -2655,7 +2820,7 @@ func solarPartialBandOverlayGeometry(
}
for polygonIndex, polygon := range polygons {
for pointIndex, point := range polygon {
polygons[polygonIndex][pointIndex].Longitude = normalizeLongitude(point.Longitude)
polygons[polygonIndex][pointIndex].Longitude = geodata.NormalizeLongitude(point.Longitude)
}
}
value, err := multiPolygonGeometry(polygons)
@@ -2743,7 +2908,7 @@ func solarPartialBandGeoPointDistanceToRing(
) float64 {
minimum := math.Inf(1)
for index := range ring {
minimum = math.Min(minimum, solarCentralBandGeoPointDistanceKM(point, ring[index]))
minimum = math.Min(minimum, geodata.MeanDistanceKM(point, ring[index]))
}
return minimum
}
@@ -2760,9 +2925,9 @@ func solarPartialBandBridgeLineToRing(
}
nearest := func(point geodata.GeoPoint) (int, float64) {
bestIndex := 0
bestDistance := solarCentralBandGeoPointDistanceKM(point, open[0])
bestDistance := geodata.MeanDistanceKM(point, open[0])
for index := 1; index < len(open); index++ {
if distance := solarCentralBandGeoPointDistanceKM(point, open[index]); distance < bestDistance {
if distance := geodata.MeanDistanceKM(point, open[index]); distance < bestDistance {
bestIndex, bestDistance = index, distance
}
}
@@ -2783,7 +2948,7 @@ func solarPartialBandBridgeLineToRing(
pathLength := func(points []geodata.GeoPoint) float64 {
length := 0.0
for index := 1; index < len(points); index++ {
length += solarCentralBandGeoPointDistanceKM(points[index-1], points[index])
length += geodata.MeanDistanceKM(points[index-1], points[index])
}
return length
}
@@ -2800,18 +2965,22 @@ func appendSolarFootprintFeatures(
features []feature,
role string,
footprints []eclipsecore.SolarEclipsePartialFootprint,
properties map[string]interface{},
) ([]feature, error) {
properties map[string]interface{}, modules ...*Generator) ([]feature, error) {
module := &defaultModule
if len(modules) > 0 {
module = modules[0]
}
for _, footprint := range footprints {
if role == solarCentralShadowFootprintRole && !footprint.Closed {
appended, err := appendSolarHorizonClosedShadowFootprint(features, footprint, properties)
appended, err := appendSolarHorizonClosedShadowFootprint(features, footprint, properties, module)
if err != nil {
return nil, err
}
features = appended
continue
}
polygon, err := solarPartialFootprintPolygon(footprint, false)
polygon, err := solarPartialFootprintPolygon(footprint, false, module)
if err != nil {
return nil, fmt.Errorf("geojson: solar %s at %s: %w", role, formatTime(footprint.Time), err)
}
@@ -2964,7 +3133,7 @@ type solarCentralBandVertexGrid map[int][]geodata.GeoPoint
func solarCentralBandVertexGridKey(latitude, longitude float64) int {
latitudeCell := int(math.Floor(latitude/solarCentralBandVertexGridDegrees)) + 90
longitudeCell := int(math.Floor(normalizeLongitude(longitude)/solarCentralBandVertexGridDegrees)) + 180
longitudeCell := int(math.Floor(geodata.NormalizeLongitude(longitude)/solarCentralBandVertexGridDegrees)) + 180
return latitudeCell*360 + longitudeCell
}
@@ -2997,7 +3166,7 @@ func (grid solarCentralBandVertexGrid) vertexWithinKM(
for latitudeCell := minimumLatitudeCell; latitudeCell <= maximumLatitudeCell; latitudeCell++ {
for longitudeCell := minimumLongitudeCell; longitudeCell <= maximumLongitudeCell; longitudeCell++ {
for _, vertex := range grid[latitudeCell*360+longitudeCell] {
if solarCentralBandGeoPointDistanceKM(point, vertex) <= toleranceKM {
if geodata.MeanDistanceKM(point, vertex) <= toleranceKM {
return true
}
}
@@ -3108,7 +3277,7 @@ func solarCentralBandCenterlineProbes(
return nil
}
probes = append(probes, geodata.GeoPoint{
Longitude: normalizeLongitude(
Longitude: geodata.NormalizeLongitude(
point.Longitude + math.Remainder(next.Longitude-point.Longitude, 360)/2,
),
Latitude: (point.Latitude + next.Latitude) / 2,
@@ -3363,7 +3532,7 @@ func solarCentralMagnitudeOneOrientedClosure(
func solarCentralBandPathDistanceKM(
first, second eclipsecore.SolarEclipsePathPoint,
) float64 {
return solarCentralBandGeoPointDistanceKM(
return geodata.MeanDistanceKM(
geodata.GeoPoint{Longitude: first.Longitude, Latitude: first.Latitude},
geodata.GeoPoint{Longitude: second.Longitude, Latitude: second.Latitude},
)
@@ -3622,7 +3791,7 @@ func snapSolarCentralSweepContacts(polygons [][]geodata.GeoPoint, contacts []geo
if used[[2]int{polygonIndex, pointIndex}] {
continue
}
distance := solarCentralBandGeoPointDistanceKM(point, contact)
distance := geodata.MeanDistanceKM(point, contact)
if distance < bestDistance {
bestDistance = distance
bestPolygon, bestPoint = polygonIndex, pointIndex
@@ -3670,8 +3839,8 @@ func solarCentralTwoLimitRibbonPieces(
{centerPrevious, centerCurrent, southCurrent},
{centerPrevious, southCurrent, southPrevious},
} {
if solarCentralBandGeoPointDistanceKM(piece[0], piece[1]) == 0 &&
solarCentralBandGeoPointDistanceKM(piece[1], piece[2]) == 0 {
if geodata.MeanDistanceKM(piece[0], piece[1]) == 0 &&
geodata.MeanDistanceKM(piece[1], piece[2]) == 0 {
continue
}
pieces = append(pieces, piece)
@@ -3934,7 +4103,7 @@ func solarCentralLocalChartUnion(inputs [][]geodata.GeoPoint) ([][]geodata.GeoPo
return geodata.GeoPoint{}
}
return geodata.GeoPoint{
Longitude: normalizeLongitude(math.Atan2(vector[1]/length, vector[0]/length) * 180 / math.Pi),
Longitude: geodata.NormalizeLongitude(math.Atan2(vector[1]/length, vector[0]/length) * 180 / math.Pi),
Latitude: math.Asin(math.Max(-1, math.Min(1, vector[2]/length))) * 180 / math.Pi,
}
}
@@ -3980,7 +4149,7 @@ func solarCentralBandRefineRingSpacing(points []geodata.GeoPoint, targetSpacingK
for index, start := range points {
end := points[(index+1)%len(points)]
result = append(result, start)
steps := int(math.Ceil(solarCentralBandGeoPointDistanceKM(start, end) / targetSpacingKM))
steps := int(math.Ceil(geodata.MeanDistanceKM(start, end) / targetSpacingKM))
for step := 1; step < steps; step++ {
result = append(result, solarCentralBandSphericalInterpolate(
start, end, float64(step)/float64(steps),
@@ -4018,7 +4187,7 @@ func solarCentralBandSphericalInterpolate(
y := firstWeight*firstVector[1] + secondWeight*secondVector[1]
z := firstWeight*firstVector[2] + secondWeight*secondVector[2]
return geodata.GeoPoint{
Longitude: normalizeLongitude(math.Atan2(y, x) * 180 / math.Pi),
Longitude: geodata.NormalizeLongitude(math.Atan2(y, x) * 180 / math.Pi),
Latitude: math.Atan2(z, math.Hypot(x, y)) * 180 / math.Pi,
}
}
@@ -4056,8 +4225,8 @@ func solarCentralBandContactCaps(
return
}
contactPoint := geodata.GeoPoint{Longitude: contact.Longitude, Latitude: contact.Latitude}
if solarCentralBandGeoPointDistanceKM(contactPoint, boundary[0]) > 2000 ||
solarCentralBandGeoPointDistanceKM(contactPoint, boundary[len(boundary)-1]) > 2000 {
if geodata.MeanDistanceKM(contactPoint, boundary[0]) > 2000 ||
geodata.MeanDistanceKM(contactPoint, boundary[len(boundary)-1]) > 2000 {
return
}
caps = append(caps, []geodata.GeoPoint{contactPoint, boundary[0], boundary[len(boundary)-1]})
@@ -4067,14 +4236,6 @@ func solarCentralBandContactCaps(
return caps
}
func solarCentralBandGeoPointDistanceKM(first, second geodata.GeoPoint) float64 {
lat1, lat2 := first.Latitude*math.Pi/180, second.Latitude*math.Pi/180
dlat := lat2 - lat1
dlon := math.Remainder((second.Longitude-first.Longitude)*math.Pi/180, 2*math.Pi)
h := math.Sin(dlat/2)*math.Sin(dlat/2) + math.Cos(lat1)*math.Cos(lat2)*math.Sin(dlon/2)*math.Sin(dlon/2)
return 6371.0088 * 2 * math.Asin(math.Sqrt(math.Max(0, math.Min(1, h))))
}
func solarCentralTwoLimitAxisCappedPolygon(
north, south, centerLine []eclipsecore.SolarEclipsePathPoint,
) ([]geodata.GeoPoint, bool) {
@@ -4217,11 +4378,10 @@ func solarCentralBandEndpointCaps(
}
func solarCentralBandPointDistanceKM(first, second eclipsecore.SolarEclipsePathPoint) float64 {
lat1, lat2 := first.Latitude*math.Pi/180, second.Latitude*math.Pi/180
dlat := lat2 - lat1
dlon := math.Mod((second.Longitude-first.Longitude)*math.Pi/180+math.Pi, 2*math.Pi) - math.Pi
h := math.Sin(dlat/2)*math.Sin(dlat/2) + math.Cos(lat1)*math.Cos(lat2)*math.Sin(dlon/2)*math.Sin(dlon/2)
return 6371.0088 * 2 * math.Asin(math.Sqrt(math.Max(0, math.Min(1, h))))
return geodata.MeanDistanceKM(
geodata.GeoPoint{Longitude: first.Longitude, Latitude: first.Latitude},
geodata.GeoPoint{Longitude: second.Longitude, Latitude: second.Latitude},
)
}
// Near a high-latitude apex a traced limit curve runs through a cusp: its time
@@ -4464,7 +4624,7 @@ func degenerateGeoJSONRing(ring [][]float64) bool {
minimumLongitude = math.Min(minimumLongitude, ring[index][0])
maximumLongitude = math.Max(maximumLongitude, ring[index][0])
}
// 顶点全落在同一条子午线上时面积只剩求和噪声,量级随顶点数增长(实测 9e-12,越过 1e-12 阈值),
// 顶点全落在同一条子午线上时面积只剩求和噪声,量级随顶点数增长,可能越过 1e-12 阈值;
// 必须先按经度跨度判死,不能只靠面积。
if maximumLongitude-minimumLongitude < 1e-9 {
return true
@@ -4496,27 +4656,41 @@ func lunarEclipseMetadata(info eclipsecore.LunarEclipseInfo) map[string]interfac
}
}
func solarSubsolarPoint(value time.Time) geodata.GeoPoint {
func solarSubsolarPoint(value time.Time, modules ...*Generator) geodata.GeoPoint {
module := &defaultModule
if len(modules) > 0 {
module = modules[0]
}
ttJDE := basic.UTC2TT(basic.Date2JD(value.UTC()))
ra, dec := basic.HSunApparentRaDec(ttJDE)
ra, dec := basic.HSunApparentRaDecWithInstance(ttJDE, module.inst)
ut1JDE := basic.TT2UT1(ttJDE)
longitude := normalizeLongitude(ra - basic.ApparentSiderealTime(ut1JDE)*15)
longitude := geodata.NormalizeLongitude(ra - basic.ApparentSiderealTime(ut1JDE)*15)
return geodata.GeoPoint{Longitude: longitude, Latitude: dec}
}
func lunarSubpoint(value time.Time) geodata.GeoPoint {
func lunarSubpoint(value time.Time, modules ...*Generator) geodata.GeoPoint {
module := &defaultModule
if len(modules) > 0 {
module = modules[0]
}
ttJDE := basic.UTC2TT(basic.Date2JD(value.UTC()))
ra, dec := basic.HMoonTrueRaDec(ttJDE)
ra, dec := basic.HMoonTrueRaDecWithInstance(ttJDE, module.inst)
ut1JDE := basic.TT2UT1(ttJDE)
longitude := normalizeLongitude(ra - basic.ApparentSiderealTime(ut1JDE)*15)
longitude := geodata.NormalizeLongitude(ra - basic.ApparentSiderealTime(ut1JDE)*15)
return geodata.GeoPoint{Longitude: longitude, Latitude: dec}
}
func lunarEclipseTimeMarkerSamples(
info eclipsecore.LunarEclipseInfo,
scale astro.TimeScale,
options TimeMarkerOptions,
) ([]pathSample, error) {
scale basic.TimeScale,
options TimeMarkerOptions, modules ...*Generator) ([]pathSample, error) {
module := &defaultModule
if len(modules) > 0 {
module = modules[0]
}
step, err := normalizeTimeMarkerStep(options.Step)
if err != nil {
return nil, fmt.Errorf("geojson: lunar eclipse time markers: %w", err)
@@ -4530,10 +4704,10 @@ func lunarEclipseTimeMarkerSamples(
current := firstTimeMarkerAfter(start, step, location)
markers := make([]pathSample, 0, capacity)
for current.Before(end) {
point := lunarSubpoint(current)
point := lunarSubpoint(current, module)
label := current
if scale == astro.TimeScaleUT1 {
label = astro.LabelIn(astro.TimeScaleUT1, current)
if scale == basic.TimeScaleUT1 {
label = basic.LabelIn(basic.TimeScaleUT1, current)
}
markers = append(markers, pathSample{
Time: label,
@@ -4559,12 +4733,12 @@ func normalizeLunarBoundaryPoints(value int) int {
}
// timeScaleForMarkers 校验导出时标选项:UT1 时刻没有时区语义,配非 UTC 时区时明确失败。
func timeScaleForMarkers(options *TimeMarkerOptions) (astro.TimeScale, error) {
if options == nil || options.TimeScale != astro.TimeScaleUT1 {
return astro.TimeScaleUTC, nil
func timeScaleForMarkers(options *TimeMarkerOptions) (basic.TimeScale, error) {
if options == nil || options.TimeScale != basic.TimeScaleUT1 {
return basic.TimeScaleUTC, nil
}
if options.Location != nil && options.Location != time.UTC {
return astro.TimeScaleUTC, fmt.Errorf("geojson: UT1 time properties do not take a non-UTC location")
return basic.TimeScaleUTC, fmt.Errorf("geojson: UT1 time properties do not take a non-UTC location")
}
return astro.TimeScaleUT1, nil
return basic.TimeScaleUT1, nil
}
+49
View File
@@ -0,0 +1,49 @@
package geojson_test
// 开启月缘时偏食足迹与食甚等时线必须落在月缘修正后的 P1–P4 内,整条管线(足迹 → 中心带 → 编码)不得被校验拒收。
import (
"testing"
"time"
"b612.me/astro/eclipse"
"b612.me/astro/geojson"
"b612.me/astro/moon/limb"
)
func TestMarshalSolarEclipseWithLunarLimb(t *testing.T) {
module := eclipse.NewWithInstance(nil).WithLunarLimb(limb.Default())
for _, date := range []time.Time{
time.Date(2028, 7, 22, 0, 0, 0, 0, time.UTC),
time.Date(2029, 6, 12, 0, 0, 0, 0, time.UTC),
time.Date(2032, 5, 9, 0, 0, 0, 0, time.UTC),
time.Date(2040, 11, 4, 0, 0, 0, 0, time.UTC),
} {
label := date.Format("2006-01-02")
partial, ok := module.SolarEclipsePartialFootprints(date, eclipse.SolarEclipsePartialFootprintOptions{})
if !ok || len(partial.Footprints) == 0 {
t.Fatalf("%s: footprints unavailable", label)
}
if !partial.Eclipse.LunarLimbApplied {
t.Fatalf("%s: limb not applied to the global contacts", label)
}
begin, end := partial.Eclipse.PartialBeginOnEarth, partial.Eclipse.PartialEndOnEarth
for index, footprint := range partial.Footprints {
if footprint.Time.Before(begin) || footprint.Time.After(end) {
t.Fatalf("%s: footprint %d at %v outside limb P1–P4 %v–%v", label, index, footprint.Time, begin, end)
}
}
for index, contour := range partial.GreatestTimeContours {
if contour.Time.Before(begin) || contour.Time.After(end) {
t.Errorf("%s: greatest-time contour %d at %v outside limb P1–P4", label, index, contour.Time)
}
}
var central *eclipse.SolarEclipsePath
if path, ok := module.SolarEclipseCentralPath(date, eclipse.SolarEclipsePathOptions{}); ok {
central = &path
}
if _, err := geojson.MarshalSolarEclipse(partial, central); err != nil {
t.Errorf("%s: %v", label, err)
}
}
}
+7 -15
View File
@@ -21,7 +21,7 @@ import (
"math"
"time"
"b612.me/astro"
"b612.me/astro/basic"
"b612.me/astro/internal/geodata"
)
@@ -75,7 +75,7 @@ type TimeMarkerOptions struct {
// 并在集合上输出 time_scale 成员消歧(RFC 3339 的 Z 后缀严格说不等于 UT1)。此时 Location 必须是 nil 或 UTC。
// TimeScale selects the scale of time properties: the zero value is UTC; TimeScaleUT1 writes UT1 labels
// and adds a time_scale member (a RFC 3339 Z suffix is not exactly UT1). Location must then be nil or UTC.
TimeScale astro.TimeScale
TimeScale basic.TimeScale
}
// dropFeaturesByRole 按 role 属性丢弃要素;空列表原样返回。
@@ -108,7 +108,7 @@ func marshalEmptyFeatureCollection() ([]byte, error) {
return encodeFeatureCollection(make([]feature, 0))
}
func marshalFeatureCollectionWithTimeScale(features []feature, scale astro.TimeScale) ([]byte, error) {
func marshalFeatureCollectionWithTimeScale(features []feature, scale basic.TimeScale) ([]byte, error) {
if len(features) == 0 {
return nil, fmt.Errorf("geojson: no geographic features")
}
@@ -116,12 +116,12 @@ func marshalFeatureCollectionWithTimeScale(features []feature, scale astro.TimeS
}
func encodeFeatureCollection(features []feature) ([]byte, error) {
return encodeFeatureCollectionWithTimeScale(features, astro.TimeScaleUTC)
return encodeFeatureCollectionWithTimeScale(features, basic.TimeScaleUTC)
}
func encodeFeatureCollectionWithTimeScale(features []feature, scale astro.TimeScale) ([]byte, error) {
func encodeFeatureCollectionWithTimeScale(features []feature, scale basic.TimeScale) ([]byte, error) {
member := ""
if scale == astro.TimeScaleUT1 {
if scale == basic.TimeScaleUT1 {
member = "UT1"
}
value, err := json.Marshal(featureCollection{Type: featureCollectionType, Features: features, TimeScale: member})
@@ -304,7 +304,7 @@ func interpolateTimeMarker(a, b pathSample, fraction float64, value time.Time) p
}
return pathSample{
Time: value,
Longitude: normalizeLongitude(a.Longitude + fraction*deltaLongitude),
Longitude: geodata.NormalizeLongitude(a.Longitude + fraction*deltaLongitude),
Latitude: a.Latitude + fraction*(b.Latitude-a.Latitude),
}
}
@@ -613,11 +613,3 @@ func formatTime(value time.Time) string {
}
return value.UTC().Format(time.RFC3339Nano)
}
func normalizeLongitude(value float64) float64 {
value = math.Mod(value+180, 360)
if value < 0 {
value += 360
}
return value - 180
}
+3
View File
@@ -2005,6 +2005,9 @@ func TestMarshalPlanetOccultationWithoutFootprintsUsesBands(t *testing.T) {
}
func TestMarshalPlanetOccultationCompactBandHandlesTangentBranchConvergence(t *testing.T) {
if testing.Short() {
t.Skip("slow regression")
}
for _, date := range []time.Time{
time.Date(1954, time.June, 30, 0, 0, 0, 0, time.UTC),
time.Date(1962, time.April, 1, 0, 0, 0, 0, time.UTC),
+3
View File
@@ -13,6 +13,9 @@ import (
// 月出侧 = 食终在地平上、本影阶段整段在地平下、食始也在地平下。本影可见性取整个区间的最大高度,
// 只比 U1/U4 会漏掉极区掠射时两刻之间的短暂窗口。用高度独立复算,并允许边界附近半格误差。
func TestLunarGeoJSONPenumbraBandsFollowUmbralContacts(t *testing.T) {
if testing.Short() {
t.Skip("slow regression")
}
date := time.Date(2029, 1, 1, 0, 0, 0, 0, time.FixedZone("CST", 8*3600))
info, ok := eclipse.LunarEclipseOnDate(date)
if !ok || !info.HasPartial {
@@ -10,10 +10,10 @@ import (
"b612.me/astro/moon"
)
// 本文件钉住 §1.5 的三条契约:全掩可见性轮廓必须校验、退化影区整条缺省、
// 本文件钉住三条契约:全掩可见性轮廓必须校验、退化影区整条缺省、
// 地平闭合必须能区分精确擦地点闭合与采样端点近似闭合。
func review15CopyContours(contours [][]moon.OccultationPathPoint) [][]moon.OccultationPathPoint {
func copyOccultationContours(contours [][]moon.OccultationPathPoint) [][]moon.OccultationPathPoint {
copied := make([][]moon.OccultationPathPoint, len(contours))
for index, contour := range contours {
copied[index] = append([]moon.OccultationPathPoint(nil), contour...)
@@ -66,7 +66,7 @@ func TestMarshalPlanetOccultationValidatesTotalVisibilityContours(t *testing.T)
}
}
func review15DegenerateBoundary(stamp time.Time) [][]eclipse.SolarEclipsePathPoint {
func degenerateShadowBoundary(stamp time.Time) [][]eclipse.SolarEclipsePathPoint {
return [][]eclipse.SolarEclipsePathPoint{{
{Time: stamp, Longitude: 0, Latitude: 0},
{Time: stamp, Longitude: 0.01, Latitude: 0},
@@ -78,7 +78,7 @@ func review15DegenerateBoundary(stamp time.Time) [][]eclipse.SolarEclipsePathPoi
func TestMarshalSolarEclipseShadowInstantSkipsDegenerateRegion(t *testing.T) {
stamp := time.Date(2024, time.April, 8, 18, 0, 0, 0, time.UTC)
instant := eclipse.SolarEclipseShadowInstant{
Time: stamp, Closed: true, Boundaries: review15DegenerateBoundary(stamp),
Time: stamp, Closed: true, Boundaries: degenerateShadowBoundary(stamp),
}
raw, err := geojson.MarshalSolarEclipseShadowInstant(instant)
if err != nil {
@@ -95,7 +95,7 @@ func TestMarshalSolarEclipseShadowInstantSkipsDegenerateRegion(t *testing.T) {
}
}
func review15HorizonCutInstant(t *testing.T) eclipse.SolarEclipseShadowInstant {
func horizonCutShadowInstant(t *testing.T) eclipse.SolarEclipseShadowInstant {
t.Helper()
date := time.Date(2009, time.July, 22, 0, 0, 0, 0, time.UTC)
info, ok := eclipse.SolarEclipsePartialFootprints(date, eclipse.SolarEclipsePartialFootprintOptions{
@@ -118,7 +118,7 @@ func review15HorizonCutInstant(t *testing.T) eclipse.SolarEclipseShadowInstant {
return eclipse.SolarEclipseShadowInstant{}
}
func review15ClosureExact(t *testing.T, instant eclipse.SolarEclipseShadowInstant) interface{} {
func shadowClosureExact(t *testing.T, instant eclipse.SolarEclipseShadowInstant) interface{} {
t.Helper()
raw, err := geojson.MarshalSolarEclipseShadowInstant(instant)
if err != nil {
@@ -136,13 +136,13 @@ func review15ClosureExact(t *testing.T, instant eclipse.SolarEclipseShadowInstan
}
func TestMarshalSolarEclipseShadowInstantMarksSampledClosure(t *testing.T) {
instant := review15HorizonCutInstant(t)
if exact := review15ClosureExact(t, instant); exact != true {
instant := horizonCutShadowInstant(t)
if exact := shadowClosureExact(t, instant); exact != true {
t.Fatalf("closure exact=%v, want true with two grazing points", exact)
}
sampled := instant
sampled.HorizonEnds = nil
if exact := review15ClosureExact(t, sampled); exact != false {
if exact := shadowClosureExact(t, sampled); exact != false {
t.Fatalf("closure exact=%v, want false without grazing points", exact)
}
}
+7 -19
View File
@@ -5,7 +5,7 @@ import (
"math"
"time"
"b612.me/astro"
"b612.me/astro/basic"
"b612.me/astro/internal/geodata"
"b612.me/astro/internal/occultationgeo"
"b612.me/astro/moon"
@@ -124,7 +124,7 @@ func marshalStarOccultation(path moon.StarOccultationPath, markerOptions *TimeMa
if scaleErr != nil {
return nil, scaleErr
}
if timeScale == astro.TimeScaleUT1 {
if timeScale == basic.TimeScaleUT1 {
path = moon.StarOccultationPathInUT1(path)
}
if err := validateStarOccultationPathData(path); err != nil {
@@ -269,7 +269,7 @@ func marshalPlanetOccultation(path moon.PlanetOccultationPath, markerOptions *Ti
if scaleErr != nil {
return nil, scaleErr
}
if timeScale == astro.TimeScaleUT1 {
if timeScale == basic.TimeScaleUT1 {
path = moon.PlanetOccultationPathInUT1(path)
}
if err := path.Planet.Validate(); err != nil {
@@ -846,7 +846,7 @@ func occultationNearestRingVertex(
index := -1
distance := math.Inf(1)
for candidate, value := range ring {
current := occultationGeoDistanceKM(point, value)
current := geodata.EquatorialDistanceKM(point, value)
if current < distance {
index, distance = candidate, current
}
@@ -862,7 +862,7 @@ func occultationRingPathLengthKM(ring []geodata.GeoPoint, start, end, direction
index := start
for index != end {
next := (index + direction + len(ring)) % len(ring)
length += occultationGeoDistanceKM(ring[index], ring[next])
length += geodata.EquatorialDistanceKM(ring[index], ring[next])
index = next
if length > 1e8 {
return math.Inf(1)
@@ -873,7 +873,7 @@ func occultationRingPathLengthKM(ring []geodata.GeoPoint, start, end, direction
func occultationRingEdgesWithinKM(ring []geodata.GeoPoint, maximum float64) bool {
for index := 1; index < len(ring); index++ {
if occultationGeoDistanceKM(ring[index-1], ring[index]) > maximum {
if geodata.EquatorialDistanceKM(ring[index-1], ring[index]) > maximum {
return false
}
}
@@ -883,23 +883,11 @@ func occultationRingEdgesWithinKM(ring []geodata.GeoPoint, maximum float64) bool
func occultationGeoLineLengthKM(points []geodata.GeoPoint) float64 {
length := 0.0
for index := 1; index < len(points); index++ {
length += occultationGeoDistanceKM(points[index-1], points[index])
length += geodata.EquatorialDistanceKM(points[index-1], points[index])
}
return length
}
func occultationGeoDistanceKM(first, second geodata.GeoPoint) float64 {
const radiusKM = 6378.1366
firstLatitude := first.Latitude * math.Pi / 180
secondLatitude := second.Latitude * math.Pi / 180
deltaLatitude := secondLatitude - firstLatitude
deltaLongitude := math.Remainder(second.Longitude-first.Longitude, 360) * math.Pi / 180
sineLatitude := math.Sin(deltaLatitude / 2)
sineLongitude := math.Sin(deltaLongitude / 2)
a := sineLatitude*sineLatitude + math.Cos(firstLatitude)*math.Cos(secondLatitude)*sineLongitude*sineLongitude
return 2 * radiusKM * math.Atan2(math.Sqrt(math.Max(0, a)), math.Sqrt(math.Max(0, 1-a)))
}
func reverseOccultationGeoPoints(points []geodata.GeoPoint) {
for left, right := 0, len(points)-1; left < right; left, right = left+1, right-1 {
points[left], points[right] = points[right], points[left]
@@ -11,6 +11,9 @@ import (
)
func TestOccultationDistantEnvelopesRemainComplete(t *testing.T) {
if testing.Short() {
t.Skip("slow regression")
}
if err := basic.LoadStarData(); err != nil {
t.Fatal(err)
}
@@ -176,14 +176,7 @@ func TestMarshalPlanetOccultationVisibilityBoundaryStaysInsidePartialBand(t *tes
// TestMarshalPlanetOccultationHorizonConnectorsFollowBandFootprintSource 固定连接线与掩带同源的
// 契约:紧凑模式下两者都由紧凑足迹给出,连接线存在;默认(密集瞬时足迹)模式下瞬时足迹没有
// 可与相位端点配对的开放地平边界,因此不产生连接线。此前掩带会走解析回退、连接线却按空的
// 原始紧凑足迹生成,两种模式的连接线来源不一致。
// TestMarshalPlanetOccultationHorizonConnectorsFollowBandFootprintSource pins the contract that
// connectors and band share one footprint source: in compact mode both come from the compact
// footprints and connectors exist, while dense instantaneous footprints carry no open horizon
// boundary to pair with phase endpoints and therefore yield none. Previously a band built from
// the analytic fallback still asked for connectors from the empty compact footprints, so the two
// modes disagreed about the connector source.
// 可与相位端点配对的开放地平边界,因此不产生连接线。
func TestMarshalPlanetOccultationHorizonConnectorsFollowBandFootprintSource(t *testing.T) {
start := time.Date(2025, time.February, 1, 0, 0, 0, 0, time.UTC)
for _, test := range []struct {
@@ -0,0 +1,70 @@
package geojson_test
import (
"encoding/json"
"testing"
"time"
"b612.me/astro/basic"
"b612.me/astro/geojson"
"b612.me/astro/moon"
)
func TestMarshalStarOccultationRegulus20261007UsesAnalyticVisibleEnvelope(t *testing.T) {
if err := basic.LoadStarData(); err != nil {
t.Fatal(err)
}
starData, err := basic.StarDataByHR(3982)
if err != nil {
t.Fatal(err)
}
star, err := moon.StarCoordinateFromStarData(starData)
if err != nil {
t.Fatal(err)
}
start := time.Date(2026, time.October, 7, 0, 0, 0, 0, time.UTC)
paths, err := moon.FindStarOccultationPaths(start, start.Add(24*time.Hour), star, moon.OccultationPathOptions{})
if err != nil || len(paths) != 1 {
t.Fatalf("FindStarOccultationPaths() paths=%d err=%v, want one", len(paths), err)
}
path := paths[0]
if len(path.Footprints) == 0 || len(path.BandFootprints) != 0 {
t.Fatalf("dense path footprints=%d bandFootprints=%d, want dense mode", len(path.Footprints), len(path.BandFootprints))
}
data, err := geojson.MarshalStarOccultation(path)
if err != nil {
t.Fatalf("MarshalStarOccultation: %v", err)
}
collection := decodeCollection(t, data)
band := featureWithRole(t, collection, "occultation-band")
if authoritative, ok := band.Properties["static_band_authoritative"].(bool); !ok || !authoritative {
t.Fatalf("occultation-band source=%v authoritative=%v, want analytic authoritative boundary",
band.Properties["source"], band.Properties["static_band_authoritative"])
}
if source := band.Properties["source"]; source != "visible-footprint-sweep" {
t.Fatalf("occultation-band source=%v, want visible-footprint-sweep", source)
}
assertClosedMultiPolygon(t, band)
if rings := geoJSONMultiPolygonOuterRings(t, band); len(rings) != 1 {
t.Fatalf("occultation-band polygons=%d, want one continuous visible band", len(rings))
}
assertOccultationBandMaximumEdge(t, band, 45)
for _, feature := range featuresWithRole(collection, "visibility-boundary") {
var lines [][][]float64
if err := json.Unmarshal(feature.Geometry.Coordinates, &lines); err != nil {
t.Fatalf("decode visibility-boundary: %v", err)
}
for lineIndex, line := range lines {
for pointIndex, point := range line {
if len(point) < 2 || !geometryContainsPointWithinKM(t, band.Geometry, point[0], point[1], 2) {
t.Fatalf("visibility-boundary line %d point %d lies outside visible band at %.6f, %.6f", lineIndex, pointIndex, point[0], point[1])
}
}
}
}
for index, point := range path.CenterLine {
if !geometryContainsPoint(t, band.Geometry, point.Longitude, point.Latitude) {
t.Fatalf("center-line sample %d lies outside visible band at %.6f, %.6f", index, point.Longitude, point.Latitude)
}
}
}
@@ -9,11 +9,11 @@ import (
"b612.me/astro/internal/geodata"
)
// 本文件钉住 §1.5 的两条导出契约:覆盖判据必须逐点成立(不能靠抽样),
// 本文件钉住两条导出契约:覆盖判据必须逐点成立(不能靠抽样),
// 中心线走廊必须让每个探针都落在容差内,且只修补越界的那一段。
const review15ToleranceKM = solarCentralBandCoverageToleranceKM
const centralBandToleranceKM = solarCentralBandCoverageToleranceKM
func review15SquareRing(minLongitude, minLatitude, maxLongitude, maxLatitude float64) []geodata.GeoPoint {
func centralBandSquareRing(minLongitude, minLatitude, maxLongitude, maxLatitude float64) []geodata.GeoPoint {
return []geodata.GeoPoint{
{Longitude: minLongitude, Latitude: minLatitude},
{Longitude: maxLongitude, Latitude: minLatitude},
@@ -22,7 +22,7 @@ func review15SquareRing(minLongitude, minLatitude, maxLongitude, maxLatitude flo
}
}
func review15CenterLine(points ...[2]float64) []eclipsecore.SolarEclipsePathPoint {
func centralBandCenterLine(points ...[2]float64) []eclipsecore.SolarEclipsePathPoint {
base := time.Date(2024, time.April, 8, 18, 0, 0, 0, time.UTC)
line := make([]eclipsecore.SolarEclipsePathPoint, len(points))
for index, point := range points {
@@ -36,7 +36,7 @@ func review15CenterLine(points ...[2]float64) []eclipsecore.SolarEclipsePathPoin
}
func TestSolarCentralBandPointsCoverProbesEveryVertex(t *testing.T) {
polygons := [][]geodata.GeoPoint{review15SquareRing(0, 0, 1, 1)}
polygons := [][]geodata.GeoPoint{centralBandSquareRing(0, 0, 1, 1)}
points := make([]geodata.GeoPoint, 0, 257)
for index := 0; index < 257; index++ {
points = append(points, geodata.GeoPoint{
@@ -44,32 +44,32 @@ func TestSolarCentralBandPointsCoverProbesEveryVertex(t *testing.T) {
Latitude: 0.1 + 0.8*float64(index%97)/96,
})
}
if !solarCentralBandPointsCover(polygons, points, review15ToleranceKM) {
if !solarCentralBandPointsCover(polygons, points, centralBandToleranceKM) {
t.Fatal("points inside the ring must be covered")
}
for index := range points {
moved := append([]geodata.GeoPoint(nil), points...)
moved[index] = geodata.GeoPoint{Longitude: 40, Latitude: 40}
if solarCentralBandPointsCover(polygons, moved, review15ToleranceKM) {
if solarCentralBandPointsCover(polygons, moved, centralBandToleranceKM) {
t.Fatalf("vertex %d outside the tolerance was accepted; every vertex must be probed", index)
}
}
}
func TestSolarCentralBandPointsCoverKeepsMacroTolerance(t *testing.T) {
polygons := [][]geodata.GeoPoint{review15SquareRing(0, 0, 1, 1)}
polygons := [][]geodata.GeoPoint{centralBandSquareRing(0, 0, 1, 1)}
near := []geodata.GeoPoint{{Longitude: 0.5, Latitude: 1.4}}
far := []geodata.GeoPoint{{Longitude: 0.5, Latitude: 2.0}}
if !solarCentralBandPointsCover(polygons, near, review15ToleranceKM) {
if !solarCentralBandPointsCover(polygons, near, centralBandToleranceKM) {
t.Fatal("a point 45 km outside must stay inside the 100 km macro tolerance")
}
if solarCentralBandPointsCover(polygons, far, review15ToleranceKM) {
if solarCentralBandPointsCover(polygons, far, centralBandToleranceKM) {
t.Fatal("a point 111 km outside must exceed the 100 km macro tolerance")
}
}
func TestSolarCentralBandRingsCoverProbesEveryFootprintVertex(t *testing.T) {
rings := [][]geodata.GeoPoint{review15SquareRing(0, 0, 1, 1)}
rings := [][]geodata.GeoPoint{centralBandSquareRing(0, 0, 1, 1)}
stamp := time.Date(2024, time.April, 8, 18, 0, 0, 0, time.UTC)
boundary := make([]eclipsecore.SolarEclipsePathPoint, 0, 64)
for index := 0; index < 64; index++ {
@@ -83,7 +83,7 @@ func TestSolarCentralBandRingsCoverProbesEveryFootprintVertex(t *testing.T) {
Time: stamp,
Boundaries: [][]eclipsecore.SolarEclipsePathPoint{boundary},
}}
if !solarCentralBandRingsCover(rings, review15CenterLine([2]float64{0.5, 0.5}), footprints) {
if !solarCentralBandRingsCover(rings, centralBandCenterLine([2]float64{0.5, 0.5}), footprints) {
t.Fatal("a covered footprint must pass the coverage check")
}
for index := range boundary {
@@ -93,15 +93,15 @@ func TestSolarCentralBandRingsCoverProbesEveryFootprintVertex(t *testing.T) {
Time: stamp,
Boundaries: [][]eclipsecore.SolarEclipsePathPoint{moved},
}}
if solarCentralBandRingsCover(rings, review15CenterLine([2]float64{0.5, 0.5}), probe) {
if solarCentralBandRingsCover(rings, centralBandCenterLine([2]float64{0.5, 0.5}), probe) {
t.Fatalf("footprint vertex %d outside the tolerance was accepted", index)
}
}
}
func TestSolarCentralBandRingsCoverProbesEveryCenterLineVertex(t *testing.T) {
rings := [][]geodata.GeoPoint{review15SquareRing(0, 0, 1, 1)}
line := review15CenterLine([2]float64{0.5, 0.5}, [2]float64{0.5, 0.6}, [2]float64{0.5, 0.7})
rings := [][]geodata.GeoPoint{centralBandSquareRing(0, 0, 1, 1)}
line := centralBandCenterLine([2]float64{0.5, 0.5}, [2]float64{0.5, 0.6}, [2]float64{0.5, 0.7})
if !solarCentralBandRingsCover(rings, line, nil) {
t.Fatal("a covered center line must pass the coverage check")
}
@@ -116,8 +116,8 @@ func TestSolarCentralBandRingsCoverProbesEveryCenterLineVertex(t *testing.T) {
}
func TestSolarCentralBandCorridorCoversEveryCenterlineProbe(t *testing.T) {
polygons := [][]geodata.GeoPoint{review15SquareRing(0, 0, 1, 1)}
line := review15CenterLine([2]float64{0.5, 1.2}, [2]float64{0.5, 1.8})
polygons := [][]geodata.GeoPoint{centralBandSquareRing(0, 0, 1, 1)}
line := centralBandCenterLine([2]float64{0.5, 1.2}, [2]float64{0.5, 1.8})
repaired := solarCentralBandWithCenterlineCorridor(polygons, line)
probes := solarCentralBandCenterlineProbes(line)
miss := geodata.SphericalPolygonsPathMissDistanceKM(
@@ -130,8 +130,8 @@ func TestSolarCentralBandCorridorCoversEveryCenterlineProbe(t *testing.T) {
}
func TestSolarCentralBandCorridorPatchesOnlyTheClippedSegment(t *testing.T) {
polygons := [][]geodata.GeoPoint{review15SquareRing(-1, -0.05, 1, 0.05)}
line := review15CenterLine([2]float64{-0.5, 0.08}, [2]float64{0.5, 0.6})
polygons := [][]geodata.GeoPoint{centralBandSquareRing(-1, -0.05, 1, 0.05)}
line := centralBandCenterLine([2]float64{-0.5, 0.08}, [2]float64{0.5, 0.6})
repaired := solarCentralBandWithCenterlineCorridor(polygons, line)
witness := []geodata.GeoPoint{{Longitude: -0.5, Latitude: 0.35}}
if covered := geodata.SphericalPolygonsContainPoints(repaired, witness); covered[0] {
@@ -140,8 +140,8 @@ func TestSolarCentralBandCorridorPatchesOnlyTheClippedSegment(t *testing.T) {
}
func TestSolarCentralBandCorridorLeavesUnboundedMissAlone(t *testing.T) {
polygons := [][]geodata.GeoPoint{review15SquareRing(0, 0, 1, 1)}
line := review15CenterLine([2]float64{0.5, 0.5}, [2]float64{0.5, 20})
polygons := [][]geodata.GeoPoint{centralBandSquareRing(0, 0, 1, 1)}
line := centralBandCenterLine([2]float64{0.5, 0.5}, [2]float64{0.5, 20})
repaired := solarCentralBandWithCenterlineCorridor(polygons, line)
if len(repaired) != len(polygons) {
t.Fatalf("polygons=%d, want the input unchanged when the miss exceeds the corridor cap", len(repaired))
@@ -154,7 +154,7 @@ func TestSolarCentralBandCorridorLeavesUnboundedMissAlone(t *testing.T) {
}
func TestSolarShadowRegionDegenerateSkipsOnlyDegenerateRings(t *testing.T) {
normal := review15SquareRing(0, 0, 0.1, 0.1)
normal := centralBandSquareRing(0, 0, 0.1, 0.1)
if solarShadowRegionDegenerate(normal, normal) {
t.Fatal("an 11 km square must not be degenerate")
}
@@ -255,8 +255,8 @@ func TestAppendSolarFootprintFeaturesSkipsDegenerateFootprint(t *testing.T) {
}
}
// review15ProjectTime 独立复算顶点在中心线上的投影时刻。
func review15ProjectTime(
// centralBandProjectTime 独立复算顶点在中心线上的投影时刻。
func centralBandProjectTime(
point eclipsecore.SolarEclipsePathPoint,
centerLine []eclipsecore.SolarEclipsePathPoint,
) time.Time {
@@ -324,10 +324,76 @@ func TestSolarCentralBandLimitSidesKeepProjectionTimes(t *testing.T) {
if !side.north && point.Latitude >= 0 {
t.Fatalf("%s vertex %d latitude=%v, want south of the center line", side.name, index, point.Latitude)
}
projected := review15ProjectTime(point, centerLine)
projected := centralBandProjectTime(point, centerLine)
if delta := point.Time.Sub(projected); delta > time.Millisecond || delta < -time.Millisecond {
t.Fatalf("%s vertex %d time=%v, projected=%v", side.name, index, point.Time, projected)
}
}
}
}
// 掠食端中心线回折时,界线顶点的最近点投影会落到回折支上(1552-07-21)。
// 投影沿环连续追踪后,顶点不得被丢弃,界线也不得跳点抄近路。
func TestSolarCentralBandLimitSidesSurviveFoldedCenterLine(t *testing.T) {
base := time.Date(1552, time.July, 21, 6, 0, 0, 0, time.UTC)
centerLine := []eclipsecore.SolarEclipsePathPoint{
{Time: base, Longitude: 0, Latitude: 0},
{Time: base.Add(5 * time.Minute), Longitude: 0.5, Latitude: 0},
{Time: base.Add(10 * time.Minute), Longitude: 1, Latitude: 0},
{Time: base.Add(12 * time.Minute), Longitude: 1, Latitude: 0.25},
{Time: base.Add(15 * time.Minute), Longitude: 0.6, Latitude: 0.25},
}
northVertices := []eclipsecore.SolarEclipsePathPoint{
{Longitude: 0, Latitude: 0.15},
{Longitude: 0.3, Latitude: 0.15},
// 这两点离回折支(0.10°)比离去程支(0.15°)更近,仍须判到北侧。
{Longitude: 0.65, Latitude: 0.15},
{Longitude: 0.8, Latitude: 0.15},
{Longitude: 0.95, Latitude: 0.15},
}
southVertices := []eclipsecore.SolarEclipsePathPoint{
{Longitude: 0.95, Latitude: -0.15},
{Longitude: 0.5, Latitude: -0.15},
{Longitude: 0, Latitude: -0.15},
}
ring := append([]eclipsecore.SolarEclipsePathPoint(nil), northVertices...)
ring = append(ring, eclipsecore.SolarEclipsePathPoint{Longitude: 1.05, Latitude: 0})
ring = append(ring, southVertices...)
ring = append(ring, eclipsecore.SolarEclipsePathPoint{Longitude: -0.05, Latitude: 0})
north, south, ok := solarCentralBandLimitSidesFromRings(
[][]eclipsecore.SolarEclipsePathPoint{ring}, centerLine,
)
if !ok {
t.Fatal("folded center line must still produce two limit sides")
}
for _, side := range []struct {
name string
want []eclipsecore.SolarEclipsePathPoint
points []eclipsecore.SolarEclipsePathPoint
}{{"north-limit", northVertices, north}, {"south-limit", southVertices, south}} {
for _, vertex := range side.want {
found := false
for _, point := range side.points {
found = found || point.Longitude == vertex.Longitude && point.Latitude == vertex.Latitude
}
if !found {
t.Fatalf("%s lost boundary vertex (%v,%v): %+v", side.name, vertex.Longitude, vertex.Latitude, side.points)
}
}
for index := 1; index < len(side.points); index++ {
if !side.points[index].Time.After(side.points[index-1].Time) {
t.Fatalf("%s times not increasing at %d: %+v", side.name, index, side.points)
}
}
}
for _, point := range north {
if point.Latitude < 0 {
t.Fatalf("north-limit took southern vertex (%v,%v)", point.Longitude, point.Latitude)
}
}
for _, point := range south {
if point.Latitude > 0 {
t.Fatalf("south-limit took northern vertex (%v,%v)", point.Longitude, point.Latitude)
}
}
}
@@ -2,11 +2,14 @@ package geojson_test
import (
"encoding/json"
"fmt"
"testing"
"time"
"b612.me/astro/eclipse"
"b612.me/astro/geojson"
"b612.me/astro/internal/ephemtest"
)
func TestSolarEclipse20120521HasCentralBandHorizonClosure(t *testing.T) {
@@ -20,10 +23,7 @@ func TestSolarEclipse20120521HasCentralBandHorizonClosure(t *testing.T) {
if len(partial.CentralBandHorizonClosures) != 2 {
t.Fatalf("central-limit horizon closures=%d, want start and end", len(partial.CentralBandHorizonClosures))
}
expectedRoots := [2][2][]float64{
{{109.6259942, 19.9359024}, {107.7443439, 22.3910851}},
{{-100.0855931, 34.1224725}, {-102.2045906, 31.7284072}},
}
expectedRoots := solarEclipse20120521ExpectedRoots(t, partial)
for closureIndex, closure := range partial.CentralBandHorizonClosures {
if len(closure) < 2 {
t.Fatalf("horizon closure %d has %d points", closureIndex, len(closure))
@@ -152,10 +152,13 @@ func assertSolarPathMaximumEdgeKM(
func TestSolarEclipse20120521HorizonClosuresAreStableAcrossSampling(t *testing.T) {
date := time.Date(2012, time.May, 21, 0, 0, 0, 0, time.UTC)
expectedRoots := [2][2][]float64{
{{109.6259942, 19.9359024}, {107.7443439, 22.3910851}},
{{-100.0855931, 34.1224725}, {-102.2045906, 31.7284072}},
reference, ok := eclipse.SolarEclipsePartialFootprints(date, eclipse.SolarEclipsePartialFootprintOptions{
Step: 2 * time.Minute, BoundaryPoints: 96, CentralShadowStep: 2 * time.Minute,
})
if !ok || len(reference.CentralBandHorizonClosures) != 2 {
t.Fatalf("reference closures=%d ok=%v, want two", len(reference.CentralBandHorizonClosures), ok)
}
expectedRoots := solarEclipse20120521ExpectedRoots(t, reference)
for _, step := range []time.Duration{time.Minute, 5 * time.Minute, 10 * time.Minute} {
partial, ok := eclipse.SolarEclipsePartialFootprints(date, eclipse.SolarEclipsePartialFootprintOptions{
Step: step, BoundaryPoints: 96, CentralShadowStep: step,
@@ -198,3 +201,24 @@ func solarGreatestCurveContainsPoint(
}
return false
}
// solarEclipse20120521ExpectedRoots 两条地平闭合线的起止根;新模型的值取自 2 分钟步长的参考结果。
func solarEclipse20120521ExpectedRoots(t *testing.T, reference eclipse.SolarEclipsePartialFootprintsInfo) [2][2][]float64 {
t.Helper()
roots := [2][2][]float64{
{{109.6259942, 19.9359024}, {107.7443439, 22.3910851}},
{{-100.0855931, 34.1224725}, {-102.2045906, 31.7284072}},
}
values := ephemtest.NewValues(t, "solar_eclipse_20120521_roots")
for c, closure := range reference.CentralBandHorizonClosures {
ends := [2]eclipse.SolarEclipsePathPoint{closure[0], closure[len(closure)-1]}
for r, end := range ends {
key := fmt.Sprintf("closure%d/root%d", c, r)
roots[c][r] = []float64{
values.Want(key+"/lon", roots[c][r][0], end.Longitude),
values.Want(key+"/lat", roots[c][r][1], end.Latitude),
}
}
}
return roots
}
@@ -7,12 +7,13 @@ import (
"b612.me/astro/eclipse"
"b612.me/astro/geojson"
"b612.me/astro/internal/geodata"
"b612.me/astro/internal/ephemtest"
)
// 3288-11-16 in the local display falls on 3288-11-15 UTC. It is an
// extremely shallow non-central eclipse: the sampled footprints are open,
// while the rise/set phase tracks still form part of the visible envelope.
// 极浅非中心食:采样足迹不闭合,日出日没相位线仍须并入可见包络。
func TestMarshalSolarEclipse32881115FallbackCoversPhaseTracks(t *testing.T) {
ephemtest.PinLegacy(t)
path, ok := eclipse.SolarEclipsePartialFootprints(
time.Date(3288, time.November, 15, 0, 0, 0, 0, time.UTC),
eclipse.SolarEclipsePartialFootprintOptions{
@@ -51,6 +51,9 @@ var grazingBandCases = []grazingBandCase{
}
func TestSolarEclipseGrazingCentralBandCoversUmbralSweep(t *testing.T) {
if testing.Short() {
t.Skip("slow regression")
}
for _, testCase := range grazingBandCases {
t.Run(testCase.date+"-"+testCase.options, func(t *testing.T) {
date := grazingBandDate(t, testCase.date)
@@ -10,16 +10,16 @@ import (
"b612.me/astro/geojson"
)
// 本文件钉住 §1.5 的限线口径:north-limit / south-limit 的标签由每个顶点自身的投影侧决定,
// 本文件钉住限线口径:north-limit / south-limit 的标签由每个顶点自身的投影侧决定,
// times 是该顶点在中心线上的投影时刻,不按顶点顺序均匀铺开。
const review15LimitSideSlackKM = 25.0
const limitSideSlackKM = 25.0
type review15LimitLine struct {
type derivedLimitLine struct {
coordinates [][2]float64
times []time.Time
}
func review15SolarEclipseFixture(
func limitSolarEclipseFixture(
t *testing.T,
text string,
) ([]byte, eclipse.SolarEclipsePath) {
@@ -45,7 +45,7 @@ func review15SolarEclipseFixture(
return raw, central
}
func review15LimitLineFrom(t *testing.T, raw []byte, role string) review15LimitLine {
func derivedLimitLineFrom(t *testing.T, raw []byte, role string) derivedLimitLine {
t.Helper()
feature := featureWithRole(t, decodeCollection(t, raw), role)
var line [][2]float64
@@ -54,18 +54,26 @@ func review15LimitLineFrom(t *testing.T, raw []byte, role string) review15LimitL
if multiErr := json.Unmarshal(feature.Geometry.Coordinates, &lines); multiErr != nil {
t.Fatalf("%s coordinates: %v", role, err)
}
if len(lines) != 1 {
t.Fatalf("%s is split into %d segments", role, len(lines))
line = nil
for index, segment := range lines {
if index > 0 && !segmentsMeetAtSeam(lines[index-1], segment) {
t.Fatalf("%s is split into %d segments", role, len(lines))
}
line = append(line, segment...)
}
line = lines[0]
}
encoded, ok := feature.Properties["times"].([]interface{})
if !ok {
t.Fatalf("%s has no times array", role)
}
if len(encoded) == 1 {
if nested, nestedOK := encoded[0].([]interface{}); nestedOK {
encoded = nested
if len(encoded) > 0 {
if _, nestedOK := encoded[0].([]interface{}); nestedOK {
var flat []interface{}
for _, segment := range encoded {
nested, _ := segment.([]interface{})
flat = append(flat, nested...)
}
encoded = flat
}
}
if len(encoded) != len(line) {
@@ -80,11 +88,20 @@ func review15LimitLineFrom(t *testing.T, raw []byte, role string) review15LimitL
}
times[index] = stamp
}
return review15LimitLine{coordinates: line, times: times}
return derivedLimitLine{coordinates: line, times: times}
}
// review15ProjectOnCenterLine 独立复算顶点在中心线上的投影纬度与插值时刻。
func review15ProjectOnCenterLine(
// segmentsMeetAtSeam 报告前段终点与后段起点是否为 ±180° 上的同一点。
func segmentsMeetAtSeam(first, second [][2]float64) bool {
if len(first) == 0 || len(second) == 0 {
return false
}
end, start := first[len(first)-1], second[0]
return math.Abs(end[0]) == 180 && end[0] == -start[0] && math.Abs(end[1]-start[1]) < 1e-9
}
// projectOnCenterLine 独立复算顶点在中心线上的投影纬度与插值时刻。
func projectOnCenterLine(
point [2]float64,
centerLine []eclipse.SolarEclipsePathPoint,
) (float64, time.Time) {
@@ -118,19 +135,19 @@ func review15ProjectOnCenterLine(
func TestSolarEclipseLimitSidesCarryTheirOwnProjectionSide(t *testing.T) {
for _, text := range []string{"2003-05-31", "1874-10-10", "2185-07-26", "4862-09-28"} {
t.Run(text, func(t *testing.T) {
raw, central := review15SolarEclipseFixture(t, text)
raw, central := limitSolarEclipseFixture(t, text)
for _, side := range []struct {
role string
north bool
}{{"north-limit", true}, {"south-limit", false}} {
line := review15LimitLineFrom(t, raw, side.role)
line := derivedLimitLineFrom(t, raw, side.role)
for index, point := range line.coordinates {
latitude, _ := review15ProjectOnCenterLine(point, central.CenterLine)
latitude, _ := projectOnCenterLine(point, central.CenterLine)
offset := (point[1] - latitude) * 111.3
if side.north && offset < -review15LimitSideSlackKM {
if side.north && offset < -limitSideSlackKM {
t.Fatalf("%s vertex %d sits %.1f km south of the center line", side.role, index, -offset)
}
if !side.north && offset > review15LimitSideSlackKM {
if !side.north && offset > limitSideSlackKM {
t.Fatalf("%s vertex %d sits %.1f km north of the center line", side.role, index, offset)
}
}
@@ -139,8 +156,8 @@ func TestSolarEclipseLimitSidesCarryTheirOwnProjectionSide(t *testing.T) {
}
}
// review15BandRingVertices 收集 central-band 环上的顶点,用于判定限线是否由带边界派生。
func review15BandRingVertices(t *testing.T, raw []byte) map[[2]float64]bool {
// bandRingVertices 收集 central-band 环上的顶点,用于判定限线是否由带边界派生。
func bandRingVertices(t *testing.T, raw []byte) map[[2]float64]bool {
t.Helper()
vertices := map[[2]float64]bool{}
for _, ring := range grazingBandRings(t, raw) {
@@ -151,7 +168,7 @@ func review15BandRingVertices(t *testing.T, raw []byte) map[[2]float64]bool {
return vertices
}
func review15LimitFollowsBandRing(line review15LimitLine, vertices map[[2]float64]bool) bool {
func limitFollowsBandRing(line derivedLimitLine, vertices map[[2]float64]bool) bool {
if len(line.coordinates) < 3 || len(vertices) == 0 {
return false
}
@@ -167,17 +184,17 @@ func review15LimitFollowsBandRing(line review15LimitLine, vertices map[[2]float6
func TestSolarEclipseDerivedLimitTimesAreProjectionTimes(t *testing.T) {
for _, text := range []string{"2003-05-31"} {
t.Run(text, func(t *testing.T) {
raw, central := review15SolarEclipseFixture(t, text)
ringVertices := review15BandRingVertices(t, raw)
raw, central := limitSolarEclipseFixture(t, text)
ringVertices := bandRingVertices(t, raw)
derived := 0
for _, role := range []string{"north-limit", "south-limit"} {
line := review15LimitLineFrom(t, raw, role)
if !review15LimitFollowsBandRing(line, ringVertices) {
line := derivedLimitLineFrom(t, raw, role)
if !limitFollowsBandRing(line, ringVertices) {
continue
}
derived++
for index, point := range line.coordinates {
_, projected := review15ProjectOnCenterLine(point, central.CenterLine)
_, projected := projectOnCenterLine(point, central.CenterLine)
if delta := line.times[index].Sub(projected); delta > 2*time.Second || delta < -2*time.Second {
t.Fatalf("%s vertex %d time=%v, projected=%v",
role, index, line.times[index].UTC(), projected.UTC())
@@ -10,8 +10,8 @@ import (
"b612.me/astro/geojson"
)
// 本文件钉住 §1.5 涉及的导出路径成本:掠食带的限线派生与打包足迹都在这条路径上。
func review15BenchmarkDate(b *testing.B, text string) time.Time {
// 本文件钉住导出路径成本:掠食带的限线派生与打包足迹都在这条路径上。
func marshalBenchmarkDate(b *testing.B, text string) time.Time {
b.Helper()
if strings.HasPrefix(text, "-") {
parts := strings.Split(strings.TrimPrefix(text, "-"), "-")
@@ -39,8 +39,8 @@ func review15BenchmarkDate(b *testing.B, text string) time.Time {
return date
}
func benchmarkReview15SolarEclipseMarshal(b *testing.B, text string) {
date := review15BenchmarkDate(b, text)
func benchmarkSolarEclipseMarshal(b *testing.B, text string) {
date := marshalBenchmarkDate(b, text)
info, ok := eclipse.SolarEclipseOnDate(date)
if !ok || !info.HasCentral {
b.Fatalf("expected a central solar eclipse on %s", text)
@@ -69,14 +69,14 @@ func benchmarkReview15SolarEclipseMarshal(b *testing.B, text string) {
}
}
func BenchmarkReview15SolarEclipseMarshalGrazing(b *testing.B) {
func BenchmarkSolarEclipseMarshalGrazing(b *testing.B) {
for _, text := range []string{"2003-05-31", "1874-10-10", "2061-10-13"} {
b.Run(text, func(b *testing.B) {
benchmarkReview15SolarEclipseMarshal(b, text)
benchmarkSolarEclipseMarshal(b, text)
})
}
}
func BenchmarkReview15SolarEclipseMarshalOrdinary(b *testing.B) {
benchmarkReview15SolarEclipseMarshal(b, "2024-04-08")
func BenchmarkSolarEclipseMarshalOrdinary(b *testing.B) {
benchmarkSolarEclipseMarshal(b, "2024-04-08")
}
@@ -12,6 +12,9 @@ import (
)
func TestMarshalSolarEclipsePolarTwoLimitBandUsesSimpleFaces(t *testing.T) {
if testing.Short() {
t.Skip("slow regression")
}
for _, sample := range []struct {
date time.Time
centralStep time.Duration
+3 -3
View File
@@ -8,7 +8,7 @@ import (
)
// MarshalSolarEclipseShadowInstant 单时刻阴影足迹的最小 GeoJSON:只含该时刻的区域与物理边界 / one instant as the smallest useful GeoJSON.
func MarshalSolarEclipseShadowInstant(
func (module *Generator) MarshalSolarEclipseShadowInstant(
instant eclipsecore.SolarEclipseShadowInstant,
) ([]byte, error) {
if instant.Empty() {
@@ -52,10 +52,10 @@ func MarshalSolarEclipseShadowInstant(
regionProperties["source_boundary_closed"] = false
regionProperties["geometry_role"] = "horizon-closed-region"
regionProperties["closure"] = solarHorizonClosureProperties(
instant.Time, instant.Time, solarHorizonClosureExact(instant.Boundaries, instant.HorizonEnds),
instant.Time, instant.Time, solarHorizonClosureExact(instant.Boundaries, instant.HorizonEnds), module,
)
ring, boundary := solarShadowFootprintHorizonRing(
instant.Time, instant.Boundaries, instant.HorizonEnds, curve,
instant.Time, instant.Boundaries, instant.HorizonEnds, curve, module,
)
if solarShadowRegionDegenerate(curve, ring) {
return marshalEmptyFeatureCollection()
+30 -14
View File
@@ -6,7 +6,6 @@ import (
"time"
"b612.me/astro/basic"
eclipsecore "b612.me/astro/eclipse"
"b612.me/astro/internal/geodata"
"b612.me/astro/internal/solarclosure"
@@ -30,8 +29,12 @@ const (
func appendSolarHorizonClosedShadowFootprint(
features []feature,
footprint eclipsecore.SolarEclipsePartialFootprint,
properties map[string]interface{},
) ([]feature, error) {
properties map[string]interface{}, modules ...*Generator) ([]feature, error) {
module := &defaultModule
if len(modules) > 0 {
module = modules[0]
}
curve, err := solarShadowFootprintCurve(footprint)
if err != nil {
return nil, fmt.Errorf("geojson: solar %s at %s: %w",
@@ -41,7 +44,7 @@ func appendSolarHorizonClosedShadowFootprint(
return features, nil
}
ring, boundary := solarShadowFootprintHorizonRing(
footprint.Time, footprint.Boundaries, footprint.HorizonEnds, curve,
footprint.Time, footprint.Boundaries, footprint.HorizonEnds, curve, module,
)
if solarShadowRegionDegenerate(curve, ring) {
return features, nil
@@ -56,7 +59,7 @@ func appendSolarHorizonClosedShadowFootprint(
regionProperties["source_boundary_closed"] = false
regionProperties["geometry_role"] = "horizon-closed-region"
regionProperties["closure"] = solarHorizonClosureProperties(
footprint.Time, footprint.Time, solarHorizonClosureExact(footprint.Boundaries, footprint.HorizonEnds),
footprint.Time, footprint.Time, solarHorizonClosureExact(footprint.Boundaries, footprint.HorizonEnds), module,
)
regionProperties["interp_signature"] = solarShadowFootprintSignature(
footprint.Boundaries, false, eclipsecore.SolarEclipseShadowUmbra,
@@ -145,7 +148,7 @@ func solarShadowRegionAreaDegrees2(ring []geodata.GeoPoint) float64 {
func solarShadowFootprintCurveLengthKM(curve []geodata.GeoPoint) float64 {
length := 0.0
for index := 1; index < len(curve); index++ {
length += solarCentralBandGeoPointDistanceKM(curve[index-1], curve[index])
length += geodata.MeanDistanceKM(curve[index-1], curve[index])
}
return length
}
@@ -154,12 +157,16 @@ func solarShadowFootprintHorizonRing(
value time.Time,
boundaries [][]eclipsecore.SolarEclipsePathPoint,
horizonEnds []eclipsecore.SolarEclipsePathPoint,
curve []geodata.GeoPoint,
) ([]geodata.GeoPoint, []geodata.GeoPoint) {
curve []geodata.GeoPoint, modules ...*Generator) ([]geodata.GeoPoint, []geodata.GeoPoint) {
module := &defaultModule
if len(modules) > 0 {
module = modules[0]
}
return solarclosure.HorizonRing(solarclosure.Footprint{
Boundaries: solarClosureSegments(boundaries),
HorizonEnds: solarClosureEnds(horizonEnds),
Subsolar: solarSubsolarPoint(value),
Subsolar: solarSubsolarPoint(value, module),
}, curve)
}
@@ -199,8 +206,12 @@ func solarClosureEnds(horizonEnds []eclipsecore.SolarEclipsePathPoint) []geodata
// solarClosureFootprint 转换瞬时足迹;边界坐标非法时报错。
func solarClosureFootprint(
footprint eclipsecore.SolarEclipsePartialFootprint,
) (solarclosure.Footprint, error) {
footprint eclipsecore.SolarEclipsePartialFootprint, modules ...*Generator) (solarclosure.Footprint, error) {
module := &defaultModule
if len(modules) > 0 {
module = modules[0]
}
segments := make([][]geodata.GeoPoint, len(footprint.Boundaries))
for index, source := range footprint.Boundaries {
segment := make([]geodata.GeoPoint, len(source))
@@ -215,7 +226,7 @@ func solarClosureFootprint(
return solarclosure.Footprint{
Boundaries: segments,
HorizonEnds: solarClosureEnds(footprint.HorizonEnds),
Subsolar: solarSubsolarPoint(footprint.Time),
Subsolar: solarSubsolarPoint(footprint.Time, module),
Closed: footprint.Closed,
}, nil
}
@@ -230,8 +241,13 @@ func solarHorizonClosureExact(
// solarHorizonClosureProperties 声明式闭合弧;exact 为假表示缺精确擦地点、按采样端点近似闭合。
// 月下点按几何时刻算,time 属性写输出时标的时刻。
func solarHorizonClosureProperties(geometryTime, labelTime time.Time, exact bool) map[string]interface{} {
subsolar := solarSubsolarPoint(geometryTime)
func solarHorizonClosureProperties(geometryTime, labelTime time.Time, exact bool, modules ...*Generator) map[string]interface{} {
module := &defaultModule
if len(modules) > 0 {
module = modules[0]
}
subsolar := solarSubsolarPoint(geometryTime, module)
return map[string]interface{}{
"kind": "horizon",
"exact": exact,
+2 -2
View File
@@ -69,7 +69,7 @@ func appendSphericalMapArcWithin(
// linear in lon/lat (notably the long horizon closure edges of shallow
// polar eclipses). The adaptive angular-error test alone cannot see that
// case and leaves visually abrupt 250+ km segments.
arcDistanceKM := solarCentralBandGeoPointDistanceKM(first, last)
arcDistanceKM := geodata.MeanDistanceKM(first, last)
if (sphericalMapChordError(first, middle, last) <= chordErrorDegrees && arcDistanceKM <= chordLimitKM) || depth >= 20 {
return append(points, first)
}
@@ -107,7 +107,7 @@ func sampleSphericalMapPath(source []pathSample) []pathSample {
func sampleSphericalMapTriangle(points []geodata.GeoPoint) []geodata.GeoPoint {
apex, shortest := 0, math.Inf(1)
for i := range points {
if length := solarCentralBandGeoPointDistanceKM(points[(i+1)%3], points[(i+2)%3]); length < shortest {
if length := geodata.MeanDistanceKM(points[(i+1)%3], points[(i+2)%3]); length < shortest {
apex, shortest = i, length
}
}
@@ -0,0 +1,10 @@
{
"closure0/root0/lat": 19.9313852763035,
"closure0/root0/lon": 109.62972147083349,
"closure0/root1/lat": 22.386460994738165,
"closure0/root1/lon": 107.74831305482383,
"closure1/root0/lat": 34.11797301252737,
"closure1/root0/lon": -100.08865816367663,
"closure1/root1/lat": 31.724001846285333,
"closure1/root1/lon": -102.2073355598305
}
+451
View File
@@ -0,0 +1,451 @@
package geojson
import (
"fmt"
"math"
"time"
"b612.me/astro/basic"
"b612.me/astro/internal/geodata"
"b612.me/astro/mercury"
"b612.me/astro/venus"
)
const planetTransitEvent = "planet-transit"
// 标准大气,与 basic 站心凌日的视地平判定及凌日 SVG 一致。
const (
planetTransitRefractionPressureHPa = 1010.0
planetTransitRefractionTemperatureC = 10.0
minimumPlanetTransitIsochroneStep = time.Second
)
// PlanetTransitOptions 是凌日 GeoJSON 导出选项;所有要素都被丢弃时导出失败。
// PlanetTransitOptions are the planetary-transit GeoJSON export options; the export fails when every
// Feature is dropped.
type PlanetTransitOptions struct {
// BoundaryPoints 是地平线采样点数,同时决定分区的经度列数 max(360, BoundaryPoints)(上限 720);
// 小于等于零时使用 360,其他值限制在 [12, 1440]。
// BoundaryPoints is the horizon sample count and also sets the region longitude columns to
// max(360, BoundaryPoints), capped at 720. Values <= 0 use 360; others are clamped to [12, 1440].
BoundaryPoints int
// IsochroneStep 为正时输出一触到四触的站心接触等时线(掠凌没有二触、三触),至少一秒;零值不输出。
// A positive IsochroneStep adds topocentric C1-C4 isochrones (no C2/C3 for a grazing transit) and must be
// at least one second. The zero value adds none.
IsochroneStep time.Duration
// TimeMarkers 非空时沿一触到四触的太阳直射点轨迹追加时间标记 Point 要素。
// TimeMarkers adds time-marker Point Features along the subsolar track from C1 through C4 when non-nil.
TimeMarkers *TimeMarkerOptions
// SkipRoles 列出不写进输出的 role;分区三个 role 都跳过时不做分区计算。
// SkipRoles lists roles to leave out; skipping all three region roles skips the region computation.
SkipRoles []string
}
// planetTransitInput 是水星、金星两种 TransitInfo 的公共视图。
type planetTransitInput struct {
planet string
planetIndex int
start time.Time
c2 time.Time
greatest time.Time
c3 time.Time
end time.Time
hasInternal bool
separation float64
sunRadius float64
planetRad float64
}
// MarshalMercuryTransit 将水星凌日的全球可见分区、一触/四触地平线与可选站心等时线编码为 GeoJSON / encode a Mercury transit as GeoJSON.
func (module *Generator) MarshalMercuryTransit(info mercury.TransitInfo, options PlanetTransitOptions) ([]byte, error) {
if !info.Valid {
return nil, fmt.Errorf("geojson: mercury transit is invalid")
}
return marshalPlanetTransit(planetTransitInput{
planet: "mercury", planetIndex: 1,
start: info.Start, c2: info.InternalStart, greatest: info.Greatest, c3: info.InternalEnd, end: info.End,
hasInternal: info.HasInternal, separation: info.MinimumSeparationArcsec,
sunRadius: info.SunSemidiameterArcsec, planetRad: info.PlanetSemidiameterArcsec,
}, options, module)
}
// MarshalVenusTransit 将金星凌日的全球可见分区、一触/四触地平线与可选站心等时线编码为 GeoJSON / encode a Venus transit as GeoJSON.
func (module *Generator) MarshalVenusTransit(info venus.TransitInfo, options PlanetTransitOptions) ([]byte, error) {
if !info.Valid {
return nil, fmt.Errorf("geojson: venus transit is invalid")
}
return marshalPlanetTransit(planetTransitInput{
planet: "venus", planetIndex: 2,
start: info.Start, c2: info.InternalStart, greatest: info.Greatest, c3: info.InternalEnd, end: info.End,
hasInternal: info.HasInternal, separation: info.MinimumSeparationArcsec,
sunRadius: info.SunSemidiameterArcsec, planetRad: info.PlanetSemidiameterArcsec,
}, options, module)
}
func validatePlanetTransitInput(input planetTransitInput) error {
if input.start.IsZero() || input.greatest.IsZero() || input.end.IsZero() {
return fmt.Errorf("geojson: %s transit contact times are required", input.planet)
}
if !input.hasInternal && (!input.c2.IsZero() || !input.c3.IsZero()) {
return fmt.Errorf("geojson: %s transit cannot have internal contact times without internal contacts", input.planet)
}
ordered := []time.Time{input.start}
if input.hasInternal {
if input.c2.IsZero() || input.c3.IsZero() {
return fmt.Errorf("geojson: %s transit internal contact times are required", input.planet)
}
ordered = append(ordered, input.c2)
}
ordered = append(ordered, input.greatest)
if input.hasInternal {
ordered = append(ordered, input.c3)
}
ordered = append(ordered, input.end)
for index := 1; index < len(ordered); index++ {
if !ordered[index-1].Before(ordered[index]) {
return fmt.Errorf("geojson: %s transit contact times are not strictly ordered", input.planet)
}
}
return nil
}
func marshalPlanetTransit(input planetTransitInput, options PlanetTransitOptions, module *Generator) ([]byte, error) {
markerOptions := options.TimeMarkers
if markerOptions != nil {
if err := validateTimeMarkerOptions(*markerOptions); err != nil {
return nil, err
}
}
if options.IsochroneStep < 0 || (options.IsochroneStep > 0 && options.IsochroneStep < minimumPlanetTransitIsochroneStep) {
return nil, fmt.Errorf("geojson: transit isochrone step must be zero or at least %s", minimumPlanetTransitIsochroneStep)
}
if err := validatePlanetTransitInput(input); err != nil {
return nil, err
}
timeScale, scaleErr := timeScaleForMarkers(markerOptions)
if scaleErr != nil {
return nil, scaleErr
}
// 几何一律用民用时刻,只有写进属性的时刻换时标。
label := func(value time.Time) string {
if value.IsZero() {
return ""
}
return formatTime(basic.LabelIn(timeScale, value.UTC()))
}
boundaryPoints := normalizeLunarBoundaryPoints(options.BoundaryPoints)
longitudePoints := lunarVisibilityLongitudePoints(boundaryPoints, LunarEclipseOptions{})
longitudes := make([]float64, longitudePoints+1)
for index := range longitudes {
longitudes[index] = -180 + 360*float64(index)/float64(longitudePoints)
}
refraction := basic.RefractionFromApparentAltitude(0, planetTransitRefractionPressureHPa, planetTransitRefractionTemperatureC)
base := map[string]interface{}{"planet": input.planet, "has_internal": input.hasInternal}
withBase := func(extra map[string]interface{}) map[string]interface{} {
properties := cloneProperties(base)
for key, value := range extra {
properties[key] = value
}
return properties
}
features := make([]feature, 0, 12)
columnsAt := func(at time.Time) [][]lunarLatitudeInterval {
subsolar := solarSubsolarPoint(at, module)
columns := make([][]lunarLatitudeInterval, len(longitudes))
for index, longitude := range longitudes {
columns[index] = planetTransitSunUpIntervals(subsolar, longitude, refraction)
}
return columns
}
wantEntire := !skippedLunarRole(options.SkipRoles, "transit-entire")
wantSunrise := !skippedLunarRole(options.SkipRoles, "transit-at-sunrise")
wantSunset := !skippedLunarRole(options.SkipRoles, "transit-at-sunset")
wantRegions := wantEntire || wantSunrise || wantSunset
needC1 := wantRegions || !skippedLunarRole(options.SkipRoles, "visible-at-c1")
needGreatest := wantRegions
needC4 := wantRegions || !skippedLunarRole(options.SkipRoles, "visible-at-c4")
var c1Columns, greatestColumns, c4Columns [][]lunarLatitudeInterval
if needC1 {
c1Columns = columnsAt(input.start)
}
if needGreatest {
greatestColumns = columnsAt(input.greatest)
}
if needC4 {
c4Columns = columnsAt(input.end)
}
for _, contact := range []struct {
role, horizonRole string
at time.Time
columns [][]lunarLatitudeInterval
}{
{"visible-at-c1", "c1-horizon", input.start, c1Columns},
{"visible-at-c4", "c4-horizon", input.end, c4Columns},
} {
if !skippedLunarRole(options.SkipRoles, contact.role) {
value, err := planetTransitRegionGeometry(longitudes, contact.columns)
if err != nil {
return nil, fmt.Errorf("geojson: %s: %w", contact.role, err)
}
features = append(features, newFeature(planetTransitEvent, contact.role, value,
withBase(map[string]interface{}{"time": label(contact.at)})))
}
if !skippedLunarRole(options.SkipRoles, contact.horizonRole) {
subsolar := solarSubsolarPoint(contact.at, module)
antisolar := geodata.GeoPoint{Longitude: geodata.NormalizeLongitude(subsolar.Longitude + 180), Latitude: -subsolar.Latitude}
value, err := geoMultiLineGeometry(geodata.SphericalCircle(antisolar, 90-refraction, boundaryPoints), true)
if err != nil {
return nil, fmt.Errorf("geojson: %s: %w", contact.horizonRole, err)
}
features = append(features, newFeature(planetTransitEvent, contact.horizonRole, value,
withBase(map[string]interface{}{"time": label(contact.at)})))
}
}
regionFeatures, err := planetTransitRegionFeatures(longitudes, c1Columns, greatestColumns, c4Columns, options.SkipRoles,
withBase(map[string]interface{}{
"time_start": label(input.start),
"time_end": label(input.end),
"longitude_points": longitudePoints,
}))
if err != nil {
return nil, err
}
features = append(features, regionFeatures...)
if options.IsochroneStep > 0 {
isochrones, isoErr := planetTransitIsochroneFeatures(input, options.IsochroneStep, timeScale, options.SkipRoles, withBase, module)
if isoErr != nil {
return nil, isoErr
}
features = append(features, isochrones...)
}
greatest := solarSubsolarPoint(input.greatest, module)
metadata := withBase(map[string]interface{}{
"start": label(input.start),
"greatest": label(input.greatest),
"end": label(input.end),
"minimum_separation_arcsec": input.separation,
"sun_semidiameter_arcsec": input.sunRadius,
"planet_semidiameter_arcsec": input.planetRad,
})
if input.hasInternal {
metadata["internal_start"], metadata["internal_end"] = label(input.c2), label(input.c3)
}
features, err = appendPointFeature(features, planetTransitEvent, "greatest",
pathSample{Time: basic.LabelIn(timeScale, input.greatest.UTC()), Longitude: greatest.Longitude, Latitude: greatest.Latitude},
metadata)
if err != nil {
return nil, err
}
if markerOptions != nil {
markers, markerErr := planetTransitTimeMarkerSamples(input.start, input.end, timeScale, *markerOptions, module)
if markerErr != nil {
return nil, markerErr
}
features, err = appendTimeMarkerPointFeatures(features, planetTransitEvent, "subsolar-track", markers, markerOptions.Location)
if err != nil {
return nil, err
}
}
return marshalFeatureCollectionWithTimeScale(dropFeaturesByRole(features, options.SkipRoles), timeScale)
}
// planetTransitSunUpIntervals 返回 longitude 子午线上太阳中心在视地平上的纬度区间:
// 地点到直射点的角距小于 90°+折射即可见,即 sinδ·sinφ + cosδ·cosΔλ·cosφ > -sin(折射)。
func planetTransitSunUpIntervals(subsolar geodata.GeoPoint, longitude, refraction float64) []lunarLatitudeInterval {
rad := math.Pi / 180
a := math.Sin(subsolar.Latitude * rad)
b := math.Cos(subsolar.Latitude*rad) * math.Cos((longitude-subsolar.Longitude)*rad)
threshold := -math.Sin(refraction * rad)
visible := func(latitude float64) bool {
return a*math.Sin(latitude*rad)+b*math.Cos(latitude*rad) > threshold
}
boundaries := []float64{-90}
if amplitude := math.Hypot(a, b); amplitude > 0 && math.Abs(threshold/amplitude) < 1 {
// a·sinφ + b·cosφ = R·sin(φ+α),α = atan2(b, a)。
alpha := math.Atan2(b, a) / rad
base := math.Asin(threshold/amplitude) / rad
roots := make([]float64, 0, 2)
for _, value := range []float64{base - alpha, 180 - base - alpha} {
for _, shift := range []float64{-360, 0, 360} {
if root := value + shift; root > -90 && root < 90 {
roots = append(roots, root)
}
}
}
if len(roots) == 2 && roots[0] > roots[1] {
roots[0], roots[1] = roots[1], roots[0]
}
boundaries = append(boundaries, roots...)
}
boundaries = append(boundaries, 90)
intervals := make([]lunarLatitudeInterval, 0, 2)
for index := 0; index+1 < len(boundaries); index++ {
low, high := boundaries[index], boundaries[index+1]
if high-low > 1e-9 && visible((low+high)/2) {
intervals = append(intervals, lunarLatitudeInterval{low: low, high: high})
}
}
return lunarUnionLatitudeIntervals(intervals...)
}
func planetTransitRegionGeometry(longitudes []float64, columns [][]lunarLatitudeInterval) (geometry, error) {
value, err := lunarVisibilityEnvelopeGeometry(longitudes, columns)
if err != nil {
return geometry{}, err
}
return dropDegenerateMultiPolygonRings(value), nil
}
// planetTransitRegionFeatures 按与凌日 SVG 相同的口径分区:全程可见是三刻都在地平上,日出带是四触可见而一触不可见,
// 其余凡有一刻可见的都归入日落带(含凌甚前后可见的极区透镜与两端可见、凌甚不可见的高纬带)。
func planetTransitRegionFeatures(
longitudes []float64,
c1, greatest, c4 [][]lunarLatitudeInterval,
skip []string,
properties map[string]interface{},
) ([]feature, error) {
wantEntire := !skippedLunarRole(skip, "transit-entire")
wantSunrise := !skippedLunarRole(skip, "transit-at-sunrise")
wantSunset := !skippedLunarRole(skip, "transit-at-sunset")
if !wantEntire && !wantSunrise && !wantSunset {
return nil, nil
}
union := func(groups ...[]lunarLatitudeInterval) []lunarLatitudeInterval {
var merged []lunarLatitudeInterval
for _, group := range groups {
merged = append(merged, group...)
}
return lunarUnionLatitudeIntervals(merged...)
}
entire := make([][]lunarLatitudeInterval, len(longitudes))
sunrise := make([][]lunarLatitudeInterval, len(longitudes))
sunset := make([][]lunarLatitudeInterval, len(longitudes))
for index := range longitudes {
bothEnds := lunarIntersectLatitudeIntervals(c1[index], c4[index])
entire[index] = lunarIntersectLatitudeIntervals(bothEnds, greatest[index])
sunrise[index] = lunarSubtractLatitudeIntervals(c4[index], c1[index])
sunset[index] = lunarSubtractLatitudeIntervals(union(c1[index], c4[index], greatest[index]),
union(entire[index], sunrise[index]))
}
features := make([]feature, 0, 3)
for _, region := range []struct {
role string
want bool
columns [][]lunarLatitudeInterval
}{
{"transit-entire", wantEntire, entire},
{"transit-at-sunrise", wantSunrise, sunrise},
{"transit-at-sunset", wantSunset, sunset},
} {
if !region.want {
continue
}
value, err := planetTransitRegionGeometry(longitudes, region.columns)
if err != nil {
return nil, fmt.Errorf("geojson: %s: %w", region.role, err)
}
features = append(features, newFeature(planetTransitEvent, region.role, value, cloneProperties(properties)))
}
return features, nil
}
// planetTransitIsochroneFeatures 把每个接触的站心等时线编码为一条带 times 的 MultiLineString:
// 每段是一条支路,times 与坐标对齐,同一支路上所有点同刻。
func planetTransitIsochroneFeatures(
input planetTransitInput,
step time.Duration,
timeScale basic.TimeScale,
skip []string,
withBase func(map[string]interface{}) map[string]interface{},
module *Generator,
) ([]feature, error) {
result := basic.PlanetTransitResultFromTimes(input.planetIndex, input.start, input.c2, input.greatest, input.c3, input.end)
features := make([]feature, 0, 4)
for _, contact := range []struct {
kind basic.PlanetTransitContactKind
role string
}{
{basic.PlanetTransitC1, "c1-isochrone"},
{basic.PlanetTransitC2, "c2-isochrone"},
{basic.PlanetTransitC3, "c3-isochrone"},
{basic.PlanetTransitC4, "c4-isochrone"},
} {
if skippedLunarRole(skip, contact.role) {
continue
}
lines := basic.PlanetTransitContactIsochronesWithInstance(result, contact.kind, step, module.inst)
coordinates := make([][][]float64, 0, len(lines))
times := make([][]string, 0, len(lines))
for _, line := range lines {
at := basic.LabelIn(timeScale, basic.JD2DateByZone(line.JD, time.UTC, false))
for _, segment := range line.Segments {
if len(segment) < 2 {
continue
}
points := make([]pathSample, len(segment))
for index, point := range segment {
points[index] = pathSample{Time: at, Longitude: point.Longitude, Latitude: point.Latitude}
}
pieces, err := splitTimedLine(points, false)
if err != nil {
return nil, fmt.Errorf("geojson: %s: %w", contact.role, err)
}
for _, piece := range pieces {
if len(piece) < 2 {
continue
}
coordinate := make([][]float64, len(piece))
stamp := make([]string, len(piece))
for index, point := range piece {
if err := validateCoordinate(point.Longitude, point.Latitude); err != nil {
return nil, fmt.Errorf("geojson: %s: %w", contact.role, err)
}
coordinate[index] = []float64{point.Longitude, point.Latitude}
stamp[index] = formatTime(point.Time)
}
coordinates = append(coordinates, coordinate)
times = append(times, stamp)
}
}
}
if len(coordinates) == 0 {
continue
}
properties := withBase(map[string]interface{}{"step_seconds": step.Seconds(), "times": times})
features = append(features, newFeature(planetTransitEvent, contact.role,
geometry{Type: "MultiLineString", Coordinates: coordinates}, properties))
}
return features, nil
}
func planetTransitTimeMarkerSamples(
start, end time.Time,
scale basic.TimeScale,
options TimeMarkerOptions,
module *Generator,
) ([]pathSample, error) {
step, err := normalizeTimeMarkerStep(options.Step)
if err != nil {
return nil, fmt.Errorf("geojson: transit time markers: %w", err)
}
location := normalizeTimeMarkerLocation(options.Location)
capacity, err := timeMarkerCapacity(start, end, step, location)
if err != nil {
return nil, fmt.Errorf("geojson: transit time markers: %w", err)
}
markers := make([]pathSample, 0, capacity)
for current := firstTimeMarkerAfter(start, step, location); current.Before(end); current = current.Add(step) {
point := solarSubsolarPoint(current, module)
markers = append(markers, pathSample{
Time: basic.LabelIn(scale, current),
Longitude: point.Longitude,
Latitude: point.Latitude,
})
}
return markers, nil
}
+147
View File
@@ -0,0 +1,147 @@
package geojson_test
import (
"encoding/json"
"testing"
"time"
"b612.me/astro"
"b612.me/astro/geojson"
"b612.me/astro/mercury"
"b612.me/astro/venus"
)
type transitCollection struct {
TimeScale string `json:"time_scale"`
Features []struct {
Properties map[string]interface{} `json:"properties"`
Geometry struct {
Type string `json:"type"`
Coordinates json.RawMessage `json:"coordinates"`
} `json:"geometry"`
} `json:"features"`
}
func decodeTransit(t *testing.T, data []byte) (transitCollection, map[string]int) {
t.Helper()
var collection transitCollection
if err := json.Unmarshal(data, &collection); err != nil {
t.Fatal(err)
}
roles := map[string]int{}
for _, item := range collection.Features {
if item.Properties["event"] != "planet-transit" {
t.Fatalf("unexpected event %v", item.Properties["event"])
}
roles[item.Properties["role"].(string)]++
}
return collection, roles
}
// 2032-11-13 水星凌日:等时线四个接触俱全,区域与地平线成对输出,凌甚点落在太阳直射点。
func TestMarshalMercuryTransit(t *testing.T) {
info := mercury.ClosestTransit(time.Date(2032, 11, 13, 0, 0, 0, 0, time.UTC))
data, err := geojson.MarshalMercuryTransit(info, geojson.PlanetTransitOptions{
IsochroneStep: 20 * time.Second,
TimeMarkers: &geojson.TimeMarkerOptions{Step: 30 * time.Minute},
})
if err != nil {
t.Fatal(err)
}
collection, roles := decodeTransit(t, data)
for _, role := range []string{
"visible-at-c1", "c1-horizon", "visible-at-c4", "c4-horizon",
"transit-entire", "transit-at-sunrise", "transit-at-sunset",
"c1-isochrone", "c2-isochrone", "c3-isochrone", "c4-isochrone", "greatest",
} {
if roles[role] != 1 {
t.Fatalf("role %s count = %d, want 1", role, roles[role])
}
}
if roles["time-marker"] < 8 {
t.Fatalf("time markers = %d, want one every 30 min over ~4.4 h", roles["time-marker"])
}
if collection.TimeScale != "" {
t.Fatal("UTC export must not carry time_scale")
}
for _, item := range collection.Features {
role := item.Properties["role"]
switch role {
case "c1-isochrone", "c2-isochrone", "c3-isochrone", "c4-isochrone":
var lines [][][]float64
if err := json.Unmarshal(item.Geometry.Coordinates, &lines); err != nil {
t.Fatal(err)
}
times := item.Properties["times"].([]interface{})
if len(times) != len(lines) || len(lines) == 0 {
t.Fatalf("%s: times %d do not align with %d lines", role, len(times), len(lines))
}
for index, line := range lines {
if len(times[index].([]interface{})) != len(line) {
t.Fatalf("%s line %d: times length mismatch", role, index)
}
}
case "greatest":
if item.Properties["internal_start"] == nil || item.Properties["minimum_separation_arcsec"] == nil {
t.Fatal("greatest point must carry contact metadata")
}
}
}
}
// 默认不出等时线;SkipRoles 跳过全部分区后只剩地平线、可见区与凌甚点。
func TestMarshalVenusTransitDefaultsAndSkip(t *testing.T) {
info := venus.ClosestTransit(time.Date(2012, 6, 6, 0, 0, 0, 0, time.UTC))
data, err := geojson.MarshalVenusTransit(info, geojson.PlanetTransitOptions{})
if err != nil {
t.Fatal(err)
}
_, roles := decodeTransit(t, data)
if roles["c1-isochrone"] != 0 || roles["transit-entire"] != 1 {
t.Fatalf("default roles = %v", roles)
}
data, err = geojson.MarshalVenusTransit(info, geojson.PlanetTransitOptions{
SkipRoles: []string{"transit-entire", "transit-at-sunrise", "transit-at-sunset"},
})
if err != nil {
t.Fatal(err)
}
_, roles = decodeTransit(t, data)
if len(roles) != 5 {
t.Fatalf("skipped region roles = %v", roles)
}
}
func TestMarshalPlanetTransitValidation(t *testing.T) {
info := mercury.ClosestTransit(time.Date(2019, 11, 11, 0, 0, 0, 0, time.UTC))
if _, err := geojson.MarshalMercuryTransit(mercury.TransitInfo{}, geojson.PlanetTransitOptions{}); err == nil {
t.Fatal("invalid transit must be rejected")
}
if _, err := geojson.MarshalMercuryTransit(info, geojson.PlanetTransitOptions{IsochroneStep: time.Millisecond}); err == nil {
t.Fatal("sub-second isochrone step must be rejected")
}
swapped := info
swapped.Start, swapped.End = info.End, info.Start
if _, err := geojson.MarshalMercuryTransit(swapped, geojson.PlanetTransitOptions{}); err == nil {
t.Fatal("unordered contacts must be rejected")
}
inconsistent := info
inconsistent.HasInternal = false
if _, err := geojson.MarshalMercuryTransit(inconsistent, geojson.PlanetTransitOptions{IsochroneStep: time.Minute}); err == nil {
t.Fatal("internal contact times without HasInternal must be rejected")
}
if _, err := geojson.MarshalMercuryTransit(info, geojson.PlanetTransitOptions{
TimeMarkers: &geojson.TimeMarkerOptions{TimeScale: astro.TimeScaleUT1, Location: time.FixedZone("CST", 8*3600)},
}); err == nil {
t.Fatal("UT1 with a non-UTC location must be rejected")
}
data, err := geojson.MarshalMercuryTransit(info, geojson.PlanetTransitOptions{
TimeMarkers: &geojson.TimeMarkerOptions{TimeScale: astro.TimeScaleUT1},
})
if err != nil {
t.Fatal(err)
}
if collection, _ := decodeTransit(t, data); collection.TimeScale != "UT1" {
t.Fatal("UT1 export must carry time_scale")
}
}