feat: 完善水星与金星全球凌日搜索及制图导出
- 基于平均下合周期与节点相位预筛候选,减少无效星历计算;跳过无法求解的单个会合周期,避免有限覆盖星历导致搜索提前终止 - 引入影轴 γ、半影半径及地球尺度几何判定,结合站心可见性识别地心无接触、仅部分地区可见的掠凌 - 添加金星 243 年、水星 46 年周期族编号,并明确编号适用范围及未分配时的零值语义 - 为 SVG、GeoJSON 和 KML 增加站心部分凌日可见区,适配接触等时线、几何元数据、图层样式及取景;为地心无接触凌日事件增加正射图,按可见区球面面积加权居中
This commit is contained in:
41 files changed
+1554
-307
No files matched your search
+51
-8
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user