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
+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
}