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
+203
View File
@@ -0,0 +1,203 @@
package astro_test
import (
"fmt"
"reflect"
"strings"
"testing"
"time"
"b612.me/astro"
"b612.me/astro/basic"
"b612.me/astro/eclipse"
"b612.me/astro/moon"
)
// UT1 转换器的字段级契约(反射穷举,而不是逐字段手写):
// 1. 结果里每一个非零 time.Time 都必须等于该时刻的 UT1 读数,零值原样保留;
// 2. 含时刻的切片必须整体复制,不能与输入共享底层数组(改输出会改到调用方原数据)。
//
// Field-level contract of the UT1 converters, enumerated by reflection rather than by hand:
// every non-zero time must move to its UT1 reading, zero instants stay zero, and every
// time-bearing slice must be deep-copied.
func TestUT1ConvertersCoverEveryTimeField(t *testing.T) {
cases := []struct {
name string
input interface{}
convert func(interface{}) interface{}
}{
{"SolarEclipseInfo", synthetic(eclipse.SolarEclipseInfo{}), func(v interface{}) interface{} {
return eclipse.SolarEclipseInfoInUT1(v.(eclipse.SolarEclipseInfo))
}},
{"SolarEclipsePartialFootprintsInfo", synthetic(eclipse.SolarEclipsePartialFootprintsInfo{}), func(v interface{}) interface{} {
return eclipse.SolarEclipsePartialFootprintsInUT1(v.(eclipse.SolarEclipsePartialFootprintsInfo))
}},
{"SolarEclipsePath", synthetic(eclipse.SolarEclipsePath{}), func(v interface{}) interface{} {
return eclipse.SolarEclipsePathInUT1(v.(eclipse.SolarEclipsePath))
}},
{"LunarEclipseInfo", synthetic(eclipse.LunarEclipseInfo{}), func(v interface{}) interface{} {
return eclipse.LunarEclipseInfoInUT1(v.(eclipse.LunarEclipseInfo))
}},
{"LocalSolarEclipseInfo", synthetic(eclipse.LocalSolarEclipseInfo{}), func(v interface{}) interface{} {
return eclipse.LocalSolarEclipseInfoInUT1(v.(eclipse.LocalSolarEclipseInfo))
}},
{"SolarEclipseGeocentricPanel", synthetic(eclipse.SolarEclipseGeocentricPanel{}), func(v interface{}) interface{} {
return eclipse.SolarEclipseGeocentricPanelInUT1(v.(eclipse.SolarEclipseGeocentricPanel))
}},
{"StarOccultationInfo", synthetic(basic.StarOccultationInfo{}), func(v interface{}) interface{} {
return moon.StarOccultationInfoInUT1(v.(basic.StarOccultationInfo))
}},
{"StarOccultationPath", synthetic(basic.StarOccultationPath{}), func(v interface{}) interface{} {
return moon.StarOccultationPathInUT1(v.(basic.StarOccultationPath))
}},
{"PlanetOccultationInfo", synthetic(basic.PlanetOccultationInfo{}), func(v interface{}) interface{} {
return moon.PlanetOccultationInfoInUT1(v.(basic.PlanetOccultationInfo))
}},
{"PlanetOccultationPath", synthetic(basic.PlanetOccultationPath{}), func(v interface{}) interface{} {
return moon.PlanetOccultationPathInUT1(v.(basic.PlanetOccultationPath))
}},
}
for _, testCase := range cases {
output := testCase.convert(testCase.input)
problems := checkUT1Coverage(reflect.ValueOf(testCase.input), reflect.ValueOf(output), "")
timeFields := countTimeFields(reflect.TypeOf(testCase.input), 0)
if timeFields == 0 {
t.Errorf("%s: synthetic value carries no time field, test would be vacuous", testCase.name)
}
for _, problem := range problems {
t.Errorf("%s: %s", testCase.name, problem)
}
}
}
var timeType = reflect.TypeOf(time.Time{})
// synthetic 递归填充每个字段,time.Time 取固定非零时刻,切片给两个元素。
func synthetic(prototype interface{}) interface{} {
value := reflect.New(reflect.TypeOf(prototype))
fillSynthetic(value.Elem(), 0)
return value.Elem().Interface()
}
func fillSynthetic(value reflect.Value, depth int) {
if depth > 8 || !value.CanSet() {
return
}
if value.Type() == timeType {
value.Set(reflect.ValueOf(time.Date(2026, 3, 3, 11, 0, 0, 0, time.UTC)))
return
}
switch value.Kind() {
case reflect.Struct:
for index := 0; index < value.NumField(); index++ {
fillSynthetic(value.Field(index), depth+1)
}
case reflect.Slice:
value.Set(reflect.MakeSlice(value.Type(), 2, 2))
for index := 0; index < value.Len(); index++ {
fillSynthetic(value.Index(index), depth+1)
}
case reflect.Ptr:
value.Set(reflect.New(value.Type().Elem()))
fillSynthetic(value.Elem(), depth+1)
case reflect.Float32, reflect.Float64:
value.SetFloat(1)
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
value.SetInt(1)
case reflect.String:
value.SetString("synthetic")
case reflect.Bool:
value.SetBool(true)
}
}
// checkUT1Coverage 并行遍历输入与输出,检查时刻换算与切片别名。
func checkUT1Coverage(input, output reflect.Value, path string) []string {
if input.Type() != output.Type() {
return []string{path + ": type mismatch"}
}
if input.Type() == timeType {
at := input.Interface().(time.Time)
got := output.Interface().(time.Time)
if at.IsZero() {
if !got.IsZero() {
return []string{path + ": zero instant became " + got.String()}
}
return nil
}
want := astro.LabelIn(astro.TimeScaleUT1, at)
if !got.Equal(want) {
return []string{fmt.Sprintf("%s: got %v, want UT1 %v", path, got, want)}
}
return nil
}
switch input.Kind() {
case reflect.Struct:
var problems []string
for index := 0; index < input.NumField(); index++ {
problems = append(problems, checkUT1Coverage(
input.Field(index), output.Field(index),
path+"."+input.Type().Field(index).Name,
)...)
}
return problems
case reflect.Slice:
var problems []string
if containsTime(input.Type().Elem(), 0) && input.Len() > 0 && output.Len() > 0 &&
input.Pointer() == output.Pointer() {
problems = append(problems, path+": output shares the input slice (aliasing)")
}
for index := 0; index < input.Len() && index < output.Len(); index++ {
problems = append(problems, checkUT1Coverage(
input.Index(index), output.Index(index), fmt.Sprintf("%s[%d]", path, index))...)
}
return problems
case reflect.Ptr:
if input.IsNil() || output.IsNil() {
return nil
}
return checkUT1Coverage(input.Elem(), output.Elem(), path)
}
return nil
}
func containsTime(value reflect.Type, depth int) bool {
if value == timeType {
return true
}
if depth > 8 {
return false
}
switch value.Kind() {
case reflect.Slice, reflect.Array, reflect.Ptr:
return containsTime(value.Elem(), depth+1)
case reflect.Struct:
for index := 0; index < value.NumField(); index++ {
if containsTime(value.Field(index).Type, depth+1) {
return true
}
}
}
return false
}
func countTimeFields(value reflect.Type, depth int) int {
if value == timeType {
return 1
}
if depth > 8 {
return 0
}
count := 0
switch value.Kind() {
case reflect.Slice, reflect.Array, reflect.Ptr:
count += countTimeFields(value.Elem(), depth+1)
case reflect.Struct:
for index := 0; index < value.NumField(); index++ {
count += countTimeFields(value.Field(index).Type, depth+1)
}
}
return count
}
var _ = strings.TrimSpace