feat: 引入可切换星历模型并扩展天象观测链路
- 新增半解析、旧模型、自定义与多源堆叠星历实例,提供 Context、Instance、SPK 读取及状态与视位置计算 - 统一太阳、月球和行星实例接口,扩展黄经穿越、留、相位、最近角距、偕日可见、任意高度升落、轨道节点及凌日等天象查询 - 完善日月食、月掩与凌日的月缘剖面、有限盘面接触、站心修正、局地可见性、全球路径和地图输出 - 新增 SVG、GeoJSON、KML 天象输出及凌日、月掩和月缘地形相关缓存与几何求解 - 新增恒星星表导入与查询,支持 CSV、Hipparcos、BSC5、Stellarium 等格式及名称、交叉标识、锥面检索和星等过滤 - 补充 IAU 2006、地球定向、折射消光、大气观测、轨道传播及半解析行星/月球数据支持 - 将默认未来民用时标切换为 TimeScaleLeapHour,更新 2027–2400 农历速查表、中文日期解析和时区边界处理 - 增加事件基线、跨模型回归、几何边界、月缘地形和输出契约测试,完善中英文手册、示例资源、NOTICE 与忽略规则
This commit is contained in:
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+91
-25
@@ -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())
|
||||
+8
-8
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user