Files
astro/geojson/eclipse.go
T

622 lines
22 KiB
Go
Raw Normal View History

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
}