feat: 完善时标与天象几何计算并扩展输出接口

- 新增时标、ΔT 模型、质心时间与 UT1 支持
- 改进日月食、月掩、行星事件及路径边界计算
- 完善恒星三维自行与动态距离传播
- 扩展 SVG、GeoJSON、KML 输出与底层距离换算工具
- 整理中英文手册、示例资源及回归测试
This commit is contained in:
2026-09-23 18:55:12 +08:00
parent 1f31a9b5b5
commit 16c62a97d5
503 changed files with 33290 additions and 9471 deletions
+780 -54
View File
File diff suppressed because it is too large Load Diff
+107
View File
@@ -0,0 +1,107 @@
package geojson
import (
"encoding/json"
"math"
"testing"
"time"
eclipsecore "b612.me/astro/eclipse"
)
// 带宽契约:单侧极限事件的 path_width_km 与"非中心食"一样是 0,消费者只能靠
// path_width_defined 区分,该键必须与 path_width_km 并列出现在同一份 properties 里。
func TestSolarEclipseMetadataPathWidthDefined(t *testing.T) {
testCases := []struct {
name string
date time.Time
defined bool
widthKM float64
}{
{name: "-1404-01-07 single-sided limit", date: time.Date(-1404, time.January, 7, 0, 0, 0, 0, time.UTC)},
{name: "2003-05-31 single-sided limit", date: time.Date(2003, time.May, 31, 0, 0, 0, 0, time.UTC)},
{name: "1874-10-10 single-sided limit", date: time.Date(1874, time.October, 10, 0, 0, 0, 0, time.UTC)},
{name: "2024-04-08 total", date: time.Date(2024, time.April, 8, 0, 0, 0, 0, time.UTC), defined: true, widthKM: 198.6161},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
info, ok := eclipsecore.SolarEclipseOnDate(tc.date)
if !ok {
t.Fatalf("no solar eclipse on %v", tc.date)
}
properties := solarEclipseMetadata(info)
defined, isBool := properties["path_width_defined"].(bool)
if !isBool {
t.Fatalf("path_width_defined=%#v, want a bool", properties["path_width_defined"])
}
if defined != tc.defined {
t.Fatalf("path_width_defined=%v want %v", defined, tc.defined)
}
if defined != info.PathWidthDefined {
t.Fatalf("path_width_defined=%v disagrees with eclipse.PathWidthDefined=%v", defined, info.PathWidthDefined)
}
width, isFloat := properties["path_width_km"].(float64)
if !isFloat {
t.Fatalf("path_width_km=%#v, want a number", properties["path_width_km"])
}
if !tc.defined {
if width != 0 {
t.Fatalf("path_width_km=%.6f with an undefined width, want 0", width)
}
return
}
if math.Abs(width-tc.widthKM) > 0.01 {
t.Fatalf("path_width_km=%.6f want %.6f", width, tc.widthKM)
}
})
}
}
func TestMarshalSolarEclipseCarriesPathWidthDefined(t *testing.T) {
date := time.Date(2024, time.April, 8, 0, 0, 0, 0, time.UTC)
partial, ok := eclipsecore.SolarEclipsePartialFootprints(date, eclipsecore.SolarEclipsePartialFootprintOptions{
Step: 20 * time.Minute, BoundaryPoints: 36,
})
if !ok {
t.Fatalf("no partial footprints on %v", date)
}
central, hasCentral := eclipsecore.SolarEclipseCentralPath(date, eclipsecore.SolarEclipsePathOptions{Step: 5 * time.Minute})
var centralPath *eclipsecore.SolarEclipsePath
if hasCentral {
centralPath = &central
}
data, err := MarshalSolarEclipse(partial, centralPath)
if err != nil {
t.Fatalf("MarshalSolarEclipse: %v", err)
}
var collection featureCollection
if err := json.Unmarshal(data, &collection); err != nil {
t.Fatalf("decode: %v", err)
}
if len(collection.Features) == 0 {
t.Fatal("no features exported")
}
// 带宽两个键只在食甚点要素上出现;其余要素沿用最小 properties。
seen := 0
for index, item := range collection.Features {
if item.Properties["role"] != "greatest" {
continue
}
defined, ok := item.Properties["path_width_defined"].(bool)
if !ok {
t.Fatalf("greatest feature has no boolean path_width_defined: %#v", item.Properties["path_width_defined"])
}
width, ok := item.Properties["path_width_km"].(float64)
if !ok {
t.Fatalf("greatest feature has no numeric path_width_km: %#v", item.Properties["path_width_km"])
}
if !defined || math.Abs(width-198.6161) > 0.01 {
t.Fatalf("greatest feature %d path_width_km=%.6f path_width_defined=%v, want 198.6161/true", index, width, defined)
}
seen++
}
if seen != 1 {
t.Fatalf("greatest features with width properties = %d, want exactly one", seen)
}
}
+47 -2
View File
@@ -21,6 +21,7 @@ import (
"math"
"time"
"b612.me/astro"
"b612.me/astro/internal/geodata"
)
@@ -33,6 +34,8 @@ const (
type featureCollection struct {
Type string `json:"type"`
Features []feature `json:"features"`
// TimeScale 只在 UT1 导出时出现;UTC 导出与既有输出逐字节一致。
TimeScale string `json:"time_scale,omitempty"`
}
type feature struct {
@@ -68,6 +71,30 @@ type TimeMarkerOptions struct {
// Location 是格式化 HH:MM 标签时使用的时区;nil 使用 UTC。
// Location is the timezone used to format HH:MM labels; nil uses UTC.
Location *time.Location
// TimeScale 选择时间属性的时标:零值 UTC;TimeScaleUT1 时所有 time 与 HH:MM 标注都写 UT1 时刻,
// 并在集合上输出 time_scale 成员消歧(RFC 3339 的 Z 后缀严格说不等于 UT1)。此时 Location 必须是 nil 或 UTC。
// TimeScale selects the scale of time properties: the zero value is UTC; TimeScaleUT1 writes UT1 labels
// and adds a time_scale member (a RFC 3339 Z suffix is not exactly UT1). Location must then be nil or UTC.
TimeScale astro.TimeScale
}
// dropFeaturesByRole 按 role 属性丢弃要素;空列表原样返回。
func dropFeaturesByRole(features []feature, skip []string) []feature {
if len(skip) == 0 {
return features
}
skipped := make(map[string]bool, len(skip))
for _, role := range skip {
skipped[role] = true
}
kept := make([]feature, 0, len(features))
for _, item := range features {
if role, _ := item.Properties["role"].(string); skipped[role] {
continue
}
kept = append(kept, item)
}
return kept
}
func marshalFeatureCollection(features []feature) ([]byte, error) {
@@ -81,8 +108,23 @@ func marshalEmptyFeatureCollection() ([]byte, error) {
return encodeFeatureCollection(make([]feature, 0))
}
func marshalFeatureCollectionWithTimeScale(features []feature, scale astro.TimeScale) ([]byte, error) {
if len(features) == 0 {
return nil, fmt.Errorf("geojson: no geographic features")
}
return encodeFeatureCollectionWithTimeScale(features, scale)
}
func encodeFeatureCollection(features []feature) ([]byte, error) {
value, err := json.Marshal(featureCollection{Type: featureCollectionType, Features: features})
return encodeFeatureCollectionWithTimeScale(features, astro.TimeScaleUTC)
}
func encodeFeatureCollectionWithTimeScale(features []feature, scale astro.TimeScale) ([]byte, error) {
member := ""
if scale == astro.TimeScaleUT1 {
member = "UT1"
}
value, err := json.Marshal(featureCollection{Type: featureCollectionType, Features: features, TimeScale: member})
if err != nil {
return nil, fmt.Errorf("geojson: encode feature collection: %w", err)
}
@@ -513,7 +555,10 @@ func splitTimedLine(points []pathSample, requireIncreasingTimes bool) ([][]pathS
fraction := (unwrappedBoundary - previousUnwrapped.Longitude) /
(b.Longitude - previousUnwrapped.Longitude)
crossing := interpolatePathSample(previousUnwrapped, b, fraction, boundary)
if !crossing.Time.Equal(current[len(current)-1].Time) {
// 按位置而不是时刻判断是否重复:食甚等时线整条支路共用同一时刻,按时刻判断会把接缝点丢掉,
// 于是反经线两侧各留一个端点,屏幕上就是一条缺口。
last := current[len(current)-1]
if crossing.Longitude != last.Longitude || crossing.Latitude != last.Latitude {
current = append(current, crossing)
}
if len(current) >= 2 {
+147 -1
View File
@@ -4,9 +4,11 @@ import (
"encoding/json"
"fmt"
"math"
"strings"
"testing"
"time"
"b612.me/astro"
"b612.me/astro/eclipse"
"b612.me/astro/geojson"
"b612.me/astro/internal/geodata"
@@ -1186,7 +1188,9 @@ func TestMarshalLunarEclipseUsesRequestedBoundarySampling(t *testing.T) {
t.Fatalf("MarshalLunarEclipse: %v", err)
}
collection := decodeCollection(t, data)
assertRoles(t, collection, "visible-at-p1", "visible-at-p4", "p1-horizon", "p4-horizon", "greatest")
assertRoles(t, collection, "visible-at-p1", "visible-at-p4",
"p1-horizon", "p4-horizon", "visible-during-eclipse",
"visible-throughout-eclipse", "greatest")
assertCollectionCoordinates(t, collection)
visible := featureWithRole(t, collection, "visible-at-p1")
if got := visible.Properties["boundary_points"]; got != float64(24) {
@@ -1207,6 +1211,119 @@ func TestMarshalLunarEclipseUsesRequestedBoundarySampling(t *testing.T) {
}
}
func TestMarshalLunarEclipseWithOptions(t *testing.T) {
date := time.Date(2029, 1, 1, 12, 0, 0, 0, time.FixedZone("CST", 8*3600))
info, ok := eclipse.LunarEclipseOnDate(date)
if !ok {
t.Fatal("missing lunar eclipse")
}
defaultData, err := geojson.MarshalLunarEclipse(info, 360)
if err != nil {
t.Fatal(err)
}
skippedData, err := geojson.MarshalLunarEclipseWithOptions(info, 360, geojson.LunarEclipseOptions{
SkipRoles: []string{"visible-during-eclipse", "visible-throughout-eclipse"},
})
if err != nil {
t.Fatal(err)
}
skipped := decodeCollection(t, skippedData)
assertRoles(t, skipped, "visible-at-p1", "visible-at-p4", "p1-horizon", "p4-horizon", "greatest")
for _, role := range []string{"visible-during-eclipse", "visible-throughout-eclipse"} {
if len(featuresWithRole(skipped, role)) != 0 {
t.Fatalf("skipped role %s is still present", role)
}
}
// 跳过包络不得改变其余要素的几何。
band := featureWithRole(t, skipped, "visible-at-p1")
expected := featureWithRole(t, decodeCollection(t, defaultData), "visible-at-p1")
if got, want := string(band.Geometry.Coordinates), string(expected.Geometry.Coordinates); got != want {
t.Fatal("skipping the envelopes changed the P1 hemisphere")
}
// 只跳过一块包络时另一块照常输出,而且不再为被跳过的那块成环。
partialData, err := geojson.MarshalLunarEclipseWithOptions(info, 360, geojson.LunarEclipseOptions{
SkipRoles: []string{"visible-throughout-eclipse"},
})
if err != nil {
t.Fatal(err)
}
partial := decodeCollection(t, partialData)
assertRoles(t, partial, "visible-at-p1", "visible-during-eclipse")
if len(featuresWithRole(partial, "visible-throughout-eclipse")) != 0 {
t.Fatal("skipped envelope role is still present")
}
coarseData, err := geojson.MarshalLunarEclipseWithOptions(info, 360, geojson.LunarEclipseOptions{
EnvelopeLongitudePoints: 180,
EnvelopeSweepSamples: 16,
})
if err != nil {
t.Fatal(err)
}
coarse := decodeCollection(t, coarseData)
for _, role := range []string{"visible-during-eclipse", "visible-throughout-eclipse"} {
feature := featureWithRole(t, coarse, role)
if got := feature.Properties["longitude_points"]; got != float64(180) {
t.Fatalf("%s longitude_points=%v, want 180", role, got)
}
assertClosedMultiPolygon(t, feature)
}
if _, err := geojson.MarshalLunarEclipseWithOptions(info, 360, geojson.LunarEclipseOptions{
SkipRoles: []string{
"visible-at-p1", "visible-at-p4", "visible-during-eclipse", "visible-throughout-eclipse",
"penumbra-moonset", "penumbra-moonrise",
"p1-horizon", "p4-horizon", "greatest",
},
}); err == nil {
t.Fatal("dropping every Feature must fail")
}
// 采样档位取值:0 或负值等于默认(逐字节相同),正值收敛到上下限。
for _, samples := range []int{-5, 0} {
data, err := geojson.MarshalLunarEclipseWithOptions(info, 360, geojson.LunarEclipseOptions{EnvelopeSweepSamples: samples})
if err != nil {
t.Fatal(err)
}
if string(data) != string(defaultData) {
t.Fatalf("EnvelopeSweepSamples=%d 应当与默认输出相同", samples)
}
}
for _, points := range []int{-5, 0} {
data, err := geojson.MarshalLunarEclipseWithOptions(info, 360, geojson.LunarEclipseOptions{EnvelopeLongitudePoints: points})
if err != nil {
t.Fatal(err)
}
if string(data) != string(defaultData) {
t.Fatalf("EnvelopeLongitudePoints=%d 应当与默认输出相同", points)
}
}
for _, testCase := range []struct {
points int
want float64
}{{1, 12}, {100000, 720}} {
data, err := geojson.MarshalLunarEclipseWithOptions(info, 360, geojson.LunarEclipseOptions{EnvelopeLongitudePoints: testCase.points})
if err != nil {
t.Fatal(err)
}
collection := decodeCollection(t, data)
for _, role := range []string{"visible-during-eclipse", "visible-throughout-eclipse"} {
if got := featureWithRole(t, collection, role).Properties["longitude_points"]; got != testCase.want {
t.Fatalf("EnvelopeLongitudePoints=%d 的 %s longitude_points=%v,期望 %v", testCase.points, role, got, testCase.want)
}
}
}
clampedData, err := geojson.MarshalLunarEclipseWithOptions(info, 360, geojson.LunarEclipseOptions{EnvelopeSweepSamples: 1})
if err != nil {
t.Fatal(err)
}
clamped := decodeCollection(t, clampedData)
for _, role := range []string{"visible-during-eclipse", "visible-throughout-eclipse"} {
assertClosedMultiPolygon(t, featureWithRole(t, clamped, role))
}
}
func TestMarshalLunarEclipseWithTimeMarkers(t *testing.T) {
info, ok := eclipse.LunarEclipseOnDate(time.Date(2026, time.March, 3, 0, 0, 0, 0, time.UTC))
if !ok {
@@ -2883,3 +3000,32 @@ func sampleFootprint(at time.Time, west, south, east, north float64) moon.Planet
}},
}
}
// UT1 导出:时间字符串写 UT1 时刻、集合上带 time_scale 成员,且拒绝非 UTC 时区。
func TestMarshalSolarEclipseUT1TimeScale(t *testing.T) {
date := time.Date(2024, time.April, 8, 0, 0, 0, 0, time.UTC)
partial, ok := eclipse.SolarEclipsePartialFootprintsNASABulletinSplitK(date, eclipse.SolarEclipsePartialFootprintOptions{
Step: 10 * time.Minute, BoundaryPoints: 24,
})
if !ok {
t.Fatal("缺少日食足迹")
}
utcData, err := geojson.MarshalSolarEclipse(partial, nil)
if err != nil {
t.Fatalf("UTC 导出失败: %v", err)
}
if strings.Contains(string(utcData), "time_scale") {
t.Error("UTC 导出不应带 time_scale 成员")
}
ut1Data, err := geojson.MarshalSolarEclipseWithTimeMarkers(partial, nil, geojson.TimeMarkerOptions{TimeScale: astro.TimeScaleUT1})
if err != nil {
t.Fatalf("UT1 导出失败: %v", err)
}
if !strings.Contains(string(ut1Data), `"time_scale":"UT1"`) {
t.Errorf("UT1 导出应带 time_scale 成员: %s", string(ut1Data)[:200])
}
cst := time.FixedZone("CST", 8*3600)
if _, err := geojson.MarshalSolarEclipseWithTimeMarkers(partial, nil, geojson.TimeMarkerOptions{TimeScale: astro.TimeScaleUT1, Location: cst}); err == nil {
t.Error("UT1 配非 UTC 时区应报错")
}
}
+291 -4
View File
@@ -36,7 +36,7 @@ func TestLunarGeoJSONUsesTopocentricHorizon(t *testing.T) {
if err := json.Unmarshal(line.Geometry.Coordinates, &segments); err != nil {
t.Fatal(err)
}
jd := basic.Date2JDE(contact.at.UTC())
jd := basic.Date2JD(contact.at.UTC())
for _, segment := range segments {
for _, point := range segment {
if math.Abs(point[0]) == 180 {
@@ -61,6 +61,124 @@ func TestLunarGeoJSONUsesTopocentricHorizon(t *testing.T) {
}
}
// TestLunarGeoJSONEnvelopeCoversPolarLens 固定时间包络的必要性:见证站点不在任何一块 P1/P4 瞬时半球里。
func TestLunarGeoJSONEnvelopeCoversPolarLens(t *testing.T) {
date := time.Date(2029, 1, 1, 12, 0, 0, 0, time.FixedZone("CST", 8*3600))
info, ok := eclipse.LunarEclipseOnDate(date)
if !ok {
t.Fatal("missing lunar eclipse")
}
data, err := geojson.MarshalLunarEclipse(info, 360)
if err != nil {
t.Fatal(err)
}
collection := decodeCollection(t, data)
const longitude, latitude = 108.729001, -59.937452
during := featureWithRole(t, collection, "visible-during-eclipse")
if !geometryContainsPoint(t, during.Geometry, longitude, latitude) {
t.Fatal("visible-during-eclipse does not cover the polar lens witness")
}
if geometryContainsPoint(t, featureWithRole(t, collection, "visible-throughout-eclipse").Geometry, longitude, latitude) {
t.Fatal("visible-throughout-eclipse covers a site that loses the penumbral ends")
}
for _, role := range []string{"visible-at-p1", "visible-at-p4"} {
if geometryContainsPoint(t, featureWithRole(t, collection, role).Geometry, longitude, latitude) {
t.Fatalf("%s unexpectedly covers the polar lens witness", role)
}
}
local, localOK := eclipse.LocalLunarEclipseOnDate(date, longitude, latitude, 0)
if !localOK || local.Visibility != eclipse.LocalLunarEclipseRiseAndSet {
t.Fatalf("local visibility=%q ok=%v, want %q", local.Visibility, localOK, eclipse.LocalLunarEclipseRiseAndSet)
}
}
// TestLunarGeoJSONEnvelopesMatchAltitudeExtrema 用高度极值独立判据钉住两个时间包络;采样取整点经度,
// 并跳过零高度 0.05° 以内的边界点,1° 经度采样在区域边缘的半格误差不算失配。
func TestLunarGeoJSONEnvelopesMatchAltitudeExtrema(t *testing.T) {
for _, day := range []string{
"0275-09-22", "0386-09-24", "1076-09-15", "1904-09-24", "2396-03-25",
"2779-03-24", "2955-09-23", "3188-09-27", "3738-03-19", "4026-03-16",
} {
t.Run(day, func(t *testing.T) {
date, err := time.Parse("2006-01-02", day)
if err != nil {
t.Fatal(err)
}
info, ok := eclipse.LunarEclipseOnDate(date)
if !ok {
t.Fatal("missing lunar eclipse")
}
data, err := geojson.MarshalLunarEclipse(info, 360)
if err != nil {
t.Fatal(err)
}
collection := decodeCollection(t, data)
during := featureWithRole(t, collection, "visible-during-eclipse")
throughout := featureWithRole(t, collection, "visible-throughout-eclipse")
jdStart := basic.Date2JD(info.PenumbralStart.UTC())
jdEnd := basic.Date2JD(info.PenumbralEnd.UTC())
for _, base := range []float64{-88, 87.5} {
for longitude := -176.0; longitude < 180; longitude += 8 {
for latitude := base; latitude <= base+2.5; latitude += 0.5 {
maximum, minimum := lunarAltitudeExtrema(jdStart, jdEnd, longitude, latitude)
if math.Abs(maximum) > 0.05 &&
geometryContainsPoint(t, during.Geometry, longitude, latitude) != (maximum > 0) {
t.Fatalf("visible-during-eclipse (%v,%v) maximum=%g", longitude, latitude, maximum)
}
if math.Abs(minimum) > 0.05 &&
geometryContainsPoint(t, throughout.Geometry, longitude, latitude) != (minimum > 0) {
t.Fatalf("visible-throughout-eclipse (%v,%v) minimum=%g", longitude, latitude, minimum)
}
}
}
}
})
}
}
func lunarAltitudeExtrema(jdStart, jdEnd, longitude, latitude float64) (float64, float64) {
const samples = 48
maximum, minimum := math.Inf(-1), math.Inf(1)
for index := 0; index <= samples; index++ {
altitude := basic.HMoonHeight(jdStart+(jdEnd-jdStart)*float64(index)/samples, longitude, latitude, 0)
maximum = math.Max(maximum, altitude)
minimum = math.Min(minimum, altitude)
}
return maximum, minimum
}
func TestLunarGeoJSONTimeEnvelopesCoverPolarWindow(t *testing.T) {
date := time.Date(1800, 4, 9, 0, 0, 0, 0, time.UTC)
info, ok := eclipse.LunarEclipseOnDate(date)
if !ok {
t.Fatal("missing lunar eclipse")
}
data, err := geojson.MarshalLunarEclipse(info, 360)
if err != nil {
t.Fatal(err)
}
collection := decodeCollection(t, data)
during := featureWithRole(t, collection, "visible-during-eclipse")
throughout := featureWithRole(t, collection, "visible-throughout-eclipse")
if during.Properties["aggregation"] != "union" || throughout.Properties["aggregation"] != "intersection" {
t.Fatalf("unexpected aggregations: during=%v throughout=%v", during.Properties["aggregation"], throughout.Properties["aggregation"])
}
if !geometryContainsPoint(t, during.Geometry, 121, 82) {
t.Fatal("visible-during-eclipse misses a short polar visibility interval")
}
if geometryContainsPoint(t, throughout.Geometry, 121, 82) {
t.Fatal("visible-throughout-eclipse contains a rise-and-set site")
}
if !geometryContainsPoint(t, during.Geometry, -69, -84) {
t.Fatal("visible-during-eclipse misses the interrupted-site witness")
}
if geometryContainsPoint(t, throughout.Geometry, -69, -84) {
t.Fatal("visible-throughout-eclipse contains an interrupted site")
}
}
func TestLunarGeoJSON19040924DoesNotFillFalseSouthPolarCap(t *testing.T) {
date := time.Date(1904, 9, 24, 0, 0, 0, 0, time.UTC)
info, ok := eclipse.LunarEclipseOnDate(date)
@@ -77,7 +195,7 @@ func TestLunarGeoJSON19040924DoesNotFillFalseSouthPolarCap(t *testing.T) {
longitude float64
latitude float64
}{-115, -89.9}
if altitude := basic.HMoonHeight(basic.Date2JDE(info.PenumbralStart), point.longitude, point.latitude, 0); altitude >= -0.01 {
if altitude := basic.HMoonHeight(basic.Date2JD(info.PenumbralStart), point.longitude, point.latitude, 0); altitude >= -0.01 {
t.Fatalf("regression witness altitude=%g, want below horizon", altitude)
}
if geometryContainsPoint(t, band.Geometry, point.longitude, point.latitude) {
@@ -126,13 +244,16 @@ func assertLunarVisibilityGrid(t *testing.T, info eclipse.LunarEclipseInfo, coun
for _, contact := range []struct {
role string
at time.Time
}{{"visible-at-p1", info.PenumbralStart}, {"visible-at-p4", info.PenumbralEnd}} {
}{
{"visible-at-p1", info.PenumbralStart},
{"visible-at-p4", info.PenumbralEnd},
} {
band := featureWithRole(t, collection, contact.role)
var polygons [][][][]float64
if err := json.Unmarshal(band.Geometry.Coordinates, &polygons); err != nil {
t.Fatal(err)
}
jd := basic.Date2JDE(contact.at.UTC())
jd := basic.Date2JD(contact.at.UTC())
visible, invisible := 0, 0
for _, lat := range latitudes {
for lon := -179.5; lon < 180; lon += 5 {
@@ -181,3 +302,169 @@ func BenchmarkLunarEclipseGeoJSON(b *testing.B) {
})
}
}
// TestLunarGeoJSONEnvelopeBoundariesMatchDenseTimeSweep 用 1000 点密集时间求极值作为连续时间真值,
// 核对两个包络的边界纬度:包络按 48 个时刻离散采样,边界处的极值是掠射型,误差必须远小于经度列距。
func TestLunarGeoJSONEnvelopeBoundariesMatchDenseTimeSweep(t *testing.T) {
for _, testCase := range []struct {
day string
longitude float64
starts []float64
}{
{"2029-01-01", -150, []float64{-20, 30}},
{"1904-09-24", -170, []float64{0, 40}},
} {
t.Run(testCase.day, func(t *testing.T) {
location := time.UTC
if testCase.day == "2029-01-01" {
location = time.FixedZone("CST", 8*3600)
}
date, err := time.ParseInLocation("2006-01-02", testCase.day, location)
if err != nil {
t.Fatal(err)
}
info, ok := eclipse.LunarEclipseOnDate(date)
if !ok {
t.Fatal("missing lunar eclipse")
}
data, err := geojson.MarshalLunarEclipse(info, 360)
if err != nil {
t.Fatal(err)
}
collection := decodeCollection(t, data)
jdStart := basic.Date2JD(info.PenumbralStart.UTC())
jdEnd := basic.Date2JD(info.PenumbralEnd.UTC())
for _, role := range []struct {
name string
maximum bool
}{
{"visible-during-eclipse", true},
{"visible-throughout-eclipse", false},
} {
feature := featureWithRole(t, collection, role.name)
for _, start := range testCase.starts {
for _, limit := range []float64{90, -90} {
got, hasPolygonEdge := lunarEnvelopeBoundaryLatitude(t, feature, testCase.longitude, start, limit)
want, hasTrueEdge := lunarDenseExtremumBoundaryLatitude(
t, jdStart, jdEnd, testCase.longitude, start, limit, role.maximum,
)
if hasPolygonEdge != hasTrueEdge {
t.Fatalf("%s 经度 %v 起点 %v 朝 %v:包络有边界=%v,密集时间真值有边界=%v",
role.name, testCase.longitude, start, limit, hasPolygonEdge, hasTrueEdge)
}
if !hasPolygonEdge {
continue
}
if difference := math.Abs(got - want); difference > 0.01 {
t.Fatalf("%s 经度 %v 起点 %v 朝 %v:包络边界 %.4f,密集时间真值 %.4f,差 %.4f",
role.name, testCase.longitude, start, limit, got, want, difference)
}
}
}
}
})
}
}
// TestLunarGeoJSONEnvelopesCrossAntimeridian 固定包络在 ±180° 的连续性:两侧同纬度必须同号,
// 且日界线拆分后的碎片仍覆盖该经度。
func TestLunarGeoJSONEnvelopesCrossAntimeridian(t *testing.T) {
date := time.Date(2029, 1, 1, 12, 0, 0, 0, time.FixedZone("CST", 8*3600))
info, ok := eclipse.LunarEclipseOnDate(date)
if !ok {
t.Fatal("missing lunar eclipse")
}
data, err := geojson.MarshalLunarEclipse(info, 360)
if err != nil {
t.Fatal(err)
}
collection := decodeCollection(t, data)
for _, role := range []string{"visible-during-eclipse", "visible-throughout-eclipse"} {
feature := featureWithRole(t, collection, role)
var polygons [][][][]float64
if err := json.Unmarshal(feature.Geometry.Coordinates, &polygons); err != nil {
t.Fatal(err)
}
touchesSeam, insideBoth := false, false
for _, polygon := range polygons {
for _, ring := range polygon {
for _, point := range ring {
if math.Abs(point[0]) == 180 {
touchesSeam = true
}
}
}
}
if !touchesSeam {
t.Fatalf("%s 没有落在 ±180° 上的碎片", role)
}
for latitude := -85.0; latitude <= 85; latitude += 5 {
left := geometryContainsPoint(t, feature.Geometry, -179.5, latitude)
right := geometryContainsPoint(t, feature.Geometry, 179.5, latitude)
if left != right {
t.Fatalf("%s 在纬 %.0f 跨越日界线不连续:%v / %v", role, latitude, left, right)
}
if left {
insideBoth = true
}
}
if !insideBoth {
t.Fatalf("%s 在 ±180° 两侧没有任何共同可见纬度", role)
}
}
}
func lunarEnvelopeBoundaryLatitude(
t *testing.T, feature decodedFeature, longitude, start, limit float64,
) (float64, bool) {
t.Helper()
if !geometryContainsPoint(t, feature.Geometry, longitude, start) ||
geometryContainsPoint(t, feature.Geometry, longitude, limit) {
return 0, false
}
low, high := start, limit
for iteration := 0; iteration < 40; iteration++ {
middle := (low + high) / 2
if geometryContainsPoint(t, feature.Geometry, longitude, middle) {
low = middle
} else {
high = middle
}
}
return low, true
}
func lunarDenseExtremumBoundaryLatitude(
t *testing.T, jdStart, jdEnd, longitude, start, limit float64, maximum bool,
) (float64, bool) {
t.Helper()
extreme := func(latitude float64) float64 {
value := math.Inf(1)
if maximum {
value = math.Inf(-1)
}
const samples = 1000
for index := 0; index <= samples; index++ {
altitude := basic.HMoonHeight(jdStart+(jdEnd-jdStart)*float64(index)/samples, longitude, latitude, 0)
if maximum {
value = math.Max(value, altitude)
} else {
value = math.Min(value, altitude)
}
}
return value
}
if extreme(start) <= 0 || extreme(limit) > 0 {
return 0, false
}
low, high := start, limit
for iteration := 0; iteration < 30; iteration++ {
middle := (low + high) / 2
if extreme(middle) > 0 {
low = middle
} else {
high = middle
}
}
return low, true
}
+188
View File
@@ -0,0 +1,188 @@
package geojson_test
import (
"testing"
"time"
"b612.me/astro/basic"
"b612.me/astro/eclipse"
"b612.me/astro/geojson"
)
// 两条"仅见半影"带的判据:月落侧 = 食始在地平上、本影阶段整段在地平下、食终也在地平下;
// 月出侧 = 食终在地平上、本影阶段整段在地平下、食始也在地平下。本影可见性取整个区间的最大高度,
// 只比 U1/U4 会漏掉极区掠射时两刻之间的短暂窗口。用高度独立复算,并允许边界附近半格误差。
func TestLunarGeoJSONPenumbraBandsFollowUmbralContacts(t *testing.T) {
date := time.Date(2029, 1, 1, 0, 0, 0, 0, time.FixedZone("CST", 8*3600))
info, ok := eclipse.LunarEclipseOnDate(date)
if !ok || !info.HasPartial {
t.Fatalf("missing umbral lunar eclipse")
}
data, err := geojson.MarshalLunarEclipse(info, 360)
if err != nil {
t.Fatal(err)
}
collection := decodeCollection(t, data)
moonset := featureWithRole(t, collection, "penumbra-moonset")
moonrise := featureWithRole(t, collection, "penumbra-moonrise")
altitude := func(at time.Time, longitude, latitude float64) float64 {
return basic.MoonStateAt(basic.Date2JD(at.UTC())).HMoonHeight(longitude, latitude)
}
umbralExtremum := func(longitude, latitude float64) float64 {
best, step := -99.0, info.PartialEnd.Sub(info.PartialStart)/400
for at := info.PartialStart; !at.After(info.PartialEnd); at = at.Add(step) {
if value := altitude(at, longitude, latitude); value > best {
best = value
}
}
return best
}
checked := map[string]int{}
for _, role := range []string{"penumbra-moonset", "penumbra-moonrise"} {
feature := moonset
if role == "penumbra-moonrise" {
feature = moonrise
}
for longitude := -179.5; longitude < 180; longitude += 3 {
for latitude := -89.5; latitude < 90; latitude += 3 {
heights := []float64{
altitude(info.PenumbralStart, longitude, latitude),
altitude(info.PartialStart, longitude, latitude),
altitude(info.PartialEnd, longitude, latitude),
altitude(info.PenumbralEnd, longitude, latitude),
}
umbral := umbralExtremum(longitude, latitude)
want := false
if role == "penumbra-moonset" {
want = heights[0] > 0 && umbral < 0 && heights[3] < 0
} else {
want = heights[3] > 0 && umbral < 0 && heights[0] < 0
}
// 区域边缘的半格误差:任一条判据高度落在 ±0.5° 内就不作断言。
margin := 0.5
for _, height := range heights {
if height < 0 {
height = -height
}
if height < margin {
margin = height
}
}
if margin < 0.5 {
continue
}
got := geometryContainsPoint(t, feature.Geometry, longitude, latitude)
if got != want {
t.Fatalf("%s (%.1f,%.1f): geometry=%v altitude criterion=%v (P1=%.2f U1=%.2f U4=%.2f P4=%.2f)",
role, longitude, latitude, got, want, heights[0], heights[1], heights[2], heights[3])
}
if want {
checked[role]++
}
}
}
}
if checked["penumbra-moonset"] < 20 || checked["penumbra-moonrise"] < 20 {
t.Fatalf("too few grid points verified: %v", checked)
}
}
// 两条带必须与本地可见性类别的细分一致,且可被 SkipRoles 关掉;纯半影月食没有本影接触,不画带。
func TestLunarGeoJSONPenumbraBandsMatchVisibilityClasses(t *testing.T) {
date := time.Date(2029, 1, 1, 0, 0, 0, 0, time.FixedZone("CST", 8*3600))
info, _ := eclipse.LunarEclipseOnDate(date)
data, err := geojson.MarshalLunarEclipse(info, 360)
if err != nil {
t.Fatal(err)
}
collection := decodeCollection(t, data)
for _, witness := range []struct {
role string
lon, lat float64
class eclipse.LocalLunarEclipseVisibility
}{
{"penumbra-moonset", -179, -61, eclipse.LocalLunarEclipsePenumbraMoonset},
{"penumbra-moonrise", -69, 61, eclipse.LocalLunarEclipsePenumbraMoonrise},
} {
local, ok := eclipse.GeometricLocalLunarEclipseOnDate(date, witness.lon, witness.lat, 0)
if !ok || local.Visibility != witness.class {
t.Fatalf("(%.0f,%.0f) visibility=%q ok=%v, want %q", witness.lon, witness.lat, local.Visibility, ok, witness.class)
}
if !geometryContainsPoint(t, featureWithRole(t, collection, witness.role).Geometry, witness.lon, witness.lat) {
t.Fatalf("%s does not cover its witness point", witness.role)
}
}
skipped, err := geojson.MarshalLunarEclipseWithOptions(info, 360, geojson.LunarEclipseOptions{
SkipRoles: []string{"penumbra-moonset", "penumbra-moonrise"},
})
if err != nil {
t.Fatal(err)
}
skippedCollection := decodeCollection(t, skipped)
for _, role := range []string{"penumbra-moonset", "penumbra-moonrise"} {
if len(featuresWithRole(skippedCollection, role)) != 0 {
t.Fatalf("%s survived SkipRoles", role)
}
}
assertRoles(t, skippedCollection, "visible-at-p1", "visible-at-p4")
penumbralDate := time.Date(2020, 1, 11, 0, 0, 0, 0, time.FixedZone("CST", 8*3600))
penumbral, ok := eclipse.LunarEclipseOnDate(penumbralDate)
if !ok || penumbral.HasPartial {
t.Fatalf("expected a purely penumbral eclipse, HasPartial=%v ok=%v", penumbral.HasPartial, ok)
}
penumbralData, err := geojson.MarshalLunarEclipse(penumbral, 360)
if err != nil {
t.Fatal(err)
}
penumbralCollection := decodeCollection(t, penumbralData)
for _, role := range []string{"penumbra-moonset", "penumbra-moonrise"} {
if len(featuresWithRole(penumbralCollection, role)) != 0 {
t.Fatalf("purely penumbral eclipse must not export %s", role)
}
}
}
// 极区掠射见证:本影区间中途再短暂也算见到本影,不能算进"仅见半影";本影整段在地平下才进带。
func TestLunarGeoJSONPenumbraBandsExcludeUmbralGrazingWindow(t *testing.T) {
date := time.Date(1932, 3, 22, 0, 0, 0, 0, time.UTC)
info, ok := eclipse.LunarEclipseOnDate(date)
if !ok || !info.HasPartial {
t.Fatal("missing umbral lunar eclipse")
}
data, err := geojson.MarshalLunarEclipse(info, 360)
if err != nil {
t.Fatal(err)
}
collection := decodeCollection(t, data)
moonset := featureWithRole(t, collection, "penumbra-moonset")
moonrise := featureWithRole(t, collection, "penumbra-moonrise")
for _, witness := range []struct {
name string
lon, lat float64
want eclipse.LocalLunarEclipseVisibility
}{
{"本影窗口 +0.0006 度", -91.8, -88.7675, eclipse.LocalLunarEclipseMoonrise},
{"U1 已在地平上", -91.75, -88.75, eclipse.LocalLunarEclipseMoonrise},
{"U1 高度 +0.21 度(陡边界)", -75.5, 6.5, eclipse.LocalLunarEclipseMoonset},
{"本影极值 -2.25 度(宽余量)", -68.75, -70.15, eclipse.LocalLunarEclipsePenumbraMoonset},
} {
local, found := eclipse.GeometricLocalLunarEclipseOnDate(date, witness.lon, witness.lat, 0)
if !found || local.Visibility != witness.want {
t.Fatalf("%s: 站点 API visibility=%q ok=%v, want %q", witness.name, local.Visibility, found, witness.want)
}
inMoonset := geometryContainsPoint(t, moonset.Geometry, witness.lon, witness.lat)
inMoonrise := geometryContainsPoint(t, moonrise.Geometry, witness.lon, witness.lat)
switch witness.want {
case eclipse.LocalLunarEclipsePenumbraMoonset:
if !inMoonset || inMoonrise {
t.Fatalf("%s: 半影月落带包含=%v 半影月出带包含=%v,want 只在前者", witness.name, inMoonset, inMoonrise)
}
default:
if inMoonset || inMoonrise {
t.Fatalf("%s: 见了本影却被算进半影带(月落=%v 月出=%v)", witness.name, inMoonset, inMoonrise)
}
}
}
}
+12 -2
View File
@@ -20,7 +20,10 @@ import (
// 1.8e-12 deg^2, just past the shared splitter's 1e-12 zero-area floor). The lunar export must
// drop it without dropping polygons that do have area.
func TestLunarGeoJSONDropsAntimeridianSlivers(t *testing.T) {
for _, day := range []string{"2022-11-08", "2026-03-03", "4026-03-16", "1904-09-24", "2025-03-14"} {
for _, day := range []string{
"2022-11-08", "2026-03-03", "4026-03-16", "1904-09-24", "2025-03-14",
"2028-12-31", "0275-09-22", "0386-09-24", "1076-09-15", "2396-03-25",
} {
t.Run(day, func(t *testing.T) {
date, err := time.Parse("2006-01-02", day)
if err != nil {
@@ -35,7 +38,14 @@ func TestLunarGeoJSONDropsAntimeridianSlivers(t *testing.T) {
t.Fatal(err)
}
collection := decodeCollection(t, data)
for _, role := range []string{"visible-at-p1", "visible-at-p4"} {
roles := []string{
"visible-at-p1", "visible-at-p4", "visible-during-eclipse", "visible-throughout-eclipse",
}
// 纯半影月食没有本影接触,不导出两条仅见半影带。
if info.HasPartial {
roles = append(roles, "penumbra-moonset", "penumbra-moonrise")
}
for _, role := range roles {
feature := featureWithRole(t, collection, role)
var polygons [][][][]float64
if err := json.Unmarshal(feature.Geometry.Coordinates, &polygons); err != nil {
+17 -2
View File
@@ -5,6 +5,7 @@ import (
"math"
"time"
"b612.me/astro"
"b612.me/astro/internal/geodata"
"b612.me/astro/internal/occultationgeo"
"b612.me/astro/moon"
@@ -119,6 +120,13 @@ func marshalStarOccultation(path moon.StarOccultationPath, markerOptions *TimeMa
return nil, err
}
}
timeScale, scaleErr := timeScaleForMarkers(markerOptions)
if scaleErr != nil {
return nil, scaleErr
}
if timeScale == astro.TimeScaleUT1 {
path = moon.StarOccultationPathInUT1(path)
}
if err := validateStarOccultationPathData(path); err != nil {
return nil, err
}
@@ -233,7 +241,7 @@ func marshalStarOccultation(path moon.StarOccultationPath, markerOptions *TimeMa
return nil, err
}
}
return marshalFeatureCollection(features)
return marshalFeatureCollectionWithTimeScale(features, timeScale)
}
// MarshalPlanetOccultation 将月掩行星的部分掩、全掩和中心线编码为 GeoJSON。
@@ -257,6 +265,13 @@ func marshalPlanetOccultation(path moon.PlanetOccultationPath, markerOptions *Ti
return nil, err
}
}
timeScale, scaleErr := timeScaleForMarkers(markerOptions)
if scaleErr != nil {
return nil, scaleErr
}
if timeScale == astro.TimeScaleUT1 {
path = moon.PlanetOccultationPathInUT1(path)
}
if err := path.Planet.Validate(); err != nil {
return nil, fmt.Errorf("geojson: planetary occultation target: %w", err)
}
@@ -501,7 +516,7 @@ func marshalPlanetOccultation(path moon.PlanetOccultationPath, markerOptions *Ti
return nil, err
}
}
return marshalFeatureCollection(features)
return marshalFeatureCollectionWithTimeScale(features, timeScale)
}
// roundAuthoritativePlanetBandJunctions runs after the partial/total
+117
View File
@@ -0,0 +1,117 @@
package geojson_test
import (
"encoding/json"
"math"
"reflect"
"testing"
"time"
"b612.me/astro"
"b612.me/astro/geojson"
"b612.me/astro/moon"
)
// 掩星 GeoJSON 与日月食共用同一份时标契约:UT1 必须写 time_scale 成员、换掉全部时刻,
// 且几何逐字节不变;UT1 配非 UTC 时区必须报错而不是静默按 UTC 导出。
// Occultation GeoJSON shares the eclipse time-scale contract.
func TestOccultationGeoJSONTimeScale(t *testing.T) {
antares := moon.StarCoordinate{
ID: "Antares", RA: 247.3516666666667, Dec: -26.431944444444444,
Epoch: time.Date(2000, time.January, 1, 12, 0, 0, 0, time.UTC),
Frame: moon.CoordinateFrameJ2000,
ProperMotionRACosDecMasPerYear: -10,
ProperMotionDecMasPerYear: -20,
ParallaxMas: 24,
}
starStart := time.Date(2026, time.February, 11, 0, 0, 0, 0, time.UTC)
starPaths, err := moon.FindStarOccultationPaths(starStart, starStart.Add(24*time.Hour), antares,
moon.OccultationPathOptions{Step: 5 * time.Minute, TargetSpacingKM: 200})
if err != nil || len(starPaths) != 1 {
t.Fatalf("star paths=%d err=%v", len(starPaths), err)
}
planetStart := time.Date(2024, time.September, 5, 0, 0, 0, 0, time.UTC)
planetPaths, err := moon.FindPlanetOccultationPaths(planetStart, planetStart.AddDate(0, 0, 1),
moon.OccultationVenus, moon.OccultationPathOptions{})
if err != nil || len(planetPaths) != 1 {
t.Fatalf("planet paths=%d err=%v", len(planetPaths), err)
}
utc := time.UTC
cst := time.FixedZone("CST", 8*3600)
for _, testCase := range []struct {
name string
marshal func(geojson.TimeMarkerOptions) ([]byte, error)
}{
{"star", func(options geojson.TimeMarkerOptions) ([]byte, error) {
return geojson.MarshalStarOccultationWithTimeMarkers(starPaths[0], options)
}},
{"planet", func(options geojson.TimeMarkerOptions) ([]byte, error) {
return geojson.MarshalPlanetOccultationWithTimeMarkers(planetPaths[0], options)
}},
} {
utcDoc, err := testCase.marshal(geojson.TimeMarkerOptions{Step: time.Hour})
if err != nil {
t.Fatalf("%s: UTC export: %v", testCase.name, err)
}
ut1Doc, err := testCase.marshal(geojson.TimeMarkerOptions{Step: time.Hour, TimeScale: astro.TimeScaleUT1})
if err != nil {
t.Fatalf("%s: UT1 export: %v", testCase.name, err)
}
if _, err := testCase.marshal(geojson.TimeMarkerOptions{
Step: time.Hour, TimeScale: astro.TimeScaleUT1, Location: cst,
}); err == nil {
t.Errorf("%s: UT1 with a non-UTC location must fail", testCase.name)
}
var utcHeader, ut1Header struct {
TimeScale string `json:"time_scale"`
}
if err := json.Unmarshal(utcDoc, &utcHeader); err != nil {
t.Fatal(err)
}
if err := json.Unmarshal(ut1Doc, &ut1Header); err != nil {
t.Fatal(err)
}
if utcHeader.TimeScale != "" {
t.Errorf("%s: UTC export carries time_scale=%q", testCase.name, utcHeader.TimeScale)
}
if ut1Header.TimeScale != "UT1" {
t.Errorf("%s: UT1 export time_scale=%q", testCase.name, ut1Header.TimeScale)
}
if !reflect.DeepEqual(decodeGeometryOnly(t, utcDoc), decodeGeometryOnly(t, ut1Doc)) {
t.Errorf("%s: switching to UT1 changed geometry", testCase.name)
}
utcGreatest := featureTimeProperty(t, utcDoc, "greatest")
ut1Greatest := featureTimeProperty(t, ut1Doc, "greatest")
shift := ut1Greatest.Sub(utcGreatest).Seconds()
if math.Abs(shift-astro.DUT1(utcGreatest)) > 1e-3 {
t.Errorf("%s: greatest time shifted %.6f s, want DUT1 %.6f s",
testCase.name, shift, astro.DUT1(utcGreatest))
}
}
_ = utc
}
func featureTimeProperty(t *testing.T, data []byte, role string) time.Time {
t.Helper()
var collection struct {
Features []struct {
Properties map[string]interface{} `json:"properties"`
} `json:"features"`
}
if err := json.Unmarshal(data, &collection); err != nil {
t.Fatal(err)
}
for _, feature := range collection.Features {
if feature.Properties["role"] != role {
continue
}
text, _ := feature.Properties["time"].(string)
parsed, err := time.Parse(time.RFC3339Nano, text)
if err != nil {
t.Fatalf("role %s time=%q: %v", role, text, err)
}
return parsed
}
t.Fatalf("role %s not found", role)
return time.Time{}
}
+2 -2
View File
@@ -20,11 +20,11 @@ import (
// exercising antimeridian, polar and non-central topology in normal tests.
func TestSolarEclipseP2SarosGeoJSONSamples(t *testing.T) {
const sarosDays = 6585.321314
seed := basic.JDECalc(2024, 4, 8)
seed := basic.JDCalc(2024, 4, 8)
for _, familyIndex := range []int{-28, -21, -14, -7, 0, 7, 14, 21, 28} {
familyIndex := familyIndex
t.Run("saros-"+formatP2SignedIndex(familyIndex), func(t *testing.T) {
date := basic.JDE2DateByZone(seed+float64(familyIndex)*sarosDays, time.UTC, false)
date := basic.JD2DateByZone(seed+float64(familyIndex)*sarosDays, time.UTC, false)
partial, ok := eclipse.SolarEclipsePartialFootprints(date, eclipse.SolarEclipsePartialFootprintOptions{
Step: 10 * time.Minute, BoundaryPoints: 96, CentralShadowStep: 5 * time.Minute,
DisableRiseSet: true,
@@ -21,8 +21,8 @@ func TestSolarEclipse20120521HasCentralBandHorizonClosure(t *testing.T) {
t.Fatalf("central-limit horizon closures=%d, want start and end", len(partial.CentralBandHorizonClosures))
}
expectedRoots := [2][2][]float64{
{{109.6236411, 19.9359003}, {107.7419895, 22.3910846}},
{{-100.0879481, 34.1224726}, {-102.2069471, 31.7284052}},
{{109.6259942, 19.9359024}, {107.7443439, 22.3910851}},
{{-100.0855931, 34.1224725}, {-102.2045906, 31.7284072}},
}
for closureIndex, closure := range partial.CentralBandHorizonClosures {
if len(closure) < 2 {
@@ -153,8 +153,8 @@ func assertSolarPathMaximumEdgeKM(
func TestSolarEclipse20120521HorizonClosuresAreStableAcrossSampling(t *testing.T) {
date := time.Date(2012, time.May, 21, 0, 0, 0, 0, time.UTC)
expectedRoots := [2][2][]float64{
{{109.6236411, 19.9359003}, {107.7419895, 22.3910846}},
{{-100.0879481, 34.1224726}, {-102.2069471, 31.7284052}},
{{109.6259942, 19.9359024}, {107.7443439, 22.3910851}},
{{-100.0855931, 34.1224725}, {-102.2045906, 31.7284072}},
}
for _, step := range []time.Duration{time.Minute, 5 * time.Minute, 10 * time.Minute} {
partial, ok := eclipse.SolarEclipsePartialFootprints(date, eclipse.SolarEclipsePartialFootprintOptions{
@@ -167,9 +167,9 @@ func centralShadowBoundaryLines(t *testing.T, feature decodedFeature) [][][2]flo
}
func centralShadowSubsolarPoint(value time.Time) (float64, float64) {
ttJDE := basic.TD2UT(basic.Date2JDE(value.UTC()), true)
ttJDE := basic.UTC2TT(basic.Date2JD(value.UTC()))
ra, dec := basic.HSunApparentRaDec(ttJDE)
utJDE := basic.TD2UT(ttJDE, false)
utJDE := basic.TT2UTC(ttJDE)
longitude := ra - basic.ApparentSiderealTime(utJDE)*15
for longitude > 180 {
longitude -= 360
@@ -358,9 +358,11 @@ func TestSolarEclipseSampledBandEdgeFollowsGreatestHorizonCurve(t *testing.T) {
}
})
}
// 采样带是少数情形:15 个夹具里只有 4 个走这条断言。若夹具筛选或"权威带"来源变化,
// 这些用例会退化成一堆 skip 而不是失败,所以钉住覆盖数下限。
if exercised < 4 {
// 采样带是少数情形:15 个夹具里只剩 3 个走这条断言。若夹具筛选或"权威带"来源变化,
// 这些用例会退化成一堆 skip 而不是失败,所以钉住覆盖数下限。4862-09-28 原来在这 4 个里,
// 它的第四个闭包根过去被三种子启发式漏掉;扫描式枚举解出后该事件成为解析带(带端太阳
// 高度 +0.004°,与 1136-06-01 的 +0.005° 同量级,即带端确实由地平线封口)。
if exercised < 3 {
t.Fatalf("sampled-band assertion exercised by %d fixtures (%d analytic skips); the fixture filter or the band source narrowed silently", exercised, skipped)
}
t.Logf("sampled-band edge assertion exercised by %d fixtures, %d analytic skips", exercised, skipped)
+121
View File
@@ -0,0 +1,121 @@
package geojson_test
import (
"bytes"
"reflect"
"sort"
"testing"
"time"
"b612.me/astro/eclipse"
"b612.me/astro/geojson"
)
// 契约:SolarEclipseOptions.SkipRoles 只裁掉列出的 role、默认输出不变、全部裁光时报错;TimeMarkers 与旧入口等价。
func solarEclipseOptionsFixture(t *testing.T) (eclipse.SolarEclipsePartialFootprintsInfo, eclipse.SolarEclipsePath) {
t.Helper()
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: 24,
})
if !ok {
t.Fatal("expected the 2024-04-08 partial phase")
}
central, ok := eclipse.SolarEclipseCentralPath(date, eclipse.SolarEclipsePathOptions{
Step: time.Minute, TargetSpacingKM: 500,
})
if !ok {
t.Fatal("expected the 2024-04-08 central path")
}
return partial, central
}
func solarRoleCounts(collection decodedCollection) map[string]int {
counts := map[string]int{}
for _, feature := range collection.Features {
role, _ := feature.Properties["role"].(string)
counts[role]++
}
return counts
}
func TestMarshalSolarEclipseSkipRoles(t *testing.T) {
partial, central := solarEclipseOptionsFixture(t)
baseline, err := geojson.MarshalSolarEclipseWithOptions(partial, &central, geojson.SolarEclipseOptions{})
if err != nil {
t.Fatalf("MarshalSolarEclipseWithOptions: %v", err)
}
legacy, err := geojson.MarshalSolarEclipse(partial, &central)
if err != nil {
t.Fatalf("MarshalSolarEclipse: %v", err)
}
if !bytes.Equal(baseline, legacy) {
t.Fatal("零值 SolarEclipseOptions 应等价于 MarshalSolarEclipse")
}
full := solarRoleCounts(decodeCollection(t, baseline))
if full["partial-footprint"] == 0 {
t.Fatalf("默认输出应含瞬时半影轮廓:%v", full)
}
skip := []string{"partial-footprint", "partial-band"}
data, err := geojson.MarshalSolarEclipseWithOptions(partial, &central, geojson.SolarEclipseOptions{SkipRoles: skip})
if err != nil {
t.Fatalf("MarshalSolarEclipseWithOptions: %v", err)
}
filtered := solarRoleCounts(decodeCollection(t, data))
want := map[string]int{}
for role, count := range full {
want[role] = count
}
for _, role := range skip {
if filtered[role] != 0 {
t.Fatalf("被跳过的 %s 仍输出 %d 个", role, filtered[role])
}
delete(want, role)
}
if !reflect.DeepEqual(filtered, want) {
t.Fatalf("跳过 %v 后其余图层被改动:got %v want %v", skip, filtered, want)
}
all := make([]string, 0, len(full))
for role := range full {
all = append(all, role)
}
sort.Strings(all)
if _, err := geojson.MarshalSolarEclipseWithOptions(partial, nil, geojson.SolarEclipseOptions{SkipRoles: all}); err == nil {
t.Fatal("全部 role 都跳过时应返回错误")
}
}
func TestMarshalSolarEclipseOptionsTimeMarkers(t *testing.T) {
partial, central := solarEclipseOptionsFixture(t)
markers := geojson.TimeMarkerOptions{Step: 30 * time.Minute}
legacy, err := geojson.MarshalSolarEclipseWithTimeMarkers(partial, &central, markers)
if err != nil {
t.Fatalf("MarshalSolarEclipseWithTimeMarkers: %v", err)
}
current, err := geojson.MarshalSolarEclipseWithOptions(partial, &central, geojson.SolarEclipseOptions{TimeMarkers: &markers})
if err != nil {
t.Fatalf("MarshalSolarEclipseWithOptions: %v", err)
}
if !bytes.Equal(legacy, current) {
t.Fatal("TimeMarkers 字段与 MarshalSolarEclipseWithTimeMarkers 不等价")
}
both, err := geojson.MarshalSolarEclipseWithOptions(partial, &central, geojson.SolarEclipseOptions{
TimeMarkers: &markers,
SkipRoles: []string{"partial-footprint"},
})
if err != nil {
t.Fatalf("MarshalSolarEclipseWithOptions: %v", err)
}
counts := solarRoleCounts(decodeCollection(t, both))
if counts["time-marker"] == 0 {
t.Fatalf("时间标记被误删:%v", counts)
}
if counts["partial-footprint"] != 0 {
t.Fatalf("瞬时半影轮廓未丢弃:%v", counts)
}
}
+64
View File
@@ -0,0 +1,64 @@
package geojson_test
import (
"encoding/json"
"math"
"testing"
"time"
"b612.me/astro/eclipse"
"b612.me/astro/geojson"
)
// 契约:等时线整条支路共用同一时刻,跨反经线时必须照样在两侧补出接缝点——否则换日线处会留下缺口。
func TestSolarEclipseIsochroneMeetsAtAntimeridian(t *testing.T) {
location := time.FixedZone("UTC+8", 8*3600)
date := time.Date(2009, time.July, 22, 0, 0, 0, 0, location)
level := time.Date(2009, time.July, 22, 11, 30, 0, 0, location) // 03:30 UT,正好跨反经线
partial, ok := eclipse.SolarEclipsePartialFootprintsNASABulletinSplitK(date,
eclipse.SolarEclipsePartialFootprintOptions{
Step: 20 * time.Minute,
BoundaryPoints: 24,
DisableRiseSet: true,
GreatestTimeValues: []time.Time{level},
})
if !ok {
t.Fatal("expected the 2009-07-22 partial phase")
}
data, err := geojson.MarshalSolarEclipse(partial, nil)
if err != nil {
t.Fatalf("MarshalSolarEclipse: %v", err)
}
collection := decodeCollection(t, data)
lines := featuresWithRole(collection, "greatest-time-line")
if len(lines) != 1 {
t.Fatalf("greatest-time-line features=%d, want 1", len(lines))
}
var coordinates [][][]float64
if err := json.Unmarshal(lines[0].Geometry.Coordinates, &coordinates); err != nil {
t.Fatalf("decode coordinates: %v", err)
}
if len(coordinates) != 2 {
t.Fatalf("跨反经线的支路应拆成 2 段,得到 %d", len(coordinates))
}
east, west := coordinates[0], coordinates[1]
if len(east) < 2 || len(west) < 2 {
t.Fatal("段点数过少")
}
if last := east[len(east)-1]; math.Abs(last[0]+180) > 1e-9 {
t.Fatalf("东段末点经度 %v,应为 −180", last[0])
}
if first := west[0]; math.Abs(first[0]-180) > 1e-9 {
t.Fatalf("西段首点经度 %v,应为 +180", first[0])
}
if east[len(east)-1][1] != west[0][1] {
t.Fatalf("接缝两侧纬度不同:%v vs %v", east[len(east)-1][1], west[0][1])
}
for index, segment := range coordinates {
for point := 1; point < len(segment); point++ {
if math.Abs(segment[point][0]-segment[point-1][0]) > 180 {
t.Fatalf("段 %d 内仍有跨 180° 的边:%v → %v", index, segment[point-1], segment[point])
}
}
}
}
+1 -1
View File
@@ -52,7 +52,7 @@ func MarshalSolarEclipseShadowInstant(
regionProperties["source_boundary_closed"] = false
regionProperties["geometry_role"] = "horizon-closed-region"
regionProperties["closure"] = solarHorizonClosureProperties(
instant.Time, solarHorizonClosureExact(instant.Boundaries, instant.HorizonEnds),
instant.Time, instant.Time, solarHorizonClosureExact(instant.Boundaries, instant.HorizonEnds),
)
ring, boundary := solarShadowFootprintHorizonRing(
instant.Time, instant.Boundaries, instant.HorizonEnds, curve,
+5 -4
View File
@@ -56,7 +56,7 @@ func appendSolarHorizonClosedShadowFootprint(
regionProperties["source_boundary_closed"] = false
regionProperties["geometry_role"] = "horizon-closed-region"
regionProperties["closure"] = solarHorizonClosureProperties(
footprint.Time, solarHorizonClosureExact(footprint.Boundaries, footprint.HorizonEnds),
footprint.Time, footprint.Time, solarHorizonClosureExact(footprint.Boundaries, footprint.HorizonEnds),
)
regionProperties["interp_signature"] = solarShadowFootprintSignature(
footprint.Boundaries, false, eclipsecore.SolarEclipseShadowUmbra,
@@ -229,12 +229,13 @@ func solarHorizonClosureExact(
}
// solarHorizonClosureProperties 声明式闭合弧;exact 为假表示缺精确擦地点、按采样端点近似闭合。
func solarHorizonClosureProperties(value time.Time, exact bool) map[string]interface{} {
subsolar := solarSubsolarPoint(value)
// 月下点按几何时刻算,time 属性写输出时标的时刻。
func solarHorizonClosureProperties(geometryTime, labelTime time.Time, exact bool) map[string]interface{} {
subsolar := solarSubsolarPoint(geometryTime)
return map[string]interface{}{
"kind": "horizon",
"exact": exact,
"time": formatTime(value),
"time": formatTime(labelTime),
"subsolar": []float64{subsolar.Longitude, subsolar.Latitude},
}
}
+153
View File
@@ -0,0 +1,153 @@
package geojson_test
import (
"encoding/json"
"reflect"
"testing"
"time"
"b612.me/astro"
"b612.me/astro/eclipse"
"b612.me/astro/geojson"
)
// 切换输出时标只能改时刻文字,不能改几何:UT1 读数一旦被当成民用时刻喂给几何,
// 月下点、地平闭合弧与食甚点都会平移。
// Switching the output time scale may only rewrite instants, never geometry.
func TestUT1ExportKeepsGeometry(t *testing.T) {
date := time.Date(2024, time.April, 8, 0, 0, 0, 0, time.UTC)
for _, testCase := range []struct {
name string
build func(t *testing.T) ([]byte, []byte)
}{
{"solar partial footprints", func(t *testing.T) ([]byte, []byte) {
partial, ok := eclipse.SolarEclipsePartialFootprints(date, eclipse.SolarEclipsePartialFootprintOptions{
Step: 20 * time.Minute, BoundaryPoints: 36,
})
if !ok {
t.Fatal("expected solar partial footprints")
}
utc, err := geojson.MarshalSolarEclipseWithTimeMarkers(partial, nil, geojson.TimeMarkerOptions{Step: time.Hour})
if err != nil {
t.Fatal(err)
}
ut1, err := geojson.MarshalSolarEclipseWithTimeMarkers(partial, nil, geojson.TimeMarkerOptions{
Step: time.Hour, TimeScale: astro.TimeScaleUT1,
})
if err != nil {
t.Fatal(err)
}
return utc, ut1
}},
// 1995-10-24 是 central_two_limits 事件:极限带几何按时刻插值,最容易再把 UT1 读数当民用时刻。
{"solar central two limits", func(t *testing.T) ([]byte, []byte) {
date := time.Date(1995, time.October, 24, 0, 0, 0, 0, time.UTC)
partial, ok := eclipse.SolarEclipsePartialFootprints(date, eclipse.SolarEclipsePartialFootprintOptions{
Step: 20 * time.Minute, BoundaryPoints: 36,
})
if !ok {
t.Fatal("expected 1995-10-24 partial footprints")
}
central, ok := eclipse.SolarEclipseCentralPath(date, eclipse.SolarEclipsePathOptions{Step: 10 * time.Minute})
if !ok {
t.Fatal("expected 1995-10-24 central path")
}
utc, err := geojson.MarshalSolarEclipseWithTimeMarkers(partial, &central, geojson.TimeMarkerOptions{Step: time.Hour})
if err != nil {
t.Fatal(err)
}
ut1, err := geojson.MarshalSolarEclipseWithTimeMarkers(partial, &central, geojson.TimeMarkerOptions{
Step: time.Hour, TimeScale: astro.TimeScaleUT1,
})
if err != nil {
t.Fatal(err)
}
return utc, ut1
}},
{"lunar eclipse", func(t *testing.T) ([]byte, []byte) {
info, ok := eclipse.LunarEclipseOnDate(time.Date(2026, 3, 3, 0, 0, 0, 0, time.UTC))
if !ok {
t.Fatal("expected lunar eclipse")
}
utc, err := geojson.MarshalLunarEclipseWithTimeMarkers(info, 24, geojson.TimeMarkerOptions{Step: time.Hour})
if err != nil {
t.Fatal(err)
}
ut1, err := geojson.MarshalLunarEclipseWithTimeMarkers(info, 24, geojson.TimeMarkerOptions{
Step: time.Hour, TimeScale: astro.TimeScaleUT1,
})
if err != nil {
t.Fatal(err)
}
return utc, ut1
}},
} {
utc, ut1 := testCase.build(t)
utcValue := decodeGeometryOnly(t, utc)
ut1Value := decodeGeometryOnly(t, ut1)
if !reflect.DeepEqual(utcValue, ut1Value) {
t.Errorf("%s: UT1 export changed geometry", testCase.name)
}
var collection struct {
TimeScale string `json:"time_scale"`
}
if err := json.Unmarshal(ut1, &collection); err != nil {
t.Fatal(err)
}
if collection.TimeScale != "UT1" {
t.Errorf("%s: UT1 export time_scale=%q", testCase.name, collection.TimeScale)
}
if testCase.name == "lunar eclipse" {
utcTime := featureTimeProperty(t, utc, "visible-at-p1")
ut1Time := featureTimeProperty(t, ut1, "visible-at-p1")
if utcTime.Equal(ut1Time) {
t.Errorf("lunar eclipse P1 time was not converted to UT1: %s", utcTime)
}
if delta := ut1Time.Sub(utcTime); delta < -time.Second || delta > time.Second {
t.Errorf("lunar eclipse P1 UTC/UT1 delta=%s, want sub-second", delta)
}
}
}
}
// decodeGeometryOnly 去掉只随时标变化的成员,保留其余结构用于逐项比较。
func decodeGeometryOnly(t *testing.T, data []byte) interface{} {
t.Helper()
var value interface{}
if err := json.Unmarshal(data, &value); err != nil {
t.Fatal(err)
}
return stripTimeScaleMembers(value)
}
func stripTimeScaleMembers(value interface{}) interface{} {
switch typed := value.(type) {
case map[string]interface{}:
// 时间标记的取点跟着所标时刻走(UT1 整点是另一个物理时刻),几何不变性对它不适用。
if properties, ok := typed["properties"].(map[string]interface{}); ok {
if role, _ := properties["role"].(string); role == "time-marker" {
return nil
}
}
for _, key := range []string{"time", "times", "label", "time_scale"} {
delete(typed, key)
}
for key, item := range typed {
// 时间属性名随结构而变(penumbral_start、partial_end_on_earth 之类),按值判定更稳。
if text, ok := item.(string); ok {
if _, err := time.Parse(time.RFC3339Nano, text); err == nil {
delete(typed, key)
continue
}
}
typed[key] = stripTimeScaleMembers(item)
}
return typed
case []interface{}:
for index, item := range typed {
typed[index] = stripTimeScaleMembers(item)
}
return typed
}
return value
}