feat: 新增月掩与日月食地理绘图并提升观测计算精度

- 新增月掩恒星和行星:支持搜索、掩甚点、全球掩带及固定地点轨迹计算
- 支持恒星星表坐标转换、有限盘面行星接触事件和月掩 SVG 输出
- 新增日月食及月掩全球投影图、时间标记和 GeoJSON 地理数据接口
- 扩展日食中心线、南北界及偏食足迹采样,支持极区投影
- 修正站心时角、月出月落、月球视半径、折射和恒星自行计算
- 优化内外行星事件搜索、边界选择、极端输入处理和计算稳定性
This commit is contained in:
2026-08-06 12:00:56 +08:00
parent 25dc7ac0bc
commit 9ee2163cc7
137 changed files with 21770 additions and 1746 deletions
+621
View File
@@ -0,0 +1,621 @@
package geojson
import (
"fmt"
"time"
"b612.me/astro/basic"
eclipsecore "b612.me/astro/eclipse"
"b612.me/astro/internal/geodata"
)
func validateSolarEclipseInput(
partial eclipsecore.SolarEclipsePartialFootprintsInfo,
central *eclipsecore.SolarEclipsePath,
) error {
info := partial.Eclipse
if info.GreatestEclipse.IsZero() {
return fmt.Errorf("geojson: solar eclipse greatest time is required")
}
if !info.HasPartial {
return fmt.Errorf("geojson: solar eclipse must contain a partial phase")
}
if info.PartialBeginOnEarth.IsZero() || info.PartialEndOnEarth.IsZero() {
return fmt.Errorf("geojson: solar eclipse partial contact times are required")
}
if !info.PartialBeginOnEarth.Before(info.GreatestEclipse) ||
!info.GreatestEclipse.Before(info.PartialEndOnEarth) {
return fmt.Errorf("geojson: solar eclipse times must be ordered partial begin, greatest, partial end")
}
if err := validateSolarPathPoint("solar greatest", eclipsecore.SolarEclipsePathPoint{
Time: info.GreatestEclipse, Longitude: info.GreatestLongitude, Latitude: info.GreatestLatitude,
}); err != nil {
return err
}
previous := time.Time{}
for index, footprint := range partial.Footprints {
if footprint.Time.IsZero() {
return fmt.Errorf("geojson: solar partial footprint %d time is required", index)
}
if !previous.IsZero() && !footprint.Time.After(previous) {
return fmt.Errorf("geojson: solar partial footprint times must be strictly increasing")
}
if footprint.Time.Before(info.PartialBeginOnEarth) || footprint.Time.After(info.PartialEndOnEarth) {
return fmt.Errorf("geojson: solar partial footprint %d time is outside the partial interval", index)
}
previous = footprint.Time
}
if central == nil {
return nil
}
if !central.Eclipse.GreatestEclipse.Equal(info.GreatestEclipse) ||
central.Eclipse.Type != info.Type || central.Eclipse.Model != info.Model {
return fmt.Errorf("geojson: partial footprints and central path describe different eclipses")
}
if central.Eclipse.CentralBeginOnEarth.IsZero() || central.Eclipse.CentralEndOnEarth.IsZero() ||
!central.Eclipse.CentralBeginOnEarth.Before(central.Eclipse.GreatestEclipse) ||
!central.Eclipse.GreatestEclipse.Before(central.Eclipse.CentralEndOnEarth) {
return fmt.Errorf("geojson: solar central path contact times are invalid")
}
if err := validateSolarPathPoint("solar central greatest", central.Greatest); err != nil {
return err
}
if !central.Greatest.Time.Equal(central.Eclipse.GreatestEclipse) {
return fmt.Errorf("geojson: solar central greatest time does not match eclipse greatest")
}
if err := validateSolarPathSeries("solar center line", central.CenterLine, true); err != nil {
return err
}
if central.Greatest.Time.Before(central.CenterLine[0].Time) ||
central.Greatest.Time.After(central.CenterLine[len(central.CenterLine)-1].Time) {
return fmt.Errorf("geojson: solar greatest time is outside the center-line interval")
}
if central.CenterLine[0].Time.Before(central.Eclipse.CentralBeginOnEarth) ||
central.CenterLine[len(central.CenterLine)-1].Time.After(central.Eclipse.CentralEndOnEarth) {
return fmt.Errorf("geojson: solar center line is outside the central interval")
}
if len(central.NorthernLimit) != len(central.SouthernLimit) {
return fmt.Errorf("geojson: solar central limits must have the same sample count")
}
if len(central.NorthernLimit) > 0 {
if err := validateSolarPathSeries("solar northern limit", central.NorthernLimit, true); err != nil {
return err
}
if err := validateSolarPathSeries("solar southern limit", central.SouthernLimit, true); err != nil {
return err
}
for index := range central.NorthernLimit {
if !central.NorthernLimit[index].Time.Equal(central.SouthernLimit[index].Time) {
return fmt.Errorf("geojson: solar central limit sample %d times must match", index)
}
}
}
return nil
}
func validateSolarPathSeries(name string, points []eclipsecore.SolarEclipsePathPoint, required bool) error {
if required && len(points) < 2 {
return fmt.Errorf("geojson: %s requires at least two points", name)
}
previous := time.Time{}
for index, point := range points {
if err := validateSolarPathPoint(fmt.Sprintf("%s[%d]", name, index), point); err != nil {
return err
}
if !previous.IsZero() && !point.Time.After(previous) {
return fmt.Errorf("geojson: %s times must be strictly increasing", name)
}
previous = point.Time
}
return nil
}
func validateSolarPathPoint(name string, point eclipsecore.SolarEclipsePathPoint) error {
if point.Time.IsZero() {
return fmt.Errorf("geojson: %s time is required", name)
}
if err := validateCoordinate(point.Longitude, point.Latitude); err != nil {
return fmt.Errorf("geojson: %s: %w", name, err)
}
if !finiteGeoJSON(point.SunAltitude) || point.SunAltitude < -90 || point.SunAltitude > 90 {
return fmt.Errorf("geojson: %s sun altitude must be finite and within [-90, 90]", name)
}
if !finiteGeoJSON(point.WidthKM) || point.WidthKM < 0 {
return fmt.Errorf("geojson: %s width must be finite and non-negative", name)
}
return nil
}
func validateLunarEclipseInfo(info eclipsecore.LunarEclipseInfo) error {
if !info.HasPenumbral || info.PenumbralStart.IsZero() || info.PenumbralEnd.IsZero() {
return fmt.Errorf("geojson: lunar eclipse penumbral contact times are required")
}
if info.Maximum.IsZero() {
return fmt.Errorf("geojson: lunar eclipse greatest time is required")
}
if info.Type != eclipsecore.LunarEclipsePenumbral && info.Type != eclipsecore.LunarEclipsePartial &&
info.Type != eclipsecore.LunarEclipseTotal {
return fmt.Errorf("geojson: lunar eclipse type is invalid")
}
switch info.Type {
case eclipsecore.LunarEclipsePenumbral:
if info.HasPartial || info.HasTotal {
return fmt.Errorf("geojson: penumbral eclipse cannot contain partial or total phases")
}
case eclipsecore.LunarEclipsePartial:
if !info.HasPartial || info.HasTotal {
return fmt.Errorf("geojson: partial eclipse must contain only a partial phase")
}
case eclipsecore.LunarEclipseTotal:
if !info.HasPartial || !info.HasTotal {
return fmt.Errorf("geojson: total eclipse must contain partial and total phases")
}
}
if !info.HasPartial && (!info.PartialStart.IsZero() || !info.PartialEnd.IsZero()) {
return fmt.Errorf("geojson: partial contact times require a partial phase")
}
if !info.HasTotal && (!info.TotalStart.IsZero() || !info.TotalEnd.IsZero()) {
return fmt.Errorf("geojson: total contact times require a total phase")
}
ordered := []time.Time{info.PenumbralStart}
if info.HasPartial {
if info.PartialStart.IsZero() || info.PartialEnd.IsZero() {
return fmt.Errorf("geojson: lunar eclipse partial contact times are required")
}
ordered = append(ordered, info.PartialStart)
}
if info.HasTotal {
if info.TotalStart.IsZero() || info.TotalEnd.IsZero() {
return fmt.Errorf("geojson: lunar eclipse total contact times are required")
}
ordered = append(ordered, info.TotalStart)
}
ordered = append(ordered, info.Maximum)
if info.HasTotal {
ordered = append(ordered, info.TotalEnd)
}
if info.HasPartial {
ordered = append(ordered, info.PartialEnd)
}
ordered = append(ordered, info.PenumbralEnd)
for index := 1; index < len(ordered); index++ {
if !ordered[index-1].Before(ordered[index]) {
return fmt.Errorf("geojson: lunar eclipse contact times are not strictly ordered")
}
}
return nil
}
const (
solarEclipseEvent = "solar-eclipse"
lunarEclipseEvent = "lunar-eclipse"
defaultLunarBoundaryPoints = 360
minimumLunarBoundaryPoints = 12
maximumLunarBoundaryPoints = 1440
)
// MarshalSolarEclipse 将日食半影足迹和可选中心食带编码为 GeoJSON。
// MarshalSolarEclipse encodes penumbral footprints and an optional central path as GeoJSON.
func MarshalSolarEclipse(
partial eclipsecore.SolarEclipsePartialFootprintsInfo,
central *eclipsecore.SolarEclipsePath,
) ([]byte, error) {
return marshalSolarEclipse(partial, central, nil)
}
// 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 marshalSolarEclipse(partial, central, &options)
}
func marshalSolarEclipse(
partial eclipsecore.SolarEclipsePartialFootprintsInfo,
central *eclipsecore.SolarEclipsePath,
markerOptions *TimeMarkerOptions,
) ([]byte, error) {
if markerOptions != nil {
if err := validateTimeMarkerOptions(*markerOptions); err != nil {
return nil, err
}
}
if len(partial.Footprints) == 0 {
return nil, fmt.Errorf("geojson: solar eclipse has no partial footprints")
}
if err := validateSolarEclipseInput(partial, central); err != nil {
return nil, err
}
properties := map[string]interface{}{
"eclipse_type": string(partial.Eclipse.Type),
"model": string(partial.Eclipse.Model),
}
features := make([]feature, 0, len(partial.Footprints)+8)
for _, footprint := range partial.Footprints {
polygon, err := solarPartialFootprintPolygon(footprint)
if err != nil {
return nil, err
}
footprintProperties := cloneProperties(properties)
footprintProperties["time"] = formatTime(footprint.Time)
footprintProperties["source_boundary_closed"] = footprint.Closed
if len(polygon) == 1 {
value, pointErr := pointGeometry(polygon[0].Longitude, polygon[0].Latitude)
if pointErr != nil {
return nil, fmt.Errorf("geojson: solar partial footprint at %s: %w", formatTime(footprint.Time), pointErr)
}
features = append(features, newFeature(
solarEclipseEvent, "partial-footprint", value, footprintProperties,
))
continue
}
value, err := multiPolygonGeometry([][]geodata.GeoPoint{polygon})
if err != nil {
return nil, fmt.Errorf("geojson: solar partial footprint at %s: %w", formatTime(footprint.Time), err)
}
features = append(features, newFeature(
solarEclipseEvent, "partial-footprint", value, footprintProperties,
))
}
if central != nil {
if len(central.NorthernLimit) > 0 {
band, err := pairedLimitPolygon(central.NorthernLimit, central.SouthernLimit)
if err != nil {
return nil, fmt.Errorf("geojson: solar central band: %w", err)
}
value, err := multiPolygonGeometry([][]geodata.GeoPoint{band})
if err != nil {
return nil, fmt.Errorf("geojson: solar central band: %w", err)
}
features = append(features, newFeature(
solarEclipseEvent, "central-band", value, cloneProperties(properties),
))
}
var err error
features, err = appendSolarPathLine(features, "center-line", central.CenterLine, properties)
if err != nil {
return nil, err
}
if len(central.NorthernLimit) > 0 {
features, err = appendSolarPathLine(features, "north-limit", central.NorthernLimit, properties)
if err != nil {
return nil, err
}
features, err = appendSolarPathLine(features, "south-limit", central.SouthernLimit, properties)
if err != nil {
return nil, err
}
}
if markerOptions != nil {
features, err = appendTimeMarkerFeatures(
features,
solarEclipseEvent,
"center-line",
solarPathSamples(central.CenterLine),
*markerOptions,
)
if err != nil {
return nil, err
}
}
}
greatest := pathSample{
Time: partial.Eclipse.GreatestEclipse,
Longitude: partial.Eclipse.GreatestLongitude,
Latitude: partial.Eclipse.GreatestLatitude,
}
greatestProperties := solarEclipseMetadata(partial.Eclipse)
if central != nil {
greatest = solarPathSample(central.Greatest)
greatestProperties["width_km"] = central.Greatest.WidthKM
greatestProperties["sun_altitude_deg"] = central.Greatest.SunAltitude
}
var err error
features, err = appendPointFeature(
features, solarEclipseEvent, "greatest", greatest, greatestProperties,
)
if err != nil {
return nil, err
}
return marshalFeatureCollection(features)
}
// MarshalLunarEclipse 将月食 P1/P4 可见半球和地平线边界编码为 GeoJSON。
// MarshalLunarEclipse encodes the P1/P4 visible hemispheres and horizon boundaries as GeoJSON.
// 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, nil)
}
// 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 marshalLunarEclipse(info, boundaryPoints, &options)
}
func marshalLunarEclipse(
info eclipsecore.LunarEclipseInfo,
boundaryPoints int,
markerOptions *TimeMarkerOptions,
) ([]byte, error) {
if markerOptions != nil {
if err := validateTimeMarkerOptions(*markerOptions); err != nil {
return nil, err
}
}
if err := validateLunarEclipseInfo(info); err != nil {
return nil, err
}
boundaryPoints = normalizeLunarBoundaryPoints(boundaryPoints)
properties := map[string]interface{}{
"eclipse_type": string(info.Type),
"boundary_points": boundaryPoints,
}
features := make([]feature, 0, 5)
contacts := []struct {
role string
horizonRole string
time time.Time
}{
{role: "visible-at-p1", horizonRole: "p1-horizon", time: info.PenumbralStart},
{role: "visible-at-p4", horizonRole: "p4-horizon", time: info.PenumbralEnd},
}
for _, contact := range contacts {
center := lunarSubpoint(contact.time)
polygons := geodata.VisibleHemispherePolygons(
center, geodata.ProjectionEquirectangular, boundaryPoints,
)
value, err := multiPolygonGeometryFromFragments(polygons)
if err != nil {
return nil, fmt.Errorf("geojson: %s: %w", contact.role, err)
}
contactProperties := cloneProperties(properties)
contactProperties["time"] = formatTime(contact.time)
features = append(features, newFeature(
lunarEclipseEvent, contact.role, value, contactProperties,
))
horizon := geodata.SphericalCircle(center, 90, boundaryPoints)
horizonValue, err := geoMultiLineGeometry(horizon, true)
if err != nil {
return nil, fmt.Errorf("geojson: %s: %w", contact.horizonRole, err)
}
features = append(features, newFeature(
lunarEclipseEvent,
contact.horizonRole,
horizonValue,
map[string]interface{}{
"eclipse_type": string(info.Type),
"time": formatTime(contact.time),
},
))
}
maximum := lunarSubpoint(info.Maximum)
features, err := appendPointFeature(
features,
lunarEclipseEvent,
"greatest",
pathSample{Time: info.Maximum, Longitude: maximum.Longitude, Latitude: maximum.Latitude},
lunarEclipseMetadata(info),
)
if err != nil {
return nil, err
}
if markerOptions != nil {
markers, markerErr := lunarEclipseTimeMarkerSamples(info, *markerOptions)
if markerErr != nil {
return nil, markerErr
}
features, err = appendTimeMarkerPointFeatures(
features,
lunarEclipseEvent,
"sublunar-track",
markers,
markerOptions.Location,
)
if err != nil {
return nil, err
}
}
return marshalFeatureCollection(features)
}
func solarPartialFootprintPolygon(
footprint eclipsecore.SolarEclipsePartialFootprint,
) ([]geodata.GeoPoint, error) {
if footprint.Time.IsZero() {
return nil, fmt.Errorf("geojson: solar partial footprint time is required")
}
segments := make([][]geodata.GeoPoint, 0, len(footprint.Boundaries))
for _, source := range footprint.Boundaries {
segment := make([]geodata.GeoPoint, len(source))
for index, point := range source {
if err := validateCoordinate(point.Longitude, point.Latitude); err != nil {
return nil, err
}
segment[index] = geodata.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude}
}
segments = append(segments, segment)
}
boundary := geodata.JoinPolylineSegments(segments)
boundary = openRing(boundary)
if len(boundary) == 1 && !footprint.Closed {
return boundary, nil
}
minimumPoints := 3
if !footprint.Closed {
minimumPoints = 2
}
if len(boundary) < minimumPoints {
return nil, fmt.Errorf("geojson: solar partial footprint boundary is incomplete")
}
polygon := append([]geodata.GeoPoint(nil), boundary...)
if !footprint.Closed {
terminator := geodata.SphericalCircle(solarSubsolarPoint(footprint.Time), 90, 360)
arc := geodata.ShortestCircleArc(terminator, boundary[len(boundary)-1], boundary[0])
if len(arc) > 1 {
polygon = append(polygon, arc[1:]...)
}
}
if len(openRing(polygon)) < 3 {
return nil, fmt.Errorf("geojson: solar partial footprint polygon is incomplete")
}
return polygon, nil
}
func pairedLimitPolygon(
northern, southern []eclipsecore.SolarEclipsePathPoint,
) ([]geodata.GeoPoint, error) {
if len(northern) != len(southern) {
return nil, fmt.Errorf("paired limits must have the same sample count")
}
count := len(northern)
if count < 2 {
return nil, fmt.Errorf("paired limits require at least two points per side")
}
for index := range northern {
if northern[index].Time.IsZero() || southern[index].Time.IsZero() {
return nil, fmt.Errorf("paired limit sample %d time is required", index)
}
if !northern[index].Time.Equal(southern[index].Time) {
return nil, fmt.Errorf("paired limit sample %d times must match", index)
}
}
polygon := make([]geodata.GeoPoint, 0, 2*count)
for _, point := range northern[:count] {
polygon = append(polygon, geodata.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude})
}
for index := count - 1; index >= 0; index-- {
point := southern[index]
polygon = append(polygon, geodata.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude})
}
return polygon, nil
}
func appendSolarPathLine(
features []feature,
role string,
points []eclipsecore.SolarEclipsePathPoint,
properties map[string]interface{},
) ([]feature, error) {
samples := make([]pathSample, len(points))
for index, point := range points {
samples[index] = solarPathSample(point)
}
return appendTimedLineFeature(features, solarEclipseEvent, role, samples, properties)
}
func solarPathSamples(points []eclipsecore.SolarEclipsePathPoint) []pathSample {
samples := make([]pathSample, len(points))
for index, point := range points {
samples[index] = solarPathSample(point)
}
return samples
}
func solarPathSample(point eclipsecore.SolarEclipsePathPoint) pathSample {
return pathSample{Time: point.Time, Longitude: point.Longitude, Latitude: point.Latitude}
}
func solarEclipseMetadata(info eclipsecore.SolarEclipseInfo) map[string]interface{} {
return map[string]interface{}{
"eclipse_type": string(info.Type),
"model": string(info.Model),
"centrality": string(info.Centrality),
"magnitude": info.Magnitude,
"gamma": info.Gamma,
"path_width_km": info.PathWidthKM,
"partial_begin_on_earth": formatTime(info.PartialBeginOnEarth),
"partial_end_on_earth": formatTime(info.PartialEndOnEarth),
"central_begin_on_earth": formatTime(info.CentralBeginOnEarth),
"central_end_on_earth": formatTime(info.CentralEndOnEarth),
}
}
func lunarEclipseMetadata(info eclipsecore.LunarEclipseInfo) map[string]interface{} {
return map[string]interface{}{
"eclipse_type": string(info.Type),
"penumbral_magnitude": info.PenumbralMagnitude,
"umbral_magnitude": info.UmbralMagnitude,
"penumbral_start": formatTime(info.PenumbralStart),
"partial_start": formatTime(info.PartialStart),
"total_start": formatTime(info.TotalStart),
"total_end": formatTime(info.TotalEnd),
"partial_end": formatTime(info.PartialEnd),
"penumbral_end": formatTime(info.PenumbralEnd),
}
}
func solarSubsolarPoint(value time.Time) geodata.GeoPoint {
ttJDE := basic.TD2UT(basic.Date2JDE(value.UTC()), true)
ra, dec := basic.HSunApparentRaDec(ttJDE)
utJDE := basic.TD2UT(ttJDE, false)
longitude := normalizeLongitude(ra - basic.ApparentSiderealTime(utJDE)*15)
return geodata.GeoPoint{Longitude: longitude, Latitude: dec}
}
func lunarSubpoint(value time.Time) geodata.GeoPoint {
ttJDE := basic.TD2UT(basic.Date2JDE(value.UTC()), true)
ra, dec := basic.HMoonTrueRaDec(ttJDE)
utJDE := basic.TD2UT(ttJDE, false)
longitude := normalizeLongitude(ra - basic.ApparentSiderealTime(utJDE)*15)
return geodata.GeoPoint{Longitude: longitude, Latitude: dec}
}
func lunarEclipseTimeMarkerSamples(
info eclipsecore.LunarEclipseInfo,
options TimeMarkerOptions,
) ([]pathSample, error) {
step, err := normalizeTimeMarkerStep(options.Step)
if err != nil {
return nil, fmt.Errorf("geojson: lunar eclipse time markers: %w", err)
}
location := normalizeTimeMarkerLocation(options.Location)
start, end := info.PenumbralStart, info.PenumbralEnd
capacity, err := timeMarkerCapacity(start, end, step, location)
if err != nil {
return nil, fmt.Errorf("geojson: lunar eclipse time markers: %w", err)
}
current := firstTimeMarkerAfter(start, step, location)
markers := make([]pathSample, 0, capacity)
for current.Before(end) {
point := lunarSubpoint(current)
markers = append(markers, pathSample{
Time: current,
Longitude: point.Longitude,
Latitude: point.Latitude,
})
current = current.Add(step)
}
return markers, nil
}
func normalizeLunarBoundaryPoints(value int) int {
if value <= 0 {
return defaultLunarBoundaryPoints
}
if value < minimumLunarBoundaryPoints {
return minimumLunarBoundaryPoints
}
if value > maximumLunarBoundaryPoints {
return maximumLunarBoundaryPoints
}
return value
}
+64
View File
@@ -0,0 +1,64 @@
package geojson_test
import (
"encoding/json"
"fmt"
"time"
"b612.me/astro/eclipse"
"b612.me/astro/geojson"
)
func ExampleMarshalSolarEclipse() {
date := time.Date(2024, time.April, 8, 0, 0, 0, 0, time.UTC)
partial, ok := eclipse.SolarEclipsePartialFootprints(
date,
eclipse.SolarEclipsePartialFootprintOptions{
Step: 10 * time.Minute,
BoundaryPoints: 180,
},
)
if !ok {
return
}
central, hasCentral := eclipse.SolarEclipseCentralPath(
date,
eclipse.SolarEclipsePathOptions{
Step: time.Minute,
TargetSpacingKM: 20,
},
)
var centralPath *eclipse.SolarEclipsePath
if hasCentral {
centralPath = &central
}
data, err := geojson.MarshalSolarEclipse(partial, centralPath)
fmt.Println(err == nil, json.Valid(data))
// Output: true true
}
func ExampleMarshalSolarEclipseWithTimeMarkers() {
date := time.Date(2024, time.April, 8, 0, 0, 0, 0, time.UTC)
partial, ok := eclipse.SolarEclipsePartialFootprints(
date,
eclipse.SolarEclipsePartialFootprintOptions{Step: 20 * time.Minute, BoundaryPoints: 36},
)
if !ok {
return
}
central, ok := eclipse.SolarEclipseCentralPath(
date,
eclipse.SolarEclipsePathOptions{Step: 5 * time.Minute},
)
if !ok {
return
}
data, err := geojson.MarshalSolarEclipseWithTimeMarkers(
partial,
&central,
geojson.TimeMarkerOptions{Step: 30 * time.Minute, Location: time.FixedZone("CST", 8*60*60)},
)
fmt.Println(err == nil, json.Valid(data))
// Output: true true
}
+512
View File
@@ -0,0 +1,512 @@
// Package geojson 将日月食和月掩地理结果编码为 RFC 7946 GeoJSON FeatureCollections。
// 坐标是 WGS84 经度和纬度,单位为度;地图投影和样式由应用处理。
// Package geojson encodes eclipse and lunar-occultation geographic results as RFC 7946 GeoJSON FeatureCollections.
// Coordinates are WGS84 longitude and latitude in degrees; map projection and styling remain application concerns.
//
// 每个要素都包含 event 和 role 属性。带时间的 MultiLineString 要素还包含与坐标段对齐的嵌套 times 数组。
// Times 编码为 UTC RFC 3339 字符串;路径采样由上游日月食和月掩选项控制后再传入本包。
// WithTimeMarkers 变体还会追加 role 为 time-marker 的 Point 要素,标签按请求地点格式化。
// Every feature has event and role properties. Timed MultiLineString features also contain a nested times array aligned with their coordinate segments.
// Times are encoded as UTC RFC 3339 strings. Path sampling is controlled by the source eclipse and occultation options before values reach this package.
// The WithTimeMarkers variants additionally append Point Features whose role is time-marker and whose label is formatted for the requested location.
package geojson
import (
"encoding/json"
"fmt"
"math"
"time"
"b612.me/astro/internal/geodata"
)
const (
featureCollectionType = "FeatureCollection"
minimumTimeMarkerStep = time.Minute
maximumTimeMarkerCount = 1440
)
type featureCollection struct {
Type string `json:"type"`
Features []feature `json:"features"`
}
type feature struct {
Type string `json:"type"`
Properties map[string]interface{} `json:"properties"`
Geometry geometry `json:"geometry"`
}
type geometry struct {
Type string `json:"type"`
Coordinates interface{} `json:"coordinates"`
}
type pathSample struct {
Time time.Time
Longitude float64
Latitude float64
}
// TimeMarkerOptions 控制供地图客户端绘制路径时间标签的可选 Point Feature。
// TimeMarkerOptions controls optional Point Features used by map clients to draw time labels along a moving event path.
// Step 控制标记间隔;零值使用 30 分钟,并对齐到下一个本地整点边界。
// Step controls the marker interval; zero uses 30 minutes and aligns markers to the next local clock boundary.
// Location 控制 HH:MM 标签,默认 UTC;底层 time 属性仍为 UTC RFC 3339。
// Location controls the HH:MM label and defaults to UTC. The underlying time property remains UTC RFC 3339.
// 正 Step 至少为一分钟,单次导出最多 1440 个标记。
// Positive Step values must be at least one minute, and one export is limited to 1440 markers.
type TimeMarkerOptions struct {
// Step 是时间标记之间的间隔;零值使用 30 分钟。
// Step is the interval between time markers; zero uses 30 minutes.
Step time.Duration
// Location 是格式化 HH:MM 标签时使用的时区;nil 使用 UTC。
// Location is the timezone used to format HH:MM labels; nil uses UTC.
Location *time.Location
}
func marshalFeatureCollection(features []feature) ([]byte, error) {
if len(features) == 0 {
return nil, fmt.Errorf("geojson: no geographic features")
}
value, err := json.Marshal(featureCollection{Type: featureCollectionType, Features: features})
if err != nil {
return nil, fmt.Errorf("geojson: encode feature collection: %w", err)
}
return value, nil
}
func newFeature(event, role string, value geometry, properties map[string]interface{}) feature {
if properties == nil {
properties = make(map[string]interface{})
}
properties["event"] = event
properties["role"] = role
return feature{Type: "Feature", Properties: properties, Geometry: value}
}
func appendTimedLineFeature(
features []feature,
event, role string,
points []pathSample,
properties map[string]interface{},
) ([]feature, error) {
value, times, err := timedMultiLineGeometry(points)
if err != nil {
return nil, fmt.Errorf("geojson: %s: %w", role, err)
}
properties = cloneProperties(properties)
properties["times"] = times
return append(features, newFeature(event, role, value, properties)), nil
}
func appendPointFeature(
features []feature,
event, role string,
point pathSample,
properties map[string]interface{},
) ([]feature, error) {
if point.Time.IsZero() {
return nil, fmt.Errorf("geojson: %s: point time is required", role)
}
value, err := pointGeometry(point.Longitude, point.Latitude)
if err != nil {
return nil, fmt.Errorf("geojson: %s: %w", role, err)
}
properties = cloneProperties(properties)
properties["time"] = formatTime(point.Time)
return append(features, newFeature(event, role, value, properties)), nil
}
func appendTimeMarkerFeatures(
features []feature,
event, sourceRole string,
points []pathSample,
options TimeMarkerOptions,
) ([]feature, error) {
markers, err := timeMarkerPoints(points, options)
if err != nil {
return nil, fmt.Errorf("geojson: %s time markers: %w", sourceRole, err)
}
return appendTimeMarkerPointFeatures(features, event, sourceRole, markers, options.Location)
}
func validateTimeMarkerOptions(options TimeMarkerOptions) error {
if _, err := normalizeTimeMarkerStep(options.Step); err != nil {
return fmt.Errorf("geojson: time markers: %w", err)
}
return nil
}
func appendTimeMarkerPointFeatures(
features []feature,
event, sourceRole string,
markers []pathSample,
location *time.Location,
) ([]feature, error) {
location = normalizeTimeMarkerLocation(location)
for _, marker := range markers {
properties := map[string]interface{}{
"source_role": sourceRole,
"label": marker.Time.In(location).Format("15:04"),
}
var err error
features, err = appendPointFeature(features, event, "time-marker", marker, properties)
if err != nil {
return nil, err
}
}
return features, nil
}
func timeMarkerPoints(points []pathSample, options TimeMarkerOptions) ([]pathSample, error) {
step, err := normalizeTimeMarkerStep(options.Step)
if err != nil {
return nil, err
}
if len(points) < 2 {
return nil, nil
}
location := normalizeTimeMarkerLocation(options.Location)
for index, point := range points {
if point.Time.IsZero() {
return nil, fmt.Errorf("sample %d has a zero time", index)
}
if err := validateCoordinate(point.Longitude, point.Latitude); err != nil {
return nil, fmt.Errorf("sample %d: %w", index, err)
}
if index > 0 && !point.Time.After(points[index-1].Time) {
return nil, fmt.Errorf("sample times must be strictly increasing")
}
}
start, end := points[0].Time, points[len(points)-1].Time
capacity, err := timeMarkerCapacity(start, end, step, location)
if err != nil {
return nil, err
}
current := firstTimeMarkerAfter(start, step, location)
markers := make([]pathSample, 0, capacity)
segment := 1
for current.Before(end) {
for segment < len(points) && points[segment].Time.Before(current) {
segment++
}
if segment >= len(points) {
break
}
a, b := points[segment-1], points[segment]
span := b.Time.Sub(a.Time)
if span > 0 {
fraction := float64(current.Sub(a.Time)) / float64(span)
markers = append(markers, interpolateTimeMarker(a, b, fraction, current))
}
current = current.Add(step)
}
return markers, nil
}
func normalizeTimeMarkerStep(value time.Duration) (time.Duration, error) {
if value < 0 {
return 0, fmt.Errorf("step must be zero or positive")
}
if value == 0 {
return 30 * time.Minute, nil
}
if value < minimumTimeMarkerStep {
return 0, fmt.Errorf("step must be at least %s", minimumTimeMarkerStep)
}
return value, nil
}
func timeMarkerCapacity(start, end time.Time, step time.Duration, location *time.Location) (int, error) {
if !end.After(start) {
return 0, fmt.Errorf("marker time range must be strictly increasing")
}
first := firstTimeMarkerAfter(start, step, normalizeTimeMarkerLocation(location))
if !first.Before(end) {
return 0, nil
}
count := 1 + (end.Sub(first)-time.Nanosecond)/step
if count > maximumTimeMarkerCount {
return 0, fmt.Errorf("time marker count %d exceeds limit %d", count, maximumTimeMarkerCount)
}
return int(count), nil
}
func firstTimeMarkerAfter(value time.Time, step time.Duration, location *time.Location) time.Time {
local := value.In(location)
dayStart := time.Date(local.Year(), local.Month(), local.Day(), 0, 0, 0, 0, location)
elapsed := local.Sub(dayStart)
return dayStart.Add((elapsed/step + 1) * step)
}
func interpolateTimeMarker(a, b pathSample, fraction float64, value time.Time) pathSample {
deltaLongitude := b.Longitude - a.Longitude
if deltaLongitude > 180 {
deltaLongitude -= 360
} else if deltaLongitude < -180 {
deltaLongitude += 360
}
return pathSample{
Time: value,
Longitude: normalizeLongitude(a.Longitude + fraction*deltaLongitude),
Latitude: a.Latitude + fraction*(b.Latitude-a.Latitude),
}
}
func normalizeTimeMarkerLocation(location *time.Location) *time.Location {
if location == nil {
return time.UTC
}
return location
}
func cloneProperties(source map[string]interface{}) map[string]interface{} {
result := make(map[string]interface{}, len(source)+2)
for key, value := range source {
result[key] = value
}
return result
}
func pointGeometry(longitude, latitude float64) (geometry, error) {
if err := validateCoordinate(longitude, latitude); err != nil {
return geometry{}, err
}
return geometry{Type: "Point", Coordinates: []float64{longitude, latitude}}, nil
}
func timedMultiLineGeometry(points []pathSample) (geometry, [][]string, error) {
segments, err := splitTimedLine(points)
if err != nil {
return geometry{}, nil, err
}
coordinates := make([][][]float64, 0, len(segments))
times := make([][]string, 0, len(segments))
for _, segment := range segments {
line := make([][]float64, len(segment))
lineTimes := make([]string, len(segment))
for index, point := range segment {
line[index] = []float64{point.Longitude, point.Latitude}
lineTimes[index] = formatTime(point.Time)
}
coordinates = append(coordinates, line)
times = append(times, lineTimes)
}
return geometry{Type: "MultiLineString", Coordinates: coordinates}, times, nil
}
func geoMultiLineGeometry(points []geodata.GeoPoint, closeLine bool) (geometry, error) {
if len(points) < 2 {
return geometry{}, fmt.Errorf("geojson: line requires at least two points")
}
for _, point := range points {
if err := validateCoordinate(point.Longitude, point.Latitude); err != nil {
return geometry{}, err
}
}
geographic := append([]geodata.GeoPoint(nil), points...)
if closeLine && !geodata.SameGeoPoint(geographic[0], geographic[len(geographic)-1]) {
geographic = append(geographic, geographic[0])
}
segments := geodata.PolylineSegments(geographic, geodata.ProjectionEquirectangular)
coordinates := make([][][]float64, 0, len(segments))
for _, segment := range segments {
if len(segment) < 2 {
continue
}
line := make([][]float64, len(segment))
for index, point := range segment {
line[index] = []float64{point.Longitude, point.Latitude}
}
coordinates = append(coordinates, line)
}
if len(coordinates) == 0 {
return geometry{}, fmt.Errorf("geojson: line has no valid segments")
}
return geometry{Type: "MultiLineString", Coordinates: coordinates}, nil
}
func multiPolygonGeometry(polygons [][]geodata.GeoPoint) (geometry, error) {
fragments := make([][]geodata.GeoPoint, 0, len(polygons))
for index, polygon := range polygons {
polygon = openRing(polygon)
if len(polygon) < 3 {
return geometry{}, fmt.Errorf("geojson: polygon %d requires at least three points", index)
}
for _, point := range polygon {
if err := validateCoordinate(point.Longitude, point.Latitude); err != nil {
return geometry{}, err
}
}
fragments = append(fragments,
geodata.PolygonFragments(polygon, geodata.ProjectionEquirectangular)...)
}
return multiPolygonGeometryFromFragments(fragments)
}
func multiPolygonGeometryFromFragments(fragments [][]geodata.GeoPoint) (geometry, error) {
coordinates := make([][][][]float64, 0, len(fragments))
for _, fragment := range fragments {
ring, err := geoJSONRing(fragment)
if err != nil {
return geometry{}, err
}
coordinates = append(coordinates, [][][]float64{ring})
}
if len(coordinates) == 0 {
return geometry{}, fmt.Errorf("geojson: polygon has no valid rings")
}
return geometry{Type: "MultiPolygon", Coordinates: coordinates}, nil
}
func geoJSONRing(points []geodata.GeoPoint) ([][]float64, error) {
points = openRing(points)
if len(points) < 3 {
return nil, fmt.Errorf("geojson: polygon ring requires at least three points")
}
for _, point := range points {
if err := validateCoordinate(point.Longitude, point.Latitude); err != nil {
return nil, err
}
}
area := polygonArea(points)
if math.Abs(area) < 1e-12 {
return nil, fmt.Errorf("geojson: polygon ring has zero area")
}
if area < 0 {
points = append([]geodata.GeoPoint(nil), points...)
for left, right := 0, len(points)-1; left < right; left, right = left+1, right-1 {
points[left], points[right] = points[right], points[left]
}
}
ring := make([][]float64, 0, len(points)+1)
for _, point := range points {
ring = append(ring, []float64{point.Longitude, point.Latitude})
}
return append(ring, []float64{points[0].Longitude, points[0].Latitude}), nil
}
func openRing(points []geodata.GeoPoint) []geodata.GeoPoint {
if len(points) > 1 && geodata.SameGeoPoint(points[0], points[len(points)-1]) {
return points[:len(points)-1]
}
return points
}
func polygonArea(points []geodata.GeoPoint) float64 {
area := 0.0
for index, point := range points {
next := points[(index+1)%len(points)]
area += point.Longitude*next.Latitude - next.Longitude*point.Latitude
}
return area / 2
}
func splitTimedLine(points []pathSample) ([][]pathSample, error) {
if len(points) < 2 {
return nil, fmt.Errorf("geojson: line requires at least two points")
}
for index, point := range points {
if point.Time.IsZero() {
return nil, fmt.Errorf("geojson: timed line contains a zero time")
}
if err := validateCoordinate(point.Longitude, point.Latitude); err != nil {
return nil, err
}
if index > 0 && !point.Time.After(points[index-1].Time) {
return nil, fmt.Errorf("geojson: timed line times must be strictly increasing")
}
}
segments := make([][]pathSample, 0, 2)
current := []pathSample{points[0]}
previousUnwrapped := points[0]
worldShift := 0.0
for index := 1; index < len(points); index++ {
b := points[index]
for b.Longitude-previousUnwrapped.Longitude > 180 {
b.Longitude -= 360
}
for b.Longitude-previousUnwrapped.Longitude < -180 {
b.Longitude += 360
}
localLongitude := b.Longitude - worldShift
if localLongitude >= -180 && localLongitude <= 180 {
b.Longitude = localLongitude
current = append(current, b)
previousUnwrapped = points[index]
previousUnwrapped.Longitude = b.Longitude + worldShift
continue
}
boundary := 180.0
if localLongitude < -180 {
boundary = -180
}
unwrappedBoundary := boundary + worldShift
fraction := (unwrappedBoundary - previousUnwrapped.Longitude) /
(b.Longitude - previousUnwrapped.Longitude)
crossing := interpolatePathSample(previousUnwrapped, b, fraction, boundary)
if !crossing.Time.Equal(current[len(current)-1].Time) {
current = append(current, crossing)
}
if len(current) >= 2 {
segments = append(segments, current)
}
crossing.Longitude = -boundary
if boundary > 0 {
worldShift += 360
} else {
worldShift -= 360
}
b.Longitude -= worldShift
current = []pathSample{crossing, b}
previousUnwrapped = points[index]
previousUnwrapped.Longitude = b.Longitude + worldShift
}
if len(current) >= 2 {
segments = append(segments, current)
}
if len(segments) == 0 {
return nil, fmt.Errorf("geojson: line has no valid segments")
}
return segments, nil
}
func interpolatePathSample(a, b pathSample, fraction, longitude float64) pathSample {
duration := b.Time.Sub(a.Time)
return pathSample{
Time: a.Time.Add(time.Duration(float64(duration) * fraction)),
Longitude: longitude,
Latitude: a.Latitude + (b.Latitude-a.Latitude)*fraction,
}
}
func validateCoordinate(longitude, latitude float64) error {
if math.IsNaN(longitude) || math.IsInf(longitude, 0) || longitude < -180 || longitude > 180 {
return fmt.Errorf("geojson: longitude must be finite and within [-180, 180]")
}
if math.IsNaN(latitude) || math.IsInf(latitude, 0) || latitude < -90 || latitude > 90 {
return fmt.Errorf("geojson: latitude must be finite and within [-90, 90]")
}
return nil
}
func finiteGeoJSON(value float64) bool {
return !math.IsNaN(value) && !math.IsInf(value, 0)
}
func formatTime(value time.Time) string {
if value.IsZero() {
return ""
}
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
}
+717
View File
@@ -0,0 +1,717 @@
package geojson_test
import (
"encoding/json"
"math"
"testing"
"time"
"b612.me/astro/eclipse"
"b612.me/astro/geojson"
"b612.me/astro/moon"
)
type decodedCollection struct {
Type string `json:"type"`
Features []decodedFeature `json:"features"`
}
type decodedFeature struct {
Type string `json:"type"`
Properties map[string]interface{} `json:"properties"`
Geometry struct {
Type string `json:"type"`
Coordinates json.RawMessage `json:"coordinates"`
} `json:"geometry"`
}
func TestMarshalSolarEclipseFeatureCollection(t *testing.T) {
date := time.Date(2024, time.April, 8, 0, 0, 0, 0, time.UTC)
partial, ok := eclipse.SolarEclipsePartialFootprints(date, eclipse.SolarEclipsePartialFootprintOptions{
Step: 20 * time.Minute,
BoundaryPoints: 36,
})
if !ok {
t.Fatal("expected solar partial footprints")
}
central, ok := eclipse.SolarEclipseCentralPath(date, eclipse.SolarEclipsePathOptions{Step: 5 * time.Minute})
if !ok {
t.Fatal("expected solar central path")
}
data, err := geojson.MarshalSolarEclipse(partial, &central)
if err != nil {
t.Fatalf("MarshalSolarEclipse: %v", err)
}
collection := decodeCollection(t, data)
assertRoles(t, collection,
"partial-footprint", "central-band", "center-line", "north-limit", "south-limit", "greatest")
assertCollectionCoordinates(t, collection)
assertClosedMultiPolygon(t, featureWithRole(t, collection, "central-band"))
assertTimedLineAligned(t, featureWithRole(t, collection, "center-line"))
}
func TestMarshalSolarEclipseAllowsSingleLimitCentrality(t *testing.T) {
date := time.Date(2003, time.May, 30, 0, 0, 0, 0, time.UTC)
partial, ok := eclipse.SolarEclipsePartialFootprints(date, eclipse.SolarEclipsePartialFootprintOptions{
Step: 20 * time.Minute, BoundaryPoints: 24,
})
if !ok {
t.Fatal("expected solar partial footprints")
}
central, ok := eclipse.SolarEclipseCentralPath(date, eclipse.SolarEclipsePathOptions{Step: 10 * time.Minute})
if !ok || central.Eclipse.Centrality != eclipse.SolarEclipseCentralOneLimit {
t.Fatalf("expected one-limit central eclipse, got ok=%v centrality=%s", ok, central.Eclipse.Centrality)
}
data, err := geojson.MarshalSolarEclipse(partial, &central)
if err != nil {
t.Fatalf("MarshalSolarEclipse: %v", err)
}
collection := decodeCollection(t, data)
if len(featuresWithRole(collection, "center-line")) != 1 || len(featuresWithRole(collection, "central-band")) != 0 {
t.Fatal("single-limit central eclipse should export center line without a band")
}
}
func TestMarshalSolarEclipseAllowsLowSampleOpenFootprints(t *testing.T) {
for _, fixture := range []struct {
date time.Time
step time.Duration
}{
{time.Date(2024, time.April, 8, 0, 0, 0, 0, time.UTC), 5 * time.Minute},
{time.Date(2025, time.March, 29, 0, 0, 0, 0, time.UTC), 5 * time.Minute},
} {
partial, ok := eclipse.SolarEclipsePartialFootprints(fixture.date, eclipse.SolarEclipsePartialFootprintOptions{
Step: fixture.step, BoundaryPoints: 12,
})
if !ok {
t.Fatalf("%s: expected solar partial footprints", fixture.date.Format("2006-01-02"))
}
if _, err := geojson.MarshalSolarEclipse(partial, nil); err != nil {
t.Fatalf("%s low-sample GeoJSON: %v", fixture.date.Format("2006-01-02"), err)
}
}
}
func TestMarshalSolarEclipseWithTimeMarkers(t *testing.T) {
date := time.Date(2024, time.April, 8, 0, 0, 0, 0, time.UTC)
partial, ok := eclipse.SolarEclipsePartialFootprints(date, eclipse.SolarEclipsePartialFootprintOptions{
Step: 20 * time.Minute,
BoundaryPoints: 36,
})
if !ok {
t.Fatal("expected solar partial footprints")
}
central, ok := eclipse.SolarEclipseCentralPath(date, eclipse.SolarEclipsePathOptions{Step: 5 * time.Minute})
if !ok {
t.Fatal("expected solar central path")
}
data, err := geojson.MarshalSolarEclipseWithTimeMarkers(partial, &central, geojson.TimeMarkerOptions{
Step: time.Hour,
Location: time.FixedZone("CST", 8*60*60),
})
if err != nil {
t.Fatalf("MarshalSolarEclipseWithTimeMarkers: %v", err)
}
collection := decodeCollection(t, data)
markers := featuresWithRole(collection, "time-marker")
if len(markers) == 0 {
t.Fatal("solar eclipse has no time markers")
}
for _, marker := range markers {
if marker.Properties["source_role"] != "center-line" {
t.Fatalf("time marker source_role=%v, want center-line", marker.Properties["source_role"])
}
label, ok := marker.Properties["label"].(string)
if !ok || len(label) != len("15:04") || label[2] != ':' {
t.Fatalf("invalid time marker label %q", label)
}
}
}
func TestMarshalLunarEclipseUsesRequestedBoundarySampling(t *testing.T) {
info, ok := eclipse.LunarEclipseOnDate(time.Date(2026, time.March, 3, 0, 0, 0, 0, time.UTC))
if !ok {
t.Fatal("expected lunar eclipse")
}
data, err := geojson.MarshalLunarEclipse(info, 24)
if err != nil {
t.Fatalf("MarshalLunarEclipse: %v", err)
}
collection := decodeCollection(t, data)
assertRoles(t, collection, "visible-at-p1", "visible-at-p4", "p1-horizon", "p4-horizon", "greatest")
assertCollectionCoordinates(t, collection)
visible := featureWithRole(t, collection, "visible-at-p1")
if got := visible.Properties["boundary_points"]; got != float64(24) {
t.Fatalf("boundary_points=%v, want 24", got)
}
assertClosedMultiPolygon(t, visible)
horizon := featureWithRole(t, collection, "p1-horizon")
var lines [][][]float64
if err := json.Unmarshal(horizon.Geometry.Coordinates, &lines); err != nil {
t.Fatalf("decode P1 horizon: %v", err)
}
pointCount := 0
for _, line := range lines {
pointCount += len(line)
}
if pointCount < 24 {
t.Fatalf("P1 horizon has %d points, want at least 24", pointCount)
}
}
func TestMarshalLunarEclipseWithTimeMarkers(t *testing.T) {
info, ok := eclipse.LunarEclipseOnDate(time.Date(2026, time.March, 3, 0, 0, 0, 0, time.UTC))
if !ok {
t.Fatal("expected lunar eclipse")
}
data, err := geojson.MarshalLunarEclipseWithTimeMarkers(info, 24, geojson.TimeMarkerOptions{Step: time.Hour})
if err != nil {
t.Fatalf("MarshalLunarEclipseWithTimeMarkers: %v", err)
}
collection := decodeCollection(t, data)
markers := featuresWithRole(collection, "time-marker")
if len(markers) == 0 {
t.Fatal("lunar eclipse has no time markers")
}
for _, marker := range markers {
if marker.Properties["source_role"] != "sublunar-track" {
t.Fatalf("time marker source_role=%v, want sublunar-track", marker.Properties["source_role"])
}
}
firstLabel, _ := markers[0].Properties["label"].(string)
lastLabel, _ := markers[len(markers)-1].Properties["label"].(string)
if firstLabel != "09:00" || lastLabel != "14:00" {
t.Fatalf("lunar marker endpoints = %q..%q, want 09:00..14:00", firstLabel, lastLabel)
}
}
func TestMarshalLunarEclipseRejectsInvalidContactOrder(t *testing.T) {
info, ok := eclipse.LunarEclipseOnDate(time.Date(2026, time.March, 3, 0, 0, 0, 0, time.UTC))
if !ok {
t.Fatal("expected lunar eclipse")
}
info.Maximum = info.PenumbralStart.Add(-time.Minute)
if _, err := geojson.MarshalLunarEclipse(info, 24); err == nil {
t.Fatal("reversed lunar eclipse contacts were accepted")
}
}
func TestMarshalStarOccultationSplitsAntimeridian(t *testing.T) {
start := time.Date(2025, time.June, 5, 17, 45, 0, 0, time.UTC)
center := occultationSamples(start, []float64{160, 175, -175, -160}, []float64{8, 4, 0, -4})
north := occultationSamples(start, []float64{158, 174, -174, -158}, []float64{18, 14, 10, 6})
south := occultationSamples(start, []float64{162, 176, -176, -162}, []float64{-2, -6, -10, -14})
path := moon.StarOccultationPath{
TargetID: "HR 4799",
Start: north[0],
Greatest: center[2],
End: north[len(north)-1],
Complete: true,
CenterLine: center,
NorthernLimit: north,
SouthernLimit: south,
Step: time.Hour,
}
data, err := geojson.MarshalStarOccultation(path)
if err != nil {
t.Fatalf("MarshalStarOccultation: %v", err)
}
collection := decodeCollection(t, data)
assertRoles(t, collection, "occultation-band", "center-line", "north-limit", "south-limit", "start", "greatest", "end")
assertCollectionCoordinates(t, collection)
centerFeature := featureWithRole(t, collection, "center-line")
var lines [][][]float64
if err := json.Unmarshal(centerFeature.Geometry.Coordinates, &lines); err != nil {
t.Fatalf("decode center line: %v", err)
}
if len(lines) != 2 {
t.Fatalf("center line has %d antimeridian segments, want 2", len(lines))
}
for _, line := range lines {
for index := 1; index < len(line); index++ {
if math.Abs(line[index][0]-line[index-1][0]) > 180 {
t.Fatalf("center line still crosses antimeridian: %#v", line)
}
}
}
assertTimedLineAligned(t, centerFeature)
}
func TestMarshalStarOccultationHandlesExactAntimeridian(t *testing.T) {
start := time.Date(2025, time.June, 5, 17, 45, 0, 0, time.UTC)
center := occultationSamples(start, []float64{-180, 180, 150}, []float64{2, 1, 0})
north := occultationSamples(start, []float64{-179, 179, 178}, []float64{12, 11, 10})
south := occultationSamples(start, []float64{-179, 179, 178}, []float64{-8, -9, -10})
path := moon.StarOccultationPath{
TargetID: "exact-antimeridian", Start: north[0], Greatest: center[1], End: north[2],
Complete: true, CenterLine: center, NorthernLimit: north, SouthernLimit: south,
Step: time.Hour,
}
if _, err := geojson.MarshalStarOccultation(path); err != nil {
t.Fatalf("exact antimeridian path: %v", err)
}
}
func TestMarshalStarOccultationWithTimeMarkers(t *testing.T) {
start := time.Date(2025, time.June, 5, 17, 45, 0, 0, time.UTC)
center := occultationSamples(start, []float64{20, 30, 40, 50}, []float64{2, 1, 0, -1})
north := occultationSamples(start, []float64{20, 30, 40, 50}, []float64{12, 11, 10, 9})
south := occultationSamples(start, []float64{20, 30, 40, 50}, []float64{-8, -9, -10, -11})
path := moon.StarOccultationPath{
TargetID: "HR 4799",
Start: north[0],
Greatest: center[1],
End: north[len(north)-1],
Complete: true,
CenterLine: center,
NorthernLimit: north,
SouthernLimit: south,
Step: time.Hour,
}
data, err := geojson.MarshalStarOccultationWithTimeMarkers(path, geojson.TimeMarkerOptions{Step: time.Hour})
if err != nil {
t.Fatalf("MarshalStarOccultationWithTimeMarkers: %v", err)
}
collection := decodeCollection(t, data)
markers := featuresWithRole(collection, "time-marker")
if len(markers) != 3 {
t.Fatalf("got %d time markers, want 3", len(markers))
}
for _, marker := range markers {
if marker.Properties["source_role"] != "center-line" {
t.Fatalf("time marker source_role=%v, want center-line", marker.Properties["source_role"])
}
if marker.Geometry.Type != "Point" {
t.Fatalf("time marker geometry=%q, want Point", marker.Geometry.Type)
}
}
}
func TestTimeMarkerInterpolationUsesShortestAntimeridianPath(t *testing.T) {
start := time.Date(2025, time.June, 5, 17, 45, 0, 0, time.UTC)
center := occultationSamples(start, []float64{170, -170}, []float64{2, 0})
north := occultationSamples(start, []float64{168, -168}, []float64{12, 10})
south := occultationSamples(start, []float64{172, -172}, []float64{-8, -10})
path := moon.StarOccultationPath{
TargetID: "HR 4799",
Start: north[0],
Greatest: center[0],
End: north[len(north)-1],
Complete: true,
CenterLine: center,
NorthernLimit: north,
SouthernLimit: south,
Step: time.Hour,
}
data, err := geojson.MarshalStarOccultationWithTimeMarkers(path, geojson.TimeMarkerOptions{Step: 15 * time.Minute})
if err != nil {
t.Fatalf("MarshalStarOccultationWithTimeMarkers: %v", err)
}
markers := featuresWithRole(decodeCollection(t, data), "time-marker")
if len(markers) != 3 {
t.Fatalf("got %d time markers, want 3", len(markers))
}
for _, marker := range markers {
var coordinate []float64
if err := json.Unmarshal(marker.Geometry.Coordinates, &coordinate); err != nil {
t.Fatalf("decode time marker: %v", err)
}
if math.Abs(coordinate[0]) < 170 {
t.Fatalf("time marker crossed through longitude %.6f instead of the antimeridian", coordinate[0])
}
}
}
func TestMarshalPlanetOccultationIncludesPartialAndTotalFootprints(t *testing.T) {
start := time.Date(2025, time.February, 1, 0, 0, 0, 0, time.UTC)
center := occultationSamples(start, []float64{20, 30, 40}, []float64{2, 1, 0})
north := occultationSamples(start, []float64{20, 30, 40}, []float64{12, 11, 10})
south := occultationSamples(start, []float64{20, 30, 40}, []float64{-8, -9, -10})
totalNorth := occultationSamples(start, []float64{22, 30, 38}, []float64{8, 7, 6})
totalSouth := occultationSamples(start, []float64{22, 30, 38}, []float64{-4, -5, -6})
for index := range totalNorth {
at := start.Add(time.Duration(index+1) * 30 * time.Minute)
totalNorth[index].Time = at
totalSouth[index].Time = at
}
path := moon.PlanetOccultationPath{
Planet: moon.OccultationSaturn,
TargetID: "Saturn",
Start: north[0],
Greatest: center[1],
End: north[len(north)-1],
Complete: true,
CenterLine: center,
NorthernLimit: north,
SouthernLimit: south,
PartialFootprints: []moon.PlanetOccultationFootprint{sampleFootprint(start.Add(time.Hour), 18, -10, 42, 14)},
HasTotalBand: true,
TotalStart: totalNorth[0],
TotalEnd: totalNorth[len(totalNorth)-1],
TotalComplete: true,
NorthernTotalLimit: totalNorth,
SouthernTotalLimit: totalSouth,
TotalFootprints: []moon.PlanetOccultationFootprint{sampleFootprint(start.Add(time.Hour), 23, -5, 37, 9)},
GreatestTotalWidthKM: 2500,
Step: time.Hour,
TargetSpacingKM: 50,
}
data, err := geojson.MarshalPlanetOccultation(path)
if err != nil {
t.Fatalf("MarshalPlanetOccultation: %v", err)
}
collection := decodeCollection(t, data)
assertRoles(t, collection,
"partial-footprint", "total-footprint", "center-line", "north-limit", "south-limit",
"north-total-limit", "south-total-limit", "start", "total-start", "greatest", "total-end", "end")
assertCollectionCoordinates(t, collection)
if got := featureWithRole(t, collection, "greatest").Properties["planet"]; got != "saturn" {
t.Fatalf("planet=%v, want saturn", got)
}
}
func TestMarshalPlanetOccultationAllowsMissingCenterLine(t *testing.T) {
start := time.Date(2024, time.September, 5, 0, 0, 0, 0, time.UTC)
paths, err := moon.FindPlanetOccultationPaths(
start, start.AddDate(0, 0, 1), moon.OccultationVenus, moon.OccultationPathOptions{},
)
if err != nil {
t.Fatalf("FindPlanetOccultationPaths: %v", err)
}
if len(paths) != 1 || len(paths[0].CenterLine) != 0 {
t.Fatalf("unexpected Venus path count/center line: paths=%d center=%d", len(paths), len(paths[0].CenterLine))
}
data, err := geojson.MarshalPlanetOccultationWithTimeMarkers(
paths[0], geojson.TimeMarkerOptions{Step: 30 * time.Minute},
)
if err != nil {
t.Fatalf("MarshalPlanetOccultationWithTimeMarkers: %v", err)
}
collection := decodeCollection(t, data)
if len(featuresWithRole(collection, "center-line")) != 0 || len(featuresWithRole(collection, "time-marker")) != 0 {
t.Fatal("edge-only planetary path contains center-line features")
}
assertRoles(t, collection, "north-limit", "south-limit", "start", "greatest", "end")
}
func TestMarshalSolarEclipseRejectsMisalignedLimits(t *testing.T) {
date := time.Date(2024, time.April, 8, 0, 0, 0, 0, time.UTC)
partial, ok := eclipse.SolarEclipsePartialFootprints(date, eclipse.SolarEclipsePartialFootprintOptions{})
if !ok {
t.Fatal("expected solar partial footprints")
}
central, ok := eclipse.SolarEclipseCentralPath(date, eclipse.SolarEclipsePathOptions{})
if !ok {
t.Fatal("expected solar central path")
}
central.SouthernLimit = central.SouthernLimit[:len(central.SouthernLimit)-1]
if _, err := geojson.MarshalSolarEclipse(partial, &central); err == nil {
t.Fatal("misaligned solar limits were accepted")
}
}
func TestMarshalStarOccultationRejectsInvalidPathData(t *testing.T) {
start := time.Date(2025, time.June, 5, 17, 45, 0, 0, time.UTC)
valid := sampleStarOccultationPath(start)
tests := []struct {
name string
mutate func(*moon.StarOccultationPath)
}{
{name: "misaligned limits", mutate: func(path *moon.StarOccultationPath) {
path.SouthernLimit = path.SouthernLimit[:len(path.SouthernLimit)-1]
}},
{name: "mismatched limit times", mutate: func(path *moon.StarOccultationPath) {
path.SouthernLimit[1].Time = path.SouthernLimit[1].Time.Add(time.Second)
}},
{name: "non-monotonic line", mutate: func(path *moon.StarOccultationPath) {
path.CenterLine[1].Time = path.CenterLine[0].Time
}},
{name: "zero event time", mutate: func(path *moon.StarOccultationPath) {
path.Start.Time = time.Time{}
}},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
path := valid
path.CenterLine = append([]moon.OccultationPathPoint(nil), valid.CenterLine...)
path.NorthernLimit = append([]moon.OccultationPathPoint(nil), valid.NorthernLimit...)
path.SouthernLimit = append([]moon.OccultationPathPoint(nil), valid.SouthernLimit...)
test.mutate(&path)
if _, err := geojson.MarshalStarOccultation(path); err == nil {
t.Fatal("invalid stellar occultation path was accepted")
}
})
}
}
func TestMarshalPlanetOccultationRejectsInvalidFootprintPolygon(t *testing.T) {
start := time.Date(2025, time.February, 1, 0, 0, 0, 0, time.UTC)
path := samplePlanetOccultationPath(start)
path.PartialFootprints = []moon.PlanetOccultationFootprint{sampleFootprint(start, 10, -10, 20, 10)}
path.PartialFootprints[0].Polygons = append(path.PartialFootprints[0].Polygons, []moon.OccultationPathPoint{
{Longitude: 30, Latitude: 0}, {Longitude: 31, Latitude: 0},
})
if _, err := geojson.MarshalPlanetOccultation(path); err == nil {
t.Fatal("invalid footprint polygon was silently dropped")
}
}
func TestMarshalFunctionsRejectIncompleteInput(t *testing.T) {
if _, err := geojson.MarshalSolarEclipse(eclipse.SolarEclipsePartialFootprintsInfo{}, nil); err == nil {
t.Fatal("empty solar eclipse input was accepted")
}
if _, err := geojson.MarshalLunarEclipse(eclipse.LunarEclipseInfo{}, 360); err == nil {
t.Fatal("empty lunar eclipse input was accepted")
}
if _, err := geojson.MarshalStarOccultation(moon.StarOccultationPath{}); err == nil {
t.Fatal("empty stellar occultation input was accepted")
}
if _, err := geojson.MarshalPlanetOccultation(moon.PlanetOccultationPath{}); err == nil {
t.Fatal("empty planetary occultation input was accepted")
}
}
func TestTimeMarkerOptionsRejectNegativeStep(t *testing.T) {
start := time.Date(2025, time.June, 5, 17, 45, 0, 0, time.UTC)
center := occultationSamples(start, []float64{20, 30, 40}, []float64{2, 1, 0})
north := occultationSamples(start, []float64{20, 30, 40}, []float64{12, 11, 10})
south := occultationSamples(start, []float64{20, 30, 40}, []float64{-8, -9, -10})
path := moon.StarOccultationPath{
TargetID: "HR 4799",
Start: north[0],
Greatest: center[1],
End: north[len(north)-1],
Complete: true,
CenterLine: center,
NorthernLimit: north,
SouthernLimit: south,
}
if _, err := geojson.MarshalStarOccultationWithTimeMarkers(path, geojson.TimeMarkerOptions{Step: -time.Minute}); err == nil {
t.Fatal("negative time-marker step was accepted")
}
if _, err := geojson.MarshalStarOccultationWithTimeMarkers(path, geojson.TimeMarkerOptions{Step: time.Nanosecond}); err == nil {
t.Fatal("sub-minute time-marker step was accepted")
}
excessiveEnd := path.CenterLine[0].Time.Add(24*time.Hour + 2*time.Minute)
path.End.Time = excessiveEnd
path.CenterLine[len(path.CenterLine)-1].Time = excessiveEnd
path.NorthernLimit[len(path.NorthernLimit)-1].Time = excessiveEnd
path.SouthernLimit[len(path.SouthernLimit)-1].Time = excessiveEnd
if _, err := geojson.MarshalStarOccultationWithTimeMarkers(path, geojson.TimeMarkerOptions{Step: time.Minute}); err == nil {
t.Fatal("excessive time-marker count was accepted")
}
}
func TestTimeMarkerOptionsAreValidatedWithoutCenterLine(t *testing.T) {
start := time.Date(2025, time.June, 5, 17, 45, 0, 0, time.UTC)
north := occultationSamples(start, []float64{20, 30, 40}, []float64{12, 11, 10})
south := occultationSamples(start, []float64{20, 30, 40}, []float64{-8, -9, -10})
path := moon.StarOccultationPath{
TargetID: "edge-only", Start: north[0], Greatest: north[1], End: north[2], Complete: true,
NorthernLimit: north, SouthernLimit: south, Step: time.Hour,
}
if _, err := geojson.MarshalStarOccultationWithTimeMarkers(path, geojson.TimeMarkerOptions{Step: time.Nanosecond}); err == nil {
t.Fatal("edge-only stellar path accepted sub-minute markers")
}
planet := moon.PlanetOccultationPath{
Planet: moon.OccultationVenus, TargetID: "Venus", Start: north[0], Greatest: north[1], End: north[2],
Complete: true, NorthernLimit: north, SouthernLimit: south, Step: time.Hour,
}
if _, err := geojson.MarshalPlanetOccultationWithTimeMarkers(planet, geojson.TimeMarkerOptions{Step: time.Nanosecond}); err == nil {
t.Fatal("edge-only planetary path accepted sub-minute markers")
}
}
func decodeCollection(t *testing.T, data []byte) decodedCollection {
t.Helper()
var collection decodedCollection
if err := json.Unmarshal(data, &collection); err != nil {
t.Fatalf("decode GeoJSON: %v", err)
}
if collection.Type != "FeatureCollection" {
t.Fatalf("collection type=%q, want FeatureCollection", collection.Type)
}
if len(collection.Features) == 0 {
t.Fatal("GeoJSON contains no features")
}
for _, feature := range collection.Features {
if feature.Type != "Feature" {
t.Fatalf("feature type=%q, want Feature", feature.Type)
}
if _, ok := feature.Properties["event"]; !ok {
t.Fatal("feature has no event property")
}
if _, ok := feature.Properties["role"]; !ok {
t.Fatal("feature has no role property")
}
}
return collection
}
func assertRoles(t *testing.T, collection decodedCollection, roles ...string) {
t.Helper()
for _, role := range roles {
featureWithRole(t, collection, role)
}
}
func featureWithRole(t *testing.T, collection decodedCollection, role string) decodedFeature {
t.Helper()
for _, feature := range collection.Features {
if feature.Properties["role"] == role {
return feature
}
}
t.Fatalf("GeoJSON is missing role %q", role)
return decodedFeature{}
}
func featuresWithRole(collection decodedCollection, role string) []decodedFeature {
result := make([]decodedFeature, 0)
for _, feature := range collection.Features {
if feature.Properties["role"] == role {
result = append(result, feature)
}
}
return result
}
func assertCollectionCoordinates(t *testing.T, collection decodedCollection) {
t.Helper()
for _, feature := range collection.Features {
var coordinates interface{}
if err := json.Unmarshal(feature.Geometry.Coordinates, &coordinates); err != nil {
t.Fatalf("decode %v coordinates: %v", feature.Properties["role"], err)
}
assertCoordinateTree(t, coordinates)
}
}
func assertCoordinateTree(t *testing.T, value interface{}) {
t.Helper()
items, ok := value.([]interface{})
if !ok {
t.Fatalf("coordinate node has type %T", value)
}
if len(items) >= 2 {
longitude, lonOK := items[0].(float64)
latitude, latOK := items[1].(float64)
if lonOK && latOK {
if math.IsNaN(longitude) || math.IsInf(longitude, 0) || longitude < -180 || longitude > 180 {
t.Fatalf("invalid longitude %.12f", longitude)
}
if math.IsNaN(latitude) || math.IsInf(latitude, 0) || latitude < -90 || latitude > 90 {
t.Fatalf("invalid latitude %.12f", latitude)
}
return
}
}
for _, item := range items {
assertCoordinateTree(t, item)
}
}
func assertClosedMultiPolygon(t *testing.T, feature decodedFeature) {
t.Helper()
if feature.Geometry.Type != "MultiPolygon" {
t.Fatalf("%v geometry=%q, want MultiPolygon", feature.Properties["role"], feature.Geometry.Type)
}
var polygons [][][][]float64
if err := json.Unmarshal(feature.Geometry.Coordinates, &polygons); err != nil {
t.Fatalf("decode %v polygon: %v", feature.Properties["role"], err)
}
if len(polygons) == 0 {
t.Fatalf("%v has no polygons", feature.Properties["role"])
}
for _, polygon := range polygons {
if len(polygon) == 0 || len(polygon[0]) < 4 {
t.Fatalf("%v contains an incomplete ring", feature.Properties["role"])
}
ring := polygon[0]
first, last := ring[0], ring[len(ring)-1]
if first[0] != last[0] || first[1] != last[1] {
t.Fatalf("%v ring is not closed", feature.Properties["role"])
}
}
}
func assertTimedLineAligned(t *testing.T, feature decodedFeature) {
t.Helper()
var lines [][][]float64
if err := json.Unmarshal(feature.Geometry.Coordinates, &lines); err != nil {
t.Fatalf("decode line coordinates: %v", err)
}
timeSegments, ok := feature.Properties["times"].([]interface{})
if !ok || len(timeSegments) != len(lines) {
t.Fatalf("times do not align with %d line segments: %#v", len(lines), feature.Properties["times"])
}
for index, rawSegment := range timeSegments {
times, ok := rawSegment.([]interface{})
if !ok || len(times) != len(lines[index]) {
t.Fatalf("times segment %d does not align with %d coordinates", index, len(lines[index]))
}
for _, rawTime := range times {
value, ok := rawTime.(string)
if !ok {
t.Fatalf("time has type %T", rawTime)
}
if _, err := time.Parse(time.RFC3339Nano, value); err != nil {
t.Fatalf("invalid RFC3339 time %q: %v", value, err)
}
}
}
}
func occultationSamples(start time.Time, longitudes, latitudes []float64) []moon.OccultationPathPoint {
result := make([]moon.OccultationPathPoint, len(longitudes))
for index := range result {
result[index] = moon.OccultationPathPoint{
Time: start.Add(time.Duration(index) * time.Hour),
Longitude: longitudes[index],
Latitude: latitudes[index],
MoonAltitude: 30,
WidthKM: 3000,
}
}
return result
}
func sampleStarOccultationPath(start time.Time) moon.StarOccultationPath {
center := occultationSamples(start, []float64{20, 30, 40}, []float64{2, 1, 0})
north := occultationSamples(start, []float64{20, 30, 40}, []float64{12, 11, 10})
south := occultationSamples(start, []float64{20, 30, 40}, []float64{-8, -9, -10})
return moon.StarOccultationPath{
TargetID: "HR 4799", Start: north[0], Greatest: center[1], End: north[len(north)-1],
Complete: true, CenterLine: center, NorthernLimit: north, SouthernLimit: south, Step: time.Hour,
}
}
func samplePlanetOccultationPath(start time.Time) moon.PlanetOccultationPath {
star := sampleStarOccultationPath(start)
return moon.PlanetOccultationPath{
Planet: moon.OccultationSaturn, TargetID: "Saturn",
Start: star.Start, Greatest: star.Greatest, End: star.End, Complete: true,
CenterLine: star.CenterLine, NorthernLimit: star.NorthernLimit, SouthernLimit: star.SouthernLimit,
Step: time.Hour,
}
}
func sampleFootprint(at time.Time, west, south, east, north float64) moon.PlanetOccultationFootprint {
return moon.PlanetOccultationFootprint{
Time: at,
Polygons: [][]moon.OccultationPathPoint{{
{Longitude: west, Latitude: south},
{Longitude: east, Latitude: south},
{Longitude: east, Latitude: north},
{Longitude: west, Latitude: north},
}},
}
}
+532
View File
@@ -0,0 +1,532 @@
package geojson
import (
"fmt"
"time"
"b612.me/astro/internal/geodata"
"b612.me/astro/moon"
)
const lunarOccultationEvent = "lunar-occultation"
// MarshalStarOccultation 将月掩恒星的全球掩带和中心线编码为 GeoJSON。
// MarshalStarOccultation encodes a global stellar occultation band and center line as GeoJSON.
func MarshalStarOccultation(path moon.StarOccultationPath) ([]byte, error) {
return marshalStarOccultation(path, nil)
}
// MarshalStarOccultationWithTimeMarkers 编码恒星月掩,并沿中心线按固定间隔追加 Point 要素。
// MarshalStarOccultationWithTimeMarkers encodes a stellar occultation and adds Point Features at regular intervals along its center line.
func MarshalStarOccultationWithTimeMarkers(
path moon.StarOccultationPath,
options TimeMarkerOptions,
) ([]byte, error) {
return marshalStarOccultation(path, &options)
}
func marshalStarOccultation(path moon.StarOccultationPath, markerOptions *TimeMarkerOptions) ([]byte, error) {
if markerOptions != nil {
if err := validateTimeMarkerOptions(*markerOptions); err != nil {
return nil, err
}
}
if err := validateStarOccultationPathData(path); err != nil {
return nil, err
}
properties := map[string]interface{}{
"target_type": "star",
"target_id": path.TargetID,
"complete": path.Complete,
"step_seconds": path.Step.Seconds(),
"target_spacing_km": path.TargetSpacingKM,
}
band, err := occultationBandPolygon(path.NorthernLimit, path.SouthernLimit)
if err != nil {
return nil, fmt.Errorf("geojson: stellar occultation band: %w", err)
}
value, err := multiPolygonGeometry([][]geodata.GeoPoint{band})
if err != nil {
return nil, fmt.Errorf("geojson: stellar occultation band: %w", err)
}
features := []feature{
newFeature(lunarOccultationEvent, "occultation-band", value, cloneProperties(properties)),
}
if len(path.CenterLine) > 0 {
features, err = appendOccultationPathLine(features, "center-line", path.CenterLine, properties)
if err != nil {
return nil, err
}
}
features, err = appendOccultationPathLine(features, "north-limit", path.NorthernLimit, properties)
if err != nil {
return nil, err
}
features, err = appendOccultationPathLine(features, "south-limit", path.SouthernLimit, properties)
if err != nil {
return nil, err
}
if markerOptions != nil && len(path.CenterLine) > 0 {
features, err = appendTimeMarkerFeatures(
features,
lunarOccultationEvent,
"center-line",
occultationPathSamples(path.CenterLine),
*markerOptions,
)
if err != nil {
return nil, err
}
}
for _, marker := range []struct {
role string
point moon.OccultationPathPoint
}{
{role: "start", point: path.Start},
{role: "greatest", point: path.Greatest},
{role: "end", point: path.End},
} {
features, err = appendOccultationPoint(features, marker.role, marker.point, properties)
if err != nil {
return nil, err
}
}
return marshalFeatureCollection(features)
}
// MarshalPlanetOccultation 将月掩行星的部分掩、全掩和中心线编码为 GeoJSON。
// MarshalPlanetOccultation encodes partial, total, and center-line planetary occultation geometry as GeoJSON.
func MarshalPlanetOccultation(path moon.PlanetOccultationPath) ([]byte, error) {
return marshalPlanetOccultation(path, nil)
}
// MarshalPlanetOccultationWithTimeMarkers 编码行星月掩,并沿中心线按固定间隔追加 Point 要素。
// MarshalPlanetOccultationWithTimeMarkers encodes a planetary occultation and adds Point Features at regular intervals along its center line.
func MarshalPlanetOccultationWithTimeMarkers(
path moon.PlanetOccultationPath,
options TimeMarkerOptions,
) ([]byte, error) {
return marshalPlanetOccultation(path, &options)
}
func marshalPlanetOccultation(path moon.PlanetOccultationPath, markerOptions *TimeMarkerOptions) ([]byte, error) {
if markerOptions != nil {
if err := validateTimeMarkerOptions(*markerOptions); err != nil {
return nil, err
}
}
if err := path.Planet.Validate(); err != nil {
return nil, fmt.Errorf("geojson: planetary occultation target: %w", err)
}
if err := validatePlanetOccultationPathData(path); err != nil {
return nil, err
}
properties := map[string]interface{}{
"target_type": "planet",
"target_id": path.TargetID,
"planet": string(path.Planet),
"complete": path.Complete,
"has_total_band": path.HasTotalBand,
"total_complete": path.TotalComplete,
"step_seconds": path.Step.Seconds(),
"target_spacing_km": path.TargetSpacingKM,
"greatest_total_width_km": path.GreatestTotalWidthKM,
}
features := make([]feature, 0, len(path.PartialFootprints)+len(path.TotalFootprints)+12)
var err error
if len(path.PartialFootprints) > 0 {
features, err = appendOccultationFootprints(
features, "partial-footprint", path.PartialFootprints, properties,
)
} else {
features, err = appendOccultationBand(
features, "partial-band", path.NorthernLimit, path.SouthernLimit, properties,
)
}
if err != nil {
return nil, err
}
if path.HasTotalBand {
if len(path.TotalFootprints) > 0 {
features, err = appendOccultationFootprints(
features, "total-footprint", path.TotalFootprints, properties,
)
} else {
features, err = appendOccultationBand(
features, "total-band", path.NorthernTotalLimit, path.SouthernTotalLimit, properties,
)
}
if err != nil {
return nil, err
}
}
if len(path.CenterLine) > 0 {
features, err = appendOccultationPathLine(features, "center-line", path.CenterLine, properties)
if err != nil {
return nil, err
}
}
features, err = appendOccultationPathLine(features, "north-limit", path.NorthernLimit, properties)
if err != nil {
return nil, err
}
features, err = appendOccultationPathLine(features, "south-limit", path.SouthernLimit, properties)
if err != nil {
return nil, err
}
if path.HasTotalBand {
features, err = appendOccultationPathLine(
features, "north-total-limit", path.NorthernTotalLimit, properties,
)
if err != nil {
return nil, err
}
features, err = appendOccultationPathLine(
features, "south-total-limit", path.SouthernTotalLimit, properties,
)
if err != nil {
return nil, err
}
}
if markerOptions != nil && len(path.CenterLine) > 0 {
features, err = appendTimeMarkerFeatures(
features,
lunarOccultationEvent,
"center-line",
occultationPathSamples(path.CenterLine),
*markerOptions,
)
if err != nil {
return nil, err
}
}
markers := []struct {
role string
point moon.OccultationPathPoint
}{
{role: "start", point: path.Start},
}
if path.HasTotalBand {
markers = append(markers, struct {
role string
point moon.OccultationPathPoint
}{role: "total-start", point: path.TotalStart})
}
markers = append(markers, struct {
role string
point moon.OccultationPathPoint
}{role: "greatest", point: path.Greatest})
if path.HasTotalBand {
markers = append(markers, struct {
role string
point moon.OccultationPathPoint
}{role: "total-end", point: path.TotalEnd})
}
markers = append(markers, struct {
role string
point moon.OccultationPathPoint
}{role: "end", point: path.End})
for _, marker := range markers {
features, err = appendOccultationPoint(features, marker.role, marker.point, properties)
if err != nil {
return nil, err
}
}
return marshalFeatureCollection(features)
}
func appendOccultationBand(
features []feature,
role string,
northern, southern []moon.OccultationPathPoint,
properties map[string]interface{},
) ([]feature, error) {
band, err := occultationBandPolygon(northern, southern)
if err != nil {
return nil, fmt.Errorf("geojson: %s: %w", role, err)
}
value, err := multiPolygonGeometry([][]geodata.GeoPoint{band})
if err != nil {
return nil, fmt.Errorf("geojson: %s: %w", role, err)
}
return append(features, newFeature(
lunarOccultationEvent, role, value, cloneProperties(properties),
)), nil
}
func appendOccultationFootprints(
features []feature,
role string,
footprints []moon.PlanetOccultationFootprint,
properties map[string]interface{},
) ([]feature, error) {
appended := 0
for _, footprint := range footprints {
if footprint.Time.IsZero() {
return nil, fmt.Errorf("geojson: %s time is required", role)
}
polygons := make([][]geodata.GeoPoint, 0, len(footprint.Polygons))
for _, source := range footprint.Polygons {
polygon := make([]geodata.GeoPoint, len(source))
for index, point := range source {
polygon[index] = geodata.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude}
}
polygons = append(polygons, polygon)
}
value, err := multiPolygonGeometry(polygons)
if err != nil {
return nil, fmt.Errorf("geojson: %s at %s: %w", role, formatTime(footprint.Time), err)
}
footprintProperties := cloneProperties(properties)
footprintProperties["time"] = formatTime(footprint.Time)
features = append(features, newFeature(
lunarOccultationEvent, role, value, footprintProperties,
))
appended++
}
if appended == 0 {
return nil, fmt.Errorf("geojson: %s has no valid polygons", role)
}
return features, nil
}
func occultationBandPolygon(
northern, southern []moon.OccultationPathPoint,
) ([]geodata.GeoPoint, error) {
if len(northern) != len(southern) {
return nil, fmt.Errorf("paired limits must have the same sample count")
}
count := len(northern)
if count < 2 {
return nil, fmt.Errorf("paired limits require at least two points per side")
}
for index := range northern {
if northern[index].Time.IsZero() || southern[index].Time.IsZero() {
return nil, fmt.Errorf("paired limit sample %d time is required", index)
}
if !northern[index].Time.Equal(southern[index].Time) {
return nil, fmt.Errorf("paired limit sample %d times must match", index)
}
}
polygon := make([]geodata.GeoPoint, 0, 2*count)
for _, point := range northern[:count] {
polygon = append(polygon, geodata.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude})
}
for index := count - 1; index >= 0; index-- {
point := southern[index]
polygon = append(polygon, geodata.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude})
}
return polygon, nil
}
func appendOccultationPathLine(
features []feature,
role string,
points []moon.OccultationPathPoint,
properties map[string]interface{},
) ([]feature, error) {
samples := make([]pathSample, len(points))
for index, point := range points {
samples[index] = occultationPathSample(point)
}
return appendTimedLineFeature(features, lunarOccultationEvent, role, samples, properties)
}
func occultationPathSamples(points []moon.OccultationPathPoint) []pathSample {
samples := make([]pathSample, len(points))
for index, point := range points {
samples[index] = occultationPathSample(point)
}
return samples
}
func appendOccultationPoint(
features []feature,
role string,
point moon.OccultationPathPoint,
properties map[string]interface{},
) ([]feature, error) {
pointProperties := cloneProperties(properties)
pointProperties["moon_altitude_deg"] = point.MoonAltitude
pointProperties["width_km"] = point.WidthKM
return appendPointFeature(
features, lunarOccultationEvent, role, occultationPathSample(point), pointProperties,
)
}
func occultationPathSample(point moon.OccultationPathPoint) pathSample {
return pathSample{Time: point.Time, Longitude: point.Longitude, Latitude: point.Latitude}
}
func validateStarOccultationPathData(path moon.StarOccultationPath) error {
if !path.Complete {
return fmt.Errorf("geojson: occultation path is incomplete")
}
if err := (moon.OccultationPathOptions{Step: path.Step, TargetSpacingKM: path.TargetSpacingKM}).Validate(); err != nil {
return fmt.Errorf("geojson: invalid occultation path sampling metadata: %w", err)
}
if err := validateOccultationPathPoint("start", path.Start); err != nil {
return err
}
if err := validateOccultationPathPoint("greatest", path.Greatest); err != nil {
return err
}
if err := validateOccultationPathPoint("end", path.End); err != nil {
return err
}
if path.Greatest.Time.Before(path.Start.Time) || path.End.Time.Before(path.Greatest.Time) {
return fmt.Errorf("geojson: occultation times must be ordered start, greatest, end")
}
if err := validateOccultationPathSeries("center line", path.CenterLine, false); err != nil {
return err
}
if err := validateOccultationPathSeries("northern limit", path.NorthernLimit, true); err != nil {
return err
}
if err := validateOccultationPathSeries("southern limit", path.SouthernLimit, true); err != nil {
return err
}
if len(path.NorthernLimit) != len(path.SouthernLimit) {
return fmt.Errorf("geojson: occultation northern and southern limits must have the same sample count")
}
for index := range path.NorthernLimit {
if !path.NorthernLimit[index].Time.Equal(path.SouthernLimit[index].Time) {
return fmt.Errorf("geojson: occultation limit sample %d times must match", index)
}
}
last := len(path.NorthernLimit) - 1
if !path.NorthernLimit[0].Time.Equal(path.Start.Time) || !path.SouthernLimit[0].Time.Equal(path.Start.Time) ||
!path.NorthernLimit[last].Time.Equal(path.End.Time) || !path.SouthernLimit[last].Time.Equal(path.End.Time) {
return fmt.Errorf("geojson: occultation limits must span start through end")
}
if len(path.CenterLine) > 0 {
if path.CenterLine[0].Time.Before(path.Start.Time) ||
path.CenterLine[len(path.CenterLine)-1].Time.After(path.End.Time) {
return fmt.Errorf("geojson: occultation center line must be inside start and end")
}
if path.Greatest.Time.Before(path.CenterLine[0].Time) ||
path.Greatest.Time.After(path.CenterLine[len(path.CenterLine)-1].Time) {
return fmt.Errorf("geojson: occultation greatest time is outside the center-line interval")
}
}
return nil
}
func validatePlanetOccultationPathData(path moon.PlanetOccultationPath) error {
starPath := moon.StarOccultationPath{
TargetID: path.TargetID, Start: path.Start, Greatest: path.Greatest, End: path.End,
Complete: path.Complete, CenterLine: path.CenterLine,
NorthernLimit: path.NorthernLimit, SouthernLimit: path.SouthernLimit,
Step: path.Step, TargetSpacingKM: path.TargetSpacingKM,
}
if err := validateStarOccultationPathData(starPath); err != nil {
return err
}
if !path.HasTotalBand {
if path.TotalComplete || !path.TotalStart.Time.IsZero() || !path.TotalEnd.Time.IsZero() ||
len(path.NorthernTotalLimit) != 0 || len(path.SouthernTotalLimit) != 0 ||
len(path.TotalFootprints) != 0 || path.GreatestTotalWidthKM != 0 {
return fmt.Errorf("geojson: total-band fields require HasTotalBand")
}
return validateOccultationFootprints("partial", path.PartialFootprints, path.Start.Time, path.End.Time)
}
if !path.TotalComplete {
return fmt.Errorf("geojson: total-occultation band is incomplete")
}
if err := validateOccultationPathPoint("total start", path.TotalStart); err != nil {
return err
}
if err := validateOccultationPathPoint("total end", path.TotalEnd); err != nil {
return err
}
if !path.Start.Time.Before(path.TotalStart.Time) || !path.TotalStart.Time.Before(path.Greatest.Time) ||
!path.Greatest.Time.Before(path.TotalEnd.Time) || !path.TotalEnd.Time.Before(path.End.Time) {
return fmt.Errorf("geojson: total-band times must be inside outer start, greatest, and end")
}
if !finiteGeoJSON(path.GreatestTotalWidthKM) || path.GreatestTotalWidthKM <= 0 ||
!finiteGeoJSON(path.Greatest.WidthKM) || path.GreatestTotalWidthKM >= path.Greatest.WidthKM {
return fmt.Errorf("geojson: total-band width must be positive and narrower than the outer band")
}
if err := validateOccultationPathSeries("northern total limit", path.NorthernTotalLimit, true); err != nil {
return err
}
if err := validateOccultationPathSeries("southern total limit", path.SouthernTotalLimit, true); err != nil {
return err
}
if len(path.NorthernTotalLimit) != len(path.SouthernTotalLimit) {
return fmt.Errorf("geojson: total northern and southern limits must have the same sample count")
}
for index := range path.NorthernTotalLimit {
if !path.NorthernTotalLimit[index].Time.Equal(path.SouthernTotalLimit[index].Time) {
return fmt.Errorf("geojson: total limit sample %d times must match", index)
}
}
last := len(path.NorthernTotalLimit) - 1
if !path.NorthernTotalLimit[0].Time.Equal(path.TotalStart.Time) ||
!path.SouthernTotalLimit[0].Time.Equal(path.TotalStart.Time) ||
!path.NorthernTotalLimit[last].Time.Equal(path.TotalEnd.Time) ||
!path.SouthernTotalLimit[last].Time.Equal(path.TotalEnd.Time) {
return fmt.Errorf("geojson: total limits must span total start through total end")
}
if err := validateOccultationFootprints("partial", path.PartialFootprints, path.Start.Time, path.End.Time); err != nil {
return err
}
return validateOccultationFootprints("total", path.TotalFootprints, path.TotalStart.Time, path.TotalEnd.Time)
}
func validateOccultationPathSeries(name string, points []moon.OccultationPathPoint, required bool) error {
if required && len(points) < 2 {
return fmt.Errorf("geojson: %s requires at least two points", name)
}
previous := time.Time{}
for index, point := range points {
if err := validateOccultationPathPoint(fmt.Sprintf("%s[%d]", name, index), point); err != nil {
return err
}
if !previous.IsZero() && !point.Time.After(previous) {
return fmt.Errorf("geojson: %s times must be strictly increasing", name)
}
previous = point.Time
}
return nil
}
func validateOccultationPathPoint(name string, point moon.OccultationPathPoint) error {
if point.Time.IsZero() {
return fmt.Errorf("geojson: %s time is required", name)
}
if err := validateCoordinate(point.Longitude, point.Latitude); err != nil {
return fmt.Errorf("geojson: %s: %w", name, err)
}
if !finiteGeoJSON(point.MoonAltitude) || point.MoonAltitude < -90 || point.MoonAltitude > 90 {
return fmt.Errorf("geojson: %s Moon altitude must be finite and within [-90, 90]", name)
}
if !finiteGeoJSON(point.WidthKM) || point.WidthKM < 0 {
return fmt.Errorf("geojson: %s width must be finite and non-negative", name)
}
return nil
}
func validateOccultationFootprints(
name string,
footprints []moon.PlanetOccultationFootprint,
start, end time.Time,
) error {
previous := time.Time{}
for index, footprint := range footprints {
if footprint.Time.IsZero() {
return fmt.Errorf("geojson: %s footprint[%d] time is required", name, index)
}
if footprint.Time.Before(start) || footprint.Time.After(end) {
return fmt.Errorf("geojson: %s footprint[%d] time must be inside its contact interval", name, index)
}
if !previous.IsZero() && !footprint.Time.After(previous) {
return fmt.Errorf("geojson: %s footprint times must be strictly increasing", name)
}
previous = footprint.Time
}
return nil
}