feat: 引入可切换星历模型并扩展天象观测链路

- 新增半解析、旧模型、自定义与多源堆叠星历实例,提供 Context、Instance、SPK 读取及状态与视位置计算
- 统一太阳、月球和行星实例接口,扩展黄经穿越、留、相位、最近角距、偕日可见、任意高度升落、轨道节点及凌日等天象查询
- 完善日月食、月掩与凌日的月缘剖面、有限盘面接触、站心修正、局地可见性、全球路径和地图输出
- 新增 SVG、GeoJSON、KML 天象输出及凌日、月掩和月缘地形相关缓存与几何求解
- 新增恒星星表导入与查询,支持 CSV、Hipparcos、BSC5、Stellarium 等格式及名称、交叉标识、锥面检索和星等过滤
- 补充 IAU 2006、地球定向、折射消光、大气观测、轨道传播及半解析行星/月球数据支持
- 将默认未来民用时标切换为 TimeScaleLeapHour,更新 2027–2400 农历速查表、中文日期解析和时区边界处理
- 增加事件基线、跨模型回归、几何边界、月缘地形和输出契约测试,完善中英文手册、示例资源、NOTICE 与忽略规则
This commit is contained in:
2026-10-15 23:54:15 +08:00
parent 16c62a97d5
commit 044f2f4381
674 changed files with 62983 additions and 8396 deletions
+451
View File
@@ -0,0 +1,451 @@
package geojson
import (
"fmt"
"math"
"time"
"b612.me/astro/basic"
"b612.me/astro/internal/geodata"
"b612.me/astro/mercury"
"b612.me/astro/venus"
)
const planetTransitEvent = "planet-transit"
// 标准大气,与 basic 站心凌日的视地平判定及凌日 SVG 一致。
const (
planetTransitRefractionPressureHPa = 1010.0
planetTransitRefractionTemperatureC = 10.0
minimumPlanetTransitIsochroneStep = time.Second
)
// PlanetTransitOptions 是凌日 GeoJSON 导出选项;所有要素都被丢弃时导出失败。
// PlanetTransitOptions are the planetary-transit GeoJSON export options; the export fails when every
// Feature is dropped.
type PlanetTransitOptions struct {
// BoundaryPoints 是地平线采样点数,同时决定分区的经度列数 max(360, BoundaryPoints)(上限 720);
// 小于等于零时使用 360,其他值限制在 [12, 1440]。
// BoundaryPoints is the horizon sample count and also sets the region longitude columns to
// max(360, BoundaryPoints), capped at 720. Values <= 0 use 360; others are clamped to [12, 1440].
BoundaryPoints int
// IsochroneStep 为正时输出一触到四触的站心接触等时线(掠凌没有二触、三触),至少一秒;零值不输出。
// A positive IsochroneStep adds topocentric C1-C4 isochrones (no C2/C3 for a grazing transit) and must be
// at least one second. The zero value adds none.
IsochroneStep time.Duration
// TimeMarkers 非空时沿一触到四触的太阳直射点轨迹追加时间标记 Point 要素。
// TimeMarkers adds time-marker Point Features along the subsolar track from C1 through C4 when non-nil.
TimeMarkers *TimeMarkerOptions
// SkipRoles 列出不写进输出的 role;分区三个 role 都跳过时不做分区计算。
// SkipRoles lists roles to leave out; skipping all three region roles skips the region computation.
SkipRoles []string
}
// planetTransitInput 是水星、金星两种 TransitInfo 的公共视图。
type planetTransitInput struct {
planet string
planetIndex int
start time.Time
c2 time.Time
greatest time.Time
c3 time.Time
end time.Time
hasInternal bool
separation float64
sunRadius float64
planetRad float64
}
// MarshalMercuryTransit 将水星凌日的全球可见分区、一触/四触地平线与可选站心等时线编码为 GeoJSON / encode a Mercury transit as GeoJSON.
func (module *Generator) MarshalMercuryTransit(info mercury.TransitInfo, options PlanetTransitOptions) ([]byte, error) {
if !info.Valid {
return nil, fmt.Errorf("geojson: mercury transit is invalid")
}
return marshalPlanetTransit(planetTransitInput{
planet: "mercury", planetIndex: 1,
start: info.Start, c2: info.InternalStart, greatest: info.Greatest, c3: info.InternalEnd, end: info.End,
hasInternal: info.HasInternal, separation: info.MinimumSeparationArcsec,
sunRadius: info.SunSemidiameterArcsec, planetRad: info.PlanetSemidiameterArcsec,
}, options, module)
}
// MarshalVenusTransit 将金星凌日的全球可见分区、一触/四触地平线与可选站心等时线编码为 GeoJSON / encode a Venus transit as GeoJSON.
func (module *Generator) MarshalVenusTransit(info venus.TransitInfo, options PlanetTransitOptions) ([]byte, error) {
if !info.Valid {
return nil, fmt.Errorf("geojson: venus transit is invalid")
}
return marshalPlanetTransit(planetTransitInput{
planet: "venus", planetIndex: 2,
start: info.Start, c2: info.InternalStart, greatest: info.Greatest, c3: info.InternalEnd, end: info.End,
hasInternal: info.HasInternal, separation: info.MinimumSeparationArcsec,
sunRadius: info.SunSemidiameterArcsec, planetRad: info.PlanetSemidiameterArcsec,
}, options, module)
}
func validatePlanetTransitInput(input planetTransitInput) error {
if input.start.IsZero() || input.greatest.IsZero() || input.end.IsZero() {
return fmt.Errorf("geojson: %s transit contact times are required", input.planet)
}
if !input.hasInternal && (!input.c2.IsZero() || !input.c3.IsZero()) {
return fmt.Errorf("geojson: %s transit cannot have internal contact times without internal contacts", input.planet)
}
ordered := []time.Time{input.start}
if input.hasInternal {
if input.c2.IsZero() || input.c3.IsZero() {
return fmt.Errorf("geojson: %s transit internal contact times are required", input.planet)
}
ordered = append(ordered, input.c2)
}
ordered = append(ordered, input.greatest)
if input.hasInternal {
ordered = append(ordered, input.c3)
}
ordered = append(ordered, input.end)
for index := 1; index < len(ordered); index++ {
if !ordered[index-1].Before(ordered[index]) {
return fmt.Errorf("geojson: %s transit contact times are not strictly ordered", input.planet)
}
}
return nil
}
func marshalPlanetTransit(input planetTransitInput, options PlanetTransitOptions, module *Generator) ([]byte, error) {
markerOptions := options.TimeMarkers
if markerOptions != nil {
if err := validateTimeMarkerOptions(*markerOptions); err != nil {
return nil, err
}
}
if options.IsochroneStep < 0 || (options.IsochroneStep > 0 && options.IsochroneStep < minimumPlanetTransitIsochroneStep) {
return nil, fmt.Errorf("geojson: transit isochrone step must be zero or at least %s", minimumPlanetTransitIsochroneStep)
}
if err := validatePlanetTransitInput(input); err != nil {
return nil, err
}
timeScale, scaleErr := timeScaleForMarkers(markerOptions)
if scaleErr != nil {
return nil, scaleErr
}
// 几何一律用民用时刻,只有写进属性的时刻换时标。
label := func(value time.Time) string {
if value.IsZero() {
return ""
}
return formatTime(basic.LabelIn(timeScale, value.UTC()))
}
boundaryPoints := normalizeLunarBoundaryPoints(options.BoundaryPoints)
longitudePoints := lunarVisibilityLongitudePoints(boundaryPoints, LunarEclipseOptions{})
longitudes := make([]float64, longitudePoints+1)
for index := range longitudes {
longitudes[index] = -180 + 360*float64(index)/float64(longitudePoints)
}
refraction := basic.RefractionFromApparentAltitude(0, planetTransitRefractionPressureHPa, planetTransitRefractionTemperatureC)
base := map[string]interface{}{"planet": input.planet, "has_internal": input.hasInternal}
withBase := func(extra map[string]interface{}) map[string]interface{} {
properties := cloneProperties(base)
for key, value := range extra {
properties[key] = value
}
return properties
}
features := make([]feature, 0, 12)
columnsAt := func(at time.Time) [][]lunarLatitudeInterval {
subsolar := solarSubsolarPoint(at, module)
columns := make([][]lunarLatitudeInterval, len(longitudes))
for index, longitude := range longitudes {
columns[index] = planetTransitSunUpIntervals(subsolar, longitude, refraction)
}
return columns
}
wantEntire := !skippedLunarRole(options.SkipRoles, "transit-entire")
wantSunrise := !skippedLunarRole(options.SkipRoles, "transit-at-sunrise")
wantSunset := !skippedLunarRole(options.SkipRoles, "transit-at-sunset")
wantRegions := wantEntire || wantSunrise || wantSunset
needC1 := wantRegions || !skippedLunarRole(options.SkipRoles, "visible-at-c1")
needGreatest := wantRegions
needC4 := wantRegions || !skippedLunarRole(options.SkipRoles, "visible-at-c4")
var c1Columns, greatestColumns, c4Columns [][]lunarLatitudeInterval
if needC1 {
c1Columns = columnsAt(input.start)
}
if needGreatest {
greatestColumns = columnsAt(input.greatest)
}
if needC4 {
c4Columns = columnsAt(input.end)
}
for _, contact := range []struct {
role, horizonRole string
at time.Time
columns [][]lunarLatitudeInterval
}{
{"visible-at-c1", "c1-horizon", input.start, c1Columns},
{"visible-at-c4", "c4-horizon", input.end, c4Columns},
} {
if !skippedLunarRole(options.SkipRoles, contact.role) {
value, err := planetTransitRegionGeometry(longitudes, contact.columns)
if err != nil {
return nil, fmt.Errorf("geojson: %s: %w", contact.role, err)
}
features = append(features, newFeature(planetTransitEvent, contact.role, value,
withBase(map[string]interface{}{"time": label(contact.at)})))
}
if !skippedLunarRole(options.SkipRoles, contact.horizonRole) {
subsolar := solarSubsolarPoint(contact.at, module)
antisolar := geodata.GeoPoint{Longitude: geodata.NormalizeLongitude(subsolar.Longitude + 180), Latitude: -subsolar.Latitude}
value, err := geoMultiLineGeometry(geodata.SphericalCircle(antisolar, 90-refraction, boundaryPoints), true)
if err != nil {
return nil, fmt.Errorf("geojson: %s: %w", contact.horizonRole, err)
}
features = append(features, newFeature(planetTransitEvent, contact.horizonRole, value,
withBase(map[string]interface{}{"time": label(contact.at)})))
}
}
regionFeatures, err := planetTransitRegionFeatures(longitudes, c1Columns, greatestColumns, c4Columns, options.SkipRoles,
withBase(map[string]interface{}{
"time_start": label(input.start),
"time_end": label(input.end),
"longitude_points": longitudePoints,
}))
if err != nil {
return nil, err
}
features = append(features, regionFeatures...)
if options.IsochroneStep > 0 {
isochrones, isoErr := planetTransitIsochroneFeatures(input, options.IsochroneStep, timeScale, options.SkipRoles, withBase, module)
if isoErr != nil {
return nil, isoErr
}
features = append(features, isochrones...)
}
greatest := solarSubsolarPoint(input.greatest, module)
metadata := withBase(map[string]interface{}{
"start": label(input.start),
"greatest": label(input.greatest),
"end": label(input.end),
"minimum_separation_arcsec": input.separation,
"sun_semidiameter_arcsec": input.sunRadius,
"planet_semidiameter_arcsec": input.planetRad,
})
if input.hasInternal {
metadata["internal_start"], metadata["internal_end"] = label(input.c2), label(input.c3)
}
features, err = appendPointFeature(features, planetTransitEvent, "greatest",
pathSample{Time: basic.LabelIn(timeScale, input.greatest.UTC()), Longitude: greatest.Longitude, Latitude: greatest.Latitude},
metadata)
if err != nil {
return nil, err
}
if markerOptions != nil {
markers, markerErr := planetTransitTimeMarkerSamples(input.start, input.end, timeScale, *markerOptions, module)
if markerErr != nil {
return nil, markerErr
}
features, err = appendTimeMarkerPointFeatures(features, planetTransitEvent, "subsolar-track", markers, markerOptions.Location)
if err != nil {
return nil, err
}
}
return marshalFeatureCollectionWithTimeScale(dropFeaturesByRole(features, options.SkipRoles), timeScale)
}
// planetTransitSunUpIntervals 返回 longitude 子午线上太阳中心在视地平上的纬度区间:
// 地点到直射点的角距小于 90°+折射即可见,即 sinδ·sinφ + cosδ·cosΔλ·cosφ > -sin(折射)。
func planetTransitSunUpIntervals(subsolar geodata.GeoPoint, longitude, refraction float64) []lunarLatitudeInterval {
rad := math.Pi / 180
a := math.Sin(subsolar.Latitude * rad)
b := math.Cos(subsolar.Latitude*rad) * math.Cos((longitude-subsolar.Longitude)*rad)
threshold := -math.Sin(refraction * rad)
visible := func(latitude float64) bool {
return a*math.Sin(latitude*rad)+b*math.Cos(latitude*rad) > threshold
}
boundaries := []float64{-90}
if amplitude := math.Hypot(a, b); amplitude > 0 && math.Abs(threshold/amplitude) < 1 {
// a·sinφ + b·cosφ = R·sin(φ+α),α = atan2(b, a)。
alpha := math.Atan2(b, a) / rad
base := math.Asin(threshold/amplitude) / rad
roots := make([]float64, 0, 2)
for _, value := range []float64{base - alpha, 180 - base - alpha} {
for _, shift := range []float64{-360, 0, 360} {
if root := value + shift; root > -90 && root < 90 {
roots = append(roots, root)
}
}
}
if len(roots) == 2 && roots[0] > roots[1] {
roots[0], roots[1] = roots[1], roots[0]
}
boundaries = append(boundaries, roots...)
}
boundaries = append(boundaries, 90)
intervals := make([]lunarLatitudeInterval, 0, 2)
for index := 0; index+1 < len(boundaries); index++ {
low, high := boundaries[index], boundaries[index+1]
if high-low > 1e-9 && visible((low+high)/2) {
intervals = append(intervals, lunarLatitudeInterval{low: low, high: high})
}
}
return lunarUnionLatitudeIntervals(intervals...)
}
func planetTransitRegionGeometry(longitudes []float64, columns [][]lunarLatitudeInterval) (geometry, error) {
value, err := lunarVisibilityEnvelopeGeometry(longitudes, columns)
if err != nil {
return geometry{}, err
}
return dropDegenerateMultiPolygonRings(value), nil
}
// planetTransitRegionFeatures 按与凌日 SVG 相同的口径分区:全程可见是三刻都在地平上,日出带是四触可见而一触不可见,
// 其余凡有一刻可见的都归入日落带(含凌甚前后可见的极区透镜与两端可见、凌甚不可见的高纬带)。
func planetTransitRegionFeatures(
longitudes []float64,
c1, greatest, c4 [][]lunarLatitudeInterval,
skip []string,
properties map[string]interface{},
) ([]feature, error) {
wantEntire := !skippedLunarRole(skip, "transit-entire")
wantSunrise := !skippedLunarRole(skip, "transit-at-sunrise")
wantSunset := !skippedLunarRole(skip, "transit-at-sunset")
if !wantEntire && !wantSunrise && !wantSunset {
return nil, nil
}
union := func(groups ...[]lunarLatitudeInterval) []lunarLatitudeInterval {
var merged []lunarLatitudeInterval
for _, group := range groups {
merged = append(merged, group...)
}
return lunarUnionLatitudeIntervals(merged...)
}
entire := make([][]lunarLatitudeInterval, len(longitudes))
sunrise := make([][]lunarLatitudeInterval, len(longitudes))
sunset := make([][]lunarLatitudeInterval, len(longitudes))
for index := range longitudes {
bothEnds := lunarIntersectLatitudeIntervals(c1[index], c4[index])
entire[index] = lunarIntersectLatitudeIntervals(bothEnds, greatest[index])
sunrise[index] = lunarSubtractLatitudeIntervals(c4[index], c1[index])
sunset[index] = lunarSubtractLatitudeIntervals(union(c1[index], c4[index], greatest[index]),
union(entire[index], sunrise[index]))
}
features := make([]feature, 0, 3)
for _, region := range []struct {
role string
want bool
columns [][]lunarLatitudeInterval
}{
{"transit-entire", wantEntire, entire},
{"transit-at-sunrise", wantSunrise, sunrise},
{"transit-at-sunset", wantSunset, sunset},
} {
if !region.want {
continue
}
value, err := planetTransitRegionGeometry(longitudes, region.columns)
if err != nil {
return nil, fmt.Errorf("geojson: %s: %w", region.role, err)
}
features = append(features, newFeature(planetTransitEvent, region.role, value, cloneProperties(properties)))
}
return features, nil
}
// planetTransitIsochroneFeatures 把每个接触的站心等时线编码为一条带 times 的 MultiLineString:
// 每段是一条支路,times 与坐标对齐,同一支路上所有点同刻。
func planetTransitIsochroneFeatures(
input planetTransitInput,
step time.Duration,
timeScale basic.TimeScale,
skip []string,
withBase func(map[string]interface{}) map[string]interface{},
module *Generator,
) ([]feature, error) {
result := basic.PlanetTransitResultFromTimes(input.planetIndex, input.start, input.c2, input.greatest, input.c3, input.end)
features := make([]feature, 0, 4)
for _, contact := range []struct {
kind basic.PlanetTransitContactKind
role string
}{
{basic.PlanetTransitC1, "c1-isochrone"},
{basic.PlanetTransitC2, "c2-isochrone"},
{basic.PlanetTransitC3, "c3-isochrone"},
{basic.PlanetTransitC4, "c4-isochrone"},
} {
if skippedLunarRole(skip, contact.role) {
continue
}
lines := basic.PlanetTransitContactIsochronesWithInstance(result, contact.kind, step, module.inst)
coordinates := make([][][]float64, 0, len(lines))
times := make([][]string, 0, len(lines))
for _, line := range lines {
at := basic.LabelIn(timeScale, basic.JD2DateByZone(line.JD, time.UTC, false))
for _, segment := range line.Segments {
if len(segment) < 2 {
continue
}
points := make([]pathSample, len(segment))
for index, point := range segment {
points[index] = pathSample{Time: at, Longitude: point.Longitude, Latitude: point.Latitude}
}
pieces, err := splitTimedLine(points, false)
if err != nil {
return nil, fmt.Errorf("geojson: %s: %w", contact.role, err)
}
for _, piece := range pieces {
if len(piece) < 2 {
continue
}
coordinate := make([][]float64, len(piece))
stamp := make([]string, len(piece))
for index, point := range piece {
if err := validateCoordinate(point.Longitude, point.Latitude); err != nil {
return nil, fmt.Errorf("geojson: %s: %w", contact.role, err)
}
coordinate[index] = []float64{point.Longitude, point.Latitude}
stamp[index] = formatTime(point.Time)
}
coordinates = append(coordinates, coordinate)
times = append(times, stamp)
}
}
}
if len(coordinates) == 0 {
continue
}
properties := withBase(map[string]interface{}{"step_seconds": step.Seconds(), "times": times})
features = append(features, newFeature(planetTransitEvent, contact.role,
geometry{Type: "MultiLineString", Coordinates: coordinates}, properties))
}
return features, nil
}
func planetTransitTimeMarkerSamples(
start, end time.Time,
scale basic.TimeScale,
options TimeMarkerOptions,
module *Generator,
) ([]pathSample, error) {
step, err := normalizeTimeMarkerStep(options.Step)
if err != nil {
return nil, fmt.Errorf("geojson: transit time markers: %w", err)
}
location := normalizeTimeMarkerLocation(options.Location)
capacity, err := timeMarkerCapacity(start, end, step, location)
if err != nil {
return nil, fmt.Errorf("geojson: transit time markers: %w", err)
}
markers := make([]pathSample, 0, capacity)
for current := firstTimeMarkerAfter(start, step, location); current.Before(end); current = current.Add(step) {
point := solarSubsolarPoint(current, module)
markers = append(markers, pathSample{
Time: basic.LabelIn(scale, current),
Longitude: point.Longitude,
Latitude: point.Latitude,
})
}
return markers, nil
}