feat: 完善时标与天象几何计算并扩展输出接口
- 新增时标、ΔT 模型、质心时间与 UT1 支持 - 改进日月食、月掩、行星事件及路径边界计算 - 完善恒星三维自行与动态距离传播 - 扩展 SVG、GeoJSON、KML 输出与底层距离换算工具 - 整理中英文手册、示例资源及回归测试
This commit is contained in:
+780
-54
File diff suppressed because it is too large
Load Diff
@@ -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 = ¢ral
|
||||
}
|
||||
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
@@ -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
@@ -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 时区应报错")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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{}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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, ¢ral, geojson.SolarEclipseOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("MarshalSolarEclipseWithOptions: %v", err)
|
||||
}
|
||||
legacy, err := geojson.MarshalSolarEclipse(partial, ¢ral)
|
||||
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, ¢ral, 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, ¢ral, markers)
|
||||
if err != nil {
|
||||
t.Fatalf("MarshalSolarEclipseWithTimeMarkers: %v", err)
|
||||
}
|
||||
current, err := geojson.MarshalSolarEclipseWithOptions(partial, ¢ral, 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, ¢ral, 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)
|
||||
}
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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, ¢ral, geojson.TimeMarkerOptions{Step: time.Hour})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ut1, err := geojson.MarshalSolarEclipseWithTimeMarkers(partial, ¢ral, 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
|
||||
}
|
||||
Reference in New Issue
Block a user