feat: 完善水星与金星全球凌日搜索及制图导出

- 基于平均下合周期与节点相位预筛候选,减少无效星历计算;跳过无法求解的单个会合周期,避免有限覆盖星历导致搜索提前终止
- 引入影轴 γ、半影半径及地球尺度几何判定,结合站心可见性识别地心无接触、仅部分地区可见的掠凌
- 添加金星 243 年、水星 46 年周期族编号,并明确编号适用范围及未分配时的零值语义
- 为 SVG、GeoJSON 和 KML 增加站心部分凌日可见区,适配接触等时线、几何元数据、图层样式及取景;为地心无接触凌日事件增加正射图,按可见区球面面积加权居中
This commit is contained in:
b612 committed 2026-10-20 22:35:39 +08:00
1 parent 72460fa228
commit ccf5880269
41 files changed
+1554 -307

No files matched your search

+51 -8
View File
@@ -28,16 +28,17 @@ type PlanetTransitOptions struct {
// 小于等于零时使用 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 控制 / events without geocentric contacts use a fixed topocentric grid.
BoundaryPoints int
// IsochroneStep 为正时输出一触到四触的站心接触等时线(掠凌没有二触、三触),至少一秒;零值不输出。
// A positive IsochroneStep adds topocentric C1-C4 isochrones (no C2/C3 for a grazing transit) and must be
// IsochroneStep 为正时输出一触到四触的站心接触等时线(地表没有站心内切的事件没有二触、三触),至少一秒;零值不输出。
// A positive IsochroneStep adds topocentric C1-C4 isochrones (no C2/C3 where no site has internal contact) 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 非空时沿太阳直射点轨迹追加时间标记;无地心接触时覆盖求解窗口。
// TimeMarkers adds subsolar-track markers over C1-C4, or over the search window when geocentric contacts are absent.
TimeMarkers *TimeMarkerOptions
// SkipRoles 列出不写进输出的 role;分区三个 role 都跳过时不做分区计算。
// SkipRoles lists roles to leave out; skipping all three region roles skips the region computation.
// SkipRoles 列出不写进输出的 role;分区 role 全部跳过时不做分区计算。
// SkipRoles lists roles to leave out; skipping every region role skips the region computation.
SkipRoles []string
}
@@ -54,6 +55,12 @@ type planetTransitInput struct {
separation float64
sunRadius float64
planetRad float64
hasExternal bool
windowStart time.Time
windowEnd time.Time
gamma float64
penumbra float64
series basic.PlanetTransitSeries
}
// MarshalMercuryTransit 将水星凌日的全球可见分区、一触/四触地平线与可选站心等时线编码为 GeoJSON / encode a Mercury transit as GeoJSON.
@@ -66,6 +73,8 @@ func (module *Generator) MarshalMercuryTransit(info mercury.TransitInfo, options
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,
hasExternal: !info.Start.IsZero() && !info.End.IsZero(), windowStart: info.WindowStart, windowEnd: info.WindowEnd,
gamma: info.Gamma, penumbra: info.PenumbralRadius, series: info.Series,
}, options, module)
}
@@ -79,10 +88,32 @@ func (module *Generator) MarshalVenusTransit(info venus.TransitInfo, options Pla
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,
hasExternal: !info.Start.IsZero() && !info.End.IsZero(), windowStart: info.WindowStart, windowEnd: info.WindowEnd,
gamma: info.Gamma, penumbra: info.PenumbralRadius, series: info.Series,
}, options, module)
}
// planetTransitHasOnlyWindow 判断输入是否只给了站心求解窗口:地心无接触,接触时刻全为零。
func planetTransitHasOnlyWindow(input planetTransitInput) bool {
if input.hasExternal || input.hasInternal {
return false
}
if !input.start.IsZero() || !input.end.IsZero() || !input.c2.IsZero() || !input.c3.IsZero() {
return false
}
if input.greatest.IsZero() || input.windowStart.IsZero() || input.windowEnd.IsZero() {
return false
}
if !input.windowStart.Before(input.greatest) || !input.greatest.Before(input.windowEnd) {
return false
}
return input.windowEnd.Sub(input.windowStart) <= 24*time.Hour
}
func validatePlanetTransitInput(input planetTransitInput) error {
if planetTransitHasOnlyWindow(input) {
return nil
}
if input.start.IsZero() || input.greatest.IsZero() || input.end.IsZero() {
return fmt.Errorf("geojson: %s transit contact times are required", input.planet)
}
@@ -140,7 +171,9 @@ func marshalPlanetTransit(input planetTransitInput, options PlanetTransitOptions
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}
base := map[string]interface{}{"planet": input.planet, "has_internal": input.hasInternal,
"has_geocentric_contacts": input.hasExternal, "gamma": input.gamma, "penumbral_radius": input.penumbra,
"series": input.series.Number, "series_period_years": input.series.PeriodYears}
withBase := func(extra map[string]interface{}) map[string]interface{} {
properties := cloneProperties(base)
for key, value := range extra {
@@ -148,6 +181,9 @@ func marshalPlanetTransit(input planetTransitInput, options PlanetTransitOptions
}
return properties
}
if !input.hasExternal {
return marshalPlanetTransitPartial(input, options, timeScale, withBase, module)
}
features := make([]feature, 0, 12)
columnsAt := func(at time.Time) [][]lunarLatitudeInterval {
@@ -354,6 +390,13 @@ func planetTransitRegionFeatures(
return features, nil
}
func planetTransitResultOf(input planetTransitInput) basic.PlanetTransitResult {
if !input.hasExternal {
return basic.PlanetTransitResultFromWindow(input.planetIndex, input.windowStart, input.greatest, input.windowEnd)
}
return basic.PlanetTransitResultFromTimes(input.planetIndex, input.start, input.c2, input.greatest, input.c3, input.end)
}
// planetTransitIsochroneFeatures 把每个接触的站心等时线编码为一条带 times 的 MultiLineString:
// 每段是一条支路,times 与坐标对齐,同一支路上所有点同刻。
func planetTransitIsochroneFeatures(
@@ -364,7 +407,7 @@ func planetTransitIsochroneFeatures(
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)
result := planetTransitResultOf(input)
features := make([]feature, 0, 4)
for _, contact := range []struct {
kind basic.PlanetTransitContactKind
+58
View File
@@ -0,0 +1,58 @@
package geojson
import (
"fmt"
"time"
"b612.me/astro/basic"
)
func marshalPlanetTransitPartial(input planetTransitInput, options PlanetTransitOptions, scale basic.TimeScale, withBase func(map[string]interface{}) map[string]interface{}, module *Generator) ([]byte, error) {
result := planetTransitResultOf(input)
label := func(value time.Time) string { return formatTime(basic.LabelIn(scale, value.UTC())) }
features := make([]feature, 0, 4)
if !skippedLunarRole(options.SkipRoles, "transit-partial") {
columns := basic.PlanetTransitVisibilityColumnsWithInstance(result, module.inst)
longitudes := make([]float64, len(columns))
intervals := make([][]lunarLatitudeInterval, len(columns))
for index, column := range columns {
longitudes[index] = column.Longitude
for _, interval := range column.Intervals {
intervals[index] = append(intervals[index], lunarLatitudeInterval{low: interval[0], high: interval[1]})
}
}
value, err := planetTransitRegionGeometry(longitudes, intervals)
if err != nil {
return nil, fmt.Errorf("geojson: partial transit: %w", err)
}
features = append(features, newFeature(planetTransitEvent, "transit-partial", value,
withBase(map[string]interface{}{"longitude_step_degrees": 2, "latitude_sample_degrees": 1})))
}
if options.IsochroneStep > 0 {
lines, err := planetTransitIsochroneFeatures(input, options.IsochroneStep, scale, options.SkipRoles, withBase, module)
if err != nil {
return nil, err
}
features = append(features, lines...)
}
point := solarSubsolarPoint(input.greatest, module)
var err error
features, err = appendPointFeature(features, planetTransitEvent, "greatest",
pathSample{Time: basic.LabelIn(scale, input.greatest.UTC()), Longitude: point.Longitude, Latitude: point.Latitude},
withBase(map[string]interface{}{"greatest": label(input.greatest), "window_start": label(input.windowStart), "window_end": label(input.windowEnd),
"minimum_separation_arcsec": input.separation, "sun_semidiameter_arcsec": input.sunRadius, "planet_semidiameter_arcsec": input.planetRad}))
if err != nil {
return nil, err
}
if options.TimeMarkers != nil {
markers, markerErr := planetTransitTimeMarkerSamples(input.windowStart, input.windowEnd, scale, *options.TimeMarkers, module)
if markerErr != nil {
return nil, markerErr
}
features, err = appendTimeMarkerPointFeatures(features, planetTransitEvent, "subsolar-track", markers, options.TimeMarkers.Location)
if err != nil {
return nil, err
}
}
return marshalFeatureCollectionWithTimeScale(dropFeaturesByRole(features, options.SkipRoles), scale)
}
+44
View File
@@ -0,0 +1,44 @@
package geojson_test
import (
"encoding/json"
"testing"
"time"
"b612.me/astro/geojson"
"b612.me/astro/venus"
)
func TestMarshalVenusTransitGlobalPartial(tester *testing.T) {
info := venus.NextTransit(time.Date(2854, 1, 1, 0, 0, 0, 0, time.UTC))
data, err := geojson.MarshalVenusTransit(info, geojson.PlanetTransitOptions{IsochroneStep: 30 * time.Second})
if err != nil {
tester.Fatal(err)
}
collection, roles := decodeTransit(tester, data)
for _, role := range []string{"transit-partial", "greatest", "c1-isochrone", "c4-isochrone"} {
if roles[role] != 1 {
tester.Fatalf("missing %s: %v", role, roles)
}
}
for _, role := range []string{"c1-horizon", "c4-horizon", "visible-at-c1", "visible-at-c4", "c2-isochrone", "c3-isochrone", "transit-entire"} {
if roles[role] != 0 {
tester.Fatalf("fabricated %s", role)
}
}
for _, item := range collection.Features {
if item.Properties["role"] != "transit-partial" {
continue
}
var polygons [][][][]float64
if err := json.Unmarshal(item.Geometry.Coordinates, &polygons); err != nil {
tester.Fatal(err)
}
if !geoJSONMultiPolygonContains(polygons, -135, -60) || geoJSONMultiPolygonContains(polygons, 0, 45) {
tester.Fatal("partial-transit footprint does not match the topocentric geometry")
}
if item.Properties["has_geocentric_contacts"] != false || item.Properties["series"] != float64(4) || item.Properties["gamma"] == nil {
tester.Fatalf("missing global-transit metadata: %v", item.Properties)
}
}
}